Skip to content

Commit ddffab3

Browse files
committed
Fix logging feature consuming response body and preventing streaming
The logging feature's wrap_response eagerly called response.body.to_s to log the body, which consumed the stream and made subsequent response.body.each calls raise StateError. Replace eager consumption with a tee-style BodyLogger stream wrapper that logs each chunk as it flows through readpartial. Also generalize Body's stream detection from is_a?(Inflater) to respond_to?(:connection) so any stream wrapper is supported. Closes #785.
1 parent a64160c commit ddffab3

5 files changed

Lines changed: 228 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323
- `LocalJumpError` when using instrumentation with instrumenters that
2424
unconditionally yield in `#instrument` (e.g., `ActiveSupport::Notifications`)
2525
(#673)
26+
- Logging feature no longer eagerly consumes the response body at debug level;
27+
body chunks are now logged as they are streamed, preserving
28+
`response.body.each` (#785)
2629

2730
### Changed
2831

lib/http/features/logging.rb

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,19 +72,95 @@ def wrap_request(request)
7272
# @api public
7373
def wrap_response(response)
7474
logger.info { "< #{response.status}" }
75-
logger.debug { "#{stringify_headers(response.headers)}\n\n#{response.body}" }
7675

77-
response
76+
return log_response_body_inline(response) unless response.body.is_a?(Response::Body)
77+
78+
logger.debug { stringify_headers(response.headers) }
79+
return response unless logger.debug?
80+
81+
Response.new(logged_response_options(response))
7882
end
7983

8084
private
8185

86+
# Log response with body inline (for non-streaming string bodies)
87+
# @return [HTTP::Response]
88+
# @api private
89+
def log_response_body_inline(response)
90+
logger.debug { "#{stringify_headers(response.headers)}\n\n#{response.body}" }
91+
response
92+
end
93+
94+
# Build options hash for a response with body logging
95+
# @return [Hash]
96+
# @api private
97+
def logged_response_options(response)
98+
{
99+
status: response.status,
100+
version: response.version,
101+
headers: response.headers,
102+
proxy_headers: response.proxy_headers,
103+
connection: response.connection,
104+
body: logged_body(response.body),
105+
request: response.request
106+
}
107+
end
108+
109+
# Wrap a response body with a logging stream
110+
# @return [HTTP::Response::Body]
111+
# @api private
112+
def logged_body(body)
113+
stream = BodyLogger.new(body.instance_variable_get(:@stream), logger)
114+
Response::Body.new(stream, encoding: body.encoding)
115+
end
116+
82117
# Convert headers to a string representation
83118
# @return [String]
84119
# @api private
85120
def stringify_headers(headers)
86121
headers.map { |name, value| "#{name}: #{value}" }.join("\n")
87122
end
123+
124+
# Stream wrapper that logs each chunk as it flows through readpartial
125+
class BodyLogger
126+
# The underlying connection
127+
#
128+
# @example
129+
# body_logger.connection
130+
#
131+
# @return [HTTP::Connection] the underlying connection
132+
# @api public
133+
attr_reader :connection
134+
135+
# Create a new BodyLogger wrapping a stream
136+
#
137+
# @example
138+
# BodyLogger.new(stream, logger)
139+
#
140+
# @param stream [#readpartial] the stream to wrap
141+
# @param logger [#debug] the logger instance
142+
# @return [BodyLogger]
143+
# @api public
144+
def initialize(stream, logger)
145+
@stream = stream
146+
@connection = stream.respond_to?(:connection) ? stream.connection : stream
147+
@logger = logger
148+
end
149+
150+
# Read a chunk from the underlying stream and log it
151+
#
152+
# @example
153+
# body_logger.readpartial # => "chunk"
154+
#
155+
# @return [String] the chunk read from the stream
156+
# @raise [EOFError] when no more data left
157+
# @api public
158+
def readpartial(*)
159+
chunk = @stream.readpartial(*)
160+
@logger.debug { chunk }
161+
chunk
162+
end
163+
end
88164
end
89165
end
90166
end

lib/http/response/body.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ class Body
4141
# @api public
4242
def initialize(stream, encoding: Encoding::BINARY)
4343
@stream = stream
44-
@connection = stream.is_a?(Inflater) ? stream.connection : stream
44+
@connection = stream.respond_to?(:connection) ? stream.connection : stream
4545
@streaming = nil
4646
@contents = nil
4747
@encoding = find_encoding(encoding)

sig/http.rbs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,17 @@ module HTTP
322322

323323
private
324324

325+
def log_response_body_inline: (untyped response) -> untyped
326+
def logged_response_options: (untyped response) -> Hash[Symbol, untyped]
327+
def logged_body: (Response::Body body) -> Response::Body
325328
def stringify_headers: (Headers headers) -> String
329+
330+
class BodyLogger
331+
attr_reader connection: untyped
332+
333+
def initialize: (untyped stream, untyped logger) -> void
334+
def readpartial: (*untyped) -> String
335+
end
326336
end
327337

328338
class NormalizeUri < Feature

test/http/features/logging_test.rb

Lines changed: 136 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -58,28 +58,147 @@
5858
end
5959

6060
describe "logging the response" do
61-
let(:response) do
62-
HTTP::Response.new(
63-
version: "1.1",
64-
status: 200,
65-
headers: { content_type: "application/json" },
66-
body: '{"success": true}',
67-
request: HTTP::Request.new(verb: :get, uri: "https://example.com")
61+
context "with a string body" do
62+
let(:response) do
63+
HTTP::Response.new(
64+
version: "1.1",
65+
status: 200,
66+
headers: { content_type: "application/json" },
67+
body: '{"success": true}',
68+
request: HTTP::Request.new(verb: :get, uri: "https://example.com")
69+
)
70+
end
71+
72+
it "logs the response with body" do
73+
feature.wrap_response(response)
74+
75+
expected = <<~OUTPUT
76+
** INFO **
77+
< 200 OK
78+
** DEBUG **
79+
Content-Type: application/json
80+
81+
{"success": true}
82+
OUTPUT
83+
assert_equal expected, logdev.string
84+
end
85+
end
86+
87+
context "with a streaming body" do
88+
let(:chunks) { %w[{"suc cess" :true}] }
89+
let(:stream) do
90+
fake(
91+
readpartial: proc { chunks.shift or raise EOFError },
92+
close: nil,
93+
closed?: true
94+
)
95+
end
96+
let(:body) { HTTP::Response::Body.new(stream) }
97+
let(:response) do
98+
HTTP::Response.new(
99+
version: "1.1",
100+
status: 200,
101+
headers: { content_type: "application/json" },
102+
body: body,
103+
request: HTTP::Request.new(verb: :get, uri: "https://example.com")
104+
)
105+
end
106+
107+
it "does not consume the body" do
108+
wrapped = feature.wrap_response(response)
109+
110+
assert_nil wrapped.body.instance_variable_get(:@streaming)
111+
end
112+
113+
it "logs body chunks as they are streamed" do
114+
wrapped = feature.wrap_response(response)
115+
wrapped.body.to_s
116+
117+
assert_includes logdev.string, '{"suc'
118+
assert_includes logdev.string, 'cess"'
119+
assert_includes logdev.string, ":true}"
120+
end
121+
122+
it "preserves the full body content" do
123+
wrapped = feature.wrap_response(response)
124+
125+
assert_equal '{"success":true}', wrapped.body.to_s
126+
end
127+
end
128+
129+
context "when logger level is above debug" do
130+
let(:feature) do
131+
logger = Logger.new(logdev)
132+
logger.formatter = ->(severity, _, _, message) { format("** %s **\n%s\n", severity, message) }
133+
logger.level = Logger::INFO
134+
135+
HTTP::Features::Logging.new(logger: logger)
136+
end
137+
138+
let(:stream) do
139+
fake(
140+
readpartial: proc { raise EOFError },
141+
close: nil,
142+
closed?: true
143+
)
144+
end
145+
let(:body) { HTTP::Response::Body.new(stream) }
146+
let(:response) do
147+
HTTP::Response.new(
148+
version: "1.1",
149+
status: 200,
150+
headers: { content_type: "text/plain" },
151+
body: body,
152+
request: HTTP::Request.new(verb: :get, uri: "https://example.com")
153+
)
154+
end
155+
156+
it "does not wrap the body" do
157+
wrapped = feature.wrap_response(response)
158+
159+
assert_same response, wrapped
160+
end
161+
end
162+
end
163+
164+
describe "BodyLogger" do
165+
let(:logger) do
166+
logger = Logger.new(logdev)
167+
logger.formatter = ->(severity, _, _, message) { format("** %s **\n%s\n", severity, message) }
168+
logger
169+
end
170+
171+
it "passes through chunks and logs them" do
172+
chunks = %w[hello world]
173+
stream = fake(readpartial: proc { chunks.shift or raise EOFError })
174+
175+
body_logger = HTTP::Features::Logging::BodyLogger.new(stream, logger)
176+
177+
assert_equal "hello", body_logger.readpartial
178+
assert_equal "world", body_logger.readpartial
179+
assert_raises(EOFError) { body_logger.readpartial }
180+
assert_includes logdev.string, "hello"
181+
assert_includes logdev.string, "world"
182+
end
183+
184+
it "exposes the underlying connection" do
185+
connection = Object.new
186+
stream = fake(
187+
readpartial: proc { raise EOFError },
188+
connection: connection
68189
)
190+
191+
body_logger = HTTP::Features::Logging::BodyLogger.new(stream, logger)
192+
193+
assert_same connection, body_logger.connection
69194
end
70195

71-
it "logs the response" do
72-
feature.wrap_response(response)
196+
it "uses the stream as connection when stream has no connection method" do
197+
stream = fake(readpartial: proc { raise EOFError })
73198

74-
expected = <<~OUTPUT
75-
** INFO **
76-
< 200 OK
77-
** DEBUG **
78-
Content-Type: application/json
199+
body_logger = HTTP::Features::Logging::BodyLogger.new(stream, logger)
79200

80-
{"success": true}
81-
OUTPUT
82-
assert_equal expected, logdev.string
201+
assert_same stream, body_logger.connection
83202
end
84203
end
85204
end

0 commit comments

Comments
 (0)