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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +28 -7
- data/docs/anthropic.md +28 -83
- data/docs/configuration.md +31 -5
- data/docs/elevenlabs.md +1 -1
- data/docs/help-center.md +120 -0
- data/docs/openai.md +63 -4
- data/docs/vertex.md +1 -0
- data/lib/coolhand/base_interceptor.rb +137 -37
- data/lib/coolhand/batch_body_cap.rb +36 -0
- data/lib/coolhand/body_redactor.rb +111 -0
- data/lib/coolhand/body_stream_tap.rb +121 -0
- data/lib/coolhand/default_intercept_addresses.yml +8 -0
- data/lib/coolhand/help_center_service.rb +69 -0
- data/lib/coolhand/logger_service.rb +55 -6
- data/lib/coolhand/net_http_interceptor.rb +190 -28
- data/lib/coolhand/open_ai/batch_result_processor.rb +139 -53
- data/lib/coolhand/open_ai/webhook_id_store.rb +7 -0
- data/lib/coolhand/open_ai/webhook_validator.rb +43 -28
- data/lib/coolhand/path_normalizer.rb +36 -0
- data/lib/coolhand/read_requests.rb +11 -2
- data/lib/coolhand/string_body_redactor.rb +83 -0
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/vertex/batch_result_processor.rb +2 -2
- data/lib/coolhand/webhook_interceptor.rb +23 -0
- data/lib/coolhand.rb +10 -0
- metadata +9 -2
|
@@ -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
|
-
|
|
63
|
-
next unless key
|
|
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 =
|
|
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
|
|
11
|
-
# otherwise every block-form read_body in
|
|
12
|
-
# ones on responses this gem never
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
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
|
-
|
|
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] =
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|