coolhand 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3f66ea8b94e39769d66543c75987c2108397f89c93dc53748753b396ca76d921
4
- data.tar.gz: 560b9ec89c71b31095b5f579bb270b295a79b87a7edbec495179f6f1b764f77e
3
+ metadata.gz: 25393afdf95625b624ca2910cb3017685705529e9628a5d15cb3bdffdf122d52
4
+ data.tar.gz: 3e478c3b8494d5fef5bc87896fbf69cb9f93d088029b9a9745563c9a789b4473
5
5
  SHA512:
6
- metadata.gz: ebd806bb3744d3fd16673a98f7746b144b468a158ec855b32a4ba142cf110ff4a78b03ea1b95106b8f29d011425bbe0ec44b8f853acb87b9e4037cf87843991f
7
- data.tar.gz: 3a8078f5dbe7f3a5c7a0892b391a7085d54b5a6aa1db386075e34470c89802ca0bd6bb5dc5654dffa7fcb2595da3df7b2210a52568595c5c77929e435d1a13b8
6
+ metadata.gz: ad635f9377f6cb81967fc24b0c2aa9f9938c41e308c6b98511ee0139b9938e01ea766baf72a085700866c69ac50207da11e29f04d30a94ad3b487d3e22243d9e
7
+ data.tar.gz: d6ef0baf4e37d29b36a0d7a2d6a41822f551a0a753a2dc963c0da9eed60b90bf29b5f0015caa8a628c6f340ec9cb2b3ef2c83ecb9b59fbcec909d70f396603df
data/CHANGELOG.md CHANGED
@@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.1] - 2026-08-02
11
+
12
+ ### Added
13
+ - **`Coolhand::Vertex::BatchResultProcessor.new` accepts an optional `model:` keyword** (falls back to a `"model"` key in `batch_info` when omitted) to populate the `model` field on batch-result logs — included only when one of those is available and non-blank, and omitted from the payload entirely otherwise (#76).
14
+ - **`Coolhand::BaseInterceptor.send_complete_request_log` accepts optional `source_api:`/`model:` keywords**, for synthetic/batch log types (like the Vertex processor above) that aren't backed by a real intercepted HTTP request (#76).
15
+
16
+ ### Changed
17
+ - **Vertex batch results with a missing or malformed job resource name or unparseable `startTime`/`endTime` are no longer logged at all.** Previously the processor still sent a request log using whatever was in `batch_info["name"]` (even if blank), just with a broken URL. It now validates these fields and skips the log entirely — with a `Rails.logger.error` explaining why — rather than sending something unusable (#76).
18
+ - **A batch whose `endTime` precedes its `startTime` (e.g. clock skew) is still logged**, with `duration_ms` clamped to `0` and a warning logged, rather than dropped — unlike the missing/malformed cases above, the request/response content itself is still valid here (#76).
19
+ - **A Vertex batch result item that isn't a `Hash` with a `"request"` and/or `"response"` key is now rejected and logged as an error**, instead of being sent as a log with `nil` request/response bodies (#76).
20
+ - **The published gem no longer packages `.claude/`, `.idea/`, or `CLAUDE.md`** — these are repo/agent tooling, not part of the library.
21
+
22
+ ### Security
23
+ - **A webhook processing error no longer leaves the request authenticated by default.** `Coolhand::WebhookInterceptor#intercept_batch_request` previously logged and swallowed *any* exception (a malformed webhook secret, a non-Hash JSON payload, a bug in `process_event`), returning normally without halting the Rails `before_action` chain — so the controller action still ran for a request whose signature was never confirmed valid. It now calls `head :unauthorized` in that rescue, matching the existing invalid-signature path, and validates the parsed webhook payload is a `Hash` before use.
24
+ - **A blank or whitespace-only OpenAI webhook secret is no longer usable as the HMAC key.** `Coolhand::OpenAi::WebhookValidator` previously only rejected `nil`/`false`, so `webhook_secret = ""` (e.g. an unset `ENV` var with a blank default) silently signed and verified requests with an empty key — a much weaker, easy-to-guess bypass than the already-known "secret not configured" path. It now uses the same `Coolhand.required_field?` blank-check used elsewhere in the gem.
25
+ - **`Cookie`/`Set-Cookie` headers are now redacted**, alongside the existing key/token/secret/signature/authorization patterns. Forwarding a Rails session cookie (a bearer credential) to Coolhand — e.g. via `LoggerService#forward_webhook(headers: request.headers)`, the pattern this gem's own docs show — was previously possible.
26
+ - **`BaseInterceptor.sanitize_url` now redacts a broader set of credential-shaped query parameters** (`sig`, `credential`, `password`, `auth`, in addition to the existing `key`/`token`/`secret`), matching the pattern-based approach already used for headers instead of a small exact-match list. This covers AWS SigV4 (`X-Amz-Signature`, `X-Amz-Credential`) and Google Cloud (`X-Goog-Signature`, `X-Goog-Credential`) presigned-URL parameters that were previously logged in full.
27
+ - **A failure capturing an intercepted request's body (e.g. an already-consumed `body_stream`) no longer prevents the real request from being attempted.** The capture happened before any error handling was in place, so an exception there escaped `NetHttpInterceptor#request` entirely — the host app's actual LLM call never happened. It's now rescued, logged, and treated as "body unavailable," and the real request still proceeds.
28
+ - **Streamed response bodies are no longer buffered into a thread-local for requests this gem never intercepted.** `ResponseInterceptor#read_body` previously buffered every block-form `read_body` call process-wide, regardless of whether the enclosing request matched `intercept_addresses` — an unbounded memory growth path on any thread that streams large non-LLM responses (file downloads, etc.). Buffering now only happens while a `NetHttpInterceptor#request` call is actively capturing.
29
+ - **A request made from inside another intercepted request's streaming callback no longer corrupts the outer request's logged response body.** The per-thread stream buffer is now saved and restored around each intercepted request instead of unconditionally cleared, so nested/reentrant LLM calls (e.g. a second provider call triggered from a chunk handler) no longer mix one request's content into another's log entry.
30
+ - **Coolhand's own request to its backend API is no longer re-intercepted and re-logged.** If a self-hosted `base_url` or custom `intercept_addresses` entry happens to match Coolhand's own API host, the log-shipping request itself would previously be captured and logged like any other request — which itself gets logged, recursively, without bound. It's now wrapped in `Coolhand.without_capture`.
31
+ - `Coolhand::OpenAi::WebhookValidator#webhook_secret` is no longer a public reader (it's only used internally) — reduces the chance of the raw secret ending up in error-tracker breadcrumbs or view-rendered controller state.
32
+ - `YAML.load_file` → `YAML.safe_load_file` for the gem's own bundled default-addresses/patterns YAML files — defense-in-depth against unsafe deserialization, consistent with modern Psych defaults.
33
+ - Added missing `require "openssl"` (`WebhookValidator`) and `require "stringio"` (`NetHttpInterceptor`) instead of relying on both being transitively loaded by something else first.
34
+
35
+ ### Fixed
36
+ - **Vertex batch result logging** — `Coolhand::Vertex::BatchResultProcessor` now sends a fully-qualified URL (`https://aiplatform.googleapis.com/v1/<resource name>`) instead of the bare Vertex job resource name, plus explicit `source_api` and `model` values, all inside `raw_request`. The Coolhand API treats `raw_request` as free-form JSON, so this improves the data available to its classification without depending on new top-level payload fields being accepted. Also fixed: `timestamp`/`completed_at` are now sent as proper ISO 8601 strings instead of raw `Time` objects, and the URL is now run through the same `sanitize_url` helper used by every other logging path in the gem, for consistency (a Vertex batch URL can't actually carry a redactable query string today, so this is defense-in-depth rather than a fix for a live leak) (#76).
37
+ - **`require "coolhand"` no longer requires `faraday`.** The gem hasn't used Faraday directly since the 0.4.0 unified `NetHttpInterceptor`, but a stale top-level `require "faraday"` meant loading the gem could raise `LoadError` for anyone without faraday installed — it was never a declared dependency. If your app relied on `require "coolhand"` transitively loading Faraday, require it yourself.
38
+ - `Coolhand::Vertex::BatchResultProcessor`, `Coolhand::OpenAi::BatchResultProcessor`, and `Coolhand::OpenAi::WebhookValidator` can now each be required independently (e.g. `require "coolhand/vertex/batch_result_processor"`) without first requiring `"coolhand"` — previously this could raise `NameError` depending on load order.
39
+ - `Coolhand::BaseInterceptor.send_complete_request_log` now runs `request_headers`/`response_headers` through `sanitize_headers` itself, rather than trusting every caller to have pre-sanitized them. `NetHttpInterceptor` already sanitized before calling it (`sanitize_headers` is idempotent, so no behavior change there); this closes the gap for any future caller that forgets.
40
+ - `Coolhand::Vertex::BatchResultProcessor#handle_failed_batch` no longer raises `NoMethodError` when a failed batch's `error` field is missing or not a Hash — it now logs the batch-failure message either way instead of falling through to the generic "failed to process" catch-all.
41
+ - **README's "Request Flow" section corrected** — it previously claimed data is sent to the Coolhand API "asynchronously in a background thread" with "zero performance impact"; it's actually sent inline, after the original response is available, bounded by the 5-second timeout described in 0.5.0's Security section.
42
+
43
+ ## [0.5.0] - 2026-07-30
44
+
45
+ ### Changed
46
+ - **`llm_request_log_id` and `workload_id` in feedback API responses are now hashid strings, not raw integers** — the Coolhand API now returns these as hashids, matching every other external-facing identifier on the record (they previously leaked the raw integer foreign key). This gem never typed or coerced these fields (plain hashes throughout), so no code changes are required here, but if your application stores or compares `result[:llm_request_log_id]` or `result[:workload_id]` as an integer, update it to treat the value as an opaque string identifier instead. The `create_feedback`/`update_feedback` input fields (`llm_request_log_id`, `workload_hashid`) are unaffected — they still accept either a raw integer or a hashid string.
47
+ - **`id` in feedback API responses has actually been a hashid string for some time** — flagging here since it's the same category of field; no gem-level change needed since this was never typed.
48
+ - `BaseInterceptor.sanitize_headers` and `LoggerService#sanitize_headers` (used for the main request/response logging path and webhook forwarding, respectively) now share the same sensitive-header pattern instead of each maintaining its own list, so both paths redact consistently.
49
+
50
+ ### Security
51
+ - **AWS Bedrock SigV4 session tokens (`X-Amz-Security-Token`) are now redacted before logging.** The interceptor's header sanitizer used a hardcoded list of known API-key header names (`api-key`, `x-api-key`, `x-goog-api-key`, `openai-api-key`) instead of a general pattern, so this header — present on requests signed with temporary/STS credentials, the common case for Bedrock — was forwarded to the Coolhand backend and printed in `debug_mode` unredacted. The sanitizer now redacts any header whose name matches `key`, `token`, `secret`, `signature`, or `authorization`, closing this and similar gaps for any current or future provider header.
52
+ - **`debug_mode`'s "skipping capture" log line no longer prints the raw URL.** When a request matched `exclude_api_patterns` while `debug_mode` was on, the log line bypassed the usual URL sanitizer, so a Gemini/Vertex `?key=...` query-param API key could be printed in full. It now goes through the same URL sanitizer as every other log line.
53
+ - **Outbound requests to the Coolhand backend now set a 5-second connect/read timeout.** Previously this call had no explicit timeout and ran inline on the same thread as the intercepted LLM request, so a slow or unreachable Coolhand endpoint (including a self-hosted `base_url`) could add Ruby's ~60s Net::HTTP default (up to ~120s total) of latency to real LLM calls made by the host app.
54
+
55
+ ### Removed
56
+ - Deleted unused `BaseInterceptor` methods with no callers anywhere in the gem: `extract_response_data`, `extract_usage_metadata`, `clean_request_headers`, `clean_response_headers`. The latter two duplicated `sanitize_headers` with a narrower, case-sensitive header list and were never wired into any interceptor — dead code carrying its own security debt.
57
+
58
+ ### Dependencies
59
+ - Bumped `faraday` from 2.14.2 to 2.14.3 (#73).
60
+
10
61
  ## [0.4.0] - 2026-06-22
11
62
 
12
63
  ### Added
data/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/coolhand.svg)](https://badge.fury.io/rb/coolhand)
4
4
 
5
- Monitor and log LLM API calls from multiple providers (OpenAI, Anthropic, Google AI, Cohere, and more) to the Coolhand analytics platform.
5
+ Monitor and log LLM API calls OpenAI, Anthropic, Google Gemini, Cohere, and any Faraday-based or Net::HTTP client — to the Coolhand analytics platform. Supports Ruby LLM monitoring, request logging, and feedback collection.
6
+
7
+ > **Scope**: Coolhand intercepts outgoing HTTP requests to configured LLM API endpoints only. It does not read, scan, or transmit your source code files.
6
8
 
7
9
  ## Installation
8
10
 
@@ -37,38 +39,21 @@ end
37
39
  # ✅ OpenAI SDK calls
38
40
  # ✅ Anthropic API calls
39
41
  # ✅ Direct HTTP requests to AI APIs
40
- # ✅ ANY library making AI API calls via Faraday
42
+ # ✅ Any library making AI API calls via Faraday's default adapter
41
43
 
42
44
  # NO code changes needed in your existing services!
43
45
  ```
44
46
 
45
47
  **✨ Why Automatic Monitoring:**
46
48
  - 🚫 **Zero refactoring** - No code changes to existing services
47
- - 📊 **Complete coverage** - Monitors ALL AI libraries using Faraday automatically
49
+ - 📊 **Complete coverage** - Monitors AI libraries using Net::HTTP or Faraday's default adapter automatically
48
50
  - 🔒 **Security built-in** - Automatic credential sanitization
49
51
  - ⚡ **Performance optimized** - Negligible overhead via async logging
50
52
  - 🛡️ **Future-proof** - Automatically captures new AI calls added by your team
51
53
 
52
54
  ## Self-Hosted Deployments
53
55
 
54
- For compliance, data-residency, or cost reasons you can run your own Coolhand-compatible endpoint and point the SDK at it via `config.base_url`:
55
-
56
- ```ruby
57
- Coolhand.configure do |config|
58
- config.api_key = ENV['COOLHAND_API_KEY']
59
- config.base_url = ENV['COOLHAND_BASE_URL'] # e.g. "https://coolhand.internal.example.com/api"
60
- end
61
- ```
62
-
63
- When `base_url` is unset the SDK defaults to `https://coolhandlabs.com/api` and behaviour is unchanged.
64
-
65
- **Accepted values:**
66
- - Any `https://` URL — required for production use
67
- - `http://localhost` or `http://127.0.0.1` — accepted for local development only
68
-
69
- **Trailing slashes** are stripped automatically, so `"https://example.com/api/"` and `"https://example.com/api"` are equivalent.
70
-
71
- The SDK raises `Coolhand::Error` at configure time if `base_url` is set to a plain `http://` URL pointing at a non-localhost host.
56
+ Point the SDK at your own Coolhand-compatible endpoint via `config.base_url` for compliance or data-residency requirements. See [Self-Hosted Deployments →](docs/configuration.md).
72
57
 
73
58
  ## Feedback API
74
59
 
@@ -80,10 +65,10 @@ Collect feedback on LLM responses to improve model performance.
80
65
  require 'coolhand'
81
66
 
82
67
  # Create feedback for an LLM response
83
- feedback_service = Coolhand::FeedbackService.new(Coolhand.configuration)
68
+ feedback_service = Coolhand::FeedbackService.new
84
69
 
85
70
  feedback = feedback_service.create_feedback(
86
- llm_request_log_id: 123,
71
+ llm_request_log_id: 'abc123def456', # hashid from a prior response; a raw integer FK also still works
87
72
  llm_provider_unique_id: 'req_xxxxxxx',
88
73
  client_unique_id: 'workorder-chat-456',
89
74
  creator_unique_id: 'user-789',
@@ -94,22 +79,7 @@ feedback = feedback_service.create_feedback(
94
79
  )
95
80
  ```
96
81
 
97
- **Field Guide:** All fields are optional, but here's how to get the best results:
98
-
99
- ### Matching Fields
100
- - **`llm_request_log_id`** 🎯 *Exact Match* - ID from the Coolhand API response when the original LLM request was logged. Provides exact matching.
101
- - **`llm_provider_unique_id`** 🎯 *Exact Match* - The x-request-id from the LLM API response (e.g., "req_xxxxxxx")
102
- - **`original_output`** 🔍 *Fuzzy Match* - The original LLM response text. Provides fuzzy matching but isn't 100% reliable.
103
- - **`client_unique_id`** 🔗 *Your Internal Matcher* - Connect to an identifier from your system for internal matching
104
-
105
- ### Quality Data
106
- - **`revised_output`** ⭐ *Best Signal* - End user revision of the LLM response. The highest value data for improving quality scores.
107
- - **`explanation`** 💬 *Medium Signal* - End user explanation of why the response was good or bad. Valuable qualitative data.
108
- - **`sentiment`** 🎭 *Preferred* - String sentiment: `'like'`, `'dislike'`, or `'neutral'`. Takes precedence over `like` if both are provided. The gem automatically converts `like` to `sentiment` before sending.
109
- - **`like`** 👍 *Low Signal (Deprecated)* - Boolean: `true` = like, `false` = dislike. Use `sentiment` instead. Conversion: `true` → `"like"`, `false` → `"dislike"`.
110
- - **`workload_hashid`** 🔗 *Workload Association* - Hashid of a workload to associate this feedback with.
111
- - **`creator_unique_id`** 👤 *User Tracking* - Unique ID to match feedback to the end user who created it
112
- - **`creator_type`** 🧑‍🤝‍🤖 *Creator Type* - What kind of creator submitted the feedback: `'human'`, `'agent'`, or `'unknown'`.
82
+ For a full field reference and matching strategy, see [Feedback API →](docs/feedback.md).
113
83
 
114
84
  ## Rails Integration
115
85
 
@@ -138,7 +108,7 @@ end
138
108
  ```ruby
139
109
  class ChatController < ApplicationController
140
110
  def create_feedback
141
- feedback_service = Coolhand::FeedbackService.new(Coolhand.configuration)
111
+ feedback_service = Coolhand::FeedbackService.new
142
112
 
143
113
  feedback = feedback_service.create_feedback(
144
114
  llm_request_log_id: params[:log_id],
@@ -163,7 +133,7 @@ end
163
133
  ```ruby
164
134
  class FeedbackCollectionJob < ApplicationJob
165
135
  def perform(feedback_data)
166
- feedback_service = Coolhand::FeedbackService.new(Coolhand.configuration)
136
+ feedback_service = Coolhand::FeedbackService.new
167
137
 
168
138
  feedback_service.create_feedback(
169
139
  llm_provider_unique_id: feedback_data[:request_id],
@@ -184,6 +154,7 @@ end
184
154
  |--------|------|---------|-------------|
185
155
  | `api_key` | String | `nil` | Your Coolhand API key. If absent, intercepted requests are skipped with a warning log rather than raising at boot time |
186
156
  | `enabled` | Boolean | `true` | Set to `false` to disable all patching and validation (e.g. `Rails.env.production?`) |
157
+ | `capture` | Boolean | `true` | Whether to capture and forward intercepted requests. Set to `false` to monitor without forwarding, then use [`Coolhand.with_capture`](#selective-capture) to re-enable selectively |
187
158
  | `silent` | Boolean | `false` | Whether to suppress console output |
188
159
  | `intercept_addresses` | Array | `["api.openai.com", "api.anthropic.com"]` | Array of API endpoint strings to monitor |
189
160
 
@@ -217,6 +188,38 @@ puts response.dig("choices", 0, "message", "content")
217
188
 
218
189
  📖 **[Complete Anthropic Integration Guide →](docs/anthropic.md)** - Supports both official and community gems with automatic detection
219
190
 
191
+ ### Selective Capture
192
+
193
+ Use `Coolhand.with_capture` and `Coolhand.without_capture` to override the global `capture` setting for a specific block of code, without changing your configuration.
194
+
195
+ **Capture a specific call when capture is globally disabled:**
196
+
197
+ ```ruby
198
+ Coolhand.configure do |config|
199
+ config.api_key = 'your_api_key_here'
200
+ config.capture = false # disabled globally
201
+ end
202
+
203
+ Coolhand.with_capture do
204
+ response = openai_client.chat(parameters: {
205
+ model: "gpt-4o-mini",
206
+ messages: [{ role: "user", content: "Hello!" }],
207
+ })
208
+ # This request is captured and forwarded to Coolhand
209
+ end
210
+ ```
211
+
212
+ **Skip capture for a specific call when capture is globally enabled:**
213
+
214
+ ```ruby
215
+ Coolhand.without_capture do
216
+ response = openai_client.chat(parameters: { ... })
217
+ # This request is NOT forwarded to Coolhand
218
+ end
219
+ ```
220
+
221
+ Both methods are thread-safe. Blocks nest correctly — the innermost block takes precedence, and the outer setting is restored when the block exits.
222
+
220
223
  ## Logging Inbound Webhooks
221
224
 
222
225
  For inbound webhooks (like audio transcripts or tool calls), the automatic interceptor won't capture them since they're incoming requests TO your application. In these cases, use the simple `forward_webhook` helper method:
@@ -294,39 +297,45 @@ The filtering is automatic and applies to all monitored API calls and webhook lo
294
297
 
295
298
  The monitor works with multiple transport layers and Ruby libraries:
296
299
 
297
- **Faraday-based libraries:**
300
+ **Faraday-based libraries** (via Faraday's default `net_http` adapter — a non-Net::HTTP adapter such as `faraday-typhoeus` is not covered):
298
301
  - OpenAI Ruby SDK
299
302
  - ruby-anthropic gem (community Anthropic gem)
300
303
  - ruby-openai gem
301
304
  - LangChain.rb
302
- - Direct Faraday requests
303
- - Any other Faraday-based HTTP client
305
+ - Direct Faraday requests using the default adapter
304
306
 
305
307
  **Native HTTP libraries:**
306
308
  - Official Anthropic Ruby SDK (using Net::HTTP)
307
309
  - GitHub Models SDK / any client using `models.github.ai`
308
310
  - Any library using Net::HTTP directly
309
311
 
312
+ **Other providers monitored out of the box:**
313
+ - Google Gemini (`generativelanguage.googleapis.com`)
314
+ - Google Vertex AI (`aiplatform.googleapis.com`) — see the [Vertex AI batch result logging guide](docs/vertex.md) for async batch jobs
315
+ - AWS Bedrock (OpenAI-compatible endpoint)
316
+ - Cloudflare AI Gateway
317
+ - OpenRouter
318
+
310
319
  **Universal Coverage**: Since most Ruby HTTP libraries use Net::HTTP under the hood, Coolhand's single interceptor provides comprehensive monitoring without needing library-specific integrations.
311
320
 
312
321
  ## How It Works
313
322
 
314
- Coolhand uses a unified Net::HTTP interceptor to monitor all HTTP traffic to configured LLM endpoints:
323
+ Coolhand uses a unified Net::HTTP interceptor to capture outgoing requests to configured LLM API endpoints:
315
324
 
316
325
  ### Net::HTTP Interceptor
317
- - Patches Ruby's core `Net::HTTP` library using `Module#prepend`
318
- - Monitors **all** HTTP libraries that use Net::HTTP under the hood (which is most of them)
326
+ - Extends Ruby's `Net::HTTP` using `Module#prepend` (a standard Ruby technique for wrapping library behavior)
327
+ - Covers HTTP libraries that delegate to Net::HTTP under the hood (which is most of them)
319
328
  - Handles both standard requests and streaming responses via `read_body` interception
320
329
  - Thread-safe design using thread-local storage for streaming buffers
321
330
 
322
331
  ### Request Flow
323
332
  When a request matches configured LLM endpoints:
324
333
 
325
- 1. The original request executes normally with zero performance impact
334
+ 1. The original request executes normally
326
335
  2. Request and response data (body, headers, status) are captured by the interceptor
327
336
  3. For streaming requests, the complete accumulated response is captured (not individual chunks)
328
- 4. Data is sent to the Coolhand API asynchronously in a background thread
329
- 5. Your application continues without interruption
337
+ 4. Data is sent to the Coolhand API inline, after the original response is available (bounded by a 5-second connect/read timeout so a slow or unreachable Coolhand backend can't hang your request indefinitely)
338
+ 5. Your application continues once the log is sent (or times out) — the original request/response is never blocked on Coolhand being reachable
330
339
 
331
340
  For non-matching endpoints, requests pass through unchanged.
332
341
 
@@ -392,106 +401,14 @@ The monitor handles errors gracefully:
392
401
  - Invalid API keys will be reported but won't crash your app
393
402
  - Network issues are handled with appropriate error messages
394
403
 
404
+ ## Documentation
395
405
 
396
- ## Batch webhook handler (OpenAI)
397
-
398
- Automatically handle OpenAI batch event logs (batch.completed, batch.failed, batch.expired, batch.cancelled)
399
- by intercepting webhook requests and enqueuing your batch result processor.
400
-
401
- Usage:
402
- - Include the interceptor in your controller:
403
- include Coolhand::WebhookInterceptor
404
- - Add the before_action to validate and populate @validator payload:
405
- before_action :intercept_batch_request, only: :openai
406
- - Ensure you skip CSRF for the webhook endpoint:
407
- skip_before_action :verify_authenticity_token
408
- - Override the webhook_secret method to return your OpenAI webhook secret
409
-
410
- Minimal example (only key lines shown):
411
-
412
- ```ruby
413
- # app/controllers/webhooks/batch_api_requests_controller.rb
414
- # ...existing code...
415
- include Coolhand::WebhookInterceptor
416
-
417
- skip_before_action :verify_authenticity_token
418
- before_action :intercept_batch_request, only: :openai
419
-
420
- def openai
421
- event = JSON.parse(@validator.payload)
422
- case event["type"]
423
- when "batch.completed", "batch.failed", "batch.expired", "batch.cancelled"
424
- batch_id = event.dig("data", "id")
425
- batch_request = BatchApiRequest.find_by(provider: "openai", provider_batch_id: batch_id)
426
-
427
- if batch_request
428
- OpenAi::BatchResultProcessor.perform_async(batch_request.id)
429
- Rails.logger.info("Queued batch result processing for BatchApiRequest #{batch_request.id}")
430
- else
431
- Rails.logger.warn("Could not find BatchApiRequest for OpenAI batch ID: #{batch_id}")
432
- end
433
- else
434
- Rails.logger.info("Unhandled OpenAI webhook event type: #{event["type"]}")
435
- end
436
-
437
- head :ok
438
- rescue JSON::ParserError
439
- head :bad_request
440
- rescue StandardError => e
441
- Rails.logger.error("OpenAI webhook error: #{e.message}")
442
- head :internal_server_error
443
- end
444
-
445
- def webhook_secret
446
- Rails.application.credentials.openai_webhook_secret
447
- end
448
- # ...existing code...
449
- ```
450
-
451
- ## Batch webhook handler (Vertex)
452
-
453
- Automatically handle Vertex batch event logs.
454
-
455
- Usage:
456
- - call Coolhand::Vertex::BatchResultProcessor service with batch_info and download batch results
457
-
458
- Minimal example (only key lines shown):
459
-
460
- ```ruby
461
- class Vertex::BatchCallbackProcessor < BaseService
462
- option :batch_request, model: BatchApiRequest
463
- option :batch_info
464
-
465
- def call
466
- case batch_info["state"]
467
- when "JOB_STATE_PENDING"
468
- nil
469
- when "JOB_STATE_RUNNING", "JOB_STATE_QUEUED"
470
- batch_request.update!(status: "processing")
471
-
472
- Coolhand::Vertex::BatchResultProcessor.new(batch_info:).call
473
- when "JOB_STATE_SUCCEEDED"
474
- output_file_id = batch_info["outputInfo"]["gcsOutputDirectory"]
475
- results = download_batch_results(output_file_id)
476
- results.each { |batch_item| process_batch_result(batch_item) }
477
-
478
- batch_request.update!(status: "completed", completed_at: Time.current, output_file_id:)
479
-
480
- Coolhand::Vertex::BatchResultProcessor.new(batch_info:).call(results)
481
-
482
- # Clean up GCS files after successful processing
483
- cleanup_gcs_files(output_file_id)
484
- when "JOB_STATE_FAILED"
485
- handle_failed_batch(batch_info["error"]["message"])
486
- end
487
- end
488
- end
489
- ```
490
-
491
- ## Integration Guides
492
-
493
- - **[Anthropic Integration](docs/anthropic.md)** - Complete guide for both official and community Anthropic gems, including streaming, dual gem handling, and troubleshooting
494
- - **[ElevenLabs Integration](docs/elevenlabs.md)** - Complete guide for integrating ElevenLabs Conversational AI with webhook capture and feedback submission
406
+ - **[Configuration](docs/configuration.md)** Self-hosted deployments, base_url rules, debug mode, custom intercept addresses
407
+ - **[Feedback API](docs/feedback.md)** — Full field reference, matching strategies, sentiment values
408
+ - **[Anthropic Integration](docs/anthropic.md)** Official and community Anthropic Ruby gems, streaming, dual gem handling, and troubleshooting
409
+ - **[ElevenLabs Integration](docs/elevenlabs.md)** Webhook capture, feedback submission, and Rails integration
410
+ - **[OpenAI Batch Webhook Handler](docs/openai.md)** — Handle OpenAI batch job completion events via webhook interception
411
+ - **[Google Vertex AI Batch Result Logging](docs/vertex.md)** — Log completed Vertex AI batch prediction job results
495
412
 
496
413
  ## Security
497
414
 
@@ -511,6 +428,10 @@ end
511
428
  - **Contribute?** [Submit a pull request](https://github.com/Coolhand-Labs/coolhand-ruby/pulls)
512
429
  - **Support?** Visit [coolhandlabs.com](https://coolhandlabs.com)
513
430
 
431
+ ## About Coolhand Labs
432
+
433
+ Coolhand Labs builds LLM observability and feedback tooling so teams can monitor, understand, and improve their AI applications. Learn more at [coolhandlabs.com](https://coolhandlabs.com).
434
+
514
435
  ## License
515
436
 
516
437
  Apache-2.0
data/SECURITY.md ADDED
@@ -0,0 +1,20 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ Only the latest published version of `coolhand` on RubyGems is supported with security fixes.
6
+ Please upgrade to the latest version before reporting an issue.
7
+
8
+ ## Reporting a Vulnerability
9
+
10
+ Please do **not** open a public GitHub issue for security vulnerabilities.
11
+
12
+ Instead, report vulnerabilities privately using one of the following:
13
+
14
+ - [GitHub Security Advisories](https://github.com/Coolhand-Labs/coolhand-ruby/security/advisories/new)
15
+ for this repository (preferred)
16
+ - Email team@coolhandlabs.com
17
+
18
+ Please include a description of the vulnerability, steps to reproduce, and the impact you believe
19
+ it has. We aim to acknowledge reports within 3 business days and to provide a fix or mitigation
20
+ plan within 30 days, depending on severity.
@@ -0,0 +1,59 @@
1
+ # Advanced Configuration
2
+
3
+ ## Self-Hosted Deployments
4
+
5
+ For compliance, data-residency, or cost reasons you can run your own Coolhand-compatible endpoint and point the SDK at it via `config.base_url`:
6
+
7
+ ```ruby
8
+ Coolhand.configure do |config|
9
+ config.api_key = ENV['COOLHAND_API_KEY']
10
+ config.base_url = ENV['COOLHAND_BASE_URL'] # e.g. "https://coolhand.internal.example.com/api"
11
+ end
12
+ ```
13
+
14
+ When `base_url` is unset the SDK defaults to `https://coolhandlabs.com/api` and behaviour is unchanged.
15
+
16
+ **URL validation rules:**
17
+ - Any `https://` URL — required for production use
18
+ - `http://localhost` or `http://127.0.0.1` — accepted for local development only
19
+ - Non-HTTPS remote URLs are rejected: the SDK raises `Coolhand::Error` at configure time if `base_url` is set to a plain `http://` URL pointing at a non-localhost host
20
+
21
+ **Trailing slashes** are stripped automatically, so `"https://example.com/api/"` and `"https://example.com/api"` are equivalent.
22
+
23
+ ---
24
+
25
+ ## Debug Mode
26
+
27
+ `config.debug_mode` is a local-development aid: it prints every prepared payload to the console instead of sending it to the Coolhand API (or your self-hosted `base_url`), so you can inspect exactly what would be logged without an API key or network access.
28
+
29
+ ```ruby
30
+ Coolhand.configure do |config|
31
+ config.debug_mode = Rails.env.development?
32
+ end
33
+ ```
34
+
35
+ **What changes when `debug_mode` is `true`:**
36
+ - No HTTP request is made — `create_log`, `create_feedback`, and `send_llm_request_log` all return `nil` and print the payload via `JSON.pretty_generate` instead.
37
+ - Capture is forced on for every request, including inside a `Coolhand.without_capture` block and when `config.capture = false`. This is intentional — debug mode is meant to show you everything, not respect capture suppression — so don't leave it enabled in an environment where you rely on `without_capture` to keep specific calls out of the logs.
38
+ - Header and URL sanitization (`[REDACTED]` API keys/tokens, redacted `key=`/`token=` query params) still applies to the printed payload, exactly as it would to a real request.
39
+
40
+ Because it forces capture unconditionally and prints full request/response bodies to the console, only enable `debug_mode` in development — never in production or in an environment handling real user data.
41
+
42
+ ## Custom Intercept Addresses
43
+
44
+ By default Coolhand captures requests to a built-in list of LLM API hosts (OpenAI, Anthropic, Google Gemini, ElevenLabs, GitHub Models, and more). To capture a custom endpoint — an internal proxy, a self-hosted model server, or a third-party gateway — override `intercept_addresses`:
45
+
46
+ ```ruby
47
+ Coolhand.configure do |config|
48
+ config.api_key = ENV['COOLHAND_API_KEY']
49
+ config.intercept_addresses = [
50
+ 'my-llm-proxy.internal',
51
+ 'api.openai.com', # include the defaults you still want
52
+ 'api.anthropic.com',
53
+ ]
54
+ end
55
+ ```
56
+
57
+ Setting `intercept_addresses` **replaces** the default list entirely, so include any default hosts you still need.
58
+
59
+ The default list can be found in `Coolhand::Configuration::DEFAULT_INTERCEPT_ADDRESSES`.
data/docs/feedback.md ADDED
@@ -0,0 +1,79 @@
1
+ # Feedback API
2
+
3
+ Collect user feedback on LLM responses to improve your AI outputs. The Feedback API lets you capture sentiment ratings, explanations, and human-corrected outputs.
4
+
5
+ > **Frontend widget:** For browser-based feedback collection, see [coolhand-js](https://github.com/Coolhand-Labs/coolhand-js) — a lightweight JavaScript widget that captures actionable user feedback on any AI output.
6
+
7
+ ## Basic Usage
8
+
9
+ ```ruby
10
+ require 'coolhand'
11
+
12
+ feedback_service = Coolhand::FeedbackService.new
13
+
14
+ # Positive feedback linked by log ID (most reliable)
15
+ feedback_service.create_feedback(
16
+ llm_request_log_id: 'abc123def456', # hashid from a prior response; a raw integer FK also still works
17
+ sentiment: 'like',
18
+ explanation: 'Clear and accurate answer.',
19
+ )
20
+
21
+ # Negative feedback with a human correction
22
+ feedback_service.create_feedback(
23
+ original_output: 'The capital of France is London.',
24
+ sentiment: 'dislike',
25
+ revised_output: 'The capital of France is Paris.',
26
+ explanation: 'Factually wrong.',
27
+ )
28
+ ```
29
+
30
+ ---
31
+
32
+ ## Field Reference
33
+
34
+ All fields are optional. Use at least one **Matching Field** to link feedback to its originating LLM request.
35
+
36
+ ### Matching Fields
37
+
38
+ These fields identify which LLM request the feedback refers to. Use the most specific one available.
39
+
40
+ | Field | Match type | Description |
41
+ |---|---|---|
42
+ | `llm_request_log_id` | Exact | Hashid returned when the original request was logged (a raw integer FK is also still accepted for backward compatibility). Most reliable. |
43
+ | `llm_provider_unique_id` | Exact | The provider's own request ID (e.g. `x-request-id` from Anthropic or OpenAI). |
44
+ | `client_unique_id` | Exact | Your own internal identifier for the request (e.g. a database row ID). |
45
+ | `original_output` | Fuzzy | The raw text the LLM produced. Used for fuzzy matching when no ID is available — less reliable. |
46
+
47
+ ### Quality Signals
48
+
49
+ | Field | Signal strength | Description |
50
+ |---|---|---|
51
+ | `revised_output` | ⭐ Best | The human-corrected version of the LLM output. Highest-value signal for quality improvement. |
52
+ | `explanation` | Medium | Free-text reason the response was good or bad. |
53
+ | `sentiment` | Low–Medium | `"like"`, `"dislike"`, or `"neutral"`. **Preferred** over the deprecated `like` boolean. |
54
+ | `like` | Low (deprecated) | Boolean: `true` = like, `false` = dislike. Auto-converted to `sentiment` before submission. Use `sentiment` instead. |
55
+
56
+ ### Attribution Fields
57
+
58
+ | Field | Description |
59
+ |---|---|
60
+ | `creator_unique_id` | ID of the user providing feedback (for per-user quality tracking). |
61
+ | `creator_type` | Who submitted the feedback: `"human"`, `"agent"`, or `"unknown"`. |
62
+ | `workload_hashid` | Associate feedback with a specific workload in the Coolhand dashboard. |
63
+
64
+ ---
65
+
66
+ ## Sentiment Values
67
+
68
+ | Value | Meaning |
69
+ |---|---|
70
+ | `"like"` | The response was helpful / correct |
71
+ | `"dislike"` | The response was unhelpful / wrong |
72
+ | `"neutral"` | Neither good nor bad (e.g. factual but irrelevant) |
73
+
74
+ The deprecated `like: bool` field is still accepted and automatically converted:
75
+
76
+ | `like` (bool) | Equivalent `sentiment` |
77
+ |---|---|
78
+ | `true` | `"like"` |
79
+ | `false` | `"dislike"` |
data/docs/openai.md ADDED
@@ -0,0 +1,60 @@
1
+ # OpenAI Batch Webhook Handler
2
+
3
+ Automatically handle OpenAI batch event logs (`batch.completed`, `batch.failed`, `batch.expired`, `batch.cancelled`) by intercepting webhook requests and logging completed batch results to Coolhand.
4
+
5
+ For monitoring regular (non-batch) OpenAI API calls, see the [OpenAI client example](../README.md#with-openai-ruby-client) in the main README — no extra setup is required beyond `Coolhand.configure`.
6
+
7
+ Requires Rails — `Coolhand::WebhookInterceptor` and `Coolhand::OpenAi::BatchResultProcessor` log via `Rails.logger` internally. `config.capture = false` and `Coolhand.without_capture` do not suppress these logs: unlike the passive Net::HTTP interceptor, `intercept_batch_request` dispatching to `Coolhand::OpenAi::BatchResultProcessor` is an explicit, deliberate act, so it always sends.
8
+
9
+ ## Usage
10
+
11
+ - Add the `openai` gem to your Gemfile — `Coolhand::OpenAi::BatchResultProcessor` needs it to download batch result files.
12
+ - Include the interceptor in your controller: `include Coolhand::WebhookInterceptor`
13
+ - Add the `before_action` to validate and populate the `@validator` payload: `before_action :intercept_batch_request, only: :openai`
14
+ - Ensure you skip CSRF for the webhook endpoint: `skip_before_action :verify_authenticity_token`
15
+ - Override the `webhook_secret` method to return your OpenAI webhook secret
16
+
17
+ `intercept_batch_request` already validates the webhook and, for `batch.completed`/`batch.failed`/`batch.expired`/`batch.cancelled` events, calls `Coolhand::OpenAi::BatchResultProcessor` **synchronously**, inline in the `before_action` — that part requires no code in your controller action. It downloads two JSONL files from OpenAI and sends one request log per batch item, so a large batch can take long enough to trip your webhook endpoint's timeout (and OpenAI's retry behavior); consider that when sizing batches. The example below shows a controller action doing additional, app-specific work on top of that (e.g. updating your own `BatchApiRequest` record and enqueuing your own background job), which is optional.
18
+
19
+ ## Minimal example
20
+
21
+ Only the key lines are shown — wire this into your own controller and background job setup.
22
+
23
+ ```ruby
24
+ # app/controllers/webhooks/batch_api_requests_controller.rb
25
+ # ...existing code...
26
+ include Coolhand::WebhookInterceptor
27
+
28
+ skip_before_action :verify_authenticity_token
29
+ before_action :intercept_batch_request, only: :openai
30
+
31
+ def openai
32
+ event = JSON.parse(@validator.payload)
33
+ case event["type"]
34
+ when "batch.completed", "batch.failed", "batch.expired", "batch.cancelled"
35
+ batch_id = event.dig("data", "id")
36
+ batch_request = BatchApiRequest.find_by(provider: "openai", provider_batch_id: batch_id)
37
+
38
+ if batch_request
39
+ MyApp::BatchResultJob.perform_async(batch_request.id)
40
+ Rails.logger.info("Queued batch result processing for BatchApiRequest #{batch_request.id}")
41
+ else
42
+ Rails.logger.warn("Could not find BatchApiRequest for OpenAI batch ID: #{batch_id}")
43
+ end
44
+ else
45
+ Rails.logger.info("Unhandled OpenAI webhook event type: #{event["type"]}")
46
+ end
47
+
48
+ head :ok
49
+ rescue JSON::ParserError
50
+ head :bad_request
51
+ rescue StandardError => e
52
+ Rails.logger.error("OpenAI webhook error: #{e.message}")
53
+ head :internal_server_error
54
+ end
55
+
56
+ def webhook_secret
57
+ Rails.application.credentials.openai_webhook_secret
58
+ end
59
+ # ...existing code...
60
+ ```