coolhand 0.5.1 → 0.7.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.
data/docs/vertex.md CHANGED
@@ -6,6 +6,8 @@ For monitoring regular (non-batch) Vertex AI calls, no extra setup is required b
6
6
 
7
7
  Requires Rails — `Coolhand::Vertex::BatchResultProcessor` logs via `Rails.logger` internally. `config.capture = false` and `Coolhand.without_capture` do not suppress these logs: unlike the passive Net::HTTP interceptor, calling this processor is an explicit, deliberate act, so it always sends.
8
8
 
9
+ > **Note:** As of May 2026, Google markets this product as "Gemini Enterprise Agent Platform" rather than "Vertex AI." The API endpoint (`aiplatform.googleapis.com`) and Ruby SDK are unchanged — it's a console/marketing rename, not an SDK rename — so `Coolhand::Vertex` naming stays accurate here. It's also still a distinct integration from the separate Gemini Developer API (`generativelanguage.googleapis.com`), which Coolhand intercepts independently.
10
+
9
11
  ## Usage
10
12
 
11
13
  ```ruby
@@ -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
- headers = create_request_options(payload)
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
- body = response.body.force_encoding("UTF-8") if response.body
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
- puts captured_data
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
@@ -11,7 +11,7 @@ module Coolhand
11
11
  # (cookie, set-cookie), and future/unknown providers using a
12
12
  # similarly-named header. Shared with LoggerService so the two logging
13
13
  # paths (interceptor + webhook forwarding) stay consistent.
14
- SENSITIVE_HEADER_PATTERN = /key|token|secret|signature|authorization|cookie/i
14
+ SENSITIVE_HEADER_PATTERN = /key|token|secret|signature|auth|passw|credential|bearer|jwt|session|cookie/i
15
15
 
16
16
  def sanitize_headers(headers)
17
17
  return {} if headers.nil?
@@ -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
- # fall through to other enumeration strategies
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,27 +82,70 @@ module Coolhand
80
82
 
81
83
  def sanitize_url(url)
82
84
  uri = URI.parse(url)
83
- return url unless uri.query
84
-
85
- params = URI.decode_www_form(uri.query)
86
- redacted = false
87
- params.map! do |n, v|
88
- if n.match?(SENSITIVE_QUERY_PARAM_PATTERN)
89
- redacted = true
90
- [n, "[REDACTED]"]
91
- else
92
- [n, v]
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
92
+ end
93
+
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
93
104
  end
105
+ if redacted_query
106
+ uri.query = URI.encode_www_form(params)
107
+ modified = true
108
+ end
109
+ end
110
+
111
+ modified ? uri.to_s : url
112
+ rescue URI::InvalidURIError
113
+ # Fail closed: an unparseable URL can't be checked param-by-param, so drop everything
114
+ # that could carry a credential (userinfo, query, fragment) rather than pass it through.
115
+ url.to_s.sub(%r{//[^/?#]*@}, "//REDACTED@").split(/[?#]/, 2).first
116
+ end
117
+
118
+ # Key names inside an Azure OpenAI "On Your Data" data_sources/dataSources entry that carry
119
+ # live datastore credentials in the request body, where header/query-param sanitization never
120
+ # looks. Matched against a separator-stripped, lowercased key name so both `connection_string`
121
+ # and `connectionString` (Cosmos/Mongo, embeds `AccountKey=...`) and `encoded_api_key`
122
+ # (Elasticsearch) are caught — not just exact or snake_case-only names.
123
+ SENSITIVE_BODY_KEY_PATTERN = /key|token|secret|passw|pwd|credential|bearer|jwt|connectionstring|signature/i
124
+
125
+ def sanitize_body(body)
126
+ return body unless body.is_a?(Hash)
127
+
128
+ sanitized = body.dup
129
+ sanitized.each_key do |key|
130
+ next unless key.to_s.delete("_-").casecmp?("datasources")
131
+
132
+ sanitized[key] = redact_sensitive_body_values(sanitized[key])
94
133
  end
134
+ sanitized
135
+ end
95
136
 
96
- if redacted
97
- uri.query = URI.encode_www_form(params)
98
- uri.to_s
137
+ def redact_sensitive_body_values(node)
138
+ case node
139
+ when Hash
140
+ node.each_with_object({}) do |(k, v), acc|
141
+ normalized = k.to_s.gsub(/[_\-\s]/, "").downcase
142
+ acc[k] = normalized.match?(SENSITIVE_BODY_KEY_PATTERN) ? "[REDACTED]" : redact_sensitive_body_values(v)
143
+ end
144
+ when Array
145
+ node.map { |v| redact_sensitive_body_values(v) }
99
146
  else
100
- url
147
+ node
101
148
  end
102
- rescue URI::InvalidURIError
103
- url
104
149
  end
105
150
 
106
151
  def send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:, response_headers:,
@@ -111,7 +156,7 @@ module Coolhand
111
156
  method: method.to_s.downcase,
112
157
  url: sanitize_url(url),
113
158
  headers: sanitize_headers(request_headers),
114
- request_body: request_body,
159
+ request_body: sanitize_body(request_body),
115
160
  response_headers: sanitize_headers(response_headers),
116
161
  response_body: response_body,
117
162
  status_code: status_code,
@@ -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
- attr_accessor :api_key, :environment, :silent, :debug_mode, :capture, :exclude_api_patterns, :enabled
21
- attr_reader :intercept_addresses, :base_url
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
- # Custom setter that preserves defaults when nil/empty array is provided
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
- return if value.nil? || (value.is_a?(Array) && value.empty?)
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,9 +1,21 @@
1
1
  # Coolhand default exclude API patterns
2
- # These substrings are matched against request URLs after the intercept_addresses
3
- # allow-list passes. Matching URLs are skipped and not forwarded as llm_request_logs.
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/"]
7
8
  # Users can disable: c.exclude_api_patterns = []
8
9
 
9
10
  - "/batchPredictionJobs/"
11
+ # Azure OpenAI control-plane paths (not inference) — both spellings are needed
12
+ # since substring matching is contiguous: "/openai/files" does not match
13
+ # "/openai/v1/files".
14
+ - "/openai/files"
15
+ - "/openai/batches"
16
+ - "/openai/fine_tuning"
17
+ - "/openai/models"
18
+ - "/openai/v1/files"
19
+ - "/openai/v1/batches"
20
+ - "/openai/v1/fine_tuning"
21
+ - "/openai/v1/models"
@@ -1,9 +1,19 @@
1
1
  # Coolhand default intercept addresses
2
- # These substrings are matched against request URLs to decide whether a request
3
- # should be captured and forwarded as an llm_request_log.
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.
7
+ # An entry may also pin a port and/or a path prefix: "host:port", "host/path",
8
+ # or "host:port/path" (the path match is on a segment boundary) — useful for
9
+ # multi-service hosts where unrelated APIs (e.g. Azure Speech, Vision, Language)
10
+ # share a domain with the LLM inference path.
4
11
  #
5
12
  # Users can extend defaults: c.intercept_addresses << "my.custom.api.com"
6
13
  # Users can override entirely: c.intercept_addresses = ["only.this.com"]
14
+ # This list cannot be emptied — it's required (unlike exclude_api_patterns, which
15
+ # can be disabled with `= []`). To disable capture entirely, use
16
+ # c.enabled = false or c.capture = false instead.
7
17
 
8
18
  - "api.openai.com"
9
19
  - "api.anthropic.com"
@@ -11,9 +21,58 @@
11
21
  - "generativelanguage.googleapis.com"
12
22
  - "models.github.ai"
13
23
  - "models.inference.ai.azure.com"
14
- - ":generateContent"
15
- - ":streamGenerateContent"
16
24
  - "aiplatform.googleapis.com"
17
25
  - "gateway.ai.cloudflare.com"
18
- - "bedrock-runtime"
26
+ - "bedrock-runtime.*.amazonaws.com"
27
+ - "api.deepseek.com"
28
+ - "api.mistral.ai"
29
+ - "api.perplexity.ai"
30
+ - "api.x.ai"
31
+ # Cohere is path-scoped, never host-wide: the server only ingests v2 chat and
32
+ # v1/v2 embed, and its other endpoints (v1 chat, rerank, tokenize, classify...)
33
+ # have different envelopes that would be recorded as empty "successes".
34
+ - "api.cohere.com/v2/chat"
35
+ - "api.cohere.com/v1/embed"
36
+ - "api.cohere.com/v2/embed"
37
+ - "api.cohere.ai/v2/chat"
38
+ - "api.cohere.ai/v1/embed"
39
+ - "api.cohere.ai/v2/embed"
40
+ # TypeSafe Jev (a "System One" model, not an LLM) — only /v1/systemone is ingested.
41
+ - "api.typesafe.ai/v1/systemone"
42
+ # Ollama (self-hosted) has no fixed host, so an entry must pin BOTH the default
43
+ # port (11434) and the path — a bare "/api/chat" would capture any app's own
44
+ # unrelated chat endpoint. "*" matches one host label (localhost, an "ollama"
45
+ # compose service, a bare hostname); a LAN IP or dotted host needs its own
46
+ # entry, e.g. "192.168.1.5:11434/api/chat".
47
+ - "*:11434/api/chat"
48
+ - "*:11434/api/generate"
49
+ - "*:11434/api/embed"
50
+ - "*:11434/api/embeddings"
51
+ - "127.0.0.1:11434/api/chat"
52
+ - "127.0.0.1:11434/api/generate"
53
+ - "127.0.0.1:11434/api/embed"
54
+ - "127.0.0.1:11434/api/embeddings"
19
55
  - "openrouter.ai"
56
+ - "opencode.ai"
57
+ # Azure OpenAI dedicated hosts (commercial, US Gov, China/21Vianet)
58
+ - "openai.azure.com"
59
+ - "openai.azure.us"
60
+ - "openai.azure.cn"
61
+ # Azure AI Services / Foundry multi-service hosts — path-anchored so Speech,
62
+ # Vision, Language and Content Safety on the same host are NOT captured
63
+ - "cognitiveservices.azure.com/openai/"
64
+ - "cognitiveservices.azure.com/models/"
65
+ - "cognitiveservices.azure.us/openai/"
66
+ - "cognitiveservices.azure.us/models/"
67
+ - "cognitiveservices.azure.cn/openai/"
68
+ - "cognitiveservices.azure.cn/models/"
69
+ - "services.ai.azure.com/openai/"
70
+ - "services.ai.azure.com/models/"
71
+ - "services.ai.azure.us/openai/"
72
+ - "services.ai.azure.us/models/"
73
+ # Serverless / MaaS + Azure ML managed online endpoints. inference.ml.azure.*
74
+ # is deliberately unanchored — see docs/configuration.md for why.
75
+ - "inference.ai.azure.com"
76
+ - "models.ai.azure.com"
77
+ - "inference.ml.azure.com"
78
+ - "inference.ml.azure.us"
@@ -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))