coolhand 0.7.0 → 0.9.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 +42 -1
- data/README.md +60 -2
- data/Rakefile +7 -0
- data/docs/configuration.md +19 -0
- data/docs/declared_workloads.md +156 -0
- data/docs/feedback-links.md +48 -0
- data/docs/log-search.md +102 -0
- data/docs/openai.md +5 -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 +9 -4
- data/lib/coolhand/base_interceptor.rb +7 -5
- data/lib/coolhand/configuration.rb +16 -2
- data/lib/coolhand/declarable.rb +178 -0
- data/lib/coolhand/declared_context.rb +106 -0
- data/lib/coolhand/errors.rb +2 -1
- data/lib/coolhand/log_queue.rb +179 -0
- data/lib/coolhand/log_service.rb +61 -0
- data/lib/coolhand/logger_service.rb +10 -18
- data/lib/coolhand/net_http_interceptor.rb +145 -139
- data/lib/coolhand/open_ai/batch_result_processor.rb +30 -29
- data/lib/coolhand/open_ai/webhook_validator.rb +27 -15
- data/lib/coolhand/optimization_feedback_link_service.rb +85 -0
- data/lib/coolhand/rails_auto_declare.rb +55 -0
- data/lib/coolhand/railtie.rb +11 -0
- data/lib/coolhand/read_query.rb +75 -0
- data/lib/coolhand/read_requests.rb +26 -13
- data/lib/coolhand/response_warnings.rb +39 -0
- data/lib/coolhand/template_service.rb +33 -33
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/vertex/batch_result_processor.rb +9 -1
- data/lib/coolhand/workload_service.rb +50 -0
- data/lib/coolhand/write_requests.rb +61 -0
- data/lib/coolhand.rb +63 -12
- metadata +17 -2
|
@@ -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,61 @@
|
|
|
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
|
+
fetch_list(query, LogSearchResult, :logs, page: page, per: per)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The full log, with `cost` and `cost_breakdown`. Both are nil when the log has no tokens or its
|
|
54
|
+
# model has no pricing.
|
|
55
|
+
def get_log(id, section: nil, max_chars: nil, search_query: nil, include_thinking: nil)
|
|
56
|
+
query = { section: section, max_chars: max_chars, search_query: search_query,
|
|
57
|
+
include_thinking: include_thinking }.compact
|
|
58
|
+
get_json(resource_url(id, BLANK_ID_MESSAGE, query), ERROR_NOUN)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -15,24 +15,8 @@ module Coolhand
|
|
|
15
15
|
# Helper method for forwarding webhook data to Coolhand
|
|
16
16
|
def forward_webhook(webhook_body:, source:, event_type: nil, headers: {}, **options)
|
|
17
17
|
# Validate required parameters
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if Coolhand.configuration.silent
|
|
21
|
-
puts "COOLHAND WARNING: #{error_msg}"
|
|
22
|
-
return false
|
|
23
|
-
else
|
|
24
|
-
raise ArgumentError, error_msg
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
unless Coolhand.required_field?(source)
|
|
29
|
-
error_msg = "source is required and cannot be nil or empty"
|
|
30
|
-
if Coolhand.configuration.silent
|
|
31
|
-
puts "COOLHAND WARNING: #{error_msg}"
|
|
32
|
-
return false
|
|
33
|
-
else
|
|
34
|
-
raise ArgumentError, error_msg
|
|
35
|
-
end
|
|
18
|
+
{ webhook_body: webhook_body, source: source }.each do |name, value|
|
|
19
|
+
return reject_webhook("#{name} is required and cannot be nil or empty") unless Coolhand.required_field?(value)
|
|
36
20
|
end
|
|
37
21
|
|
|
38
22
|
# Auto-generate required fields unless provided
|
|
@@ -55,6 +39,14 @@ module Coolhand
|
|
|
55
39
|
|
|
56
40
|
private
|
|
57
41
|
|
|
42
|
+
# Silent mode warns and drops the webhook; otherwise the caller gets the error.
|
|
43
|
+
def reject_webhook(error_msg)
|
|
44
|
+
raise ArgumentError, error_msg unless Coolhand.configuration.silent
|
|
45
|
+
|
|
46
|
+
puts "COOLHAND WARNING: #{error_msg}"
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
58
50
|
def build_webhook_url(source, event_type)
|
|
59
51
|
base = "webhook://#{source}"
|
|
60
52
|
event_type ? "#{base}/#{event_type}" : base
|
|
@@ -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)
|
|
@@ -48,13 +46,12 @@ module Coolhand
|
|
|
48
46
|
Net::HTTPResponse.prepend(ResponseInterceptor)
|
|
49
47
|
|
|
50
48
|
@patched = true
|
|
51
|
-
Coolhand.log "🔗 Net::HTTP interceptor patched"
|
|
52
49
|
rescue StandardError
|
|
53
50
|
# Roll back this call's hold — it never actually took effect, so it must not count
|
|
54
51
|
# toward the refcount or a legitimate later unpatch! would underflow against it. This
|
|
55
52
|
# branch only runs when @patched was false on entry (see `next if @patched` above), so
|
|
56
53
|
# forcing it back to false here is correct regardless of which line above raised —
|
|
57
|
-
# including a failure in the
|
|
54
|
+
# including a failure in the second prepend, after the first already succeeded.
|
|
58
55
|
@patch_count -= 1
|
|
59
56
|
@patched = false
|
|
60
57
|
raise
|
|
@@ -70,7 +67,7 @@ module Coolhand
|
|
|
70
67
|
next if @patch_count.positive? || !@patched
|
|
71
68
|
|
|
72
69
|
@patched = false
|
|
73
|
-
Coolhand.log "🔌
|
|
70
|
+
Coolhand.log "🔌 Net::HTTP interceptor unpatched (capture stopped)"
|
|
74
71
|
end
|
|
75
72
|
end
|
|
76
73
|
|
|
@@ -93,7 +90,7 @@ module Coolhand
|
|
|
93
90
|
active = (Thread.current[:coolhand_active_requests] ||= {}.compare_by_identity)
|
|
94
91
|
return super if active.key?(self)
|
|
95
92
|
|
|
96
|
-
url = capturable_url(req)
|
|
93
|
+
url = Helpers.capturable_url(self, req)
|
|
97
94
|
return super unless url
|
|
98
95
|
|
|
99
96
|
# Capture body before setting the guard — if this raises we skip logging cleanly
|
|
@@ -102,7 +99,7 @@ module Coolhand
|
|
|
102
99
|
# from being attempted — this gem must never be the reason the host
|
|
103
100
|
# app's actual LLM call doesn't happen.
|
|
104
101
|
captured_body = begin
|
|
105
|
-
capture_request_body(req, body)
|
|
102
|
+
Helpers.capture_request_body(req, body)
|
|
106
103
|
rescue StandardError => e
|
|
107
104
|
Coolhand.log "❌ Error capturing request body: #{e.message}"
|
|
108
105
|
nil
|
|
@@ -129,9 +126,9 @@ module Coolhand
|
|
|
129
126
|
body_content = Thread.current[:coolhand_stream_buffer] || response&.body
|
|
130
127
|
body_content = body_content.dup.force_encoding("UTF-8") if body_content.is_a?(String)
|
|
131
128
|
status_code = response.respond_to?(:code) ? response.code.to_i : nil
|
|
132
|
-
response_body = parse_json(body_content)
|
|
129
|
+
response_body = BaseInterceptor.parse_json(body_content)
|
|
133
130
|
rescue StandardError => e
|
|
134
|
-
status_code = extract_status_from_exception(e)
|
|
131
|
+
status_code = Helpers.extract_status_from_exception(e)
|
|
135
132
|
response_body = { "error" => { "class" => e.class.name, "message" => e.message } }
|
|
136
133
|
raise
|
|
137
134
|
ensure
|
|
@@ -141,13 +138,15 @@ module Coolhand
|
|
|
141
138
|
end_time = Time.now
|
|
142
139
|
duration_ms = ((end_time - start_time) * 1000).round(2)
|
|
143
140
|
|
|
144
|
-
|
|
141
|
+
# Read here on the calling thread: the log worker thread has no access to our thread-locals.
|
|
142
|
+
BaseInterceptor.send_complete_request_log(
|
|
143
|
+
declared: DeclaredContext.current,
|
|
145
144
|
request_id: request_id,
|
|
146
145
|
method: req.method,
|
|
147
146
|
url: url,
|
|
148
|
-
request_headers: sanitize_headers(req),
|
|
147
|
+
request_headers: BaseInterceptor.sanitize_headers(req),
|
|
149
148
|
request_body: captured_body,
|
|
150
|
-
response_headers: sanitize_headers(response),
|
|
149
|
+
response_headers: BaseInterceptor.sanitize_headers(response),
|
|
151
150
|
response_body: response_body,
|
|
152
151
|
status_code: status_code,
|
|
153
152
|
start_time: start_time,
|
|
@@ -160,164 +159,171 @@ module Coolhand
|
|
|
160
159
|
response
|
|
161
160
|
end
|
|
162
161
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
162
|
+
# Stateless helpers for #request. They live in their own namespaced module (not on the
|
|
163
|
+
# prepended module itself) so the only method NetHttpInterceptor adds to Net::HTTP is
|
|
164
|
+
# `request` — generically named helpers like `intercept?` would otherwise pollute
|
|
165
|
+
# Net::HTTP and could collide with, or be shadowed by, other code.
|
|
166
|
+
module Helpers
|
|
167
|
+
module_function
|
|
167
168
|
|
|
168
|
-
|
|
169
|
-
|
|
169
|
+
def should_capture?
|
|
170
|
+
return true if Coolhand.configuration.debug_mode
|
|
170
171
|
|
|
171
|
-
|
|
172
|
-
|
|
172
|
+
override = Thread.current[:coolhand_capture_override]
|
|
173
|
+
return override unless override.nil?
|
|
173
174
|
|
|
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)
|
|
175
|
+
Coolhand.configuration.capture
|
|
185
176
|
end
|
|
186
|
-
return nil if content.nil?
|
|
187
177
|
|
|
188
|
-
|
|
189
|
-
|
|
178
|
+
def capture_request_body(req, body)
|
|
179
|
+
# Check content-type before touching body_stream at all — for a binary
|
|
180
|
+
# upload (multipart/form-data, audio/*, etc.) this avoids reading the
|
|
181
|
+
# stream into memory a second time just to build a log entry no one
|
|
182
|
+
# can read anyway.
|
|
183
|
+
return skipped_capture_marker(req, "non_json_content_type") if binary_upload?(req)
|
|
184
|
+
|
|
185
|
+
content = body || req.body
|
|
186
|
+
if content.nil? && req.respond_to?(:body_stream) && req.body_stream
|
|
187
|
+
content = req.body_stream.read
|
|
188
|
+
req.body_stream = StringIO.new(content)
|
|
189
|
+
end
|
|
190
|
+
return nil if content.nil?
|
|
190
191
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
content_type && !content_type.match?(/json/i)
|
|
194
|
-
end
|
|
192
|
+
cap_and_parse(content, req)
|
|
193
|
+
end
|
|
195
194
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
return skipped_capture_marker(req, "body_too_large", size_bytes: content.bytesize, max_bytes: max_bytes)
|
|
195
|
+
def binary_upload?(req)
|
|
196
|
+
content_type = req.respond_to?(:content_type) ? req.content_type : nil
|
|
197
|
+
content_type && !content_type.match?(/json/i)
|
|
200
198
|
end
|
|
201
199
|
|
|
202
|
-
|
|
203
|
-
|
|
200
|
+
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
|
|
204
205
|
|
|
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
|
|
206
|
+
BaseInterceptor.parse_json(content)
|
|
207
|
+
end
|
|
211
208
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
209
|
+
def skipped_capture_marker(req, reason, extra = {})
|
|
210
|
+
marker = { "_coolhand_capture_skipped" => reason }.merge(extra.transform_keys(&:to_s))
|
|
211
|
+
content_type = req.respond_to?(:content_type) ? req.content_type : nil
|
|
212
|
+
marker["content_type"] = content_type if content_type
|
|
213
|
+
marker
|
|
214
|
+
end
|
|
215
215
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
def extract_status_from_exception(e)
|
|
217
|
+
return e.status if e.respond_to?(:status) && e.status.is_a?(Integer)
|
|
218
|
+
return e.response.status if e.respond_to?(:response) && e.response.respond_to?(:status)
|
|
219
219
|
|
|
220
|
-
|
|
221
|
-
|
|
220
|
+
match = e.message.to_s.match(/status[=:\s]+(\d{3})/)
|
|
221
|
+
match ? match[1].to_i : nil
|
|
222
|
+
end
|
|
222
223
|
|
|
223
|
-
|
|
224
|
-
|
|
224
|
+
def intercept?(url)
|
|
225
|
+
return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
|
|
225
226
|
|
|
226
|
-
|
|
227
|
+
uri = safe_parse(url)
|
|
228
|
+
return false unless uri&.host
|
|
227
229
|
|
|
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) } }
|
|
230
|
+
return false if excluded_by_pattern?(uri)
|
|
233
231
|
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
host = uri.host.downcase.chomp(".")
|
|
233
|
+
path = uri.path.to_s
|
|
234
|
+
addresses = Coolhand.configuration.intercept_addresses
|
|
235
|
+
authorities = [host, "#{host}:#{uri.port}"]
|
|
236
|
+
return true if addresses.any? { |a| authorities.any? { |authority| address_matches?(authority, path, a) } }
|
|
236
237
|
|
|
237
|
-
|
|
238
|
-
|
|
238
|
+
return false unless google_api_host_configured?(addresses)
|
|
239
|
+
return false unless host == "googleapis.com" || host.end_with?(".googleapis.com")
|
|
239
240
|
|
|
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
|
|
241
|
+
Coolhand.configuration.intercept_path_patterns.any? { |p| path.include?(p) }
|
|
242
|
+
end
|
|
249
243
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
244
|
+
# The capture decision runs before the host's real request, so a bad config value (e.g. a
|
|
245
|
+
# non-array exclude_api_patterns) must degrade to "don't capture", never raise into the host.
|
|
246
|
+
def capturable_url(http, req)
|
|
247
|
+
url = build_url_for_request(http, req)
|
|
248
|
+
url if intercept?(url) && should_capture?
|
|
249
|
+
rescue StandardError => e
|
|
250
|
+
Coolhand.log "⚠️ Skipping capture, could not evaluate intercept rules: #{e.class}"
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def excluded_by_pattern?(uri)
|
|
255
|
+
patterns = Coolhand.configuration.exclude_api_patterns
|
|
256
|
+
return false if patterns.nil? || patterns.empty?
|
|
253
257
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
+
path = uri.path.to_s
|
|
259
|
+
matched = patterns.find { |pattern| path.include?(pattern) }
|
|
260
|
+
if matched && Coolhand.configuration.debug_mode
|
|
261
|
+
Coolhand.log "🚫 Skipping capture for #{BaseInterceptor.sanitize_url(uri.to_s)} " \
|
|
262
|
+
"(matched exclude_api_pattern: \"#{matched}\")"
|
|
263
|
+
end
|
|
264
|
+
!!matched
|
|
258
265
|
end
|
|
259
|
-
!!matched
|
|
260
|
-
end
|
|
261
266
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
267
|
+
# intercept_path_patterns only ever applies to googleapis.com hosts, and only when the
|
|
268
|
+
# user still wants Google API traffic intercepted at all — otherwise overriding
|
|
269
|
+
# intercept_addresses to exclude Google hosts wouldn't actually stop Google API capture.
|
|
270
|
+
def google_api_host_configured?(addresses)
|
|
271
|
+
addresses.any? do |a|
|
|
272
|
+
a = a.to_s.downcase
|
|
273
|
+
a == "googleapis.com" || a.end_with?(".googleapis.com")
|
|
274
|
+
end
|
|
269
275
|
end
|
|
270
|
-
end
|
|
271
276
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
277
|
+
# An intercept_addresses entry may optionally pin a port ("host:port") and/or anchor to a
|
|
278
|
+
# path prefix by embedding a "/" — e.g. "api.cohere.com/v2/chat" only matches requests to
|
|
279
|
+
# that host whose path starts with "/v2/chat", and "cognitiveservices.azure.com/openai/" only
|
|
280
|
+
# matches that multi-service Azure host's OpenAI paths, so unrelated endpoints on a shared
|
|
281
|
+
# host aren't swept in alongside the ones we mean to capture.
|
|
282
|
+
# The match is on a path *segment* boundary (trailing "/" on the pattern is optional and
|
|
283
|
+
# stripped before comparing), so "host.com/openai" matches "/openai" and "/openai/x" but not
|
|
284
|
+
# a same-prefix-but-different-segment path like "/openaiz".
|
|
285
|
+
def address_matches?(host, path, pattern)
|
|
286
|
+
host_pattern, sep, path_pattern = pattern.to_s.partition("/")
|
|
287
|
+
return host_matches?(host, host_pattern) if sep.empty?
|
|
288
|
+
return false unless host_matches?(host, host_pattern)
|
|
289
|
+
|
|
290
|
+
path_pattern = path_pattern.delete_suffix("/")
|
|
291
|
+
return true if path_pattern.empty?
|
|
292
|
+
|
|
293
|
+
prefix = "/#{path_pattern}"
|
|
294
|
+
path == prefix || path.start_with?("#{prefix}/")
|
|
295
|
+
end
|
|
291
296
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
297
|
+
# Host-boundary match: exact, or a dot-delimited suffix (case-insensitive).
|
|
298
|
+
# A single "*" in `pattern` matches exactly one host label, e.g.
|
|
299
|
+
# "bedrock-runtime.*.amazonaws.com" matches "bedrock-runtime.us-east-1.amazonaws.com".
|
|
300
|
+
def host_matches?(host, pattern)
|
|
301
|
+
pattern = pattern.to_s.downcase
|
|
302
|
+
return host == pattern || host.end_with?(".#{pattern}") unless pattern.include?("*")
|
|
298
303
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
304
|
+
regex = /\A#{pattern.split('*', -1).map { |part| Regexp.escape(part) }.join('[^.]+')}\z/
|
|
305
|
+
!!(host =~ regex)
|
|
306
|
+
end
|
|
302
307
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
+
def safe_parse(url)
|
|
309
|
+
URI.parse(url)
|
|
310
|
+
rescue URI::InvalidURIError
|
|
311
|
+
nil
|
|
312
|
+
end
|
|
308
313
|
|
|
309
|
-
|
|
310
|
-
|
|
314
|
+
def build_url_for_request(http, req)
|
|
315
|
+
return req.path if %r{\Ahttps?://}.match?(req.path)
|
|
311
316
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
317
|
+
scheme = http.use_ssl? ? "https" : "http"
|
|
318
|
+
host = http.address
|
|
319
|
+
port = http.port
|
|
320
|
+
default = http.use_ssl? ? 443 : 80
|
|
316
321
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
322
|
+
url = "#{scheme}://#{host}"
|
|
323
|
+
url << ":#{port}" if port != default
|
|
324
|
+
url << req.path
|
|
325
|
+
url
|
|
326
|
+
end
|
|
321
327
|
end
|
|
322
328
|
end
|
|
323
329
|
end
|