coolhand 0.5.1 → 0.6.0
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/AGENTS.harness.md +139 -0
- data/CHANGELOG.md +31 -0
- data/README.md +30 -1
- data/docs/configuration.md +40 -0
- data/docs/openai.md +31 -0
- data/docs/template-search.md +218 -0
- data/lib/coolhand/api_service.rb +49 -15
- data/lib/coolhand/base_interceptor.rb +27 -17
- data/lib/coolhand/configuration.rb +37 -4
- data/lib/coolhand/default_exclude_api_patterns.yml +3 -2
- data/lib/coolhand/default_intercept_addresses.yml +10 -5
- data/lib/coolhand/default_intercept_path_patterns.yml +12 -0
- data/lib/coolhand/errors.rb +19 -0
- data/lib/coolhand/logger_service.rb +1 -1
- data/lib/coolhand/net_http_interceptor.rb +127 -20
- data/lib/coolhand/open_ai/batch_result_processor.rb +1 -1
- data/lib/coolhand/open_ai/webhook_id_store.rb +45 -0
- data/lib/coolhand/open_ai/webhook_validator.rb +44 -10
- data/lib/coolhand/pagination.rb +82 -0
- data/lib/coolhand/read_requests.rb +77 -0
- data/lib/coolhand/template_service.rb +76 -0
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand.rb +9 -4
- metadata +10 -2
data/lib/coolhand/api_service.rb
CHANGED
|
@@ -4,9 +4,19 @@ require "net/http"
|
|
|
4
4
|
require "uri"
|
|
5
5
|
require "json"
|
|
6
6
|
require_relative "collector"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
require_relative "read_requests"
|
|
7
9
|
|
|
8
10
|
module Coolhand
|
|
9
11
|
class ApiService
|
|
12
|
+
# The GET half of this class. See Coolhand::ReadRequests for why reads raise where writes
|
|
13
|
+
# log and return nil.
|
|
14
|
+
include ReadRequests
|
|
15
|
+
|
|
16
|
+
# Caps how much of a failed response body is interpolated into a log line or an exception
|
|
17
|
+
# message. Without it an oversized body from a proxy or gateway becomes the message.
|
|
18
|
+
ERROR_BODY_LIMIT = 2000
|
|
19
|
+
|
|
10
20
|
attr_reader :api_endpoint
|
|
11
21
|
|
|
12
22
|
def initialize(endpoint = "v2/llm_request_logs")
|
|
@@ -77,12 +87,7 @@ module Coolhand
|
|
|
77
87
|
http.read_timeout = 5
|
|
78
88
|
|
|
79
89
|
request = Net::HTTP::Post.new(uri.request_uri)
|
|
80
|
-
|
|
81
|
-
headers.each do |key, value|
|
|
82
|
-
# Ensure header values are UTF-8 encoded
|
|
83
|
-
encoded_value = value.is_a?(String) ? value.dup.force_encoding("UTF-8") : value
|
|
84
|
-
request[key] = encoded_value
|
|
85
|
-
end
|
|
90
|
+
apply_headers(request, create_request_options(payload))
|
|
86
91
|
|
|
87
92
|
# Clean payload and ensure UTF-8 encoding before JSON generation
|
|
88
93
|
cleaned_payload = sanitize_payload_for_json(payload)
|
|
@@ -105,14 +110,7 @@ module Coolhand
|
|
|
105
110
|
log success_message
|
|
106
111
|
result
|
|
107
112
|
else
|
|
108
|
-
|
|
109
|
-
# Only show first part of HTML error pages
|
|
110
|
-
error_msg = if body&.include?("<!DOCTYPE html>")
|
|
111
|
-
"#{body[0..200]}... [HTML error page truncated]"
|
|
112
|
-
else
|
|
113
|
-
body
|
|
114
|
-
end
|
|
115
|
-
log "❌ Request failed: #{response.code} - #{error_msg}"
|
|
113
|
+
log "❌ Request failed: #{response.code} - #{format_error_body(response.body)}"
|
|
116
114
|
nil
|
|
117
115
|
end
|
|
118
116
|
rescue StandardError => e
|
|
@@ -210,6 +208,26 @@ module Coolhand
|
|
|
210
208
|
|
|
211
209
|
private
|
|
212
210
|
|
|
211
|
+
# Shared by the write path's failure log and the read path's raised message, so a large
|
|
212
|
+
# response body never dumps a whole document into either. An HTML error page (a proxy's 502,
|
|
213
|
+
# say) is cut short hard; anything else keeps enough to diagnose from and no more.
|
|
214
|
+
def format_error_body(body)
|
|
215
|
+
return nil if body.nil?
|
|
216
|
+
|
|
217
|
+
text = body.dup.force_encoding("UTF-8")
|
|
218
|
+
return "#{text[0..200]}... [HTML error page truncated]" if text.include?("<!DOCTYPE html>")
|
|
219
|
+
return text if text.length <= ERROR_BODY_LIMIT
|
|
220
|
+
|
|
221
|
+
"#{text[0, ERROR_BODY_LIMIT]}... [truncated]"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def apply_headers(request, headers)
|
|
225
|
+
headers.each do |key, value|
|
|
226
|
+
# Net::HTTP rejects header values that are not UTF-8.
|
|
227
|
+
request[key] = value.is_a?(String) ? value.dup.force_encoding("UTF-8") : value
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
213
231
|
def missing_api_key?
|
|
214
232
|
return false if Coolhand.required_field?(api_key)
|
|
215
233
|
|
|
@@ -281,8 +299,24 @@ module Coolhand
|
|
|
281
299
|
return if silent
|
|
282
300
|
|
|
283
301
|
puts "\n🎉 LOGGING OpenAI API Call #{@api_endpoint}"
|
|
284
|
-
|
|
302
|
+
|
|
303
|
+
if debug_mode?
|
|
304
|
+
puts captured_data
|
|
305
|
+
else
|
|
306
|
+
puts request_body_summary(captured_data)
|
|
307
|
+
end
|
|
308
|
+
|
|
285
309
|
puts "📤 Sending to: #{@api_endpoint}"
|
|
286
310
|
end
|
|
311
|
+
|
|
312
|
+
def request_body_summary(captured_data)
|
|
313
|
+
return "captured_data: (unavailable)" unless captured_data.is_a?(Hash)
|
|
314
|
+
|
|
315
|
+
id = captured_data[:id] || captured_data["id"] || "N/A"
|
|
316
|
+
body = captured_data[:request_body] || captured_data["request_body"]
|
|
317
|
+
"id: #{id}, request_body: #{body.to_json.bytesize} bytes"
|
|
318
|
+
rescue StandardError
|
|
319
|
+
"captured_data: (unavailable)"
|
|
320
|
+
end
|
|
287
321
|
end
|
|
288
322
|
end
|
|
@@ -23,7 +23,9 @@ module Coolhand
|
|
|
23
23
|
begin
|
|
24
24
|
headers.to_hash.transform_keys(&:to_s).transform_values { |v| normalize_header_value(v) }
|
|
25
25
|
rescue StandardError
|
|
26
|
-
#
|
|
26
|
+
# Deliberately fails closed to an empty hash rather than trying each_header/each on
|
|
27
|
+
# this object next — an object whose own to_hash raises is untrustworthy enough that
|
|
28
|
+
# guessing at another enumeration strategy isn't worth the risk of a second failure.
|
|
27
29
|
nil
|
|
28
30
|
end
|
|
29
31
|
elsif headers.respond_to?(:each_header)
|
|
@@ -80,25 +82,33 @@ module Coolhand
|
|
|
80
82
|
|
|
81
83
|
def sanitize_url(url)
|
|
82
84
|
uri = URI.parse(url)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
[n, "[REDACTED]"]
|
|
91
|
-
else
|
|
92
|
-
[n, v]
|
|
93
|
-
end
|
|
85
|
+
modified = false
|
|
86
|
+
|
|
87
|
+
if uri.userinfo
|
|
88
|
+
# URI userinfo syntax disallows "[" / "]", so this can't reuse the [REDACTED]
|
|
89
|
+
# placeholder used elsewhere.
|
|
90
|
+
uri.userinfo = "REDACTED"
|
|
91
|
+
modified = true
|
|
94
92
|
end
|
|
95
93
|
|
|
96
|
-
if
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
94
|
+
if uri.query
|
|
95
|
+
params = URI.decode_www_form(uri.query)
|
|
96
|
+
redacted_query = false
|
|
97
|
+
params.map! do |n, v|
|
|
98
|
+
if n.match?(SENSITIVE_QUERY_PARAM_PATTERN)
|
|
99
|
+
redacted_query = true
|
|
100
|
+
[n, "[REDACTED]"]
|
|
101
|
+
else
|
|
102
|
+
[n, v]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
if redacted_query
|
|
106
|
+
uri.query = URI.encode_www_form(params)
|
|
107
|
+
modified = true
|
|
108
|
+
end
|
|
101
109
|
end
|
|
110
|
+
|
|
111
|
+
modified ? uri.to_s : url
|
|
102
112
|
rescue URI::InvalidURIError
|
|
103
113
|
url
|
|
104
114
|
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
require "uri"
|
|
5
5
|
|
|
6
|
+
require_relative "open_ai/webhook_id_store"
|
|
7
|
+
|
|
6
8
|
module Coolhand
|
|
7
9
|
# Handles all configuration settings for the gem.
|
|
8
10
|
class Configuration
|
|
@@ -14,11 +16,21 @@ module Coolhand
|
|
|
14
16
|
File.join(__dir__, "default_intercept_addresses.yml")
|
|
15
17
|
).freeze
|
|
16
18
|
|
|
19
|
+
DEFAULT_INTERCEPT_PATH_PATTERNS = YAML.safe_load_file(
|
|
20
|
+
File.join(__dir__, "default_intercept_path_patterns.yml")
|
|
21
|
+
).freeze
|
|
22
|
+
|
|
17
23
|
BASE_URL_ERROR_MSG = "base_url must use https:// (or http://localhost / http://127.0.0.1 for local dev)"
|
|
18
24
|
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
# 1 MB — generous for real chat/completion payloads, small enough to stop
|
|
27
|
+
# a multi-MB file upload from being logged in full when its content-type
|
|
28
|
+
# happens to look JSON-ish (or is unset).
|
|
29
|
+
DEFAULT_MAX_CAPTURED_BODY_BYTES = 1_000_000
|
|
30
|
+
|
|
31
|
+
attr_accessor :api_key, :environment, :silent, :debug_mode, :capture, :exclude_api_patterns, :enabled,
|
|
32
|
+
:max_captured_body_bytes, :webhook_replay_tolerance_seconds, :webhook_id_store
|
|
33
|
+
attr_reader :intercept_addresses, :intercept_path_patterns, :base_url
|
|
22
34
|
|
|
23
35
|
def initialize
|
|
24
36
|
# Set defaults
|
|
@@ -26,20 +38,41 @@ module Coolhand
|
|
|
26
38
|
@api_key = nil
|
|
27
39
|
@silent = false
|
|
28
40
|
@intercept_addresses = DEFAULT_INTERCEPT_ADDRESSES.dup
|
|
41
|
+
@intercept_path_patterns = DEFAULT_INTERCEPT_PATH_PATTERNS.dup
|
|
29
42
|
self.base_url = "https://coolhandlabs.com/api"
|
|
30
43
|
@debug_mode = false
|
|
31
44
|
@capture = true
|
|
32
45
|
@exclude_api_patterns = DEFAULT_EXCLUDE_API_PATTERNS.dup
|
|
33
46
|
@enabled = true
|
|
47
|
+
@max_captured_body_bytes = DEFAULT_MAX_CAPTURED_BODY_BYTES
|
|
48
|
+
@webhook_replay_tolerance_seconds = 300
|
|
49
|
+
@webhook_id_store = Coolhand::OpenAi::WebhookIdStore.new
|
|
34
50
|
end
|
|
35
51
|
|
|
36
|
-
#
|
|
52
|
+
# intercept_addresses is a required allow-list: NetHttpInterceptor#intercept? only
|
|
53
|
+
# captures a request whose URL matches an entry here, so an empty list would mean
|
|
54
|
+
# "never capture anything" (validate! deliberately rejects that). Unlike
|
|
55
|
+
# exclude_api_patterns, `= []` is not a supported way to disable it — use
|
|
56
|
+
# config.enabled = false or config.capture = false to disable capture entirely.
|
|
57
|
+
# nil/empty here is treated as "leave the current value alone" rather than cleared.
|
|
37
58
|
def intercept_addresses=(value)
|
|
38
|
-
|
|
59
|
+
if value.nil? || (value.is_a?(Array) && value.empty?)
|
|
60
|
+
Coolhand.log "⚠️ Coolhand: intercept_addresses = #{value.inspect} is ignored " \
|
|
61
|
+
"(would disable capture entirely) — keeping the current value. " \
|
|
62
|
+
"Use config.enabled = false or config.capture = false to disable capture."
|
|
63
|
+
return
|
|
64
|
+
end
|
|
39
65
|
|
|
40
66
|
@intercept_addresses = value.is_a?(Array) ? value : [value]
|
|
41
67
|
end
|
|
42
68
|
|
|
69
|
+
# Custom setter that preserves defaults when nil/empty array is provided
|
|
70
|
+
def intercept_path_patterns=(value)
|
|
71
|
+
return if value.nil? || (value.is_a?(Array) && value.empty?)
|
|
72
|
+
|
|
73
|
+
@intercept_path_patterns = value.is_a?(Array) ? value : [value]
|
|
74
|
+
end
|
|
75
|
+
|
|
43
76
|
def base_url=(value)
|
|
44
77
|
stripped = value&.sub(%r{/+\z}, "")
|
|
45
78
|
raise Error, BASE_URL_ERROR_MSG unless stripped.nil? || valid_base_url?(stripped)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Coolhand default exclude API patterns
|
|
2
|
-
# These substrings are matched against request
|
|
3
|
-
#
|
|
2
|
+
# These substrings are matched against the request's path (not the full URL or
|
|
3
|
+
# query string). A request whose path matches is skipped and not forwarded as
|
|
4
|
+
# an llm_request_log, regardless of whether it would otherwise match intercept_addresses.
|
|
4
5
|
#
|
|
5
6
|
# Users can extend defaults: c.exclude_api_patterns << "/myOperationalPath/"
|
|
6
7
|
# Users can override entirely: c.exclude_api_patterns = ["/only_this/"]
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
# Coolhand default intercept addresses
|
|
2
|
-
# These
|
|
3
|
-
#
|
|
2
|
+
# These are matched against the request's parsed host (exact match, or a
|
|
3
|
+
# dot-boundary suffix match, case-insensitive) to decide whether a request
|
|
4
|
+
# should be captured and forwarded as an llm_request_log. A single "*" in an
|
|
5
|
+
# entry matches exactly one host label — e.g. "bedrock-runtime.*.amazonaws.com"
|
|
6
|
+
# matches "bedrock-runtime.us-east-1.amazonaws.com" but nothing else.
|
|
4
7
|
#
|
|
5
8
|
# Users can extend defaults: c.intercept_addresses << "my.custom.api.com"
|
|
6
9
|
# Users can override entirely: c.intercept_addresses = ["only.this.com"]
|
|
10
|
+
# This list cannot be emptied — it's required (unlike exclude_api_patterns, which
|
|
11
|
+
# can be disabled with `= []`). To disable capture entirely, use
|
|
12
|
+
# c.enabled = false or c.capture = false instead.
|
|
7
13
|
|
|
8
14
|
- "api.openai.com"
|
|
9
15
|
- "api.anthropic.com"
|
|
@@ -11,9 +17,8 @@
|
|
|
11
17
|
- "generativelanguage.googleapis.com"
|
|
12
18
|
- "models.github.ai"
|
|
13
19
|
- "models.inference.ai.azure.com"
|
|
14
|
-
- ":generateContent"
|
|
15
|
-
- ":streamGenerateContent"
|
|
16
20
|
- "aiplatform.googleapis.com"
|
|
17
21
|
- "gateway.ai.cloudflare.com"
|
|
18
|
-
- "bedrock-runtime"
|
|
22
|
+
- "bedrock-runtime.*.amazonaws.com"
|
|
19
23
|
- "openrouter.ai"
|
|
24
|
+
- "opencode.ai"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Colon-action path suffixes from Google's API Discovery convention
|
|
2
|
+
# (e.g. ".../v1beta/models/gemini-pro:generateContent"). These have no host
|
|
3
|
+
# component of their own, so — unlike intercept_addresses — they are only
|
|
4
|
+
# ever matched against the *path* of requests whose host is already
|
|
5
|
+
# googleapis.com or a googleapis.com subdomain. They can never match on
|
|
6
|
+
# their own against an unrelated/attacker-controlled host.
|
|
7
|
+
#
|
|
8
|
+
# Users can extend defaults: c.intercept_path_patterns << ":myAction"
|
|
9
|
+
# Users can override entirely: c.intercept_path_patterns = [":onlyThis"]
|
|
10
|
+
|
|
11
|
+
- ":generateContent"
|
|
12
|
+
- ":streamGenerateContent"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised by the gem's read methods when the Coolhand API answers with a non-2xx status.
|
|
7
|
+
#
|
|
8
|
+
# `status` is carried so callers can branch on it (404 vs retryable 504) without matching the
|
|
9
|
+
# message text.
|
|
10
|
+
class HttpError < Error
|
|
11
|
+
attr_reader :status, :body
|
|
12
|
+
|
|
13
|
+
def initialize(message, status:, body: nil)
|
|
14
|
+
super(message)
|
|
15
|
+
@status = status
|
|
16
|
+
@body = body
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -44,7 +44,7 @@ module Coolhand
|
|
|
44
44
|
headers: sanitize_headers(headers),
|
|
45
45
|
request_body: clean_webhook_body(webhook_body, source),
|
|
46
46
|
response_body: options[:response_body],
|
|
47
|
-
response_headers: options[:response_headers],
|
|
47
|
+
response_headers: options[:response_headers] && sanitize_headers(options[:response_headers]),
|
|
48
48
|
status_code: options[:status_code] || 200,
|
|
49
49
|
source: "#{source}_webhook"
|
|
50
50
|
}.merge(options.slice(:metadata, :conversation_id, :agent_id))
|
|
@@ -24,27 +24,69 @@ module Coolhand
|
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
@patch_mutex = Mutex.new
|
|
28
|
+
@patch_count = 0
|
|
29
|
+
@patched = false
|
|
30
|
+
|
|
31
|
+
# patch!/unpatch! are reference-counted so concurrent/nested callers (e.g. overlapping
|
|
32
|
+
# Coolhand.capture blocks across threads) compose safely — the interceptor only actually
|
|
33
|
+
# unpatches once every outstanding caller has released it. Coolhand.configure's patch! is
|
|
34
|
+
# never balanced by an unpatch!, so it permanently holds the count at >=1 for the process
|
|
35
|
+
# lifetime once the gem is enabled; that's intentional, not a leak.
|
|
36
|
+
#
|
|
37
|
+
# Unlike before, patch! is no longer idempotent on its own — every call must be matched by
|
|
38
|
+
# exactly one unpatch! to release it. A host app that calls Coolhand.configure more than once
|
|
39
|
+
# (e.g. a reloader re-running an initializer) will hold an extra, permanent reference each
|
|
40
|
+
# time rather than no-op'ing; harmless (interception simply stays on), but worth knowing.
|
|
27
41
|
def self.patch!
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
@patch_mutex.synchronize do
|
|
43
|
+
@patch_count += 1
|
|
44
|
+
next if @patched
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
Net::HTTP.prepend(self)
|
|
48
|
+
Net::HTTPResponse.prepend(ResponseInterceptor)
|
|
49
|
+
|
|
50
|
+
@patched = true
|
|
51
|
+
Coolhand.log "🔗 Net::HTTP interceptor patched"
|
|
52
|
+
rescue StandardError
|
|
53
|
+
# Roll back this call's hold — it never actually took effect, so it must not count
|
|
54
|
+
# toward the refcount or a legitimate later unpatch! would underflow against it. This
|
|
55
|
+
# branch only runs when @patched was false on entry (see `next if @patched` above), so
|
|
56
|
+
# forcing it back to false here is correct regardless of which line above raised —
|
|
57
|
+
# including a failure in the log call itself, after prepend already succeeded.
|
|
58
|
+
@patch_count -= 1
|
|
59
|
+
@patched = false
|
|
60
|
+
raise
|
|
61
|
+
end
|
|
62
|
+
end
|
|
35
63
|
end
|
|
36
64
|
|
|
37
65
|
def self.unpatch!
|
|
38
66
|
# NOTE: With prepend, there's no clean way to unpatch
|
|
39
67
|
# We'll mark it as unpatched so it can be re-patched
|
|
40
|
-
@
|
|
41
|
-
|
|
68
|
+
@patch_mutex.synchronize do
|
|
69
|
+
@patch_count -= 1 if @patch_count.positive?
|
|
70
|
+
next if @patch_count.positive? || !@patched
|
|
71
|
+
|
|
72
|
+
@patched = false
|
|
73
|
+
Coolhand.log "🔌 Faraday monitoring disabled ..."
|
|
74
|
+
end
|
|
42
75
|
end
|
|
43
76
|
|
|
44
77
|
def self.patched?
|
|
45
78
|
@patched
|
|
46
79
|
end
|
|
47
80
|
|
|
81
|
+
# Testing-only: force a clean slate. Real callers should only ever use balanced
|
|
82
|
+
# patch!/unpatch! pairs.
|
|
83
|
+
def self.reset!
|
|
84
|
+
@patch_mutex.synchronize do
|
|
85
|
+
@patch_count = 0
|
|
86
|
+
@patched = false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
48
90
|
def request(req, body = nil, &block)
|
|
49
91
|
return super unless NetHttpInterceptor.patched?
|
|
50
92
|
|
|
@@ -131,16 +173,41 @@ module Coolhand
|
|
|
131
173
|
end
|
|
132
174
|
|
|
133
175
|
def capture_request_body(req, body)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
176
|
+
# Check content-type before touching body_stream at all — for a binary
|
|
177
|
+
# upload (multipart/form-data, audio/*, etc.) this avoids reading the
|
|
178
|
+
# stream into memory a second time just to build a log entry no one
|
|
179
|
+
# can read anyway.
|
|
180
|
+
return skipped_capture_marker(req, "non_json_content_type") if binary_upload?(req)
|
|
181
|
+
|
|
182
|
+
content = body || req.body
|
|
183
|
+
if content.nil? && req.respond_to?(:body_stream) && req.body_stream
|
|
138
184
|
content = req.body_stream.read
|
|
139
185
|
req.body_stream = StringIO.new(content)
|
|
140
|
-
return parse_json(content)
|
|
141
186
|
end
|
|
187
|
+
return nil if content.nil?
|
|
142
188
|
|
|
143
|
-
|
|
189
|
+
cap_and_parse(content, req)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def binary_upload?(req)
|
|
193
|
+
content_type = req.respond_to?(:content_type) ? req.content_type : nil
|
|
194
|
+
content_type && !content_type.match?(/json/i)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def cap_and_parse(content, req)
|
|
198
|
+
max_bytes = Coolhand.configuration.max_captured_body_bytes
|
|
199
|
+
if max_bytes && content.bytesize > max_bytes
|
|
200
|
+
return skipped_capture_marker(req, "body_too_large", size_bytes: content.bytesize, max_bytes: max_bytes)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
parse_json(content)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def skipped_capture_marker(req, reason, extra = {})
|
|
207
|
+
marker = { "_coolhand_capture_skipped" => reason }.merge(extra.transform_keys(&:to_s))
|
|
208
|
+
content_type = req.respond_to?(:content_type) ? req.content_type : nil
|
|
209
|
+
marker["content_type"] = content_type if content_type
|
|
210
|
+
marker
|
|
144
211
|
end
|
|
145
212
|
|
|
146
213
|
def extract_status_from_exception(e)
|
|
@@ -153,22 +220,62 @@ module Coolhand
|
|
|
153
220
|
|
|
154
221
|
def intercept?(url)
|
|
155
222
|
return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
|
|
156
|
-
return false if excluded_by_pattern?(url)
|
|
157
223
|
|
|
158
|
-
|
|
224
|
+
uri = safe_parse(url)
|
|
225
|
+
return false unless uri&.host
|
|
226
|
+
|
|
227
|
+
return false if excluded_by_pattern?(uri)
|
|
228
|
+
|
|
229
|
+
host = uri.host.downcase
|
|
230
|
+
addresses = Coolhand.configuration.intercept_addresses
|
|
231
|
+
return true if addresses.any? { |a| host_matches?(host, a) }
|
|
232
|
+
|
|
233
|
+
return false unless google_api_host_configured?(addresses)
|
|
234
|
+
return false unless host == "googleapis.com" || host.end_with?(".googleapis.com")
|
|
235
|
+
|
|
236
|
+
path = uri.path.to_s
|
|
237
|
+
Coolhand.configuration.intercept_path_patterns.any? { |p| path.include?(p) }
|
|
159
238
|
end
|
|
160
239
|
|
|
161
|
-
def excluded_by_pattern?(
|
|
240
|
+
def excluded_by_pattern?(uri)
|
|
162
241
|
patterns = Coolhand.configuration.exclude_api_patterns
|
|
163
242
|
return false if patterns.nil? || patterns.empty?
|
|
164
243
|
|
|
165
|
-
|
|
244
|
+
path = uri.path.to_s
|
|
245
|
+
matched = patterns.find { |pattern| path.include?(pattern) }
|
|
166
246
|
if matched && Coolhand.configuration.debug_mode
|
|
167
|
-
Coolhand.log "🚫 Skipping capture for #{sanitize_url(
|
|
247
|
+
Coolhand.log "🚫 Skipping capture for #{sanitize_url(uri.to_s)} (matched exclude_api_pattern: \"#{matched}\")"
|
|
168
248
|
end
|
|
169
249
|
!!matched
|
|
170
250
|
end
|
|
171
251
|
|
|
252
|
+
# intercept_path_patterns only ever applies to googleapis.com hosts, and only when the
|
|
253
|
+
# user still wants Google API traffic intercepted at all — otherwise overriding
|
|
254
|
+
# intercept_addresses to exclude Google hosts wouldn't actually stop Google API capture.
|
|
255
|
+
def google_api_host_configured?(addresses)
|
|
256
|
+
addresses.any? do |a|
|
|
257
|
+
a = a.to_s.downcase
|
|
258
|
+
a == "googleapis.com" || a.end_with?(".googleapis.com")
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Host-boundary match: exact, or a dot-delimited suffix (case-insensitive).
|
|
263
|
+
# A single "*" in `pattern` matches exactly one host label, e.g.
|
|
264
|
+
# "bedrock-runtime.*.amazonaws.com" matches "bedrock-runtime.us-east-1.amazonaws.com".
|
|
265
|
+
def host_matches?(host, pattern)
|
|
266
|
+
pattern = pattern.to_s.downcase
|
|
267
|
+
return host == pattern || host.end_with?(".#{pattern}") unless pattern.include?("*")
|
|
268
|
+
|
|
269
|
+
regex = /\A#{pattern.split('*', -1).map { |part| Regexp.escape(part) }.join('[^.]+')}\z/
|
|
270
|
+
!!(host =~ regex)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def safe_parse(url)
|
|
274
|
+
URI.parse(url)
|
|
275
|
+
rescue URI::InvalidURIError
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
|
|
172
279
|
def build_url_for_request(http, req)
|
|
173
280
|
return req.path if %r{\Ahttps?://}.match?(req.path)
|
|
174
281
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
module OpenAi
|
|
5
|
+
# Thread-safe, in-memory, TTL-bounded store used to detect replayed
|
|
6
|
+
# `webhook-id` values. This is the default for
|
|
7
|
+
# `Coolhand.configuration.webhook_id_store` and only dedupes within a
|
|
8
|
+
# single process - deployments running multiple processes/dynos should
|
|
9
|
+
# supply their own store (e.g. backed by Rails.cache) via configuration.
|
|
10
|
+
class WebhookIdStore
|
|
11
|
+
def initialize
|
|
12
|
+
@entries = {}
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Atomically checks-and-records `id` in a single critical section, so
|
|
17
|
+
# two concurrent replays of the same id can't both observe "unseen"
|
|
18
|
+
# before either records it. Returns true the first time `id` is
|
|
19
|
+
# claimed, false if it was already claimed within its TTL.
|
|
20
|
+
def claim!(id, ttl_seconds)
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
prune
|
|
23
|
+
return false if @entries.key?(id)
|
|
24
|
+
|
|
25
|
+
@entries[id] = Time.now.to_i + ttl_seconds
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def seen?(id)
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
prune
|
|
33
|
+
@entries.key?(id)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def prune
|
|
40
|
+
now = Time.now.to_i
|
|
41
|
+
@entries.delete_if { |_id, expires_at| expires_at < now }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|