coolhand 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.harness.md +139 -0
- data/CHANGELOG.md +64 -0
- data/README.md +46 -105
- data/docs/configuration.md +40 -0
- data/docs/openai.md +91 -0
- data/docs/template-search.md +218 -0
- data/docs/vertex.md +54 -0
- data/lib/coolhand/api_service.rb +56 -16
- data/lib/coolhand/base_interceptor.rb +58 -38
- data/lib/coolhand/configuration.rb +39 -6
- data/lib/coolhand/default_exclude_api_patterns.yml +3 -2
- data/lib/coolhand/default_intercept_addresses.yml +10 -5
- data/lib/coolhand/default_intercept_path_patterns.yml +12 -0
- data/lib/coolhand/errors.rb +19 -0
- data/lib/coolhand/logger_service.rb +1 -1
- data/lib/coolhand/net_http_interceptor.rb +154 -24
- data/lib/coolhand/open_ai/batch_result_processor.rb +3 -1
- data/lib/coolhand/open_ai/webhook_id_store.rb +45 -0
- data/lib/coolhand/open_ai/webhook_validator.rb +50 -12
- data/lib/coolhand/pagination.rb +82 -0
- data/lib/coolhand/read_requests.rb +77 -0
- data/lib/coolhand/template_service.rb +76 -0
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/vertex/batch_result_processor.rb +97 -40
- data/lib/coolhand/webhook_interceptor.rb +11 -0
- data/lib/coolhand.rb +10 -5
- metadata +16 -9
- data/.claude/skills/loop-review/SKILL.md +0 -112
- data/.claude/skills/prep-release/SKILL.md +0 -160
- data/.idea/coolhand-ruby.iml +0 -6
- data/CLAUDE.md +0 -47
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a3c1e767ea245f56520ee634e48cd2f12c6dc06be79b3998537fea9e9b8ea9ff
|
|
4
|
+
data.tar.gz: aad0485d474aa1e3f796f2af30083304a1ed704ede8428ddc4b0c9a4ff32678e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2b497e1401c64e9eb6a6b1bbb061ecb359985a39f8dd95ce8018406d36e3c49ba061e5557299d4e6ee00498f8f8bf91ac9f3b91e56a32f5b08af599f5ef16768
|
|
7
|
+
data.tar.gz: 84d13bff930f045607270849ad02fd5c8548b15b39f2043636c868bcfe857e04fab4c0ae113fe4e5872ca299d197c83b04c2f0adce5bffe3723c4bacd5890b03
|
data/AGENTS.harness.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# RUBY agent — API client harness
|
|
2
|
+
|
|
3
|
+
You are the **ruby agent**, working in the `coolhand-ruby` gem repo. You wrap one server
|
|
4
|
+
endpoint, prove it against the live local server, open a PR, and **stop**.
|
|
5
|
+
|
|
6
|
+
You are a dead end in the tree. You launch nobody.
|
|
7
|
+
|
|
8
|
+
This file is self-contained. You do not share a context window with the agent that
|
|
9
|
+
launched you.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 0. Your inputs
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
node <workspaceRoot>/coolhand/harness/harness.mjs context --run <RUN_DIR>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| field | meaning |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `baseUrl` | the live local server, already booted for you |
|
|
22
|
+
| `branch` | the shared branch name — use it here too |
|
|
23
|
+
| `specPath` | `coolhand/swagger/v2/coolhand_api.yaml` = **the API definition** |
|
|
24
|
+
| `dryRun` | if true, build and commit locally but **do not push and do not open a PR** — see `RESIST_RULES.md` → Dry runs |
|
|
25
|
+
|
|
26
|
+
Your channel is `ruby`. Your parent is `node`.
|
|
27
|
+
|
|
28
|
+
**Node opened a GitHub issue for you before it launched you.** That issue holds your
|
|
29
|
+
complete instructions and is the system of record for this work — read it first. Read your
|
|
30
|
+
own number back at any time with:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
node <workspaceRoot>/coolhand/harness/harness.mjs my-issue --run <RUN_DIR> --repo ruby
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 1. Read before writing any code
|
|
37
|
+
|
|
38
|
+
1. **Your issue.** It is what you were asked to build.
|
|
39
|
+
2. `<workspaceRoot>/coolhand/harness/RESIST_RULES.md` — the refuse list.
|
|
40
|
+
3. The API definition at `specPath`. It is your only source of truth **for the endpoint's
|
|
41
|
+
contract** — paths, params, response fields, status codes.
|
|
42
|
+
4. `coolhand-ruby/CLAUDE.md` — this repo's own rulebook. **It is authoritative.** Where it
|
|
43
|
+
disagrees with this harness file, CLAUDE.md wins — follow it and say so in your PR.
|
|
44
|
+
|
|
45
|
+
**Your issue links node's PR as the reference implementation. Use it for structure, not
|
|
46
|
+
for facts.** Node went first so you do not have to rediscover how a REST method fits into
|
|
47
|
+
a monitoring SDK — copy its *shape*: which class the method hangs off, how errors surface,
|
|
48
|
+
how pagination is exposed, what the method is called.
|
|
49
|
+
|
|
50
|
+
**Do not take a field name, a param, or a status code from node's code.** Those come from
|
|
51
|
+
the definition, every time. If node's wrapper and the definition disagree, that is not
|
|
52
|
+
yours to reconcile — it means one of them is wrong. Escalate (R3) and STOP.
|
|
53
|
+
|
|
54
|
+
Naming does not port. `searchFeedback` in node is `search_feedback` here. Match the
|
|
55
|
+
concept, not the characters.
|
|
56
|
+
|
|
57
|
+
## 2. Build the wrapper
|
|
58
|
+
|
|
59
|
+
1. `git checkout -b <branch>`
|
|
60
|
+
2. Add the method following the existing pattern in `lib/coolhand/` —
|
|
61
|
+
`api_service.rb` and `feedback_service.rb` are your references.
|
|
62
|
+
3. Require/expose it the way existing surface is exposed in `lib/coolhand.rb`.
|
|
63
|
+
4. Name the method in Ruby style (`search_feedback`), frozen string literal comment at top.
|
|
64
|
+
|
|
65
|
+
**Do not restructure the gem to make this fit (R5).** If the endpoint cannot be expressed
|
|
66
|
+
inside the current architecture, escalate and STOP.
|
|
67
|
+
|
|
68
|
+
**Never add a provider SDK `require` (openai, anthropic, google-generativeai, etc.) to
|
|
69
|
+
`lib/coolhand.rb`, and never add one to `coolhand-ruby.gemspec` as a hard dependency (R5).**
|
|
70
|
+
Require it inside the file that actually uses it, executed only when that provider's
|
|
71
|
+
functionality is accessed. Clients may not use every provider and must not be forced to
|
|
72
|
+
install gems they do not need — apps consuming this as a path gem break on missing optional
|
|
73
|
+
dependencies. See `coolhand-ruby/CLAUDE.md`.
|
|
74
|
+
|
|
75
|
+
## 3. Prove it against the real server
|
|
76
|
+
|
|
77
|
+
Not a mock. Make real calls to `baseUrl`.
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
bundle exec rake
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
That runs both RSpec and RuboCop — both must pass. **Never delete an assertion, mark a
|
|
84
|
+
spec pending, or rescue-and-swallow to get green (R4).**
|
|
85
|
+
|
|
86
|
+
**RSpec is the normal, correct test framework in this gem.** If you have seen the rule
|
|
87
|
+
that RSpec is only for API-definition docs — that is the `coolhand` Rails repo, which
|
|
88
|
+
migrated to Minitest and kept RSpec solely to generate the API docs. It does not apply
|
|
89
|
+
here. Write RSpec.
|
|
90
|
+
|
|
91
|
+
## 4. Escalate the moment something does not make sense
|
|
92
|
+
|
|
93
|
+
Escalate to **node**, your parent — not to the server. If it is a question about the API
|
|
94
|
+
definition, node passes it up and relays the answer back down. You never message the
|
|
95
|
+
server directly; the tree only has parents and children.
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
node <workspaceRoot>/coolhand/harness/harness.mjs send --run <RUN_DIR> --channel ruby \
|
|
99
|
+
--from ruby --to node --kind escalation --text "R2: definition exposes POST /get_optimization"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Then wait, and stop working while you wait:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
node <workspaceRoot>/coolhand/harness/harness.mjs wait --run <RUN_DIR> --channel ruby --for ruby --after <messageId>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Name the rule number (`R1`–`R5`). Do not guess. Do not stub. Do not work around it.
|
|
109
|
+
|
|
110
|
+
## 5. Run your review skill
|
|
111
|
+
|
|
112
|
+
**Mandatory, before you push.** Follow `<workspaceRoot>/coolhand/harness/RESIST_RULES.md`
|
|
113
|
+
→ "Before you push: run your repo's review skill" (R8) — load
|
|
114
|
+
`.claude/skills/loop-review/SKILL.md` off disk and run it for real against your diff. Do
|
|
115
|
+
not approximate its steps manually; if you cannot spawn the reviewer subagent it calls
|
|
116
|
+
for, escalate to node (R8) and STOP rather than substitute a self-review.
|
|
117
|
+
|
|
118
|
+
## 6. Open your PR — then STOP
|
|
119
|
+
|
|
120
|
+
**If `dryRun` is true, stop here.** Commit locally, report what you built, and push nothing.
|
|
121
|
+
|
|
122
|
+
1. Push and open the PR in `coolhand-ruby`.
|
|
123
|
+
2. Body must reference your issue with `Closes #N` so it auto-closes on merge, and must
|
|
124
|
+
say: **depends on the server PR — deploy that first.**
|
|
125
|
+
3. Record it: `node <workspaceRoot>/coolhand/harness/harness.mjs pr --run <RUN_DIR> --repo ruby --url <url>`
|
|
126
|
+
4. Post your review skill's Iteration Breakdown table (section 5) as a comment on this PR
|
|
127
|
+
— see `RESIST_RULES.md` → "After the loop exits."
|
|
128
|
+
5. **Stop.** You launch no one. The tree ends with you on this branch.
|
|
129
|
+
|
|
130
|
+
## 7. Done means
|
|
131
|
+
|
|
132
|
+
- [ ] Method exists, matches the API definition exactly
|
|
133
|
+
- [ ] `bundle exec rake` passes (RSpec + RuboCop)
|
|
134
|
+
- [ ] At least one spec hit the real local server, not a mock
|
|
135
|
+
- [ ] PR opened, recorded, references its issue, states its dependency on the server PR
|
|
136
|
+
- [ ] Your review skill ran for real (not approximated) and its Iteration Breakdown table
|
|
137
|
+
is posted as a comment on your PR
|
|
138
|
+
- [ ] Every field and status code came from the definition, not from node's code
|
|
139
|
+
- [ ] You launched no child agents
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-09-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`Coolhand::TemplateService`, reached via `Coolhand.template_service`** — reads LLM request templates back out of Coolhand (`GET /api/v2/llm_request_templates`, `GET /api/v2/llm_request_templates/{id}`). `search_templates` filters and pages, returning a `Coolhand::TemplateSearchResult` of `templates` and `pagination`; `get_template` adds the `user_prompt_pattern`/`system_prompt_pattern` regexes the list omits. Both require the **private** API key — the public key is write-only on this API. Needs a server carrying these endpoints; a self-hosted one that predates them answers `404`. See [docs/template-search.md](docs/template-search.md) (#105).
|
|
14
|
+
- **`Coolhand::HttpError`** — raised by the read methods on a non-2xx response, carrying `#status` and `#body` so a caller can branch on `401` vs `404` vs `504` without matching message text. It subclasses `Coolhand::Error`, so `rescue Coolhand::Error` still catches everything this gem raises.
|
|
15
|
+
- **OpenCode added to default intercept addresses** — `opencode.ai` (OpenCode Zen's OpenAI-compatible gateway) is now monitored out of the box with no configuration required. Since matching is now host-based (see Security below), this also covers requests to `api.opencode.ai` without needing a separate entry, so misrouted requests show up in logs instead of vanishing (#97).
|
|
16
|
+
- **`examples/`** — runnable scripts (`openai_example.rb`, `anthropic_example.rb`, `elevenlabs_example.rb`) that make a real provider call through Coolhand, useful as a live smoke test. Excluded from the packaged gem. See [Examples](examples/README.md) (#109).
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- **`config.intercept_addresses = []` (or `nil`) now logs a warning instead of silently doing nothing.** `intercept_addresses` is a required allow-list — an empty list would mean "capture nothing" — so `[]` has always been ignored and the previous value kept, unlike the deny-list `exclude_api_patterns`, which genuinely supports `= []` to disable exclusion. That asymmetry meant a user modeling `intercept_addresses` on its sibling setting got no error and no signal that anything happened. Behavior is unchanged; it's just no longer silent. Documented next to both settings in `docs/configuration.md` and the README (#89).
|
|
20
|
+
- **The read methods raise where the write methods return `nil`.** `send_llm_request_log`, `create_log` and `create_feedback` still log a failure and return `nil`; the new read methods raise `Coolhand::HttpError`, because a caller that asked for data has to be able to tell a `404` from a timeout from a genuinely empty result. Reads also allow 60 seconds where writes allow 5.
|
|
21
|
+
- **`search_templates` is not a port of the `search_templates` MCP tool and does not agree with its numbers.** `log_count` here counts only directly-collected client logs, so evals, bakeoff comparisons and synthetic logs are excluded; templates whose workload has been archived are returned rather than hidden.
|
|
22
|
+
- `Coolhand::Error` moved from `lib/coolhand.rb` into a new `lib/coolhand/errors.rb`, alongside `Coolhand::HttpError`, and is now required first. The constant, its superclass and its behaviour are unchanged.
|
|
23
|
+
- The GET half of `Coolhand::ApiService` now lives in a `Coolhand::ReadRequests` mixin (`lib/coolhand/read_requests.rb`), keeping the class inside this repo's 200-line budget, with `format_error_body` and `apply_headers` extracted from the POST path and shared by both halves. The one write-path behaviour change: a failed response body over 2000 characters is now truncated in the log line instead of printed in full.
|
|
24
|
+
|
|
25
|
+
### Security
|
|
26
|
+
- **`NetHttpInterceptor`'s patched/unpatched state is no longer a plain boolean shared across threads.** `Coolhand.capture` snapshotted `NetHttpInterceptor.patched?` before yielding and used that snapshot at the end of a potentially long-running block (an in-flight HTTP request) to decide whether to unpatch. Two threads concurrently entering `Coolhand.capture` before the interceptor was already permanently patched could both observe `patched? == false`, and whichever thread's block finished first would call `unpatch!`, silently disabling interception for the other thread's still in-flight request. `patch!`/`unpatch!` are now a mutex-guarded reference count, so overlapping/nested callers compose correctly and the interceptor only actually unpatches once every outstanding caller has released it (#90). One behavior change: `patch!` is no longer idempotent on its own — every call now needs a matching `unpatch!` to release it, so an app that calls `Coolhand.configure` more than once (e.g. a reloader re-running an initializer) holds an extra, permanent reference each time rather than no-op'ing. This is harmless (interception simply stays on) but is a change from the prior behavior.
|
|
27
|
+
- **Captured request bodies are now capped in size and skipped entirely for non-JSON content types.** `Coolhand::NetHttpInterceptor#capture_request_body` previously read the entirety of `req.body_stream` (or `req.body`/the `body` param) into memory with no limit and forwarded it in full as `request_body` — since `intercept?` matches configured hosts on any path, a large upload to an endpoint like `/v1/files`, `/v1/audio/transcriptions`, or `/v1/batches` was held in memory and shipped to Coolhand's API in its entirety. A binary/multipart upload (audio, a batch JSONL file, a fine-tune corpus) is now skipped before it's ever read into memory for capture, and any body over the new `config.max_captured_body_bytes` (default 1 MB) is replaced with a placeholder instead of the full content. The real request to the LLM provider is unaffected — only what gets captured/logged changes (#88).
|
|
28
|
+
- **`Coolhand::OpenAi::WebhookValidator#should_enforce_strict_validation?` no longer fails open on unrecognized `Rails.env` names.** It previously blocklisted `"production"`/`"staging"` exactly, so any other spelling — `"prod"`, `"production-eu"`, a Heroku/Render `"review"` app env, or an unset `Rails.env` — fell into the permissive fallback path, where a missing/misconfigured webhook secret let an unsigned request pass as valid. It now uses a fail-safe allowlist of `"development"`/`"test"` — every other value, including `nil`, enforces strict validation (#84).
|
|
29
|
+
- **The full captured request/webhook body is no longer printed to stdout by default.** `Coolhand::ApiService#log_request_info` (reached via `LoggerService#log_to_api`/`#forward_webhook`) previously dumped the entire captured payload — including raw webhook bodies like voice-call transcripts — to stdout whenever `silent` was `false` (the default). In a containerized deploy, stdout typically flows straight into a log aggregator with a different retention/access model than Coolhand itself. It now logs only an identifier and a byte count by default; the full payload is still printed when `debug_mode` is explicitly enabled (#87).
|
|
30
|
+
- **A previously-valid OpenAI webhook request can no longer be replayed indefinitely.** `Coolhand::OpenAi::WebhookValidator#verify_signature` included `webhook-timestamp` and `webhook-id` in the signed payload, but never checked the timestamp for freshness or tracked the id for idempotency — a signature computed over a `webhook-timestamp` from the past (e.g. captured from a log aggregator, an APM trace, or a proxy log) still validated as `true`, and each replay re-triggered `Coolhand::OpenAi::BatchResultProcessor`, causing redundant OpenAI file downloads and duplicate request logs. It now rejects requests whose `webhook-timestamp` is more than `Coolhand.configuration.webhook_replay_tolerance_seconds` (default 300, matching the Standard Webhooks tolerance) away from the current time, and atomically claims `webhook-id` via a new `Coolhand.configuration.webhook_id_store` (default: an in-memory, per-process, TTL-bounded store — swappable for multi-process deployments), rejecting a request whose id was already claimed within the window. A missing `webhook-id` header (previously only the signature/timestamp headers were required) is now also treated as a missing-headers failure, rather than falling through as `nil` and letting two distinct requests both dedupe against the same `nil` key (#86).
|
|
31
|
+
- **`intercept_addresses` and `exclude_api_patterns` no longer match against the raw URL string.** `Coolhand::NetHttpInterceptor#intercept?`/`#excluded_by_pattern?` previously did `url.include?(entry)` against the whole URL, so a configured entry like `"api.openai.com"` could be triggered by an attacker-controlled URL that merely *mentions* it — `https://evil.com/redirect?to=api.openai.com` (wrong destination) or `https://api.openai.com.attacker.net/...` (suffix confusion) both wrongly matched, while `https://API.OPENAI.COM/...` wrongly didn't (case-sensitive). Matching is now done against the parsed `uri.host`, exact or dot-boundary suffix, case-insensitively — so a request can only be captured because it's actually going to a configured host. `exclude_api_patterns` now matches only `uri.path`, so a pattern like `/batchPredictionJobs/` can no longer be tripped by an unrelated request that merely has it in a query string.
|
|
32
|
+
- **Bare, hostless entries (`:generateContent`, `:streamGenerateContent`) have moved out of `intercept_addresses` into a new `intercept_path_patterns` setting.** These colon-suffixed path tokens (Google's API Discovery convention) had no host component, so under the old substring matching *any* URL whose path happened to contain them — e.g. `https://attacker.tld/:generateContent` — was wrongly captured. They're now matched only against the path of requests whose host is already `googleapis.com` or a subdomain of it, and can never match on their own — and only take effect at all if `intercept_addresses` still includes a `googleapis.com` host, so overriding `intercept_addresses` to exclude Google entirely also disables `intercept_path_patterns`.
|
|
33
|
+
- **The default AWS Bedrock entry (`bedrock-runtime`) is now the precise host pattern `bedrock-runtime.*.amazonaws.com`** instead of a bare substring, using new minimal wildcard support in host matching (a single `*` matches exactly one host label). Previously it matched anywhere `"bedrock-runtime"` appeared in a URL, not just the real Bedrock host.
|
|
34
|
+
- **`LoggerService#forward_webhook`'s `response_headers:` option is now redacted like every other header path in the gem.** It was passed straight through to Coolhand unredacted — unlike the sibling `headers:` option — so a caller forwarding a provider webhook alongside its own upstream response (e.g. `forward_webhook(..., response_headers: upstream_response.to_hash)`) shipped that response's `Authorization`/`Set-Cookie`/signature headers to Coolhand in plaintext.
|
|
35
|
+
- **`Coolhand::BaseInterceptor.sanitize_url` now redacts credentials embedded in a URL's userinfo component** (`https://user:pass@host/...`), not just sensitive query parameters. Previously a URL with no query string returned immediately, before userinfo was ever inspected.
|
|
36
|
+
- **`Coolhand::OpenAi::BatchResultProcessor`'s request-log URL is now run through the shared `sanitize_url`**, instead of being forwarded as-is — this path had its own hand-written logging call that predated (and didn't pick up) the shared header/URL sanitization used everywhere else in the gem.
|
|
37
|
+
|
|
38
|
+
### Dependencies
|
|
39
|
+
- Bumped `json` to 2.21.2, flagged by `bundler-audit` (GHSA-9hj4-r449-hfvc: `JSON::ResumableParser#partial_value` dereferences a freed input buffer on truncated duplicate-key streams). `json` is a transitive dependency (via `rubocop`/`faraday`), not something this gem or its consumers pin directly.
|
|
40
|
+
|
|
41
|
+
## [0.5.1] - 2026-08-02
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
- **`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).
|
|
45
|
+
- **`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).
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
- **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).
|
|
49
|
+
- **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).
|
|
50
|
+
- **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).
|
|
51
|
+
- **The published gem no longer packages `.claude/`, `.idea/`, or `CLAUDE.md`** — these are repo/agent tooling, not part of the library.
|
|
52
|
+
|
|
53
|
+
### Security
|
|
54
|
+
- **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.
|
|
55
|
+
- **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.
|
|
56
|
+
- **`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.
|
|
57
|
+
- **`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.
|
|
58
|
+
- **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.
|
|
59
|
+
- **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.
|
|
60
|
+
- **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.
|
|
61
|
+
- **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`.
|
|
62
|
+
- `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.
|
|
63
|
+
- `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.
|
|
64
|
+
- Added missing `require "openssl"` (`WebhookValidator`) and `require "stringio"` (`NetHttpInterceptor`) instead of relying on both being transitively loaded by something else first.
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
- **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).
|
|
68
|
+
- **`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.
|
|
69
|
+
- `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.
|
|
70
|
+
- `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.
|
|
71
|
+
- `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.
|
|
72
|
+
- **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.
|
|
73
|
+
|
|
10
74
|
## [0.5.0] - 2026-07-30
|
|
11
75
|
|
|
12
76
|
### Changed
|
data/README.md
CHANGED
|
@@ -39,14 +39,14 @@ end
|
|
|
39
39
|
# ✅ OpenAI SDK calls
|
|
40
40
|
# ✅ Anthropic API calls
|
|
41
41
|
# ✅ Direct HTTP requests to AI APIs
|
|
42
|
-
# ✅
|
|
42
|
+
# ✅ Any library making AI API calls via Faraday's default adapter
|
|
43
43
|
|
|
44
44
|
# NO code changes needed in your existing services!
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
**✨ Why Automatic Monitoring:**
|
|
48
48
|
- 🚫 **Zero refactoring** - No code changes to existing services
|
|
49
|
-
- 📊 **Complete coverage** - Monitors
|
|
49
|
+
- 📊 **Complete coverage** - Monitors AI libraries using Net::HTTP or Faraday's default adapter automatically
|
|
50
50
|
- 🔒 **Security built-in** - Automatic credential sanitization
|
|
51
51
|
- ⚡ **Performance optimized** - Negligible overhead via async logging
|
|
52
52
|
- 🛡️ **Future-proof** - Automatically captures new AI calls added by your team
|
|
@@ -81,6 +81,28 @@ feedback = feedback_service.create_feedback(
|
|
|
81
81
|
|
|
82
82
|
For a full field reference and matching strategy, see [Feedback API →](docs/feedback.md).
|
|
83
83
|
|
|
84
|
+
## Reading Templates
|
|
85
|
+
|
|
86
|
+
Read back the LLM request templates your logs are matched against. Unlike the logging and feedback methods, these require your **private** API key — the public key is write-only on this API and is rejected exactly like an invalid one.
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
require 'coolhand'
|
|
90
|
+
|
|
91
|
+
templates = Coolhand.template_service
|
|
92
|
+
|
|
93
|
+
result = templates.search_templates(search: 'summar', status: 'published')
|
|
94
|
+
result.templates.each { |template| puts "#{template[:name]} (#{template[:log_count]} logs)" }
|
|
95
|
+
result.pagination.total_count
|
|
96
|
+
|
|
97
|
+
# Prompt patterns come from a single-template fetch only
|
|
98
|
+
detail = templates.get_template(result.templates.first[:id])
|
|
99
|
+
detail[:user_prompt_pattern]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Search is a parameter on the list endpoint, not a route of its own, and the `Unmatched` / `Ignored API Calls` system buckets are hidden unless you pass `include_system: true`. These are read methods, so they **raise** `Coolhand::HttpError` where the write methods log and return `nil`.
|
|
103
|
+
|
|
104
|
+
For the full filter reference, pagination, and error handling, see [Reading Templates →](docs/template-search.md).
|
|
105
|
+
|
|
84
106
|
## Rails Integration
|
|
85
107
|
|
|
86
108
|
### Configuration
|
|
@@ -156,7 +178,10 @@ end
|
|
|
156
178
|
| `enabled` | Boolean | `true` | Set to `false` to disable all patching and validation (e.g. `Rails.env.production?`) |
|
|
157
179
|
| `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 |
|
|
158
180
|
| `silent` | Boolean | `false` | Whether to suppress console output |
|
|
159
|
-
| `intercept_addresses` | Array | `["api.openai.com", "api.anthropic.com"]` | Array of API
|
|
181
|
+
| `intercept_addresses` | Array | `["api.openai.com", "api.anthropic.com"]` | Array of API hosts to monitor (matched by host, not full URL). This is a required allow-list — `[]` is ignored (a warning is logged) rather than disabling capture; use `enabled` or `capture` for that. See [Configuration](docs/configuration.md) |
|
|
182
|
+
| `intercept_path_patterns` | Array | `[":generateContent", ":streamGenerateContent"]` | Path patterns to additionally monitor on Google API hosts — see [Configuration](docs/configuration.md) |
|
|
183
|
+
| `exclude_api_patterns` | Array | `["/batchPredictionJobs/"]` | Deny-list checked after `intercept_addresses`; matching paths are skipped. Unlike `intercept_addresses`, `exclude_api_patterns = []` genuinely disables exclusion. See [Configuration](docs/configuration.md) |
|
|
184
|
+
| `max_captured_body_bytes` | Integer | `1_000_000` | Maximum size of a captured JSON request body — oversized bodies are replaced with a placeholder. Non-JSON bodies (e.g. file/audio uploads) are always skipped regardless of size — see [Advanced Configuration](docs/configuration.md) |
|
|
160
185
|
|
|
161
186
|
## Usage Examples
|
|
162
187
|
|
|
@@ -297,19 +322,26 @@ The filtering is automatic and applies to all monitored API calls and webhook lo
|
|
|
297
322
|
|
|
298
323
|
The monitor works with multiple transport layers and Ruby libraries:
|
|
299
324
|
|
|
300
|
-
**Faraday-based libraries
|
|
325
|
+
**Faraday-based libraries** (via Faraday's default `net_http` adapter — a non-Net::HTTP adapter such as `faraday-typhoeus` is not covered):
|
|
301
326
|
- OpenAI Ruby SDK
|
|
302
327
|
- ruby-anthropic gem (community Anthropic gem)
|
|
303
328
|
- ruby-openai gem
|
|
304
329
|
- LangChain.rb
|
|
305
|
-
- Direct Faraday requests
|
|
306
|
-
- Any other Faraday-based HTTP client
|
|
330
|
+
- Direct Faraday requests using the default adapter
|
|
307
331
|
|
|
308
332
|
**Native HTTP libraries:**
|
|
309
333
|
- Official Anthropic Ruby SDK (using Net::HTTP)
|
|
310
334
|
- GitHub Models SDK / any client using `models.github.ai`
|
|
311
335
|
- Any library using Net::HTTP directly
|
|
312
336
|
|
|
337
|
+
**Other providers monitored out of the box:**
|
|
338
|
+
- Google Gemini (`generativelanguage.googleapis.com`)
|
|
339
|
+
- Google Vertex AI (`aiplatform.googleapis.com`) — see the [Vertex AI batch result logging guide](docs/vertex.md) for async batch jobs
|
|
340
|
+
- AWS Bedrock (OpenAI-compatible endpoint)
|
|
341
|
+
- Cloudflare AI Gateway
|
|
342
|
+
- OpenRouter
|
|
343
|
+
- OpenCode Zen (`opencode.ai`)
|
|
344
|
+
|
|
313
345
|
**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.
|
|
314
346
|
|
|
315
347
|
## How It Works
|
|
@@ -325,11 +357,11 @@ Coolhand uses a unified Net::HTTP interceptor to capture outgoing requests to co
|
|
|
325
357
|
### Request Flow
|
|
326
358
|
When a request matches configured LLM endpoints:
|
|
327
359
|
|
|
328
|
-
1. The original request executes normally
|
|
360
|
+
1. The original request executes normally
|
|
329
361
|
2. Request and response data (body, headers, status) are captured by the interceptor
|
|
330
362
|
3. For streaming requests, the complete accumulated response is captured (not individual chunks)
|
|
331
|
-
4. Data is sent to the Coolhand API
|
|
332
|
-
5. Your application continues
|
|
363
|
+
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)
|
|
364
|
+
5. Your application continues once the log is sent (or times out) — the original request/response is never blocked on Coolhand being reachable
|
|
333
365
|
|
|
334
366
|
For non-matching endpoints, requests pass through unchanged.
|
|
335
367
|
|
|
@@ -394,109 +426,18 @@ The monitor handles errors gracefully:
|
|
|
394
426
|
- Failed API logging attempts are logged to console but don't interrupt your application
|
|
395
427
|
- Invalid API keys will be reported but won't crash your app
|
|
396
428
|
- Network issues are handled with appropriate error messages
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
## Batch webhook handler (OpenAI)
|
|
400
|
-
|
|
401
|
-
Automatically handle OpenAI batch event logs (batch.completed, batch.failed, batch.expired, batch.cancelled)
|
|
402
|
-
by intercepting webhook requests and enqueuing your batch result processor.
|
|
403
|
-
|
|
404
|
-
Usage:
|
|
405
|
-
- Include the interceptor in your controller:
|
|
406
|
-
include Coolhand::WebhookInterceptor
|
|
407
|
-
- Add the before_action to validate and populate @validator payload:
|
|
408
|
-
before_action :intercept_batch_request, only: :openai
|
|
409
|
-
- Ensure you skip CSRF for the webhook endpoint:
|
|
410
|
-
skip_before_action :verify_authenticity_token
|
|
411
|
-
- Override the webhook_secret method to return your OpenAI webhook secret
|
|
412
|
-
|
|
413
|
-
Minimal example (only key lines shown):
|
|
414
|
-
|
|
415
|
-
```ruby
|
|
416
|
-
# app/controllers/webhooks/batch_api_requests_controller.rb
|
|
417
|
-
# ...existing code...
|
|
418
|
-
include Coolhand::WebhookInterceptor
|
|
419
|
-
|
|
420
|
-
skip_before_action :verify_authenticity_token
|
|
421
|
-
before_action :intercept_batch_request, only: :openai
|
|
422
|
-
|
|
423
|
-
def openai
|
|
424
|
-
event = JSON.parse(@validator.payload)
|
|
425
|
-
case event["type"]
|
|
426
|
-
when "batch.completed", "batch.failed", "batch.expired", "batch.cancelled"
|
|
427
|
-
batch_id = event.dig("data", "id")
|
|
428
|
-
batch_request = BatchApiRequest.find_by(provider: "openai", provider_batch_id: batch_id)
|
|
429
|
-
|
|
430
|
-
if batch_request
|
|
431
|
-
OpenAi::BatchResultProcessor.perform_async(batch_request.id)
|
|
432
|
-
Rails.logger.info("Queued batch result processing for BatchApiRequest #{batch_request.id}")
|
|
433
|
-
else
|
|
434
|
-
Rails.logger.warn("Could not find BatchApiRequest for OpenAI batch ID: #{batch_id}")
|
|
435
|
-
end
|
|
436
|
-
else
|
|
437
|
-
Rails.logger.info("Unhandled OpenAI webhook event type: #{event["type"]}")
|
|
438
|
-
end
|
|
439
|
-
|
|
440
|
-
head :ok
|
|
441
|
-
rescue JSON::ParserError
|
|
442
|
-
head :bad_request
|
|
443
|
-
rescue StandardError => e
|
|
444
|
-
Rails.logger.error("OpenAI webhook error: #{e.message}")
|
|
445
|
-
head :internal_server_error
|
|
446
|
-
end
|
|
447
|
-
|
|
448
|
-
def webhook_secret
|
|
449
|
-
Rails.application.credentials.openai_webhook_secret
|
|
450
|
-
end
|
|
451
|
-
# ...existing code...
|
|
452
|
-
```
|
|
453
|
-
|
|
454
|
-
## Batch webhook handler (Vertex)
|
|
455
|
-
|
|
456
|
-
Automatically handle Vertex batch event logs.
|
|
457
|
-
|
|
458
|
-
Usage:
|
|
459
|
-
- call Coolhand::Vertex::BatchResultProcessor service with batch_info and download batch results
|
|
460
|
-
|
|
461
|
-
Minimal example (only key lines shown):
|
|
462
|
-
|
|
463
|
-
```ruby
|
|
464
|
-
class Vertex::BatchCallbackProcessor < BaseService
|
|
465
|
-
option :batch_request, model: BatchApiRequest
|
|
466
|
-
option :batch_info
|
|
467
|
-
|
|
468
|
-
def call
|
|
469
|
-
case batch_info["state"]
|
|
470
|
-
when "JOB_STATE_PENDING"
|
|
471
|
-
nil
|
|
472
|
-
when "JOB_STATE_RUNNING", "JOB_STATE_QUEUED"
|
|
473
|
-
batch_request.update!(status: "processing")
|
|
474
|
-
|
|
475
|
-
Coolhand::Vertex::BatchResultProcessor.new(batch_info:).call
|
|
476
|
-
when "JOB_STATE_SUCCEEDED"
|
|
477
|
-
output_file_id = batch_info["outputInfo"]["gcsOutputDirectory"]
|
|
478
|
-
results = download_batch_results(output_file_id)
|
|
479
|
-
results.each { |batch_item| process_batch_result(batch_item) }
|
|
480
|
-
|
|
481
|
-
batch_request.update!(status: "completed", completed_at: Time.current, output_file_id:)
|
|
482
|
-
|
|
483
|
-
Coolhand::Vertex::BatchResultProcessor.new(batch_info:).call(results)
|
|
484
|
-
|
|
485
|
-
# Clean up GCS files after successful processing
|
|
486
|
-
cleanup_gcs_files(output_file_id)
|
|
487
|
-
when "JOB_STATE_FAILED"
|
|
488
|
-
handle_failed_batch(batch_info["error"]["message"])
|
|
489
|
-
end
|
|
490
|
-
end
|
|
491
|
-
end
|
|
492
|
-
```
|
|
429
|
+
- The read methods (`search_templates`, `get_template`) are the exception — they raise `Coolhand::HttpError`; see [Reading Templates →](docs/template-search.md)
|
|
493
430
|
|
|
494
431
|
## Documentation
|
|
495
432
|
|
|
496
433
|
- **[Configuration](docs/configuration.md)** — Self-hosted deployments, base_url rules, debug mode, custom intercept addresses
|
|
497
434
|
- **[Feedback API](docs/feedback.md)** — Full field reference, matching strategies, sentiment values
|
|
435
|
+
- **[Reading Templates](docs/template-search.md)** — Search LLM request templates and fetch a single one, prompt patterns included, using the private API key
|
|
498
436
|
- **[Anthropic Integration](docs/anthropic.md)** — Official and community Anthropic Ruby gems, streaming, dual gem handling, and troubleshooting
|
|
499
437
|
- **[ElevenLabs Integration](docs/elevenlabs.md)** — Webhook capture, feedback submission, and Rails integration
|
|
438
|
+
- **[OpenAI Batch Webhook Handler](docs/openai.md)** — Handle OpenAI batch job completion events via webhook interception
|
|
439
|
+
- **[Google Vertex AI Batch Result Logging](docs/vertex.md)** — Log completed Vertex AI batch prediction job results
|
|
440
|
+
- **[Examples](examples/README.md)** — Runnable scripts that make a real provider call through Coolhand, useful as a live smoke test
|
|
500
441
|
|
|
501
442
|
## Security
|
|
502
443
|
|
data/docs/configuration.md
CHANGED
|
@@ -39,6 +39,22 @@ end
|
|
|
39
39
|
|
|
40
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
41
|
|
|
42
|
+
## Request Body Capture Limits
|
|
43
|
+
|
|
44
|
+
To avoid holding large uploads (batch JSONL files, fine-tune corpora, audio for transcription) in memory and shipping them to Coolhand in full, `config.max_captured_body_bytes` caps how much of a request body gets captured:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
Coolhand.configure do |config|
|
|
48
|
+
config.max_captured_body_bytes = 2_000_000 # default: 1_000_000 (1 MB)
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**What happens at the limits:**
|
|
53
|
+
- A request body whose `Content-Type` doesn't look like JSON (e.g. `multipart/form-data`, `audio/mpeg`) is never read into memory for capture — the log entry gets a placeholder (`{"_coolhand_capture_skipped" => "non_json_content_type", ...}`) instead of the body. This is intentional: an opaque binary blob isn't human/LLM-readable in Coolhand's UI anyway.
|
|
54
|
+
- A JSON (or content-type-unset) body larger than the configured limit is replaced with a placeholder (`{"_coolhand_capture_skipped" => "body_too_large", ...}`) instead of being logged in full.
|
|
55
|
+
|
|
56
|
+
In both cases, only the *captured log entry* is affected — the real request to the LLM provider always sends the complete, untruncated body.
|
|
57
|
+
|
|
42
58
|
## Custom Intercept Addresses
|
|
43
59
|
|
|
44
60
|
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`:
|
|
@@ -57,3 +73,27 @@ end
|
|
|
57
73
|
Setting `intercept_addresses` **replaces** the default list entirely, so include any default hosts you still need.
|
|
58
74
|
|
|
59
75
|
The default list can be found in `Coolhand::Configuration::DEFAULT_INTERCEPT_ADDRESSES`.
|
|
76
|
+
|
|
77
|
+
Each entry is matched against the request's parsed host — an exact match, or a dot-boundary suffix match (`my-llm-proxy.internal` also matches `eu.my-llm-proxy.internal`) — case-insensitively. A single `*` in an entry matches exactly one host label, e.g. `bedrock-runtime.*.amazonaws.com`. Entries are never matched against the full URL, so query strings or paths that merely mention a host can't trigger a false match.
|
|
78
|
+
|
|
79
|
+
A small number of Google API endpoints use a colon-suffixed path (e.g. `.../gemini-pro:generateContent`) with no host of their own. Those live in the separate `intercept_path_patterns` setting (default: `Coolhand::Configuration::DEFAULT_INTERCEPT_PATH_PATTERNS`) and, unlike `intercept_addresses`, are only checked against the path of requests whose host is already `googleapis.com` or a subdomain of it — and only when `intercept_addresses` still includes at least one `googleapis.com` host. If you override `intercept_addresses` to a list with no Google hosts, `intercept_path_patterns` has no effect and Google API traffic is not captured at all.
|
|
80
|
+
|
|
81
|
+
`intercept_addresses` is a required allow-list — nothing is captured unless its host matches an entry here. Because of that, `config.intercept_addresses = []` is **not** a supported way to disable capture (it's ignored, with a warning logged, and the previous value is kept). To disable capture entirely, use `config.enabled = false` or `config.capture = false` instead — see [Capture Control](#capture-control) below.
|
|
82
|
+
|
|
83
|
+
## Excluding API Patterns
|
|
84
|
+
|
|
85
|
+
`config.exclude_api_patterns` is a deny-list checked *after* `intercept_addresses` allows a request through — a match against the request's path is skipped and never forwarded as an `llm_request_log`. It defaults to `["/batchPredictionJobs/"]`, to suppress Vertex AI batch job management noise.
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
Coolhand.configure do |config|
|
|
89
|
+
config.exclude_api_patterns << "/myOperationalPath/" # extend the defaults
|
|
90
|
+
# config.exclude_api_patterns = ["/only_this/"] # replace the defaults entirely
|
|
91
|
+
# config.exclude_api_patterns = [] # disable exclusion entirely
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Unlike `intercept_addresses`, `exclude_api_patterns` genuinely supports `= []` — since it only narrows an allow-list that already passed, an empty deny-list just means "exclude nothing," which is safe.
|
|
96
|
+
|
|
97
|
+
## Capture Control
|
|
98
|
+
|
|
99
|
+
`config.enabled` and `config.capture` are the supported ways to turn capture off entirely — see the Configuration Parameters table in the [README](../README.md#configuration-options).
|
data/docs/openai.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
```
|
|
61
|
+
|
|
62
|
+
## Replay protection
|
|
63
|
+
|
|
64
|
+
`Coolhand::OpenAi::WebhookValidator` rejects requests whose `webhook-timestamp` is more than **300 seconds** away from the current time, and rejects a `webhook-id` it has already seen within that window — this prevents a captured request (from a log aggregator, APM trace, or proxy log) from being replayed to re-trigger `Coolhand::OpenAi::BatchResultProcessor`.
|
|
65
|
+
|
|
66
|
+
Both are configurable:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
Coolhand.configure do |config|
|
|
70
|
+
# Widen or narrow the timestamp tolerance window (seconds). Default: 300.
|
|
71
|
+
config.webhook_replay_tolerance_seconds = 600
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The default `webhook-id` dedup store is in-memory and per-process, so it only protects a single process — replays across multiple app processes/dynos within the tolerance window aren't caught by default. If you run more than one process, supply your own store via `config.webhook_id_store`; any object responding to `claim!(id, ttl_seconds)` works, e.g. one backed by `Rails.cache`. `claim!` must atomically check-and-record the id in one operation (not a separate read then write) — otherwise two requests racing on the same id can both "win" — so lean on your cache backend's compare-and-set primitive (`unless_exist:` below maps to Redis `SET NX` when using `RedisCacheStore`):
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
class RailsCacheWebhookIdStore
|
|
79
|
+
# Returns true if this is the first time `id` has been claimed (the
|
|
80
|
+
# write happened), false if it was already claimed within its TTL.
|
|
81
|
+
def claim!(id, ttl_seconds)
|
|
82
|
+
Rails.cache.write("coolhand:openai_webhook:#{id}", true, expires_in: ttl_seconds, unless_exist: true)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
Coolhand.configure do |config|
|
|
87
|
+
config.webhook_id_store = RailsCacheWebhookIdStore.new
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Note: the `webhook-id` is claimed as soon as the signature and timestamp are confirmed valid — before your controller's `process_event` runs. If something downstream fails before the batch is actually handled (e.g. an unexpectedly-shaped payload), a same-id retry within the tolerance window will be rejected as a replay rather than reprocessed.
|