coolhand 0.7.0 → 0.8.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 +49 -14
- data/CHANGELOG.md +22 -1
- data/README.md +34 -1
- data/docs/configuration.md +19 -0
- data/docs/feedback-links.md +48 -0
- data/docs/log-search.md +102 -0
- data/docs/openai.md +1 -1
- data/docs/template-search.md +46 -4
- data/docs/vertex.md +1 -0
- data/docs/workload-search.md +116 -0
- data/lib/coolhand/api_service.rb +4 -3
- data/lib/coolhand/base_interceptor.rb +4 -4
- data/lib/coolhand/configuration.rb +2 -1
- data/lib/coolhand/errors.rb +2 -1
- data/lib/coolhand/log_queue.rb +179 -0
- data/lib/coolhand/log_service.rb +67 -0
- data/lib/coolhand/net_http_interceptor.rb +142 -137
- data/lib/coolhand/open_ai/batch_result_processor.rb +30 -29
- data/lib/coolhand/optimization_feedback_link_service.rb +85 -0
- data/lib/coolhand/read_query.rb +61 -0
- data/lib/coolhand/read_requests.rb +26 -13
- data/lib/coolhand/template_service.rb +13 -29
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/vertex/batch_result_processor.rb +9 -1
- data/lib/coolhand/workload_service.rb +46 -0
- data/lib/coolhand/write_requests.rb +47 -0
- data/lib/coolhand.rb +29 -0
- metadata +11 -2
|
@@ -169,10 +169,10 @@ module Coolhand
|
|
|
169
169
|
|
|
170
170
|
request_data = { raw_request: raw_request }
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
172
|
+
LogQueue.submit do
|
|
173
|
+
Coolhand::ApiService.new.send_llm_request_log(request_data)
|
|
174
|
+
Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
|
|
175
|
+
end
|
|
176
176
|
rescue StandardError => e
|
|
177
177
|
Coolhand.log "❌ Error sending complete request log: #{e.message}"
|
|
178
178
|
end
|
|
@@ -29,7 +29,7 @@ module Coolhand
|
|
|
29
29
|
DEFAULT_MAX_CAPTURED_BODY_BYTES = 1_000_000
|
|
30
30
|
|
|
31
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
|
|
32
|
+
:max_captured_body_bytes, :webhook_replay_tolerance_seconds, :webhook_id_store, :async_logging
|
|
33
33
|
attr_reader :intercept_addresses, :intercept_path_patterns, :base_url
|
|
34
34
|
|
|
35
35
|
def initialize
|
|
@@ -44,6 +44,7 @@ module Coolhand
|
|
|
44
44
|
@capture = true
|
|
45
45
|
@exclude_api_patterns = DEFAULT_EXCLUDE_API_PATTERNS.dup
|
|
46
46
|
@enabled = true
|
|
47
|
+
@async_logging = true
|
|
47
48
|
@max_captured_body_bytes = DEFAULT_MAX_CAPTURED_BODY_BYTES
|
|
48
49
|
@webhook_replay_tolerance_seconds = 300
|
|
49
50
|
@webhook_id_store = Coolhand::OpenAi::WebhookIdStore.new
|
data/lib/coolhand/errors.rb
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module Coolhand
|
|
4
4
|
class Error < StandardError; end
|
|
5
5
|
|
|
6
|
-
# Raised by the gem's
|
|
6
|
+
# Raised by the gem's raising API methods (template reads, feedback links) when the Coolhand API
|
|
7
|
+
# answers with a non-2xx status.
|
|
7
8
|
#
|
|
8
9
|
# `status` is carried so callers can branch on it (404 vs retryable 504) without matching the
|
|
9
10
|
# message text.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Runs log-shipping work on background threads so a slow or unreachable Coolhand backend can't
|
|
5
|
+
# add latency to the host app's own LLM calls.
|
|
6
|
+
#
|
|
7
|
+
# Work is spread over two lanes, each with its own bounded queue and worker thread: `:default`
|
|
8
|
+
# for the one-log-per-intercepted-call sends, and `:batch` for whole-batch jobs (download +
|
|
9
|
+
# one send per item) that can run for minutes. Keeping them apart stops a long batch from
|
|
10
|
+
# delaying — or, once the queue fills, evicting — the real-time logs.
|
|
11
|
+
#
|
|
12
|
+
# Each queue is bounded (oldest job dropped when full), restarts itself in a forked child, and
|
|
13
|
+
# is flushed at process exit. Work runs inline instead when `config.async_logging` is false,
|
|
14
|
+
# when `config.debug_mode` is on (debug output should appear in order), or when the caller is
|
|
15
|
+
# already a worker thread (a queued job's own sends don't re-enter a queue).
|
|
16
|
+
module LogQueue
|
|
17
|
+
MAX_QUEUE_SIZE = 1000
|
|
18
|
+
DEFAULT_FLUSH_TIMEOUT = 5
|
|
19
|
+
|
|
20
|
+
# One bounded FIFO plus the thread that drains it.
|
|
21
|
+
class Lane
|
|
22
|
+
attr_reader :thread
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@wakeup = ConditionVariable.new
|
|
27
|
+
@idle = ConditionVariable.new
|
|
28
|
+
@jobs = []
|
|
29
|
+
@thread = nil
|
|
30
|
+
@pid = Process.pid
|
|
31
|
+
@busy = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def enqueue(job)
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
restart_after_fork
|
|
37
|
+
drop_oldest if @jobs.size >= MAX_QUEUE_SIZE
|
|
38
|
+
@jobs << job
|
|
39
|
+
ensure_worker
|
|
40
|
+
@wakeup.signal
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# True once every queued job has finished, false if the timeout elapsed first.
|
|
45
|
+
def flush(deadline)
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
# A forked child's copy of the queue belongs to the parent's worker; nothing to wait on.
|
|
48
|
+
return true if @pid != Process.pid
|
|
49
|
+
# A dead worker will never drain the queue, so don't wait on it.
|
|
50
|
+
return @jobs.empty? unless @thread&.alive?
|
|
51
|
+
|
|
52
|
+
while @jobs.any? || @busy
|
|
53
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
54
|
+
return false unless remaining.positive?
|
|
55
|
+
|
|
56
|
+
@idle.wait(@mutex, remaining)
|
|
57
|
+
end
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reset!
|
|
63
|
+
thread = nil
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
thread = @thread
|
|
66
|
+
@jobs.clear
|
|
67
|
+
@thread = nil
|
|
68
|
+
@busy = false
|
|
69
|
+
@wakeup.broadcast
|
|
70
|
+
@idle.broadcast
|
|
71
|
+
end
|
|
72
|
+
thread&.join(1)
|
|
73
|
+
thread&.kill if thread&.alive?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def drop_oldest
|
|
79
|
+
@jobs.shift
|
|
80
|
+
Coolhand.log "⚠️ Log queue full (#{MAX_QUEUE_SIZE}); dropping the oldest pending log"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A forked child inherits the queue's memory but not its worker thread.
|
|
84
|
+
def restart_after_fork
|
|
85
|
+
return if @pid == Process.pid
|
|
86
|
+
|
|
87
|
+
@pid = Process.pid
|
|
88
|
+
@jobs = []
|
|
89
|
+
@thread = nil
|
|
90
|
+
@busy = false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def ensure_worker
|
|
94
|
+
return if @thread&.alive?
|
|
95
|
+
|
|
96
|
+
@thread = Thread.new { work }
|
|
97
|
+
@thread.report_on_exception = false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def work
|
|
101
|
+
loop do
|
|
102
|
+
job = next_job
|
|
103
|
+
break unless job
|
|
104
|
+
|
|
105
|
+
begin
|
|
106
|
+
run(job)
|
|
107
|
+
ensure
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
@busy = false
|
|
110
|
+
@idle.broadcast
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def next_job
|
|
117
|
+
@mutex.synchronize do
|
|
118
|
+
@wakeup.wait(@mutex) while @jobs.empty? && @thread == Thread.current
|
|
119
|
+
return nil unless @thread == Thread.current
|
|
120
|
+
|
|
121
|
+
@busy = true
|
|
122
|
+
@jobs.shift
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def run(job)
|
|
127
|
+
job.call
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
Coolhand.log "❌ Error in background log job: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
LANES = { default: Lane.new, batch: Lane.new }.freeze
|
|
134
|
+
|
|
135
|
+
@at_exit_mutex = Mutex.new
|
|
136
|
+
@at_exit_registered = false
|
|
137
|
+
|
|
138
|
+
class << self
|
|
139
|
+
def submit(lane: :default, &job)
|
|
140
|
+
return yield unless async?
|
|
141
|
+
|
|
142
|
+
register_at_exit
|
|
143
|
+
LANES.fetch(lane).enqueue(job)
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Blocks until every queued job has finished or the timeout elapses. Returns true when
|
|
148
|
+
# every lane drained in time.
|
|
149
|
+
def flush(timeout = DEFAULT_FLUSH_TIMEOUT)
|
|
150
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
151
|
+
LANES.values.map { |lane| lane.flush(deadline) }.all?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Testing-only: discard queued work and stop the workers.
|
|
155
|
+
def reset!
|
|
156
|
+
LANES.each_value(&:reset!)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def async?
|
|
162
|
+
Coolhand.configuration.async_logging && !Coolhand.configuration.debug_mode && !worker_thread?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def worker_thread?
|
|
166
|
+
LANES.each_value.any? { |lane| lane.thread == Thread.current }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def register_at_exit
|
|
170
|
+
@at_exit_mutex.synchronize do
|
|
171
|
+
return if @at_exit_registered
|
|
172
|
+
|
|
173
|
+
@at_exit_registered = true
|
|
174
|
+
at_exit { flush }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
LogSearchResult = Struct.new(:logs, :pagination, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
# Reads logs back out of Coolhand. The write side is {LoggerService}. Requires the client's
|
|
12
|
+
# **private** key. See docs/log-search.md.
|
|
13
|
+
class LogService < ApiService
|
|
14
|
+
include ReadQuery
|
|
15
|
+
|
|
16
|
+
ERROR_NOUN = "Log"
|
|
17
|
+
BLANK_ID_MESSAGE = "get_log: id must be a non-empty log hashid"
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
super("v2/llm_request_logs")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Filters, cost ordering, error semantics and server behaviour: docs/log-search.md.
|
|
24
|
+
def search_logs(template_id: nil, workload_id: nil, system_prompt_contains: nil, user_prompt_contains: nil,
|
|
25
|
+
model: nil, source_api: nil, source_api_result: nil, source_application: nil, project_path: nil,
|
|
26
|
+
unmatched_only: nil, days_back: nil, since: nil, until: nil, min_cost: nil, order: nil,
|
|
27
|
+
include_prompts: nil, include_total: nil, sort: nil, page: nil, per: nil)
|
|
28
|
+
query = {
|
|
29
|
+
template_id: template_id,
|
|
30
|
+
workload_id: workload_id,
|
|
31
|
+
system_prompt_contains: system_prompt_contains,
|
|
32
|
+
user_prompt_contains: user_prompt_contains,
|
|
33
|
+
model: model,
|
|
34
|
+
source_api: source_api,
|
|
35
|
+
source_api_result: source_api_result,
|
|
36
|
+
source_application: source_application,
|
|
37
|
+
project_path: project_path,
|
|
38
|
+
unmatched_only: unmatched_only,
|
|
39
|
+
days_back: days_back,
|
|
40
|
+
**window_params(since, binding.local_variable_get(:until)),
|
|
41
|
+
min_cost: min_cost,
|
|
42
|
+
order: order,
|
|
43
|
+
include_prompts: include_prompts,
|
|
44
|
+
include_total: include_total,
|
|
45
|
+
"q[s]": sort,
|
|
46
|
+
page: page,
|
|
47
|
+
per: per
|
|
48
|
+
}.compact
|
|
49
|
+
|
|
50
|
+
logs, response = get_json_with_headers(list_url(query), ERROR_NOUN)
|
|
51
|
+
raise Error, "#{ERROR_NOUN} response was not a JSON array" unless logs.is_a?(Array)
|
|
52
|
+
|
|
53
|
+
LogSearchResult.new(
|
|
54
|
+
logs: logs,
|
|
55
|
+
pagination: Pagination.from_headers(response, items: logs, page: page, per: per)
|
|
56
|
+
).freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The full log, with `cost` and `cost_breakdown`. Both are nil when the log has no tokens or its
|
|
60
|
+
# model has no pricing.
|
|
61
|
+
def get_log(id, section: nil, max_chars: nil, search_query: nil, include_thinking: nil)
|
|
62
|
+
query = { section: section, max_chars: max_chars, search_query: search_query,
|
|
63
|
+
include_thinking: include_thinking }.compact
|
|
64
|
+
get_json(resource_url(id, BLANK_ID_MESSAGE, query), ERROR_NOUN)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -4,8 +4,6 @@ require "stringio"
|
|
|
4
4
|
|
|
5
5
|
module Coolhand
|
|
6
6
|
module NetHttpInterceptor
|
|
7
|
-
include BaseInterceptor
|
|
8
|
-
|
|
9
7
|
# Response streaming interceptor nested under NetHttpInterceptor
|
|
10
8
|
module ResponseInterceptor
|
|
11
9
|
def read_body(dest = nil, &block)
|
|
@@ -70,7 +68,7 @@ module Coolhand
|
|
|
70
68
|
next if @patch_count.positive? || !@patched
|
|
71
69
|
|
|
72
70
|
@patched = false
|
|
73
|
-
Coolhand.log "🔌
|
|
71
|
+
Coolhand.log "🔌 Net::HTTP interceptor unpatched (capture stopped)"
|
|
74
72
|
end
|
|
75
73
|
end
|
|
76
74
|
|
|
@@ -93,7 +91,7 @@ module Coolhand
|
|
|
93
91
|
active = (Thread.current[:coolhand_active_requests] ||= {}.compare_by_identity)
|
|
94
92
|
return super if active.key?(self)
|
|
95
93
|
|
|
96
|
-
url = capturable_url(req)
|
|
94
|
+
url = Helpers.capturable_url(self, req)
|
|
97
95
|
return super unless url
|
|
98
96
|
|
|
99
97
|
# Capture body before setting the guard — if this raises we skip logging cleanly
|
|
@@ -102,7 +100,7 @@ module Coolhand
|
|
|
102
100
|
# from being attempted — this gem must never be the reason the host
|
|
103
101
|
# app's actual LLM call doesn't happen.
|
|
104
102
|
captured_body = begin
|
|
105
|
-
capture_request_body(req, body)
|
|
103
|
+
Helpers.capture_request_body(req, body)
|
|
106
104
|
rescue StandardError => e
|
|
107
105
|
Coolhand.log "❌ Error capturing request body: #{e.message}"
|
|
108
106
|
nil
|
|
@@ -129,9 +127,9 @@ module Coolhand
|
|
|
129
127
|
body_content = Thread.current[:coolhand_stream_buffer] || response&.body
|
|
130
128
|
body_content = body_content.dup.force_encoding("UTF-8") if body_content.is_a?(String)
|
|
131
129
|
status_code = response.respond_to?(:code) ? response.code.to_i : nil
|
|
132
|
-
response_body = parse_json(body_content)
|
|
130
|
+
response_body = BaseInterceptor.parse_json(body_content)
|
|
133
131
|
rescue StandardError => e
|
|
134
|
-
status_code = extract_status_from_exception(e)
|
|
132
|
+
status_code = Helpers.extract_status_from_exception(e)
|
|
135
133
|
response_body = { "error" => { "class" => e.class.name, "message" => e.message } }
|
|
136
134
|
raise
|
|
137
135
|
ensure
|
|
@@ -141,13 +139,13 @@ module Coolhand
|
|
|
141
139
|
end_time = Time.now
|
|
142
140
|
duration_ms = ((end_time - start_time) * 1000).round(2)
|
|
143
141
|
|
|
144
|
-
send_complete_request_log(
|
|
142
|
+
BaseInterceptor.send_complete_request_log(
|
|
145
143
|
request_id: request_id,
|
|
146
144
|
method: req.method,
|
|
147
145
|
url: url,
|
|
148
|
-
request_headers: sanitize_headers(req),
|
|
146
|
+
request_headers: BaseInterceptor.sanitize_headers(req),
|
|
149
147
|
request_body: captured_body,
|
|
150
|
-
response_headers: sanitize_headers(response),
|
|
148
|
+
response_headers: BaseInterceptor.sanitize_headers(response),
|
|
151
149
|
response_body: response_body,
|
|
152
150
|
status_code: status_code,
|
|
153
151
|
start_time: start_time,
|
|
@@ -160,164 +158,171 @@ module Coolhand
|
|
|
160
158
|
response
|
|
161
159
|
end
|
|
162
160
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
161
|
+
# Stateless helpers for #request. They live in their own namespaced module (not on the
|
|
162
|
+
# prepended module itself) so the only method NetHttpInterceptor adds to Net::HTTP is
|
|
163
|
+
# `request` — generically named helpers like `intercept?` would otherwise pollute
|
|
164
|
+
# Net::HTTP and could collide with, or be shadowed by, other code.
|
|
165
|
+
module Helpers
|
|
166
|
+
module_function
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
def should_capture?
|
|
169
|
+
return true if Coolhand.configuration.debug_mode
|
|
170
170
|
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
override = Thread.current[:coolhand_capture_override]
|
|
172
|
+
return override unless override.nil?
|
|
173
173
|
|
|
174
|
-
|
|
175
|
-
# Check content-type before touching body_stream at all — for a binary
|
|
176
|
-
# upload (multipart/form-data, audio/*, etc.) this avoids reading the
|
|
177
|
-
# stream into memory a second time just to build a log entry no one
|
|
178
|
-
# can read anyway.
|
|
179
|
-
return skipped_capture_marker(req, "non_json_content_type") if binary_upload?(req)
|
|
180
|
-
|
|
181
|
-
content = body || req.body
|
|
182
|
-
if content.nil? && req.respond_to?(:body_stream) && req.body_stream
|
|
183
|
-
content = req.body_stream.read
|
|
184
|
-
req.body_stream = StringIO.new(content)
|
|
174
|
+
Coolhand.configuration.capture
|
|
185
175
|
end
|
|
186
|
-
return nil if content.nil?
|
|
187
176
|
|
|
188
|
-
|
|
189
|
-
|
|
177
|
+
def capture_request_body(req, body)
|
|
178
|
+
# Check content-type before touching body_stream at all — for a binary
|
|
179
|
+
# upload (multipart/form-data, audio/*, etc.) this avoids reading the
|
|
180
|
+
# stream into memory a second time just to build a log entry no one
|
|
181
|
+
# can read anyway.
|
|
182
|
+
return skipped_capture_marker(req, "non_json_content_type") if binary_upload?(req)
|
|
183
|
+
|
|
184
|
+
content = body || req.body
|
|
185
|
+
if content.nil? && req.respond_to?(:body_stream) && req.body_stream
|
|
186
|
+
content = req.body_stream.read
|
|
187
|
+
req.body_stream = StringIO.new(content)
|
|
188
|
+
end
|
|
189
|
+
return nil if content.nil?
|
|
190
190
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
content_type && !content_type.match?(/json/i)
|
|
194
|
-
end
|
|
191
|
+
cap_and_parse(content, req)
|
|
192
|
+
end
|
|
195
193
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
return skipped_capture_marker(req, "body_too_large", size_bytes: content.bytesize, max_bytes: max_bytes)
|
|
194
|
+
def binary_upload?(req)
|
|
195
|
+
content_type = req.respond_to?(:content_type) ? req.content_type : nil
|
|
196
|
+
content_type && !content_type.match?(/json/i)
|
|
200
197
|
end
|
|
201
198
|
|
|
202
|
-
|
|
203
|
-
|
|
199
|
+
def cap_and_parse(content, req)
|
|
200
|
+
max_bytes = Coolhand.configuration.max_captured_body_bytes
|
|
201
|
+
if max_bytes && content.bytesize > max_bytes
|
|
202
|
+
return skipped_capture_marker(req, "body_too_large", size_bytes: content.bytesize, max_bytes: max_bytes)
|
|
203
|
+
end
|
|
204
204
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
content_type = req.respond_to?(:content_type) ? req.content_type : nil
|
|
208
|
-
marker["content_type"] = content_type if content_type
|
|
209
|
-
marker
|
|
210
|
-
end
|
|
205
|
+
BaseInterceptor.parse_json(content)
|
|
206
|
+
end
|
|
211
207
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
208
|
+
def skipped_capture_marker(req, reason, extra = {})
|
|
209
|
+
marker = { "_coolhand_capture_skipped" => reason }.merge(extra.transform_keys(&:to_s))
|
|
210
|
+
content_type = req.respond_to?(:content_type) ? req.content_type : nil
|
|
211
|
+
marker["content_type"] = content_type if content_type
|
|
212
|
+
marker
|
|
213
|
+
end
|
|
215
214
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
215
|
+
def extract_status_from_exception(e)
|
|
216
|
+
return e.status if e.respond_to?(:status) && e.status.is_a?(Integer)
|
|
217
|
+
return e.response.status if e.respond_to?(:response) && e.response.respond_to?(:status)
|
|
219
218
|
|
|
220
|
-
|
|
221
|
-
|
|
219
|
+
match = e.message.to_s.match(/status[=:\s]+(\d{3})/)
|
|
220
|
+
match ? match[1].to_i : nil
|
|
221
|
+
end
|
|
222
222
|
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
def intercept?(url)
|
|
224
|
+
return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
|
|
225
225
|
|
|
226
|
-
|
|
226
|
+
uri = safe_parse(url)
|
|
227
|
+
return false unless uri&.host
|
|
227
228
|
|
|
228
|
-
|
|
229
|
-
path = uri.path.to_s
|
|
230
|
-
addresses = Coolhand.configuration.intercept_addresses
|
|
231
|
-
authorities = [host, "#{host}:#{uri.port}"]
|
|
232
|
-
return true if addresses.any? { |a| authorities.any? { |authority| address_matches?(authority, path, a) } }
|
|
229
|
+
return false if excluded_by_pattern?(uri)
|
|
233
230
|
|
|
234
|
-
|
|
235
|
-
|
|
231
|
+
host = uri.host.downcase.chomp(".")
|
|
232
|
+
path = uri.path.to_s
|
|
233
|
+
addresses = Coolhand.configuration.intercept_addresses
|
|
234
|
+
authorities = [host, "#{host}:#{uri.port}"]
|
|
235
|
+
return true if addresses.any? { |a| authorities.any? { |authority| address_matches?(authority, path, a) } }
|
|
236
236
|
|
|
237
|
-
|
|
238
|
-
|
|
237
|
+
return false unless google_api_host_configured?(addresses)
|
|
238
|
+
return false unless host == "googleapis.com" || host.end_with?(".googleapis.com")
|
|
239
239
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def capturable_url(req)
|
|
243
|
-
url = build_url_for_request(self, req)
|
|
244
|
-
url if intercept?(url) && should_capture?
|
|
245
|
-
rescue StandardError => e
|
|
246
|
-
Coolhand.log "⚠️ Skipping capture, could not evaluate intercept rules: #{e.class}"
|
|
247
|
-
nil
|
|
248
|
-
end
|
|
240
|
+
Coolhand.configuration.intercept_path_patterns.any? { |p| path.include?(p) }
|
|
241
|
+
end
|
|
249
242
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
243
|
+
# The capture decision runs before the host's real request, so a bad config value (e.g. a
|
|
244
|
+
# non-array exclude_api_patterns) must degrade to "don't capture", never raise into the host.
|
|
245
|
+
def capturable_url(http, req)
|
|
246
|
+
url = build_url_for_request(http, req)
|
|
247
|
+
url if intercept?(url) && should_capture?
|
|
248
|
+
rescue StandardError => e
|
|
249
|
+
Coolhand.log "⚠️ Skipping capture, could not evaluate intercept rules: #{e.class}"
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def excluded_by_pattern?(uri)
|
|
254
|
+
patterns = Coolhand.configuration.exclude_api_patterns
|
|
255
|
+
return false if patterns.nil? || patterns.empty?
|
|
253
256
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
257
|
+
path = uri.path.to_s
|
|
258
|
+
matched = patterns.find { |pattern| path.include?(pattern) }
|
|
259
|
+
if matched && Coolhand.configuration.debug_mode
|
|
260
|
+
Coolhand.log "🚫 Skipping capture for #{BaseInterceptor.sanitize_url(uri.to_s)} " \
|
|
261
|
+
"(matched exclude_api_pattern: \"#{matched}\")"
|
|
262
|
+
end
|
|
263
|
+
!!matched
|
|
258
264
|
end
|
|
259
|
-
!!matched
|
|
260
|
-
end
|
|
261
265
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
266
|
+
# intercept_path_patterns only ever applies to googleapis.com hosts, and only when the
|
|
267
|
+
# user still wants Google API traffic intercepted at all — otherwise overriding
|
|
268
|
+
# intercept_addresses to exclude Google hosts wouldn't actually stop Google API capture.
|
|
269
|
+
def google_api_host_configured?(addresses)
|
|
270
|
+
addresses.any? do |a|
|
|
271
|
+
a = a.to_s.downcase
|
|
272
|
+
a == "googleapis.com" || a.end_with?(".googleapis.com")
|
|
273
|
+
end
|
|
269
274
|
end
|
|
270
|
-
end
|
|
271
275
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
276
|
+
# An intercept_addresses entry may optionally pin a port ("host:port") and/or anchor to a
|
|
277
|
+
# path prefix by embedding a "/" — e.g. "api.cohere.com/v2/chat" only matches requests to
|
|
278
|
+
# that host whose path starts with "/v2/chat", and "cognitiveservices.azure.com/openai/" only
|
|
279
|
+
# matches that multi-service Azure host's OpenAI paths, so unrelated endpoints on a shared
|
|
280
|
+
# host aren't swept in alongside the ones we mean to capture.
|
|
281
|
+
# The match is on a path *segment* boundary (trailing "/" on the pattern is optional and
|
|
282
|
+
# stripped before comparing), so "host.com/openai" matches "/openai" and "/openai/x" but not
|
|
283
|
+
# a same-prefix-but-different-segment path like "/openaiz".
|
|
284
|
+
def address_matches?(host, path, pattern)
|
|
285
|
+
host_pattern, sep, path_pattern = pattern.to_s.partition("/")
|
|
286
|
+
return host_matches?(host, host_pattern) if sep.empty?
|
|
287
|
+
return false unless host_matches?(host, host_pattern)
|
|
288
|
+
|
|
289
|
+
path_pattern = path_pattern.delete_suffix("/")
|
|
290
|
+
return true if path_pattern.empty?
|
|
291
|
+
|
|
292
|
+
prefix = "/#{path_pattern}"
|
|
293
|
+
path == prefix || path.start_with?("#{prefix}/")
|
|
294
|
+
end
|
|
291
295
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
296
|
+
# Host-boundary match: exact, or a dot-delimited suffix (case-insensitive).
|
|
297
|
+
# A single "*" in `pattern` matches exactly one host label, e.g.
|
|
298
|
+
# "bedrock-runtime.*.amazonaws.com" matches "bedrock-runtime.us-east-1.amazonaws.com".
|
|
299
|
+
def host_matches?(host, pattern)
|
|
300
|
+
pattern = pattern.to_s.downcase
|
|
301
|
+
return host == pattern || host.end_with?(".#{pattern}") unless pattern.include?("*")
|
|
298
302
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
303
|
+
regex = /\A#{pattern.split('*', -1).map { |part| Regexp.escape(part) }.join('[^.]+')}\z/
|
|
304
|
+
!!(host =~ regex)
|
|
305
|
+
end
|
|
302
306
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
307
|
+
def safe_parse(url)
|
|
308
|
+
URI.parse(url)
|
|
309
|
+
rescue URI::InvalidURIError
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
308
312
|
|
|
309
|
-
|
|
310
|
-
|
|
313
|
+
def build_url_for_request(http, req)
|
|
314
|
+
return req.path if %r{\Ahttps?://}.match?(req.path)
|
|
311
315
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
+
scheme = http.use_ssl? ? "https" : "http"
|
|
317
|
+
host = http.address
|
|
318
|
+
port = http.port
|
|
319
|
+
default = http.use_ssl? ? 443 : 80
|
|
316
320
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
+
url = "#{scheme}://#{host}"
|
|
322
|
+
url << ":#{port}" if port != default
|
|
323
|
+
url << req.path
|
|
324
|
+
url
|
|
325
|
+
end
|
|
321
326
|
end
|
|
322
327
|
end
|
|
323
328
|
end
|