coolhand 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f38b442e23ef4733417edbaa234c3459734a11f861caeb4261ea034f7a464dd
4
- data.tar.gz: c3d6aee98d5eea898ff53bedba1f43497c82517ad335d330d5e6619a5035e13f
3
+ metadata.gz: 48857d802183f91a163c82c3e1dd89ce34ac7a8f1f3d1079c5ae6d5d594ad300
4
+ data.tar.gz: a852465d5b9dced73580eff8c81f45093a97c48c79a66579cff27c2dbedc7cbe
5
5
  SHA512:
6
- metadata.gz: feafe561a70cd33c6c995f1ebd20c1d93e93ca9bb092dc6214f5b52464eb23e9ac68797d2c64377a2e215ed9ca0d50fa1b4b43f91dd2b355577ca33a2c80483f
7
- data.tar.gz: 749542ecea37f0d9f08840bf6e0eda16fe5aa65f4484abb1e720c3358667190137509e99f80d798e0b3292809f95efd7823e7655a8c05db1b0747ebdcf784b02
6
+ metadata.gz: 0d4e6bc5e9ef29365afaba5b9c4a4edbadb552f77268adfe27ac6276f23cd44e4c75fe6df85b1ddef8bdf25d99d88204a46a127dcce7c3a2183edc09e5019508
7
+ data.tar.gz: ee192c55d280b47abc3f6a9af7f2ddd48d4966493955cee5614557feeea2d87d71a28c16f488fee4dc9fddd916b88fc9ee98621e058e3ad0de80431fc6bd871c
data/AGENTS.harness.md CHANGED
@@ -39,8 +39,8 @@ node <workspaceRoot>/coolhand/harness/harness.mjs my-issue --run <RUN_DIR> --rep
39
39
  2. `<workspaceRoot>/coolhand/harness/RESIST_RULES.md` — the refuse list.
40
40
  3. The API definition at `specPath`. It is your only source of truth **for the endpoint's
41
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.
42
+ 4. `coolhand-ruby/AGENTS.md` — this repo's own rulebook. **It is authoritative.** Where it
43
+ disagrees with this harness file, AGENTS.md wins — follow it and say so in your PR.
44
44
 
45
45
  **Your issue links node's PR as the reference implementation. Use it for structure, not
46
46
  for facts.** Node went first so you do not have to rediscover how a REST method fits into
@@ -70,7 +70,7 @@ inside the current architecture, escalate and STOP.
70
70
  Require it inside the file that actually uses it, executed only when that provider's
71
71
  functionality is accessed. Clients may not use every provider and must not be forced to
72
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`.
73
+ dependencies. See `coolhand-ruby/AGENTS.md`.
74
74
 
75
75
  ## 3. Prove it against the real server
76
76
 
@@ -107,13 +107,44 @@ node <workspaceRoot>/coolhand/harness/harness.mjs wait --run <RUN_DIR> --channel
107
107
 
108
108
  Name the rule number (`R1`–`R5`). Do not guess. Do not stub. Do not work around it.
109
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.
110
+ ## 5. Get your review — you cannot run it yourself
111
+
112
+ **Mandatory, before you push. You do not run this section yourself.** You are three
113
+ levels deep in this tree (server → node → you), and the platform does not let an agent at
114
+ this depth spawn the reviewer subagent the review skill needs — see
115
+ `<workspaceRoot>/coolhand/harness/RESIST_RULES.md` → R8's depth table. This is not something
116
+ to confirm by trying; go straight to the request below.
117
+
118
+ 1. Commit locally first — build, prove it against the local server, run `bundle exec rake`,
119
+ all of section 3, all done before this step.
120
+ 2. Send an R8 escalation to node naming your repo path, branch, and `HEAD` sha:
121
+ ```
122
+ node <workspaceRoot>/coolhand/harness/harness.mjs send --run <RUN_DIR> --channel ruby \
123
+ --from ruby --to node --kind escalation \
124
+ --text "R8: ready for review. repo=<workspaceRoot>/coolhand-ruby branch=<branch> sha=<sha>"
125
+ ```
126
+ 3. `wait --after` the `messageId` your `send` in step 2 just printed. A timeout is silence,
127
+ not permission — call `wait` again, `--after` that same id, rather than pushing
128
+ (`RESIST_RULES.md` → "What escalate and stop means, mechanically"). If you loop back here
129
+ from step 5, use the fresh escalation's `messageId` instead.
130
+ 4. **The first reply is an `ack`, not the answer** — it means node has started, not that it
131
+ is done. `wait` again, `--after` that ack's `messageId`, for the `resolution` that
132
+ actually follows.
133
+ 5. **If what follows is a second `ack` instead of a `resolution`, node is asking you to do
134
+ something before it can start — most often that your checkout is not clean** (see
135
+ `RESIST_RULES.md` → R8 → "Serving a review for a child" step 2). Do what it asks, then
136
+ send a fresh R8 escalation with your current `HEAD` sha (it may have changed) and go back
137
+ to step 3. Do not just `wait` again — nothing arrives until you re-escalate.
138
+ 6. **The `resolution` that eventually arrives always carries the full Iteration Breakdown
139
+ table plus an explicit `CLEAN`/`capped` verdict** — the same `result` value node has
140
+ already recorded (it records before it replies) — from a review node ran against your
141
+ actual repo. Not just one or the other, not your own read of the diff, and not an ack.
142
+ This is what you post as your own PR comment below, unedited plus the "Reviewed by node
143
+ against `<sha>`" line.
144
+ 7. **Then move on to section 6 (Open your PR).** If node's review applied fixes,
145
+ it already committed them directly into your checkout while you waited — whatever is at
146
+ `HEAD` when you push there already includes them, and you do not need to reproduce or
147
+ look for them separately.
117
148
 
118
149
  ## 6. Open your PR — then STOP
119
150
 
@@ -123,8 +154,11 @@ for, escalate to node (R8) and STOP rather than substitute a self-review.
123
154
  2. Body must reference your issue with `Closes #N` so it auto-closes on merge, and must
124
155
  say: **depends on the server PR — deploy that first.**
125
156
  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."
157
+ 4. Post node's Iteration Breakdown table (section 5) as a comment on this PR — see
158
+ `RESIST_RULES.md` → "After the loop exits" — and add one line the table itself does not
159
+ carry: `Reviewed by node against <sha>`, since you did not run it yourself. **Do not call
160
+ `harness.mjs loop-review` here — node already recorded it**, against the sha that
161
+ resulted from its own fix commits, not the one you originally escalated with.
128
162
  5. **Stop.** You launch no one. The tree ends with you on this branch.
129
163
 
130
164
  ## 7. Done means
@@ -133,7 +167,8 @@ for, escalate to node (R8) and STOP rather than substitute a self-review.
133
167
  - [ ] `bundle exec rake` passes (RSpec + RuboCop)
134
168
  - [ ] At least one spec hit the real local server, not a mock
135
169
  - [ ] 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
170
+ - [ ] Node ran your review for real (not approximated by you) and its Iteration Breakdown
171
+ table is posted as a comment on your PR, naming node and the reviewed sha, and
172
+ recorded with `harness.mjs loop-review`
138
173
  - [ ] Every field and status code came from the definition, not from node's code
139
174
  - [ ] You launched no child agents
data/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-10-18
11
+
12
+ ### Added
13
+ - **Declare the workload and template of captured LLM calls** ([#153](https://github.com/Coolhand-Labs/coolhand-ruby/pull/153)) — `Coolhand.with_context(reporter:, workload:, template:) { ... }` stamps every LLM call captured inside the block with the workload and template it belongs to, sent as `llm_request_log.declared`. The context is snapshotted on the calling thread, so it survives `async_logging`. Nested blocks override per field (innermost wins). `Coolhand::Declarable` (`coolhand_workload`, `coolhand_declare`, `coolhand_declare_all`) declares it once on a class, inherited by subclasses, and opt-in `config.auto_declare = [:active_job, :controller]` does the same for Rails jobs and controller actions (the gem still has no Rails dependency). `Coolhand.workload_service.upsert` and `Coolhand.template_service.upsert` create or update records by key with your **private** API key and raise `Coolhand::HttpError` on failure, and `warnings` returned with a logged request (e.g. the public-key cap) are now logged. Keys longer than 255 characters, with surrounding whitespace or a leading, trailing or doubled `/`, and a template `key` with no workload raise `ArgumentError` up front, because the server would reject and drop the whole log. Older servers ignore `declared`. See [Declared Workloads](docs/declared_workloads.md).
14
+ - `bin/live-examples` (also `bundle exec rake examples:live`) runs every `examples/*.rb` against real provider keys and prints one `PASS` / `SKIP` / `FAIL` line per script ([#155](https://github.com/Coolhand-Labs/coolhand-ruby/pull/155)). Development tooling only: `bin/` and `examples/` are not packaged in the gem. See [Examples](examples/README.md).
15
+
16
+ ### Changed
17
+ - The `🔗 Net::HTTP interceptor patched` log line is no longer printed when the interceptor is installed ([#154](https://github.com/Coolhand-Labs/coolhand-ruby/pull/154)). **Migration note:** anything that matched on that line in log output should stop doing so; use `Coolhand::NetHttpInterceptor.patched?` instead.
18
+ - Internal de-duplication with no behavior change ([#156](https://github.com/Coolhand-Labs/coolhand-ruby/pull/156)): list reads share `ReadQuery#fetch_list`, `LoggerService` shares one webhook-rejection path, `OpenAi::WebhookValidator` shares one enforce-or-allow path (same error messages and same strict/lenient behavior by `Rails.env`), and `Coolhand.with_capture` / `without_capture` share one override helper. `contract/` (declared, intentional duplication) is excluded from the gem package.
19
+ - CI: added the `exhale` duplication and complexity gate ([#156](https://github.com/Coolhand-Labs/coolhand-ruby/pull/156)), and bumped `ruby/setup-ruby` to 1.329.0 ([#150](https://github.com/Coolhand-Labs/coolhand-ruby/pull/150)) and `actions/upload-artifact` to 7.0.2 ([#151](https://github.com/Coolhand-Labs/coolhand-ruby/pull/151)).
20
+
21
+ ### Fixed
22
+ - `OpenAi::WebhookValidator` now accepts a `webhook-signature` header carrying several space-separated signatures (`v1,sigA v1,sigB`) and passes if any `v1,` entry matches ([#161](https://github.com/Coolhand-Labs/coolhand-ruby/pull/161)). Previously the whole remainder after `v1,` was compared as one value, so a legitimate webhook sent during secret rotation was rejected with a 401. Every candidate is compared in constant time, non-`v1` entries are ignored, a header that is not valid UTF-8 is rejected rather than raising, and the timestamp and replay-id checks still run only after a match. Single-signature headers behave as before. See [OpenAI Batch Webhook Handler](docs/openai.md#signature-header-and-secret-rotation).
23
+
24
+ ### Security
25
+ - Warnings returned in a Coolhand log response are now sanitized before they are printed ([#162](https://github.com/Coolhand-Labs/coolhand-ruby/pull/162)): entries are coerced with `to_s`, invalid UTF-8 is scrubbed, control characters and line separators become a space, invisible Unicode format characters are removed, entries over 500 characters are truncated, and at most 5 are printed per response. Well-formed warnings print as before.
26
+
27
+ ### Documentation
28
+ - `docs/declared_workloads.md` and the README now state that `Coolhand.with_context`, `with_capture` and `without_capture` are fiber-local, so work started in a new Fiber or Async task inside the block is not covered, and show opening the block inside the Fiber instead ([#160](https://github.com/Coolhand-Labs/coolhand-ruby/pull/160)).
29
+
30
+ ## [0.8.0] - 2026-10-09
31
+
32
+ ### Added
33
+ - **Read workloads and logs, with cost and performance metrics** ([#138](https://github.com/Coolhand-Labs/coolhand-ruby/pull/138)) — `Coolhand.workload_service.search_workloads` lists workloads (`GET /api/v2/workloads`) and `Coolhand.log_service` reads logs back out: `search_logs` (filters including `since`, `until`, `min_cost`, `order`, `project_path`, `source_application`, `include_total`, `sort`) and `get_log`, which returns `cost` and `cost_breakdown`. `search_templates` and `get_template` gain `include_metrics`, `days_back`, `since` and `until`. `since:`/`until:` accept a `Time`, `DateTime`, `Date` or ISO8601 String; anything else raises `ArgumentError` before a request is made. These need your **private** API key and raise `Coolhand::Error` (`Coolhand::HttpError` for non-2xx responses) rather than logging and returning `nil`. **Deploy note:** requires the matching Coolhand server release (Coolhand-Labs/coolhand#1753). See [Reading Workloads](docs/workload-search.md) and [Reading Logs](docs/log-search.md).
34
+ - **Link feedback to an optimization** ([#133](https://github.com/Coolhand-Labs/coolhand-ruby/pull/133)) — `Coolhand.optimization_feedback_link_service` provides `link_feedback`, `bulk_link_feedback` (sent in batches of 100, results summed) and `unlink_feedback` for `/api/v2/optimizations/:id/feedback_links`. Private API key required; failures raise `Coolhand::Error` / `Coolhand::HttpError`. See [Linking Feedback](docs/feedback-links.md).
35
+ - **Logs are sent from a background queue by default** ([#148](https://github.com/Coolhand-Labs/coolhand-ruby/pull/148)) — the Coolhand POST no longer runs inline in the intercepted LLM call, so a slow or unreachable Coolhand backend can no longer add up to about 10s to each call. `Coolhand::LogQueue` holds up to 1,000 jobs per lane and drops the oldest when full, restarts itself in forked processes, and flushes at exit (waiting up to 5s). OpenAI and Vertex batch results are processed as one background job per batch, on their own lane so a long batch cannot delay or evict per-request logs. New `config.async_logging` (default `true`) and `Coolhand.flush(timeout: 5)`; `examples/benchmark_async_logging.rb` is a pass/fail benchmark. See [Asynchronous Logging](docs/configuration.md#asynchronous-logging). **Migration note:** anything that relied on the log POST finishing before the intercepted call returned (tests asserting on a stubbed `ApiService` inline, scripts that end with `exit!`, a process killed by `kill -9` or the OOM killer, which loses queued logs) should set `config.async_logging = false` or call `Coolhand.flush`. `debug_mode` always runs inline.
36
+
37
+ ### Changed
38
+ - Repo guidance moved from `CLAUDE.md` to `AGENTS.md` ([#130](https://github.com/Coolhand-Labs/coolhand-ruby/pull/130)), and the agent-harness review handshake in `AGENTS.harness.md` was rewritten ([#134](https://github.com/Coolhand-Labs/coolhand-ruby/pull/134)). Neither ships in the gem, and `AGENTS.md` is excluded from the gem package in place of `CLAUDE.md`.
39
+ - CI now uses `ruby/setup-ruby` 1.327.0 ([#135](https://github.com/Coolhand-Labs/coolhand-ruby/pull/135)).
40
+ - Shared read-URL building moved into `Coolhand::ReadQuery`, and POST/DELETE helpers that raise on failure into `Coolhand::WriteRequests`; `TemplateService` now uses `ReadQuery`. Internal refactor with no behavior change to `search_templates`/`get_template` beyond the new options.
41
+ - `Coolhand::NetHttpInterceptor` no longer includes `BaseInterceptor` ([#148](https://github.com/Coolhand-Labs/coolhand-ruby/pull/148)); its helpers moved to `Coolhand::NetHttpInterceptor::Helpers`, so `Net::HTTP` gains only `#request` instead of about 15 generically named private methods (`intercept?`, `parse_json`, `sanitize_url`, ...). **Migration note:** internal API, but if you called those helpers on a `Net::HTTP` instance, use `Coolhand::NetHttpInterceptor::Helpers` or `Coolhand::BaseInterceptor`.
42
+
43
+ ### Fixed
44
+ - Disabling the Net::HTTP interceptor logged a stale "Faraday monitoring disabled" message ([#148](https://github.com/Coolhand-Labs/coolhand-ruby/pull/148)); it now says "Net::HTTP interceptor unpatched (capture stopped)".
45
+
46
+ ### Security
47
+ - **Release-time hardening from a whole-package security review** ([#148](https://github.com/Coolhand-Labs/coolhand-ruby/pull/148), plus fixes made on the release branch):
48
+ - Requests forwarded by the OpenAI batch webhook handler now go through the same request-body redaction as live interception, so Azure OpenAI "On Your Data" datastore credentials in a batch request body are replaced with `[REDACTED]` before being sent to Coolhand.
49
+ - The OpenAI batch handler now matches requests to results with a one-pass index instead of a scan per result, so a large batch no longer takes quadratic time. When a `custom_id` is duplicated the first request still wins, as before.
50
+
10
51
  ## [0.7.0] - 2026-09-20
11
52
 
12
53
  ### Added
@@ -22,7 +63,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
22
63
 
23
64
  ### Security
24
65
  - **Azure OpenAI "On Your Data" datastore credentials are now redacted from captured request bodies** ([#115](https://github.com/Coolhand-Labs/coolhand-ruby/pull/115)) — a request's `data_sources`/`dataSources` entries can carry live credentials (Azure AI Search API keys, Cosmos/Mongo connection strings, Elasticsearch encoded keys) that header and URL sanitization never looked at. `BaseInterceptor.sanitize_body` now replaces any value under those entries whose key name contains `key`, `token`, `secret`, `password`, `credential`, `connectionstring` or `signature` (separators and case ignored) with `[REDACTED]` before the log is forwarded. Message content and tool definitions outside `data_sources` are untouched.
25
- - **Release-time hardening from a whole-package security review:**
66
+ - **Release-time hardening from a whole-package security review** ([#148](https://github.com/Coolhand-Labs/coolhand-ruby/pull/148), plus fixes made on the release branch):
26
67
  - `Coolhand.log` no longer raises when stdout is unwritable. Previously a failing `puts` inside the interceptor's `ensure` block could replace the host application's own LLM response with an `IOError`.
27
68
  - A bad `intercept_addresses`/`exclude_api_patterns` value (e.g. a String or Symbol instead of an array of Strings) now degrades to "don't capture" instead of raising into every intercepted request.
28
69
  - `BaseInterceptor.sanitize_url` now fails closed on an unparseable URL, dropping userinfo, query and fragment rather than returning the raw URL with credentials intact.
data/README.md CHANGED
@@ -103,6 +103,58 @@ Search is a parameter on the list endpoint, not a route of its own, and the `Unm
103
103
 
104
104
  For the full filter reference, pagination, and error handling, see [Reading Templates →](docs/template-search.md).
105
105
 
106
+ ### Cost and performance metrics
107
+
108
+ Pass `include_metrics: true` to `search_templates` (or `search_workloads`) for the dashboard's cost and performance numbers, over a rolling `days_back` window or an explicit `since:` / `until:` one. `search_logs` carries each log's `cost` and can sort and filter by it.
109
+
110
+ ```ruby
111
+ window = { since: Time.utc(2026, 9, 1), until: Time.utc(2026, 10, 1) }
112
+
113
+ Coolhand.workload_service.search_workloads(include_metrics: true, **window).workloads
114
+ .map { |w| [w[:name], w[:metrics][:total_cost]] }
115
+
116
+ Coolhand.log_service.search_logs(order: 'cost_desc', min_cost: 1.0, per: 10, **window).logs
117
+ ```
118
+
119
+ See [Reading Workloads →](docs/workload-search.md) and [Reading Logs →](docs/log-search.md).
120
+
121
+ ## Linking Feedback to an Optimization
122
+
123
+ Attach feedback to an optimization as evidence, one at a time or in bulk. These require your **private** API key and **raise** `Coolhand::Error` on failure (`Coolhand::HttpError` for non-2xx responses).
124
+
125
+ ```ruby
126
+ links = Coolhand.optimization_feedback_link_service
127
+
128
+ link = links.link_feedback('optimizationHashid', 'feedbackHashid', note: 'why')
129
+ links.bulk_link_feedback('optimizationHashid', %w[fb1 fb2 fb3]) # => { linked:, already_linked:, errored:, not_found: }
130
+ links.unlink_feedback('optimizationHashid', link[:id])
131
+ ```
132
+
133
+ For batching, error semantics, and details, see [Linking Feedback →](docs/feedback-links.md).
134
+
135
+ ## Declaring Workloads and Templates
136
+
137
+ Tell Coolhand which workload and template an LLM call belongs to, instead of waiting for it to be matched. Calls captured inside the block carry the declared identity:
138
+
139
+ ```ruby
140
+ Coolhand.with_context(reporter: 'my-app', workload: { key: 'Parser#call' }, template: { key: 'Parser#call/summarize' }) do
141
+ client.chat(...) # captured as usual
142
+ end
143
+
144
+ Coolhand.workload_service.upsert(key: 'Parser#call', name: 'Parser') # private key; create or rename
145
+ ```
146
+
147
+ Or declare it once on the class and let every capture inside it pick it up, subclasses included:
148
+
149
+ ```ruby
150
+ class ApplicationService
151
+ include Coolhand::Declarable
152
+ coolhand_declare_all # each public method declares "ClassName#method"
153
+ end
154
+ ```
155
+
156
+ For nesting rules, class-level declarations, Rails jobs and controllers, upserts, and public-key limits, see [Declared Workloads →](docs/declared_workloads.md).
157
+
106
158
  ## Rails Integration
107
159
 
108
160
  ### Configuration
@@ -182,6 +234,8 @@ end
182
234
  | `intercept_path_patterns` | Array | `[":generateContent", ":streamGenerateContent"]` | Path patterns to additionally monitor on Google API hosts — see [Configuration](docs/configuration.md) |
183
235
  | `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
236
  | `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) |
237
+ | `async_logging` | Boolean | `true` | Send logs to Coolhand from a background thread so a slow Coolhand API never delays your LLM calls. Set to `false` to send inline — see [Advanced Configuration](docs/configuration.md#asynchronous-logging) |
238
+ | `auto_declare` | Array | `[]` | Rails only: `:active_job` and/or `:controller` to declare a workload and template for LLM calls made from jobs and controller actions — see [Declared Workloads](docs/declared_workloads.md#rails-auto-declare) |
185
239
 
186
240
  ## Usage Examples
187
241
 
@@ -243,7 +297,7 @@ Coolhand.without_capture do
243
297
  end
244
298
  ```
245
299
 
246
- Both methods are thread-safe. Blocks nest correctly — the innermost block takes precedence, and the outer setting is restored when the block exits.
300
+ Both methods are thread-safe. Blocks nest correctly — the innermost block takes precedence, and the outer setting is restored when the block exits. The override is fiber-local, so it does not follow into new Threads, Fibers or Async tasks started inside the block; see [Threads and background logging](docs/declared_workloads.md#threads-and-background-logging).
247
301
 
248
302
  ## Logging Inbound Webhooks
249
303
 
@@ -434,13 +488,17 @@ The monitor handles errors gracefully:
434
488
  - Failed API logging attempts are logged to console but don't interrupt your application
435
489
  - Invalid API keys will be reported but won't crash your app
436
490
  - Network issues are handled with appropriate error messages
437
- - The read methods (`search_templates`, `get_template`) are the exception — they raise `Coolhand::HttpError`; see [Reading Templates →](docs/template-search.md)
491
+ - The read methods (`search_templates`, `get_template`, `search_workloads`, `search_logs`, `get_log`), the feedback-link methods and the `upsert` methods are the exception — they raise `Coolhand::Error` (`Coolhand::HttpError` for non-2xx responses); see [Reading Templates →](docs/template-search.md), [Reading Workloads →](docs/workload-search.md), [Reading Logs →](docs/log-search.md), [Linking Feedback →](docs/feedback-links.md) and [Declared Workloads →](docs/declared_workloads.md)
438
492
 
439
493
  ## Documentation
440
494
 
441
495
  - **[Configuration](docs/configuration.md)** — Self-hosted deployments, base_url rules, debug mode, custom intercept addresses
442
496
  - **[Feedback API](docs/feedback.md)** — Full field reference, matching strategies, sentiment values
443
497
  - **[Reading Templates](docs/template-search.md)** — Search LLM request templates and fetch a single one, prompt patterns included, using the private API key
498
+ - **[Reading Workloads](docs/workload-search.md)** — List workloads with optional cost and performance metrics over a rolling or explicit time window
499
+ - **[Reading Logs](docs/log-search.md)** — Search logs with per-log cost, filter and sort by cost, and fetch one log with its cost breakdown
500
+ - **[Linking Feedback](docs/feedback-links.md)** — Link feedback to an optimization as evidence, singly or in bulk, using the private API key
501
+ - **[Declared Workloads](docs/declared_workloads.md)** — Declare the workload and template of captured LLM calls with `Coolhand.with_context`, and create or rename them with `upsert`
444
502
  - **[Anthropic Integration](docs/anthropic.md)** — Official and community Anthropic Ruby gems, streaming, dual gem handling, and troubleshooting
445
503
  - **[ElevenLabs Integration](docs/elevenlabs.md)** — Webhook capture, feedback submission, and Rails integration
446
504
  - **[OpenAI Batch Webhook Handler](docs/openai.md)** — Handle OpenAI batch job completion events via webhook interception
data/Rakefile CHANGED
@@ -9,4 +9,11 @@ require "rubocop/rake_task"
9
9
 
10
10
  RuboCop::RakeTask.new
11
11
 
12
+ namespace :examples do
13
+ desc "Run every live script in examples/ against real provider keys (loads keys via zsh -ic)"
14
+ task :live do
15
+ sh RbConfig.ruby, File.expand_path("bin/live-examples", __dir__)
16
+ end
17
+ end
18
+
12
19
  task default: %i[spec rubocop]
@@ -39,6 +39,25 @@ 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
+ ## Asynchronous Logging
43
+
44
+ By default Coolhand ships each captured request to its API from background worker threads (one for per-request logs, a separate one for batch jobs), so a slow or unreachable Coolhand backend never adds latency to your own LLM calls. The request-path work is limited to capturing and sanitizing the payload.
45
+
46
+ ```ruby
47
+ Coolhand.configure do |config|
48
+ config.async_logging = false # default: true — send inline, in the intercepted call's request path
49
+ end
50
+ ```
51
+
52
+ **How it behaves:**
53
+ - The queue is bounded at 1,000 pending logs. If Coolhand is down long enough to fill it, the *oldest* pending log is dropped (with a log line) rather than blocking your app or growing memory.
54
+ - The bound is on the *number* of pending logs, not their size: each holds its captured response body in memory until sent, so a long Coolhand outage under heavy traffic with large responses can hold a lot of memory. Batch jobs use their own lane, so a long-running batch never delays or evicts per-request logs.
55
+ - A forked process (Puma/Unicorn workers, Spring) starts its own worker automatically.
56
+ - Pending logs are flushed at process exit (up to 5 seconds in total, so a batch job still running at exit may be only partly logged). In a short-lived script, or to wait explicitly, call `Coolhand.flush(timeout: 5)` — it returns `true` once everything has been sent.
57
+ - OpenAI and Vertex batch results are processed as a single background job per batch.
58
+ - `config.debug_mode` always runs inline so printed payloads appear in order.
59
+ - Logs queued but not yet sent are lost if the process is killed hard (`kill -9`, OOM). Set `async_logging = false` if you need delivery before each call returns.
60
+
42
61
  ## Request Body Capture Limits
43
62
 
44
63
  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:
@@ -0,0 +1,156 @@
1
+ # Declaring workloads and templates
2
+
3
+ By default Coolhand works out which workload and template an LLM call belongs to by matching the log against known templates. If your code already knows (a service object, a prompt library, an adapter), declare it instead. A declared log skips matching entirely and is filed under the workload and template you named.
4
+
5
+ There are two parts:
6
+
7
+ - `Coolhand.with_context` stamps the LLM calls the gem captures automatically.
8
+ - `Coolhand.workload_service.upsert` and `Coolhand.template_service.upsert` create or update workloads and templates explicitly. They need your **private** API key.
9
+
10
+ ## Declaring on captured calls
11
+
12
+ ```ruby
13
+ Coolhand.with_context(
14
+ reporter: "squishling",
15
+ workload: { key: "Parser#call", name: "Parser#call", description: "Parses incoming tickets" },
16
+ template: { key: "Parser#call/squish:L2", name: "Parser#call · squish L2" }
17
+ ) do
18
+ client.chat(...) # captured as usual, and sent with the declared identity
19
+ end
20
+ ```
21
+
22
+ | Field | Accepts | Notes |
23
+ |---|---|---|
24
+ | `reporter` | String | Who is making the declaration, e.g. the library or app. Used when a record is first created. |
25
+ | `workload` | Hash: `id`, `key`, `name`, `description` | Needs an `id` or a `key`. Other keys are ignored. |
26
+ | `template` | Hash: `id`, `key`, `name` | Needs an `id` or a `key`. Other keys are ignored. |
27
+
28
+ - `id` refers to an existing record. `key` is a stable string of your choosing; Coolhand finds or creates the record with that key. On a template, `id` wins over `key`.
29
+ - `name`, `description` and `reporter` are used only when a record is **first created**. Declaring never renames or re-attributes an existing record; use `upsert` for that. A display name that is already taken gets a numeric suffix, and names such as "Unmatched" are reserved.
30
+ - A declaration with only a `workload` gets a default template keyed `<workload key>/default`. A template given by `key` needs a workload alongside it, either in the same block or an enclosing one.
31
+ - Keys are at most 255 characters, printable, and cannot start or end with whitespace or `/` or contain `//`. `#`, `:` and `·` are fine. Descriptions are cut to 1000 characters.
32
+ - Keys must be stable, so never put a request id or timestamp in one: each distinct key creates a record.
33
+ - A `workload` or `template` that is not a Hash, has neither `id` nor `key`, has an invalid key, or a template `key` with no workload raises `ArgumentError` before your block runs. The server would answer these with a `422` and **not save the log**.
34
+ - An unknown `id` (or one from another client) gets the same `422` and the log is not saved, so prefer `key`.
35
+
36
+ ### Nesting
37
+
38
+ Blocks nest. Each field (`reporter`, `workload`, `template`) is taken from the innermost block that sets it, and fields an inner block leaves out are inherited (with the one exception below):
39
+
40
+ ```ruby
41
+ Coolhand.with_context(reporter: "my-app", workload: { key: "Parser#call" }) do
42
+ Coolhand.with_context(template: { key: "Parser#call/summarize" }) do
43
+ # reporter: "my-app", workload: Parser#call, template: Parser#call/summarize
44
+ end
45
+ end
46
+ ```
47
+
48
+ A template belongs to a workload, so an inner block that declares a `workload` without a `template` does not inherit the outer block's template. Declare both together when you change workload.
49
+
50
+ A `workload` or `template` Hash is replaced as a whole, not merged key by key. An inner `workload: { key: "b" }` inside an outer `workload: { id: "a" }` declares only `b`, rather than a mix of `id` and `key`.
51
+
52
+ ### Threads and background logging
53
+
54
+ The context is thread-local, like `Coolhand.with_capture`. It is also fiber-local, so it does not carry into threads, fibers, `Enumerator#next` blocks or async tasks you start inside the block; calls made there are captured without a declaration. The identity is read on the thread that made the LLM call, before the log is queued, so it survives `async_logging` (the default).
55
+
56
+ This matters for Fiber-scheduler concurrency such as [Async](https://github.com/socketry/async). Nothing raises: the call is logged, just without a workload or template.
57
+
58
+ ```ruby
59
+ Coolhand.with_context(workload: { key: "Parser#call" }) do
60
+ Async do
61
+ client.chat(...) # runs in a new Fiber: captured with no declared workload
62
+ end
63
+ end
64
+ ```
65
+
66
+ `Coolhand.with_capture` and `Coolhand.without_capture` work the same way, so a `without_capture` block does not suppress calls made from a Fiber started inside it, and with `config.capture = false` a `with_capture` block does not enable them. To cover work that runs in another Fiber, open the block inside that Fiber:
67
+
68
+ ```ruby
69
+ Async do
70
+ Coolhand.with_context(workload: { key: "Parser#call" }) do
71
+ client.chat(...) # captured with the declared workload
72
+ end
73
+ end
74
+ ```
75
+
76
+ ### Public keys
77
+
78
+ Declarations made with a public key are capped per client at 200 workloads and 1,000 templates. Past the cap, Coolhand ignores the declaration, ingests the log normally, and returns a warning, which the gem prints unless `config.silent` is on. Server-supplied warnings are sanitized before printing (control and invisible characters removed, each entry cut at 500 characters, at most 5 printed per response). Declarations from a private key are not capped.
79
+
80
+ Older Coolhand servers ignore `declared`, so it is safe to use against them.
81
+
82
+ ## Declaring on a class
83
+
84
+ Wrapping every call site in `with_context` does not suit passive capture. Include `Coolhand::Declarable` and declare the identity once, on the class. Wrapped methods run inside `with_context`, so everything above (nesting, precedence, async) applies unchanged.
85
+
86
+ ```ruby
87
+ class ApplicationService
88
+ include Coolhand::Declarable
89
+
90
+ coolhand_workload reporter: "my-app"
91
+ coolhand_declare_all
92
+ end
93
+
94
+ class Parser < ApplicationService
95
+ def call(ticket) = client.chat(...) # workload "Parser#call", template "Parser#call"
96
+ end
97
+
98
+ class Summariser < ApplicationService
99
+ coolhand_workload key: "Summariser" # one workload for every method; templates stay per method
100
+
101
+ def call(ticket) = client.chat(...) # workload "Summariser", template "Summariser#call"
102
+ end
103
+ ```
104
+
105
+ - `coolhand_declare :call, :other` wraps just those methods, whether they are defined before or after the line. `template:` takes a key or a `{ key:, name: }` Hash instead of the default `"ClassName#method"`: `coolhand_declare :call, template: "Parser#call/summarise"`.
106
+ - `coolhand_declare_all` wraps every public instance method defined after it, in the class and its subclasses, so put it at the top of the class body. Private and protected methods are left alone, however they are made private (`private def`, `private :name`, or a `private` section), as are attribute readers and writers and object-protocol methods such as `to_s`, `==` and `method_missing`. A method you name in `coolhand_declare` is wrapped regardless.
107
+ - The key uses the **runtime** class, so a method inherited from `ApplicationService` declares as `Parser#call` when called on a `Parser`. Anonymous classes are not declared.
108
+ - `coolhand_workload(key:, name:, description:, reporter:)` sets class-level defaults and is inherited by subclasses, field by field, with the nearest class winning. Without a `key`, each method gets its own workload, `ClassName#method`. `name` and `description` need a `key`, since one name shared by per-method workloads would collide.
109
+ - Services that call each other nest: the innermost wrapped method wins, and it brings its own template (see [Nesting](#nesting)).
110
+ - It only works where the LLM call runs on the same thread and fiber as the wrapped method (see [Threads and background logging](#threads-and-background-logging)).
111
+
112
+ ## Rails auto-declare
113
+
114
+ For Rails apps, Coolhand can declare jobs and controller actions without touching their classes. It is off by default, because every distinct job and action becomes a workload.
115
+
116
+ ```ruby
117
+ Coolhand.configure do |config|
118
+ config.api_key = ENV.fetch("COOLHAND_API_KEY")
119
+ config.auto_declare = [:active_job, :controller]
120
+ end
121
+ ```
122
+
123
+ | Target | Wraps | Declares |
124
+ |---|---|---|
125
+ | `:active_job` | `around_perform` on `ActiveJob::Base` | `SummariseJob#perform`, reporter `rails` |
126
+ | `:controller` | `around_action` on `ActionController::Base` and `::API` | `UsersController#show`, reporter `rails` |
127
+
128
+ Coolhand must be loaded after Rails (the default with `Bundler.require` in a Rails app), otherwise the hooks are never registered. They are installed after your initializers have run, so `Coolhand.configure` can come before or after anything else. Anything declared inside the job or action (a `Declarable` service, a `with_context` block) takes precedence over the job or action declaration. With a public API key the per-client declaration cap applies (see [Public keys](#public-keys)), so enable only the targets you want as workloads.
129
+
130
+ ## Creating and renaming explicitly
131
+
132
+ ```ruby
133
+ Coolhand.configure { |config| config.api_key = ENV.fetch("COOLHAND_PRIVATE_API_KEY") }
134
+
135
+ workload = Coolhand.workload_service.upsert(
136
+ key: "Parser#call", name: "Parser", description: "Parses incoming tickets", reporter: "my-app"
137
+ )
138
+
139
+ Coolhand.template_service.upsert(
140
+ key: "Parser#call/summarize",
141
+ workload: { key: "Parser#call" }, # or { id: workload[:id] }
142
+ name: "Parser · summarize",
143
+ reporter: "my-app"
144
+ )
145
+ ```
146
+
147
+ - Both are `PUT .../by_key/<key>`: they create the record if the key is new and update it otherwise. The server answers `201` on create and `200` on update; both return the record as a Hash.
148
+ - The key never changes. Calling `upsert` with the same key and a new `name` renames the record, and an update changes only the fields you send.
149
+ - A template needs a `workload` (`{ id: }` or `{ key: }`, sent to the server as `workload_id` / `workload_key`) when it is new. For an existing template, naming a different workload is a `422`.
150
+ - A key may contain `/`, `#`, `:` and other characters; the gem escapes it for the URL.
151
+
152
+ ## Errors
153
+
154
+ `upsert` raises, unlike the logging writes. A non-2xx response raises `Coolhand::HttpError` whose `status` is the HTTP code (`401` for a missing or public key, `422` for an invalid key, a taken name, a missing or unknown workload, or a key held by a deprecated template, `504` if the server timed out). A blank key, or one made of `.` / `..` path segments, and transport failures raise `Coolhand::Error`; a `workload` that is not a Hash raises `ArgumentError`.
155
+
156
+ `config.debug_mode` does not suppress `upsert`: it changes server state, so it is always sent.
@@ -0,0 +1,48 @@
1
+ # Linking feedback to an optimization
2
+
3
+ Attach feedback to an optimization as supporting evidence. `Coolhand::OptimizationFeedbackLinkService`
4
+ wraps `POST /api/v2/optimizations/{optimization_id}/feedback_links` (single and bulk share the route,
5
+ chosen by the body) and `DELETE /api/v2/optimizations/{optimization_id}/feedback_links/{id}`.
6
+
7
+ These methods require your **private** API key; the public key gets a `401`.
8
+
9
+ ```ruby
10
+ require "coolhand"
11
+
12
+ Coolhand.configure do |config|
13
+ config.api_key = ENV.fetch("COOLHAND_PRIVATE_API_KEY")
14
+ end
15
+
16
+ links = Coolhand.optimization_feedback_link_service
17
+
18
+ # One feedback. link[:id] is the link's hashid, not the feedback's.
19
+ link = links.link_feedback("optimizationHashid", "feedbackHashid", note: "why")
20
+
21
+ # Many feedbacks.
22
+ result = links.bulk_link_feedback("optimizationHashid", %w[fb1 fb2 fb3])
23
+ result # => { linked: 2, already_linked: 1, errored: 0, not_found: [] }
24
+
25
+ # Remove a link.
26
+ links.unlink_feedback("optimizationHashid", link[:id])
27
+ ```
28
+
29
+ ## Bulk behavior
30
+
31
+ - The server accepts at most 100 ids per request. `bulk_link_feedback` splits longer lists into
32
+ batches of 100, sums `linked` / `already_linked` / `errored` and concatenates `not_found`.
33
+ - Already-linked ids count as `already_linked`, not errors. Unknown, malformed and other-client ids
34
+ are all reported in `not_found`.
35
+ - If a batch fails, the call raises and earlier batches stay applied. Repeating the call is safe.
36
+ - `link_feedback` is not retry-safe: after a transport error, a `422` "already linked" on retry may mean
37
+ the first attempt succeeded. Use `bulk_link_feedback` with a single id for retry-safe semantics.
38
+ - An empty list or a blank id raises `Coolhand::Error` before any request is made.
39
+ - Ids are trimmed of surrounding whitespace but not de-duplicated client-side.
40
+
41
+ ## Errors
42
+
43
+ Unlike the logging writes, all three methods raise. A non-2xx response raises `Coolhand::HttpError`
44
+ whose `status` is the HTTP code: `401` missing or public key, `404` unknown optimization, feedback or
45
+ link, `422` invalid input, an already-linked feedback (single mode) or a `note` that is too long.
46
+ Client-side validation and transport failures raise `Coolhand::Error`.
47
+
48
+ `config.debug_mode` does not suppress these calls: they change server state, so they are always sent.
@@ -0,0 +1,102 @@
1
+ # Reading Logs (Search + Get)
2
+
3
+ `Coolhand::LogService` reads logs back out of Coolhand, with the per-log `cost` the dashboard shows.
4
+ The write side, `Coolhand.logger_service`, is a different class. It wraps two read-only endpoints:
5
+
6
+ | method | endpoint |
7
+ |---|---|
8
+ | `search_logs` | `GET /api/v2/llm_request_logs` |
9
+ | `get_log` | `GET /api/v2/llm_request_logs/{id}` |
10
+
11
+ Both require your **private** API key. The public key is write-only on this API and is rejected exactly
12
+ like an invalid one.
13
+
14
+ ```ruby
15
+ require "coolhand"
16
+
17
+ Coolhand.configure do |config|
18
+ config.api_key = ENV.fetch("COOLHAND_PRIVATE_API_KEY")
19
+ end
20
+
21
+ logs = Coolhand.log_service
22
+
23
+ # The ten most expensive logs of the last 30 days
24
+ result = logs.search_logs(since: Time.now.utc - (30 * 86_400), order: "cost_desc", per: 10)
25
+ result.logs.each { |log| puts "#{log[:id]} #{log[:model]} $#{log[:cost]}" }
26
+
27
+ detail = logs.get_log(result.logs.first[:id])
28
+ detail[:cost_breakdown][:input_cost]
29
+ ```
30
+
31
+ ## `search_logs`
32
+
33
+ All keywords are optional, and named after the wire parameters (`sort` maps to `q[s]`). Two wire
34
+ parameters are not exposed: the `q[source_api_in][]` array filter, so use the single-value `source_api`
35
+ keyword, and the `per_page` alias, since this gem only sends `per`.
36
+
37
+ | keyword | type | notes |
38
+ |---|---|---|
39
+ | `template_id`, `workload_id` | String | Hashids. One that does not decode, or that belongs to another client, is a `422`. |
40
+ | `system_prompt_contains`, `user_prompt_contains` | String | Case-insensitive substring. |
41
+ | `model`, `source_api`, `source_api_result`, `source_application` | String | Exact match. `source_api_result` is `success`, `failed`, `operational` or `unmatched`. |
42
+ | `project_path` | String | Exact match against `metadata.project_path`. |
43
+ | `unmatched_only` | Boolean | Only logs with no template. |
44
+ | `days_back` | Integer | Last N days. Unset means unrestricted. Ignored when `since` is given; with only `until`, the window is `days_back` long ending at `until`. |
45
+ | `since`, `until` | Time, DateTime, Date or String | Bounds on `created_at`: `since` inclusive, `until` exclusive. See [Windows](workload-search.md#windows-since-and-until). |
46
+ | `min_cost` | Numeric | Only logs whose `cost` (USD) is at least this. `0` is sent. |
47
+ | `order` | String | `"cost_desc"` sorts by `cost`, highest first, replacing `sort`. Any other value is a `422` from the server. |
48
+ | `include_prompts` | Boolean | Adds `:system_prompt` and `:user_prompt`, each truncated to 500 characters. |
49
+ | `include_total` | Boolean | Asks the server for the `X-Total-*` headers. Off by default there (it skips a `COUNT(*)`). |
50
+ | `sort` | String | Ransack sort expression, sent as `q[s]`, e.g. `"created_at desc"`. |
51
+ | `page`, `per` | Integer | 1-based; `per` defaults to 25, max 100. |
52
+
53
+ `min_cost` and `order: "cost_desc"` only consider logs that can be priced (non-nil `cost`). The cost is
54
+ computed per log, so on a client with a lot of history combine them with `since` or `days_back`, or the
55
+ query can time out (`504`).
56
+
57
+ ### Return value
58
+
59
+ A `Coolhand::LogSearchResult`, a `Struct` with `#logs` and `#pagination`. The response body is a bare
60
+ array, never wrapped. Rows are Symbol-keyed Hashes: `:id`, `:collector`, `:source_api`,
61
+ `:source_application`, `:metadata`, `:source_api_result`, `:model`, `:template_id`, `:template_name`,
62
+ `:input_tokens`, `:output_tokens`, `:latency_ms`, `:created_at`, `:updated_at`, `:ingest_evidence` and
63
+ `:cost`.
64
+
65
+ `:cost` is a Float in USD, or `nil` when the log has no tokens or its model has no pricing.
66
+
67
+ **Pagination without totals.** `X-Page` and `X-Per-Page` are always sent, but `X-Total-Count` and
68
+ `X-Total-Pages` only come with `include_total: true`. Without it, `pagination.total_count` and
69
+ `total_pages` are lower bounds the gem derives, not counts, and `has_next_page` is true when the page
70
+ came back full. Pass `include_total: true` when you need real totals.
71
+
72
+ ## `get_log(id, section: nil, max_chars: nil, search_query: nil, include_thinking: nil)`
73
+
74
+ `id` is the log hashid. Returns the full log as a Symbol-keyed Hash: the list fields plus the prompts and
75
+ `:output`, and:
76
+
77
+ | field | type |
78
+ |---|---|
79
+ | `:url` | String: the log's dashboard path (`/c/<client>/llm_request_logs/<id>`) |
80
+ | `:cost` | Float or nil |
81
+ | `:cost_breakdown` | Hash or nil: `:total_cost`, `:input_cost`, `:output_cost`, `:cached_input_cost`, `:cache_creation_input_cost`, `:reasoning_output_cost` |
82
+
83
+ `section` (`"full"`, `"beginning"`, `"end"`), `max_chars` and `search_query` bound or search the content;
84
+ `include_thinking` adds `:thinking_response`. Truncation and search are reported back as `:truncated`,
85
+ `:total_chars`, `:search_query` and `:matches`. Only directly-collected logs are fetchable; internally
86
+ generated records are a `404`.
87
+
88
+ ## Errors
89
+
90
+ The read methods raise where the write methods log and return `nil`. `Coolhand::HttpError` carries
91
+ `#status` and `#body`.
92
+
93
+ | status | when |
94
+ |---|---|
95
+ | `401` | No API key, an invalid key, or the public key. |
96
+ | `404` | `get_log` only: unknown id, another client's log, or an internally generated record. |
97
+ | `422` | Bad `template_id`, `workload_id`, `days_back`, `since`, `until`, `min_cost`, `order` or `max_chars`. |
98
+ | `504` | The search exceeded the statement timeout. Narrow the window and retry. |
99
+
100
+ `Coolhand::Error` covers no API key configured, a transport failure, a body that is not valid JSON, a
101
+ non-array list body, and a blank `id` passed to `get_log`. An unsupported `since`/`until` type raises
102
+ `ArgumentError` before any request.