coolhand 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "api_service"
4
+ require_relative "pagination"
5
+ require_relative "read_query"
6
+
7
+ module Coolhand
8
+ # Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
9
+ HelpArticleSearchResult = Struct.new(:articles, :pagination, keyword_init: true)
10
+
11
+ # Read-only access to the Coolhand help center: the `public_content` endpoints (`GET /api/v2/public_content`,
12
+ # `/:slug` and `/categories`), the same pages served at `/help/:slug` and `/updates/:slug`. Create, update and
13
+ # archive are Coolhand-internal and not wrapped.
14
+ #
15
+ # Unlike every other service this one needs no API key. A configured `api_key` is sent as `X-API-Key` and lifts
16
+ # the anonymous limit of 30 requests per minute per IP (429 beyond); with none, no key header is sent. A key the
17
+ # server rejects raises a 401 {Coolhand::HttpError}; it is not retried anonymously. See docs/help-center.md.
18
+ class HelpCenterService < ApiService
19
+ include ReadQuery
20
+
21
+ ERROR_NOUN = "Help center"
22
+ BLANK_SLUG_MESSAGE = "get_help_article: slug must be a non-empty string"
23
+
24
+ def initialize
25
+ super("v2/public_content")
26
+ end
27
+
28
+ # Relevance search with `query`, a full listing without. `category` filters to one section slug; the
29
+ # endpoint has no such parameter, so it is sent as `q[help_category_slug_eq]` and `query` moves to
30
+ # `q[full_text]` (the plain `q` string and the `q[...]` form cannot be mixed). A blank `query` or
31
+ # `category` counts as omitted. Returns a {HelpArticleSearchResult}; raises {Coolhand::HttpError} on a
32
+ # non-2xx response.
33
+ def search_help_articles(query: nil, category: nil, page: nil, per_page: nil)
34
+ params = { **search_params(query, category), page: page, per_page: per_page }.compact
35
+
36
+ fetch_list(params, HelpArticleSearchResult, :articles, page: page, per: per_page)
37
+ end
38
+
39
+ # One published article with its markdown `body`, `noindex` and `toc`. Raises {Coolhand::Error} before any
40
+ # request for a blank slug, and {Coolhand::HttpError} (404) for an unknown or unpublished one.
41
+ def get_help_article(slug)
42
+ get_json(resource_url(slug, BLANK_SLUG_MESSAGE), ERROR_NOUN)
43
+ end
44
+
45
+ # Every section an article can be filed under, with its `url_prefix` and `allowed_tags`.
46
+ def list_help_categories
47
+ get_json(URI.parse("#{api_endpoint}/categories"), ERROR_NOUN)
48
+ end
49
+
50
+ protected
51
+
52
+ def api_key_required?
53
+ false
54
+ end
55
+
56
+ private
57
+
58
+ def search_params(query, category)
59
+ query = nil if omitted?(query)
60
+ return { q: query } if omitted?(category)
61
+
62
+ { "q[help_category_slug_eq]": category, "q[full_text]": query }
63
+ end
64
+
65
+ def omitted?(value)
66
+ value.nil? || value.to_s.strip.empty?
67
+ end
68
+ end
69
+ end
@@ -4,6 +4,25 @@ require_relative "api_service"
4
4
 
5
5
  module Coolhand
6
6
  class LoggerService < ApiService
7
+ # CGI/Rack/Rails/app-server variables that show up in a Rails `request.headers` iteration
8
+ # (it walks the whole Rack env) but are not HTTP headers. CONTENT_TYPE and
9
+ # CONTENT_LENGTH are real headers and are deliberately absent. ORIGINAL_FULLPATH and
10
+ # REQUEST_PATH carry the query string, so they must never be forwarded. HTTP_PROXY is the
11
+ # process env var (it may embed credentials), never a client header (the "httpoxy" case).
12
+ RACK_ENV_CGI_KEYS = %w[
13
+ AUTH_TYPE GATEWAY_INTERFACE HTTPS HTTP_PROXY HTTP_VERSION ORIGINAL_FULLPATH ORIGINAL_SCRIPT_NAME
14
+ PATH_INFO PATH_TRANSLATED QUERY_STRING REMOTE_ADDR REMOTE_HOST REMOTE_IDENT REMOTE_PORT
15
+ REMOTE_USER REQUEST_METHOD REQUEST_PATH REQUEST_URI SCRIPT_NAME SERVER_ADDR SERVER_NAME
16
+ SERVER_PORT SERVER_PROTOCOL SERVER_SOFTWARE
17
+ ].freeze
18
+
19
+ # CGI-style env key: uppercase letters, digits and underscores (REQUEST_METHOD, DATABASE_URL).
20
+ CGI_ENV_KEY_PATTERN = /\A[A-Z0-9_]+\z/
21
+ # Lowercase dotted Rack/Rails/server key: rack.session, action_dispatch.request.parameters, puma.config.
22
+ RACK_DOTTED_KEY_PATTERN = /\A[a-z0-9_]+(?:\.[a-z0-9_]+)+\z/
23
+ RACK_ENV_HEADER_KEYS = %w[CONTENT_TYPE CONTENT_LENGTH].freeze
24
+ private_constant :CGI_ENV_KEY_PATTERN, :RACK_DOTTED_KEY_PATTERN, :RACK_ENV_HEADER_KEYS
25
+
7
26
  def initialize
8
27
  super("v2/llm_request_logs")
9
28
  end
@@ -24,10 +43,10 @@ module Coolhand
24
43
  id: options[:id] || SecureRandom.uuid,
25
44
  timestamp: options[:timestamp] || Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ"),
26
45
  method: options[:method] || "POST",
27
- url: options[:url] || build_webhook_url(source, event_type),
46
+ url: BaseInterceptor.sanitize_url(options[:url] || build_webhook_url(source, event_type)),
28
47
  headers: sanitize_headers(headers),
29
- request_body: clean_webhook_body(webhook_body, source),
30
- response_body: options[:response_body],
48
+ request_body: BodyRedactor.redact(clean_webhook_body(webhook_body, source)),
49
+ response_body: BodyRedactor.redact(options[:response_body]),
31
50
  response_headers: options[:response_headers] && sanitize_headers(options[:response_headers]),
32
51
  status_code: options[:status_code] || 200,
33
52
  source: "#{source}_webhook"
@@ -58,21 +77,51 @@ module Coolhand
58
77
  return {} unless headers.respond_to?(:each)
59
78
 
60
79
  # Handle Rails request headers or plain hash
80
+ entries = headers.enum_for(:each).to_a
81
+ rack_env = rack_env_shaped?(entries)
82
+
61
83
  clean_headers = {}
62
- headers.each do |key, value|
63
- next unless key && value
84
+ entries.each do |key, value|
85
+ next unless forwardable_header?(key, value, rack_env: rack_env)
64
86
 
65
87
  # Convert Rails HTTP_ prefix headers
66
88
  clean_key = key.to_s.gsub(/^HTTP_/, "").tr("_", "-").downcase
67
89
 
68
90
  # Redact sensitive headers (shared with BaseInterceptor so both logging
69
91
  # paths treat the same header names as sensitive)
70
- clean_value = clean_key.match?(BaseInterceptor::SENSITIVE_HEADER_PATTERN) ? "[REDACTED]" : value.to_s
92
+ clean_value = BaseInterceptor.sensitive_header?(clean_key) ? "[REDACTED]" : value.to_s
71
93
  clean_headers[clean_key] = clean_value
72
94
  end
73
95
  clean_headers
74
96
  end
75
97
 
98
+ # Rails' request.headers#each yields the whole Rack env, not just headers. Drop the
99
+ # non-header entries (rack.session, action_dispatch.*, REQUEST_METHOD, QUERY_STRING, ...)
100
+ # so session/params data is never forwarded. Ordinary header names (including normalized
101
+ # plain-Hash ones like "Content-Type" or "x-webhook-id") never match the denylist.
102
+ # CGI/FastCGI handlers also put the process env (DATABASE_URL, REDIS_URL, ...) in the Rack
103
+ # env, so once the input looks like one, only HTTP_*, CONTENT_TYPE and CONTENT_LENGTH pass
104
+ # (a process env var that itself starts with HTTP_ still gets through; HTTP_PROXY is denied).
105
+ def forwardable_header?(key, value, rack_env: false)
106
+ return false unless key && value
107
+
108
+ name = key.to_s
109
+ return false if name.include?(".") || RACK_ENV_CGI_KEYS.include?(name)
110
+
111
+ !rack_env || name.start_with?("HTTP_") || RACK_ENV_HEADER_KEYS.include?(name)
112
+ end
113
+
114
+ # A Rack env is recognised by a positive signal the Rack spec guarantees (REQUEST_METHOD, or
115
+ # a lowercase dotted key like rack.input), or by every key being env-shaped. Requiring only
116
+ # the latter would let a single lowercase key (Warden's "warden", a shell's http_proxy)
117
+ # switch the allowlist off for the whole env. Mixed-case dotted names ("X.Trace") are
118
+ # valid header names, so they are not a signal.
119
+ def rack_env_shaped?(entries)
120
+ keys = entries.filter_map { |key, _value| key&.to_s }
121
+ keys.any? { |name| name == "REQUEST_METHOD" || name.match?(RACK_DOTTED_KEY_PATTERN) } ||
122
+ (keys.any? && keys.all? { |name| name.match?(CGI_ENV_KEY_PATTERN) })
123
+ end
124
+
76
125
  def clean_webhook_body(body, source)
77
126
  # Service-specific binary field filters
78
127
  binary_fields = case source.to_s.downcase
@@ -1,22 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "net/http"
4
+ require "openssl"
3
5
  require "stringio"
6
+ require "timeout"
7
+ require "zlib"
8
+
9
+ require_relative "body_stream_tap"
10
+ require_relative "path_normalizer"
4
11
 
5
12
  module Coolhand
6
13
  module NetHttpInterceptor
7
14
  # Response streaming interceptor nested under NetHttpInterceptor
8
15
  module ResponseInterceptor
9
16
  def read_body(dest = nil, &block)
10
- # Only buffer while a #request call below is actively capturing —
11
- # otherwise every block-form read_body in the process (including
12
- # ones on responses this gem never intercepted) accumulates into a
13
- # thread-local that's never freed, which is an unbounded memory
14
- # leak on any thread that streams large non-LLM responses.
15
- return super unless block && Thread.current[:coolhand_capturing_stream]
17
+ # Only buffer this exact response while a #request call below is
18
+ # actively capturing it — otherwise every block-form read_body in
19
+ # the process (including ones on responses this gem never
20
+ # intercepted, such as a nested request made from inside the host's
21
+ # streaming block) accumulates into a thread-local that's never
22
+ # freed, and leaks one request's chunks into another's log.
23
+ return super unless block && Thread.current[:coolhand_capturing_stream].equal?(self)
16
24
 
17
25
  super do |chunk|
18
- Thread.current[:coolhand_stream_buffer] ||= +""
19
- Thread.current[:coolhand_stream_buffer] << chunk
26
+ Helpers.buffer_stream_chunk(chunk)
20
27
  yield(chunk)
21
28
  end
22
29
  end
@@ -119,19 +126,19 @@ module Coolhand
119
126
  previous_stream_buffer = Thread.current[:coolhand_stream_buffer]
120
127
  previous_capturing_stream = Thread.current[:coolhand_capturing_stream]
121
128
  Thread.current[:coolhand_stream_buffer] = nil
122
- Thread.current[:coolhand_capturing_stream] = true
129
+ Thread.current[:coolhand_capturing_stream] = nil
130
+ host_block_errors = []
131
+ tracked_block = Helpers.track_streaming_block(block, host_block_errors)
123
132
 
124
133
  begin
125
- response = super
126
- body_content = Thread.current[:coolhand_stream_buffer] || response&.body
127
- body_content = body_content.dup.force_encoding("UTF-8") if body_content.is_a?(String)
128
- status_code = response.respond_to?(:code) ? response.code.to_i : nil
129
- response_body = BaseInterceptor.parse_json(body_content)
134
+ response = super(req, body, &tracked_block)
135
+ status_code, response_body = Helpers.capture_response(response)
130
136
  rescue StandardError => e
131
- status_code = Helpers.extract_status_from_exception(e)
132
- response_body = { "error" => { "class" => e.class.name, "message" => e.message } }
137
+ status_code, response_body = Helpers.error_details(e, host_block_errors)
133
138
  raise
134
139
  ensure
140
+ # First, so the host's own stream is back on its request whatever fails below.
141
+ request_body = Helpers.finalize_request_body(captured_body, req)
135
142
  active.delete(self)
136
143
  Thread.current[:coolhand_stream_buffer] = previous_stream_buffer
137
144
  Thread.current[:coolhand_capturing_stream] = previous_capturing_stream
@@ -145,7 +152,7 @@ module Coolhand
145
152
  method: req.method,
146
153
  url: url,
147
154
  request_headers: BaseInterceptor.sanitize_headers(req),
148
- request_body: captured_body,
155
+ request_body: request_body,
149
156
  response_headers: BaseInterceptor.sanitize_headers(response),
150
157
  response_body: response_body,
151
158
  status_code: status_code,
@@ -166,6 +173,46 @@ module Coolhand
166
173
  module Helpers
167
174
  module_function
168
175
 
176
+ HOST_BLOCK_ERROR_MESSAGE = "[redacted: raised in host streaming block]"
177
+ # Socket-level failures can surface inside the host's block (read_body runs there); they
178
+ # carry no app data, so they keep their message even when they pass through the host block.
179
+ TRANSPORT_ERRORS = [
180
+ IOError, SystemCallError, Timeout::Error, Net::ProtocolError, OpenSSL::SSL::SSLError,
181
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Zlib::Error
182
+ ].freeze
183
+ private_constant :HOST_BLOCK_ERROR_MESSAGE, :TRANSPORT_ERRORS
184
+
185
+ # Wraps the host's streaming block so (a) read_body only buffers the response being
186
+ # captured here, and (b) exceptions raised by the host's own code can be told apart from
187
+ # transport errors (see TRANSPORT_ERRORS). Returns nil when there is no block.
188
+ def track_streaming_block(block, host_block_errors)
189
+ return unless block
190
+
191
+ proc do |res|
192
+ Thread.current[:coolhand_capturing_stream] = res
193
+ block.call(res)
194
+ rescue StandardError => e
195
+ host_block_errors << e unless transport_error?(e)
196
+ raise
197
+ end
198
+ end
199
+
200
+ # Matched by class, so a host block raising its own IOError/SystemCallError subclass keeps its
201
+ # message too; accepted trade-off, as telling those apart from socket failures isn't possible.
202
+ def transport_error?(error)
203
+ TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
204
+ end
205
+
206
+ # An error raised by the host's streaming block can carry app-level data in its message (and
207
+ # in a #status or message-embedded status code), so only its class is logged and no status is
208
+ # derived from it; transport errors keep their message and status. Returns [status, body].
209
+ def error_details(error, host_block_errors)
210
+ from_host = host_block_errors.any? { |host_error| host_error.equal?(error) }
211
+ status = from_host ? nil : extract_status_from_exception(error)
212
+ message = from_host ? HOST_BLOCK_ERROR_MESSAGE : error.message
213
+ [status, { "error" => { "class" => error.class.name, "message" => message } }]
214
+ end
215
+
169
216
  def should_capture?
170
217
  return true if Coolhand.configuration.debug_mode
171
218
 
@@ -184,24 +231,132 @@ module Coolhand
184
231
 
185
232
  content = body || req.body
186
233
  if content.nil? && req.respond_to?(:body_stream) && req.body_stream
187
- content = req.body_stream.read
188
- req.body_stream = StringIO.new(content)
234
+ result = capture_body_stream(req)
235
+ return result unless result.is_a?(String)
236
+
237
+ content = result
189
238
  end
190
239
  return nil if content.nil?
191
240
 
192
241
  cap_and_parse(content, req)
193
242
  end
194
243
 
244
+ # A body_stream request: the log body (a marker, or the BodyStreamTap resolved after the send),
245
+ # or the stream's content as a String when it was read here and still needs cap_and_parse.
246
+ def capture_body_stream(req)
247
+ # A declared Content-Length over the cap means we never need the bytes: skip
248
+ # before .read so a huge stream is not buffered just to be discarded.
249
+ too_large = oversized_declared_length(req)
250
+ return too_large if too_large
251
+
252
+ # No usable Content-Length (chunked): the size is unknown, so don't read ahead. Tap the
253
+ # stream instead and build the log body once Net::HTTP has sent it (finalize_request_body).
254
+ unless declared_content_length(req)
255
+ return skipped_capture_marker(req, "unsupported_body_stream") unless BodyStreamTap.tappable?(req.body_stream)
256
+
257
+ return BodyStreamTap.install(req)
258
+ end
259
+
260
+ content = req.body_stream.read
261
+ req.body_stream = StringIO.new(content)
262
+ content
263
+ end
264
+
265
+ # The effective cap: nil disables it; a non-Integer or negative value (e.g. an unparsed
266
+ # ENV string) falls back to the default rather than raising into the host's request or stream.
267
+ def capture_limit
268
+ limit = Coolhand.configuration.max_captured_body_bytes
269
+ return limit if limit.nil? || (limit.is_a?(Integer) && !limit.negative?)
270
+
271
+ Coolhand::Configuration::DEFAULT_MAX_CAPTURED_BODY_BYTES
272
+ end
273
+
274
+ def over_capture_limit?(size)
275
+ limit = capture_limit
276
+ !limit.nil? && size > limit
277
+ end
278
+
279
+ def body_too_large_marker(carrier, size)
280
+ skipped_capture_marker(carrier, "body_too_large", size_bytes: size, max_bytes: capture_limit)
281
+ end
282
+
283
+ # Returns a body_too_large marker when the request declares a Content-Length above the
284
+ # cap, else nil. Chunked/absent/malformed lengths return nil (the caller then taps the
285
+ # stream, which enforces the cap on the bytes actually sent).
286
+ def oversized_declared_length(req)
287
+ length = declared_content_length(req)
288
+ body_too_large_marker(req, length) if length && over_capture_limit?(length)
289
+ end
290
+
291
+ # Net::HTTP sends a chunked body without regard to any Content-Length header, so it is not a
292
+ # declaration of what goes on the wire then.
293
+ def declared_content_length(req)
294
+ req.content_length if req.respond_to?(:content_length) && !req.chunked?
295
+ rescue StandardError
296
+ nil
297
+ end
298
+
299
+ # The request body for the log. A tapped body_stream (see BodyStreamTap.install) only has its
300
+ # content once the request has been attempted, so it is resolved here and the host's own
301
+ # stream is put back on the request. Runs in #request's ensure: it must not raise.
302
+ def finalize_request_body(captured, req)
303
+ return captured unless captured.is_a?(BodyStreamTap)
304
+
305
+ captured.restore_stream(req)
306
+ captured.body_for_log(req)
307
+ rescue StandardError => e
308
+ Coolhand.log "❌ Error capturing request body: #{e.message}"
309
+ nil
310
+ end
311
+
312
+ # Appends a streamed response chunk to the thread-local capture buffer, up to the cap.
313
+ # Past the cap the buffer is dropped and replaced by a running byte count (an Integer),
314
+ # so memory stays bounded; the host always still receives every chunk because the caller
315
+ # yields it regardless. Only #request (via response_body_for_log) reads this slot.
316
+ def buffer_stream_chunk(chunk)
317
+ buffer = Thread.current[:coolhand_stream_buffer]
318
+ total = (buffer.is_a?(Integer) ? buffer : buffer.to_s.bytesize) + chunk.bytesize
319
+
320
+ Thread.current[:coolhand_stream_buffer] =
321
+ if buffer.is_a?(Integer) || over_capture_limit?(total)
322
+ total
323
+ else
324
+ (buffer || +"") << chunk
325
+ end
326
+ rescue StandardError => e
327
+ # Runs inside the host's read_body loop: a capture failure must never reach the host.
328
+ Coolhand.log "❌ Error buffering streamed response chunk: #{e.message}"
329
+ end
330
+
331
+ # [status_code, response_body] for the log. Called after the real request has succeeded, so a
332
+ # failure here is logged and swallowed rather than re-raised into the host app.
333
+ def capture_response(response)
334
+ captured = Thread.current[:coolhand_stream_buffer] || response&.body
335
+ # captured is a String, or an Integer byte count when a streamed body overflowed the cap.
336
+ [response.respond_to?(:code) ? response.code.to_i : nil, response_body_for_log(captured, response)]
337
+ rescue StandardError => e
338
+ Coolhand.log "❌ Error capturing response: #{e.message}"
339
+ [nil, nil]
340
+ end
341
+
342
+ # Builds the logged response body from either an Integer (streamed body that overflowed
343
+ # the cap, see buffer_stream_chunk) or the body String/nil. An oversized String is
344
+ # replaced by a marker before the dup/JSON.parse so we don't copy or parse it.
345
+ def response_body_for_log(body, response)
346
+ size = body.is_a?(Integer) ? body : body.to_s.bytesize
347
+ return body_too_large_marker(response, size) if body.is_a?(Integer) || over_capture_limit?(size)
348
+
349
+ body = body.dup.force_encoding("UTF-8") if body.is_a?(String)
350
+ BaseInterceptor.parse_json(body)
351
+ end
352
+
195
353
  def binary_upload?(req)
196
354
  content_type = req.respond_to?(:content_type) ? req.content_type : nil
197
355
  content_type && !content_type.match?(/json/i)
198
356
  end
199
357
 
200
358
  def cap_and_parse(content, req)
201
- max_bytes = Coolhand.configuration.max_captured_body_bytes
202
- if max_bytes && content.bytesize > max_bytes
203
- return skipped_capture_marker(req, "body_too_large", size_bytes: content.bytesize, max_bytes: max_bytes)
204
- end
359
+ return body_too_large_marker(req, content.bytesize) if over_capture_limit?(content.bytesize)
205
360
 
206
361
  BaseInterceptor.parse_json(content)
207
362
  end
@@ -227,10 +382,11 @@ module Coolhand
227
382
  uri = safe_parse(url)
228
383
  return false unless uri&.host
229
384
 
230
- return false if excluded_by_pattern?(uri)
385
+ raw_path = uri.path.to_s
386
+ path = normalize_path(raw_path)
387
+ return false if excluded_by_pattern?(uri, raw_path, path)
231
388
 
232
389
  host = uri.host.downcase.chomp(".")
233
- path = uri.path.to_s
234
390
  addresses = Coolhand.configuration.intercept_addresses
235
391
  authorities = [host, "#{host}:#{uri.port}"]
236
392
  return true if addresses.any? { |a| authorities.any? { |authority| address_matches?(authority, path, a) } }
@@ -251,12 +407,13 @@ module Coolhand
251
407
  nil
252
408
  end
253
409
 
254
- def excluded_by_pattern?(uri)
410
+ # The deny-list fails closed: a pattern that matches either the raw or the normalized path
411
+ # excludes the request, so normalization can only add exclusions, never lift one.
412
+ def excluded_by_pattern?(uri, raw_path, path)
255
413
  patterns = Coolhand.configuration.exclude_api_patterns
256
414
  return false if patterns.nil? || patterns.empty?
257
415
 
258
- path = uri.path.to_s
259
- matched = patterns.find { |pattern| path.include?(pattern) }
416
+ matched = patterns.find { |pattern| path.include?(pattern) || raw_path.include?(pattern) }
260
417
  if matched && Coolhand.configuration.debug_mode
261
418
  Coolhand.log "🚫 Skipping capture for #{BaseInterceptor.sanitize_url(uri.to_s)} " \
262
419
  "(matched exclude_api_pattern: \"#{matched}\")"
@@ -264,6 +421,11 @@ module Coolhand
264
421
  !!matched
265
422
  end
266
423
 
424
+ # See PathNormalizer: used for matching only, never for the URL that is sent or logged.
425
+ def normalize_path(path)
426
+ PathNormalizer.normalize(path)
427
+ end
428
+
267
429
  # intercept_path_patterns only ever applies to googleapis.com hosts, and only when the
268
430
  # user still wants Google API traffic intercepted at all — otherwise overriding
269
431
  # intercept_addresses to exclude Google hosts wouldn't actually stop Google API capture.