@@ -9,6 +9,17 @@ module Features
99 #
1010 # HTTP.use(logging: {logger: Logger.new(STDOUT)}).get("https://example.com/")
1111 #
12+ # Binary bodies (IO/Enumerable request sources and binary-encoded
13+ # responses) are formatted using the +binary_formatter+ option instead
14+ # of being dumped raw. Available formatters:
15+ #
16+ # - +:stats+ (default) — logs <tt>BINARY DATA (N bytes)</tt>
17+ # - +:base64+ — logs <tt>BINARY DATA (N bytes)\n<base64></tt>
18+ # - +Proc+ — calls the proc with the raw binary string
19+ #
20+ # @example Custom binary formatter
21+ # HTTP.use(logging: {logger: Logger.new(STDOUT), binary_formatter: :base64})
22+ #
1223 class Logging < Feature
1324 HTTP ::Options . register_feature ( :logging , self )
1425
@@ -39,12 +50,17 @@ class NullLogger
3950 # @example
4051 # Logging.new(logger: Logger.new(STDOUT))
4152 #
53+ # @example With binary formatter
54+ # Logging.new(logger: Logger.new(STDOUT), binary_formatter: :base64)
55+ #
4256 # @param logger [#info, #debug] logger instance
57+ # @param binary_formatter [:stats, :base64, #call] how to log binary bodies
4358 # @return [Logging]
4459 # @api public
45- def initialize ( logger : NullLogger . new )
60+ def initialize ( logger : NullLogger . new , binary_formatter : :stats )
4661 super ( )
4762 @logger = logger
63+ @binary_formatter = validate_binary_formatter! ( binary_formatter )
4864 end
4965
5066 # Logs and returns the request
@@ -57,7 +73,7 @@ def initialize(logger: NullLogger.new)
5773 # @api public
5874 def wrap_request ( request )
5975 logger . info { "> #{ request . verb . to_s . upcase } #{ request . uri } " }
60- logger . debug { " #{ stringify_headers ( request . headers ) } \n \n #{ request . body . source } " }
76+ log_request_details ( request )
6177
6278 request
6379 end
@@ -83,11 +99,43 @@ def wrap_response(response)
8399
84100 private
85101
102+ # Validate and return the binary_formatter option
103+ # @return [:stats, :base64, #call]
104+ # @raise [ArgumentError] if the formatter is not a valid option
105+ # @api private
106+ def validate_binary_formatter! ( formatter )
107+ return formatter if formatter == :stats || formatter == :base64 || formatter . respond_to? ( :call )
108+
109+ raise ArgumentError ,
110+ "binary_formatter must be :stats, :base64, or a callable " \
111+ "(got #{ formatter . inspect } )"
112+ end
113+
114+ # Log request headers and body (when loggable)
115+ # @return [void]
116+ # @api private
117+ def log_request_details ( request )
118+ headers = stringify_headers ( request . headers )
119+ if request . body . loggable?
120+ source = request . body . source
121+ body = source . encoding == Encoding ::BINARY ? format_binary ( source ) : source # steep:ignore
122+ logger . debug { "#{ headers } \n \n #{ body } " }
123+ else
124+ logger . debug { headers }
125+ end
126+ end
127+
86128 # Log response with body inline (for non-streaming string bodies)
87129 # @return [HTTP::Response]
88130 # @api private
89131 def log_response_body_inline ( response )
90- logger . debug { "#{ stringify_headers ( response . headers ) } \n \n #{ response . body } " }
132+ body = response . body
133+ headers = stringify_headers ( response . headers )
134+ if body . respond_to? ( :encoding ) && body . encoding == Encoding ::BINARY # steep:ignore
135+ logger . debug { "#{ headers } \n \n #{ format_binary ( body ) } " } # steep:ignore
136+ else
137+ logger . debug { "#{ headers } \n \n #{ body } " }
138+ end
91139 response
92140 end
93141
@@ -110,10 +158,25 @@ def logged_response_options(response)
110158 # @return [HTTP::Response::Body]
111159 # @api private
112160 def logged_body ( body )
113- stream = BodyLogger . new ( body . instance_variable_get ( :@stream ) , logger )
161+ formatter = body . loggable? ? nil : method ( :format_binary ) # steep:ignore
162+ stream = BodyLogger . new ( body . instance_variable_get ( :@stream ) , logger , formatter : formatter ) # steep:ignore
114163 Response ::Body . new ( stream , encoding : body . encoding )
115164 end
116165
166+ # Format binary data according to the configured binary_formatter
167+ # @return [String]
168+ # @api private
169+ def format_binary ( data )
170+ case @binary_formatter
171+ when :stats
172+ format ( "BINARY DATA (%d bytes)" , data . bytesize )
173+ when :base64
174+ format ( "BINARY DATA (%d bytes)\n %s" , data . bytesize , [ data ] . pack ( "m0" ) )
175+ else
176+ @binary_formatter . call ( data ) # steep:ignore
177+ end
178+ end
179+
117180 # Convert headers to a string representation
118181 # @return [String]
119182 # @api private
@@ -139,12 +202,14 @@ class BodyLogger
139202 #
140203 # @param stream [#readpartial] the stream to wrap
141204 # @param logger [#debug] the logger instance
205+ # @param formatter [#call, nil] optional formatter for each chunk
142206 # @return [BodyLogger]
143207 # @api public
144- def initialize ( stream , logger )
208+ def initialize ( stream , logger , formatter : nil )
145209 @stream = stream
146210 @connection = stream . respond_to? ( :connection ) ? stream . connection : stream
147211 @logger = logger
212+ @formatter = formatter
148213 end
149214
150215 # Read a chunk from the underlying stream and log it
@@ -157,7 +222,7 @@ def initialize(stream, logger)
157222 # @api public
158223 def readpartial ( *)
159224 chunk = @stream . readpartial ( *)
160- @logger . debug { chunk }
225+ @logger . debug { @formatter ? @formatter . call ( chunk ) : chunk } # steep:ignore
161226 chunk
162227 end
163228 end
0 commit comments