coolhand 0.8.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: 256c9237619b3f7116aa9751f78f0d0e367381b12afa694742da07ed1d7d3123
4
- data.tar.gz: 149ddf8c4cd1800b105382ade5d2a2e9e57a360884e34e1b988b926739e90f30
3
+ metadata.gz: 48857d802183f91a163c82c3e1dd89ce34ac7a8f1f3d1079c5ae6d5d594ad300
4
+ data.tar.gz: a852465d5b9dced73580eff8c81f45093a97c48c79a66579cff27c2dbedc7cbe
5
5
  SHA512:
6
- metadata.gz: 2ccbf54c2787a8df93178120583d83666553454a1b86385ec1c4ea10e228b859b995a35bed1450e9ab6d38dbb5d145d9b7a269be89f5562870d51a63630e5653
7
- data.tar.gz: 4e6db88c4530737370030809a9d8e094d791761a6306a63701edc4413ed81f61a716d975953799c3d46b0cebbb3ddd9e1d172b22ee5d7b29c18bdd65ffe9e25c
6
+ metadata.gz: 0d4e6bc5e9ef29365afaba5b9c4a4edbadb552f77268adfe27ac6276f23cd44e4c75fe6df85b1ddef8bdf25d99d88204a46a127dcce7c3a2183edc09e5019508
7
+ data.tar.gz: ee192c55d280b47abc3f6a9af7f2ddd48d4966493955cee5614557feeea2d87d71a28c16f488fee4dc9fddd916b88fc9ee98621e058e3ad0de80431fc6bd871c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ 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
+
10
30
  ## [0.8.0] - 2026-10-09
11
31
 
12
32
  ### Added
data/README.md CHANGED
@@ -132,6 +132,29 @@ links.unlink_feedback('optimizationHashid', link[:id])
132
132
 
133
133
  For batching, error semantics, and details, see [Linking Feedback →](docs/feedback-links.md).
134
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
+
135
158
  ## Rails Integration
136
159
 
137
160
  ### Configuration
@@ -212,6 +235,7 @@ end
212
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) |
213
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) |
214
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) |
215
239
 
216
240
  ## Usage Examples
217
241
 
@@ -273,7 +297,7 @@ Coolhand.without_capture do
273
297
  end
274
298
  ```
275
299
 
276
- 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).
277
301
 
278
302
  ## Logging Inbound Webhooks
279
303
 
@@ -464,7 +488,7 @@ The monitor handles errors gracefully:
464
488
  - Failed API logging attempts are logged to console but don't interrupt your application
465
489
  - Invalid API keys will be reported but won't crash your app
466
490
  - Network issues are handled with appropriate error messages
467
- - The read methods (`search_templates`, `get_template`, `search_workloads`, `search_logs`, `get_log`) and the feedback-link 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) and [Linking Feedback →](docs/feedback-links.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)
468
492
 
469
493
  ## Documentation
470
494
 
@@ -474,6 +498,7 @@ The monitor handles errors gracefully:
474
498
  - **[Reading Workloads](docs/workload-search.md)** — List workloads with optional cost and performance metrics over a rolling or explicit time window
475
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
476
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`
477
502
  - **[Anthropic Integration](docs/anthropic.md)** — Official and community Anthropic Ruby gems, streaming, dual gem handling, and troubleshooting
478
503
  - **[ElevenLabs Integration](docs/elevenlabs.md)** — Webhook capture, feedback submission, and Rails integration
479
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]
@@ -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.
data/docs/openai.md CHANGED
@@ -59,6 +59,10 @@ end
59
59
  # ...existing code...
60
60
  ```
61
61
 
62
+ ## Signature header and secret rotation
63
+
64
+ The `webhook-signature` header may carry several space-separated signatures (`v1,sigA v1,sigB`), as senders produce while a webhook secret is being rotated. `Coolhand::OpenAi::WebhookValidator` accepts the request if any `v1,` entry matches your secret, comparing each in constant time and ignoring entries with any other version prefix. The timestamp and replay checks below still run, and only after a signature matches.
65
+
62
66
  ## Replay protection
63
67
 
64
68
  `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`.
@@ -6,12 +6,14 @@ require "json"
6
6
  require_relative "collector"
7
7
  require_relative "errors"
8
8
  require_relative "read_requests"
9
+ require_relative "response_warnings"
9
10
 
10
11
  module Coolhand
11
12
  class ApiService
12
13
  # The GET half of this class. See Coolhand::ReadRequests for why reads raise where writes
13
14
  # log and return nil.
14
15
  include ReadRequests
16
+ include ResponseWarnings
15
17
 
16
18
  # Caps how much of a failed response body is interpolated into a log line or an exception
17
19
  # message. Without it an oversized body from a proxy or gateway becomes the message.
@@ -36,7 +38,9 @@ module Coolhand
36
38
  log JSON.pretty_generate(sanitize_payload_for_json(payload))
37
39
  nil
38
40
  else
39
- send_request(payload, "✅ Successfully sent request metadata")
41
+ result = send_request(payload, "✅ Successfully sent request metadata")
42
+ log_response_warnings(result)
43
+ result
40
44
  end
41
45
  end
42
46
 
@@ -149,7 +149,8 @@ module Coolhand
149
149
  end
150
150
 
151
151
  def send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:, response_headers:,
152
- response_body:, status_code:, start_time:, end_time:, duration_ms:, is_streaming:, source_api: nil, model: nil)
152
+ response_body:, status_code:, start_time:, end_time:, duration_ms:, is_streaming:, source_api: nil, model: nil,
153
+ declared: nil)
153
154
  raw_request = {
154
155
  id: request_id,
155
156
  timestamp: start_time.iso8601,
@@ -168,6 +169,7 @@ module Coolhand
168
169
  raw_request[:model] = model if Coolhand.required_field?(model)
169
170
 
170
171
  request_data = { raw_request: raw_request }
172
+ request_data[:declared] = declared if declared && !declared.empty?
171
173
 
172
174
  LogQueue.submit do
173
175
  Coolhand::ApiService.new.send_llm_request_log(request_data)
@@ -30,7 +30,7 @@ module Coolhand
30
30
 
31
31
  attr_accessor :api_key, :environment, :silent, :debug_mode, :capture, :exclude_api_patterns, :enabled,
32
32
  :max_captured_body_bytes, :webhook_replay_tolerance_seconds, :webhook_id_store, :async_logging
33
- attr_reader :intercept_addresses, :intercept_path_patterns, :base_url
33
+ attr_reader :intercept_addresses, :intercept_path_patterns, :base_url, :auto_declare
34
34
 
35
35
  def initialize
36
36
  # Set defaults
@@ -45,6 +45,7 @@ module Coolhand
45
45
  @exclude_api_patterns = DEFAULT_EXCLUDE_API_PATTERNS.dup
46
46
  @enabled = true
47
47
  @async_logging = true
48
+ @auto_declare = []
48
49
  @max_captured_body_bytes = DEFAULT_MAX_CAPTURED_BODY_BYTES
49
50
  @webhook_replay_tolerance_seconds = 300
50
51
  @webhook_id_store = Coolhand::OpenAi::WebhookIdStore.new
@@ -74,6 +75,18 @@ module Coolhand
74
75
  @intercept_path_patterns = value.is_a?(Array) ? value : [value]
75
76
  end
76
77
 
78
+ # Rails entry points to declare a workload/template for automatically: any of
79
+ # RailsAutoDeclare::TARGETS (:active_job, :controller). Off by default. See docs/declared_workloads.md.
80
+ def auto_declare=(value)
81
+ targets = Array(value).map { |target| target.to_s.to_sym }
82
+ unknown = targets - RailsAutoDeclare::TARGETS
83
+ unless unknown.empty?
84
+ raise Error, "auto_declare supports #{RailsAutoDeclare::TARGETS.inspect}, got #{unknown.inspect}"
85
+ end
86
+
87
+ @auto_declare = targets.uniq
88
+ end
89
+
77
90
  def base_url=(value)
78
91
  stripped = value&.sub(%r{/+\z}, "")
79
92
  raise Error, BASE_URL_ERROR_MSG unless stripped.nil? || valid_base_url?(stripped)
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Declares the workload/template of the LLM calls a class makes, once, on the class, so passive
5
+ # capture picks them up without a `Coolhand.with_context` block at every call site. Wrapped methods
6
+ # run inside `with_context`, so nesting and precedence are exactly those documented in
7
+ # docs/declared_workloads.md. Settings are inherited by subclasses.
8
+ #
9
+ # class ApplicationService
10
+ # include Coolhand::Declarable
11
+ # coolhand_workload reporter: "my-app"
12
+ # coolhand_declare_all
13
+ # end
14
+ #
15
+ # class Parser < ApplicationService
16
+ # def call = client.chat(...) # workload "Parser#call", template "Parser#call"
17
+ # end
18
+ module Declarable
19
+ def self.included(base)
20
+ raise ArgumentError, "Coolhand::Declarable can only be included in a class" unless base.is_a?(Class)
21
+
22
+ base.extend(ClassMethods)
23
+ end
24
+
25
+ # Never an LLM call site, and wrapping them would tax every call or declare a junk workload.
26
+ SKIPPED_METHODS = %i[method_missing respond_to? respond_to_missing? == === eql? equal? hash to_s inspect
27
+ to_str to_ary to_proc coerce <=>].freeze
28
+ ATTRIBUTE_MACROS = %w[attr attr_reader attr_writer attr_accessor].freeze
29
+ private_constant :SKIPPED_METHODS, :ATTRIBUTE_MACROS
30
+
31
+ module ClassMethods
32
+ # Class-level defaults, inherited field by field (the nearest class that sets a field wins). With
33
+ # no `key`, each wrapped method declares its own workload, "ClassName#method"; a `key` makes every
34
+ # method share one workload. The class name is the runtime class, so an inherited method declares
35
+ # under the subclass.
36
+ def coolhand_workload(key: nil, name: nil, description: nil, reporter: nil)
37
+ workload = DeclaredContext.normalize_part(:workload, { key: key, name: name, description: description }.compact,
38
+ DeclaredContext::WORKLOAD_FIELDS)
39
+ @coolhand_settings = { workload: workload, reporter: DeclaredContext.normalize_reporter(reporter) }.compact
40
+ end
41
+
42
+ # Wraps the named instance methods (now, or when they are defined later). `template:` is a key or a
43
+ # `{ key:, name: }` Hash; by default the template key is "ClassName#method".
44
+ def coolhand_declare(*names, template: nil)
45
+ # Checked here, at class definition, so a bad value never surfaces inside the host's method.
46
+ template = DeclaredContext.normalize_part(:template, template.is_a?(String) ? { key: template } : template,
47
+ DeclaredContext::TEMPLATE_FIELDS)
48
+ names.flatten.each do |name|
49
+ name = name.to_sym
50
+ (@coolhand_declared ||= {})[name] = { template: template }
51
+ coolhand_wrap(name) if method_defined?(name) || private_method_defined?(name)
52
+ end
53
+ end
54
+
55
+ # Wraps every public instance method defined in this class and its subclasses after this call, so
56
+ # put it at the top of the class body. Private and protected methods, attribute readers/writers
57
+ # and object-protocol methods (see SKIPPED_METHODS) are left alone; `coolhand_declare` wraps one of
58
+ # those if you name it.
59
+ def coolhand_declare_all
60
+ @coolhand_declare_all = true
61
+ end
62
+
63
+ # @api private
64
+ def coolhand_identity(method_name)
65
+ key = "#{name}##{method_name}"
66
+ template = coolhand_template_for(method_name)
67
+ {
68
+ reporter: coolhand_setting(:reporter),
69
+ workload: { key: key }.merge(coolhand_setting(:workload) || {}),
70
+ template: template || { key: key }
71
+ }
72
+ end
73
+
74
+ # @api private
75
+ def coolhand_declare_call?(method_name)
76
+ return false if name.nil? || !Coolhand.configuration.enabled
77
+ return false unless DeclaredContext.valid_key?("#{name}##{method_name}")
78
+
79
+ public_method_defined?(method_name) || !coolhand_declaration(method_name).nil?
80
+ end
81
+
82
+ # @api private
83
+ def coolhand_own_state
84
+ [@coolhand_settings, @coolhand_declared, @coolhand_declare_all]
85
+ end
86
+
87
+ private
88
+
89
+ def method_added(method_name)
90
+ super
91
+ coolhand_wrap(method_name) if !@coolhand_wrapping && coolhand_wrap?(method_name)
92
+ end
93
+
94
+ def coolhand_wrap?(method_name)
95
+ return true if coolhand_declaration(method_name)
96
+
97
+ public_method_defined?(method_name) && !SKIPPED_METHODS.include?(method_name) &&
98
+ coolhand_declare_all? && !attribute_definition?
99
+ end
100
+
101
+ # True while `attr_reader`/`attr_writer`/`attr_accessor` is what is defining the method. Read from
102
+ # the stack rather than by overriding those macros, which would lose the class body's
103
+ # `private`/`protected` default visibility. A window of frames, not one fixed depth, so a
104
+ # `method_added` override that calls `super` does not hide the macro.
105
+ def attribute_definition?
106
+ caller_locations(1, 8)&.any? { |frame| ATTRIBUTE_MACROS.include?(frame.base_label) } || false
107
+ end
108
+
109
+ def coolhand_declare_all?
110
+ coolhand_each_class { |klass| return true if klass.coolhand_own_state[2] }
111
+ false
112
+ end
113
+
114
+ # Redefines the method in the same class, rather than prepending a wrapper, so a later
115
+ # `private :name` (or `private def name`) applies to the wrapper like it would to the original.
116
+ def coolhand_wrap(method_name)
117
+ original = instance_method(method_name)
118
+ wrappers = (@coolhand_wrappers ||= {})
119
+ return if wrappers[method_name] == original
120
+
121
+ visibility = coolhand_visibility(method_name)
122
+ @coolhand_wrapping = true
123
+ define_method(method_name) do |*args, **kwargs, &block|
124
+ klass = self.class
125
+ next original.bind_call(self, *args, **kwargs, &block) unless klass.coolhand_declare_call?(method_name)
126
+
127
+ Coolhand.with_context(**klass.coolhand_identity(method_name)) do
128
+ original.bind_call(self, *args, **kwargs, &block)
129
+ end
130
+ end
131
+ send(visibility, method_name)
132
+ wrappers[method_name] = instance_method(method_name)
133
+ ensure
134
+ @coolhand_wrapping = false
135
+ end
136
+
137
+ # Includes inherited methods: wrapping one a parent made private must not make it public here.
138
+ def coolhand_visibility(method_name)
139
+ return :private if private_method_defined?(method_name)
140
+ return :protected if protected_method_defined?(method_name)
141
+
142
+ :public
143
+ end
144
+
145
+ # Yields this class and its superclasses that include Declarable, nearest first. A loop rather
146
+ # than a collected Array, since the lookups below run on every wrapped call.
147
+ def coolhand_each_class
148
+ klass = self
149
+ while klass.respond_to?(:coolhand_own_state)
150
+ yield klass
151
+ klass = klass.superclass
152
+ end
153
+ end
154
+
155
+ def coolhand_declaration(method_name)
156
+ coolhand_nearest(1, method_name)
157
+ end
158
+
159
+ def coolhand_template_for(method_name)
160
+ coolhand_declaration(method_name)&.fetch(:template)
161
+ end
162
+
163
+ def coolhand_setting(field)
164
+ coolhand_nearest(0, field)
165
+ end
166
+
167
+ # The value for `key` in the nearest class whose own state (slot `slot` of `coolhand_own_state`)
168
+ # sets it, or nil.
169
+ def coolhand_nearest(slot, key)
170
+ coolhand_each_class do |klass|
171
+ state = klass.coolhand_own_state[slot]
172
+ return state[key] if state&.key?(key)
173
+ end
174
+ nil
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Ambient workload/template identity for captured calls, set with {Coolhand.with_context}. The
5
+ # interceptor snapshots {current} on the calling thread before a log is queued, because the
6
+ # background log worker does not inherit thread-locals. See docs/declared_workloads.md.
7
+ module DeclaredContext
8
+ THREAD_KEY = :coolhand_declared_context
9
+
10
+ # The fields the server accepts under `declared.workload` / `declared.template`; anything else
11
+ # is dropped here rather than sent.
12
+ WORKLOAD_FIELDS = %i[id key name description].freeze
13
+ TEMPLATE_FIELDS = %i[id key name].freeze
14
+
15
+ module_function
16
+
17
+ # A frozen Hash, or nil when no context is active.
18
+ def current
19
+ Thread.current[THREAD_KEY]
20
+ end
21
+
22
+ def clear!
23
+ Thread.current[THREAD_KEY] = nil
24
+ end
25
+
26
+ # Each field is replaced wholesale by the innermost block that sets it. Hashes are not merged
27
+ # key by key: `{ id: x }` merged with `{ key: y }` would declare both, and the server prefers `id`.
28
+ # A template belongs to a workload, so a block that declares a workload without a template does
29
+ # not inherit the outer one; it would pair the new workload with a template from another.
30
+ def with(reporter: nil, workload: nil, template: nil)
31
+ previous = current
32
+ declared = {
33
+ reporter: normalize_reporter(reporter),
34
+ workload: normalize_part(:workload, workload, WORKLOAD_FIELDS),
35
+ template: normalize_part(:template, template, TEMPLATE_FIELDS)
36
+ }.compact
37
+ inherited = previous || {}
38
+ inherited = inherited.except(:template) if declared.key?(:workload) && !declared.key?(:template)
39
+ merged = inherited.merge(declared)
40
+ ensure_template_has_workload!(merged)
41
+ Thread.current[THREAD_KEY] = merged.empty? ? nil : merged.freeze
42
+ yield
43
+ ensure
44
+ Thread.current[THREAD_KEY] = previous
45
+ end
46
+
47
+ def normalize_reporter(reporter)
48
+ return nil if reporter.nil?
49
+
50
+ reporter.to_s
51
+ end
52
+
53
+ # Mirrors the server's key rules: at most 255 characters, no control or invisible characters, no
54
+ # leading or trailing whitespace, and no leading, trailing or doubled "/". The server answers a
55
+ # bad key with a 422 that rejects the whole log, so it is checked here, before the call is made.
56
+ MAX_KEY_LENGTH = 255
57
+ KEY_FORMAT = %r{
58
+ \A(?!/)(?!.*//)
59
+ [^\p{Cntrl}\p{Cf}[:space:]]
60
+ (?:[^\p{Cntrl}\p{Cf}]*[^\p{Cntrl}\p{Cf}[:space:]])?
61
+ (?<!/)\z
62
+ }x
63
+
64
+ def valid_key?(key)
65
+ key.is_a?(String) && key.length <= MAX_KEY_LENGTH && KEY_FORMAT.match?(key)
66
+ rescue ArgumentError, EncodingError
67
+ false # a string with a broken or incompatible encoding is not a valid key
68
+ end
69
+
70
+ # A template given by key, with no workload to create it in, is a 422 that drops the log. A
71
+ # template `id` refers to an existing record and needs no workload.
72
+ def ensure_template_has_workload!(declared)
73
+ template = declared[:template]
74
+ return if template.nil? || template.key?(:id) || declared.key?(:workload)
75
+
76
+ raise ArgumentError, "template with a :key needs a workload; declare one in this or an enclosing block"
77
+ end
78
+
79
+ # Validated up front: the server answers a non-object `workload`/`template` with a 422 for the
80
+ # whole log, which would drop the call from Coolhand rather than just its identity.
81
+ def normalize_part(name, value, fields)
82
+ return nil if value.nil?
83
+ raise ArgumentError, "#{name} must be a Hash, got #{value.class}" unless value.is_a?(Hash)
84
+
85
+ part = value.each_with_object({}) do |(k, v), acc|
86
+ field = k.respond_to?(:to_sym) ? k.to_sym : nil
87
+ next unless fields.include?(field) && !v.nil?
88
+ # A blank id refers to nothing and would 422 the log, so it counts as not given.
89
+ next if field == :id && v.to_s.strip.empty?
90
+
91
+ acc[field] = v.to_s.dup.freeze
92
+ end
93
+ return nil if part.empty?
94
+
95
+ if part.key?(:key) && !valid_key?(part[:key])
96
+ raise ArgumentError, "#{name} key must be at most #{MAX_KEY_LENGTH} printable characters with no " \
97
+ "surrounding whitespace and no leading, trailing or doubled \"/\""
98
+ end
99
+ unless part.key?(:id) || part.key?(:key)
100
+ raise ArgumentError, "#{name} needs an :id or a :key, got #{value.keys.inspect}"
101
+ end
102
+
103
+ part.freeze
104
+ end
105
+ end
106
+ end
@@ -47,13 +47,7 @@ module Coolhand
47
47
  per: per
48
48
  }.compact
49
49
 
50
- logs, response = get_json_with_headers(list_url(query), ERROR_NOUN)
51
- raise Error, "#{ERROR_NOUN} response was not a JSON array" unless logs.is_a?(Array)
52
-
53
- LogSearchResult.new(
54
- logs: logs,
55
- pagination: Pagination.from_headers(response, items: logs, page: page, per: per)
56
- ).freeze
50
+ fetch_list(query, LogSearchResult, :logs, page: page, per: per)
57
51
  end
58
52
 
59
53
  # The full log, with `cost` and `cost_breakdown`. Both are nil when the log has no tokens or its
@@ -15,24 +15,8 @@ module Coolhand
15
15
  # Helper method for forwarding webhook data to Coolhand
16
16
  def forward_webhook(webhook_body:, source:, event_type: nil, headers: {}, **options)
17
17
  # Validate required parameters
18
- unless Coolhand.required_field?(webhook_body)
19
- error_msg = "webhook_body is required and cannot be nil or empty"
20
- if Coolhand.configuration.silent
21
- puts "COOLHAND WARNING: #{error_msg}"
22
- return false
23
- else
24
- raise ArgumentError, error_msg
25
- end
26
- end
27
-
28
- unless Coolhand.required_field?(source)
29
- error_msg = "source is required and cannot be nil or empty"
30
- if Coolhand.configuration.silent
31
- puts "COOLHAND WARNING: #{error_msg}"
32
- return false
33
- else
34
- raise ArgumentError, error_msg
35
- end
18
+ { webhook_body: webhook_body, source: source }.each do |name, value|
19
+ return reject_webhook("#{name} is required and cannot be nil or empty") unless Coolhand.required_field?(value)
36
20
  end
37
21
 
38
22
  # Auto-generate required fields unless provided
@@ -55,6 +39,14 @@ module Coolhand
55
39
 
56
40
  private
57
41
 
42
+ # Silent mode warns and drops the webhook; otherwise the caller gets the error.
43
+ def reject_webhook(error_msg)
44
+ raise ArgumentError, error_msg unless Coolhand.configuration.silent
45
+
46
+ puts "COOLHAND WARNING: #{error_msg}"
47
+ false
48
+ end
49
+
58
50
  def build_webhook_url(source, event_type)
59
51
  base = "webhook://#{source}"
60
52
  event_type ? "#{base}/#{event_type}" : base
@@ -46,13 +46,12 @@ module Coolhand
46
46
  Net::HTTPResponse.prepend(ResponseInterceptor)
47
47
 
48
48
  @patched = true
49
- Coolhand.log "🔗 Net::HTTP interceptor patched"
50
49
  rescue StandardError
51
50
  # Roll back this call's hold — it never actually took effect, so it must not count
52
51
  # toward the refcount or a legitimate later unpatch! would underflow against it. This
53
52
  # branch only runs when @patched was false on entry (see `next if @patched` above), so
54
53
  # forcing it back to false here is correct regardless of which line above raised —
55
- # including a failure in the log call itself, after prepend already succeeded.
54
+ # including a failure in the second prepend, after the first already succeeded.
56
55
  @patch_count -= 1
57
56
  @patched = false
58
57
  raise
@@ -139,7 +138,9 @@ module Coolhand
139
138
  end_time = Time.now
140
139
  duration_ms = ((end_time - start_time) * 1000).round(2)
141
140
 
141
+ # Read here on the calling thread: the log worker thread has no access to our thread-locals.
142
142
  BaseInterceptor.send_complete_request_log(
143
+ declared: DeclaredContext.current,
143
144
  request_id: request_id,
144
145
  method: req.method,
145
146
  url: url,
@@ -7,6 +7,9 @@ require_relative "webhook_id_store"
7
7
  module Coolhand
8
8
  module OpenAi
9
9
  class WebhookValidator
10
+ SIGNATURE_VERSION_PREFIX = "v1,"
11
+ private_constant :SIGNATURE_VERSION_PREFIX
12
+
10
13
  attr_reader :request, :errors, :payload
11
14
 
12
15
  def initialize(request, webhook_secret)
@@ -41,27 +44,25 @@ module Coolhand
41
44
  def payload_valid?
42
45
  return true if @payload
43
46
 
44
- if should_enforce_strict_validation?
45
- @errors << "Empty webhook payload - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
46
- "not in development/test allowlist)"
47
- Rails.logger.error(@errors.last)
48
- false
49
- else
50
- Rails.logger.warn("Empty webhook payload - allowing in #{Rails.env} environment")
51
- true
52
- end
47
+ enforce_or_allow("Empty webhook payload",
48
+ "Empty webhook payload - allowing in #{Rails.env} environment")
53
49
  end
54
50
 
55
51
  def validate_in_non_production_env
52
+ enforce_or_allow("OpenAI webhook secret not configured",
53
+ "Webhook Secret is not configured - skipping signature verification in #{Rails.env}")
54
+ end
55
+
56
+ # Outside the development/test allowlist the webhook is rejected with `problem`; inside it the
57
+ # check is skipped with a warning.
58
+ def enforce_or_allow(problem, allow_warning)
56
59
  if should_enforce_strict_validation?
57
- @errors << "OpenAI webhook secret not configured - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
60
+ @errors << "#{problem} - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
58
61
  "not in development/test allowlist)"
59
62
  Rails.logger.error(@errors.last)
60
63
  false
61
64
  else
62
- Rails.logger.warn(
63
- "Webhook Secret is not configured - skipping signature verification in #{Rails.env}"
64
- )
65
+ Rails.logger.warn(allow_warning)
65
66
  true
66
67
  end
67
68
  end
@@ -99,8 +100,7 @@ module Coolhand
99
100
  expected_signature = calculate_expected_signature(secret_bytes, signed_payload)
100
101
 
101
102
  # An empty key (a bare "whsec_" secret) makes the HMAC forgeable by anyone, so never accept it.
102
- signature_valid = !secret_bytes.empty? && webhook_signature.start_with?("v1,") &&
103
- secure_compare(webhook_signature[3..], expected_signature)
103
+ signature_valid = !secret_bytes.empty? && signature_matches?(webhook_signature, expected_signature)
104
104
 
105
105
  unless signature_valid
106
106
  @errors << "OpenAI webhook signature verification failed"
@@ -114,6 +114,18 @@ module Coolhand
114
114
  true
115
115
  end
116
116
 
117
+ # Standard Webhooks allows several space-separated signatures (e.g. during secret rotation);
118
+ # accept the request if any v1 entry matches. Every candidate is compared (`map`, not an
119
+ # `any?` block) so there is no early exit on the first match.
120
+ # Split as binary so a header that isn't valid UTF-8 is rejected rather than raising.
121
+ def signature_matches?(webhook_signature, expected_signature)
122
+ candidates = webhook_signature.b.split.select { |entry| entry.start_with?(SIGNATURE_VERSION_PREFIX) }
123
+ results = candidates.map do |entry|
124
+ secure_compare(entry.delete_prefix(SIGNATURE_VERSION_PREFIX), expected_signature)
125
+ end
126
+ results.any?
127
+ end
128
+
117
129
  # Checked only after the signature is confirmed valid, so an
118
130
  # unsigned/forged request can't poison the id-dedup store (or fail a
119
131
  # freshness check) and DoS a later legitimate webhook with the same id.
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Opt-in (`config.auto_declare`) declaration of the workload and template for LLM calls made from a
5
+ # Rails job or controller action, keyed "JobClass#perform" / "ControllerClass#action". Installed from
6
+ # {Railtie} once the app has configured Coolhand. Kept free of any Rails require: it only touches
7
+ # ActiveSupport and ActiveJob when a target is enabled, and only through their own load hooks.
8
+ module RailsAutoDeclare
9
+ REPORTER = "rails"
10
+ TARGETS = %i[active_job controller].freeze
11
+
12
+ @installed = []
13
+
14
+ module_function
15
+
16
+ def install(targets = Coolhand.configuration.auto_declare)
17
+ TARGETS.each do |target|
18
+ next unless targets.include?(target) && !@installed.include?(target)
19
+
20
+ @installed << target
21
+ send(:"hook_#{target}")
22
+ end
23
+ end
24
+
25
+ def hook_active_job
26
+ ActiveSupport.on_load(:active_job) do
27
+ around_perform do |job, block|
28
+ Coolhand::RailsAutoDeclare.declare(job.class, "perform", &block)
29
+ end
30
+ end
31
+ end
32
+
33
+ def hook_controller
34
+ ActiveSupport.on_load(:action_controller) do
35
+ around_action do |controller, block|
36
+ Coolhand::RailsAutoDeclare.declare(controller.class, controller.action_name, &block)
37
+ end
38
+ end
39
+ end
40
+
41
+ # Runs the block inside a declared context. Anonymous classes have no stable key, so they are left
42
+ # undeclared rather than named after an object id.
43
+ def declare(klass, method_name, &block)
44
+ key = "#{klass.name}##{method_name}"
45
+ return yield if klass.name.nil? || !Coolhand.configuration.enabled || !DeclaredContext.valid_key?(key)
46
+
47
+ Coolhand.with_context(reporter: REPORTER, workload: { key: key }, template: { key: key }, &block)
48
+ end
49
+
50
+ # Testing-only: forget which targets were hooked.
51
+ def reset!
52
+ @installed.clear
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Loaded by lib/coolhand.rb only when Rails is present, so the gem keeps no Rails dependency.
5
+ class Railtie < ::Rails::Railtie
6
+ # After the app's initializers, so `config.auto_declare` has been set by `Coolhand.configure`.
7
+ config.after_initialize do
8
+ Coolhand::RailsAutoDeclare.install
9
+ end
10
+ end
11
+ end
@@ -4,6 +4,7 @@ require "date"
4
4
  require "time"
5
5
  require "uri"
6
6
  require_relative "errors"
7
+ require_relative "pagination"
7
8
 
8
9
  module Coolhand
9
10
  # URL building shared by the read services. Everything goes through `URI.encode_www_form`, so a
@@ -17,6 +18,19 @@ module Coolhand
17
18
  uri
18
19
  end
19
20
 
21
+ # GETs a list endpoint and wraps the JSON array and its pagination headers in `result_class`,
22
+ # keyed by `rows_key` (the field the result struct names its rows by).
23
+ def fetch_list(query, result_class, rows_key, page:, per:)
24
+ noun = self.class::ERROR_NOUN
25
+ rows, response = get_json_with_headers(list_url(query), noun)
26
+ raise Error, "#{noun} response was not a JSON array" unless rows.is_a?(Array)
27
+
28
+ result_class.new(
29
+ rows_key => rows,
30
+ pagination: Pagination.from_headers(response, items: rows, page: page, per: per)
31
+ ).freeze
32
+ end
33
+
20
34
  def resource_url(id, blank_message, query = {})
21
35
  raise Error, blank_message unless id.is_a?(String)
22
36
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Prints the `warnings` a Coolhand log response carries, for {ApiService}.
5
+ #
6
+ # Warnings come from the response body and are written to the host app's stdout, so they are
7
+ # untrusted: each is coerced to a string, stripped of control characters and length-capped, and
8
+ # the number printed per response is bounded.
9
+ module ResponseWarnings
10
+ MAX_WARNING_LENGTH = 500
11
+ MAX_WARNINGS = 5
12
+
13
+ protected
14
+
15
+ # The server ingests a log normally but ignores a declared workload/template it cannot honour
16
+ # (e.g. over the public-key cap), and says so here.
17
+ def log_response_warnings(result)
18
+ warnings = result.is_a?(Hash) ? Array(result[:warnings]) : []
19
+ warnings.first(MAX_WARNINGS).each { |warning| log "⚠️ Coolhand: #{sanitize_warning(warning)}" }
20
+
21
+ omitted = warnings.size - MAX_WARNINGS
22
+ log "⚠️ Coolhand: ... and #{omitted} more warning(s) omitted" if omitted.positive?
23
+ end
24
+
25
+ private
26
+
27
+ # Control characters and line/paragraph separators are replaced with a space, not dropped: a
28
+ # newline would forge a log line and ESC starts a terminal escape sequence. Unicode format
29
+ # characters (bidi overrides, zero-width) are invisible and can reorder or hide text in a
30
+ # terminal, so they are removed outright.
31
+ def sanitize_warning(warning)
32
+ text = warning.to_s.dup.force_encoding("UTF-8").scrub
33
+ text = text.gsub(/\p{Cf}+/, "").gsub(/[[:cntrl:]\p{Zl}\p{Zp}]+/, " ").strip
34
+ return text if text.length <= MAX_WARNING_LENGTH
35
+
36
+ "#{text[0, MAX_WARNING_LENGTH]}... [truncated]"
37
+ end
38
+ end
39
+ end
@@ -3,18 +3,20 @@
3
3
  require_relative "api_service"
4
4
  require_relative "pagination"
5
5
  require_relative "read_query"
6
+ require_relative "write_requests"
6
7
 
7
8
  module Coolhand
8
9
  # Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
9
10
  TemplateSearchResult = Struct.new(:templates, :pagination, keyword_init: true)
10
11
 
11
- # Read-only. Requires the client's **private** key — the public key is write-only here and is
12
- # rejected like an invalid one.
12
+ # Reads templates and upserts them by key. Requires the client's **private** key — the public key is
13
+ # write-only here and is rejected like an invalid one.
13
14
  #
14
15
  # Not a port of the MCP `search_templates` tool and does not agree with its `log_count`.
15
- # See docs/template-search.md.
16
+ # See docs/template-search.md and docs/declared_workloads.md.
16
17
  class TemplateService < ApiService
17
18
  include ReadQuery
19
+ include WriteRequests
18
20
 
19
21
  ERROR_NOUN = "Template"
20
22
  BLANK_ID_MESSAGE = "get_template: id must be a non-empty template hashid"
@@ -39,13 +41,7 @@ module Coolhand
39
41
  per: per
40
42
  }.compact
41
43
 
42
- templates, response = get_json_with_headers(list_url(query), ERROR_NOUN)
43
- raise Error, "#{ERROR_NOUN} response was not a JSON array" unless templates.is_a?(Array)
44
-
45
- TemplateSearchResult.new(
46
- templates: templates,
47
- pagination: Pagination.from_headers(response, items: templates, page: page, per: per)
48
- ).freeze
44
+ fetch_list(query, TemplateSearchResult, :templates, page: page, per: per)
49
45
  end
50
46
 
51
47
  # Adds `user_prompt_pattern` / `system_prompt_pattern`, which the list omits, and unlike the
@@ -56,5 +52,25 @@ module Coolhand
56
52
  **window_params(since, binding.local_variable_get(:until)) }.compact
57
53
  get_json(resource_url(id, BLANK_ID_MESSAGE, query), ERROR_NOUN)
58
54
  end
55
+
56
+ # Creates the template with this `key`, or updates the existing one. The key is immutable; a new
57
+ # `name` renames the display name. `workload` (`{ id: }` or `{ key: }`, the latter find-or-created)
58
+ # is needed only when the template is new; an existing one keeps its workload. Returns the template
59
+ # Hash. Raises {Coolhand::HttpError} on a non-2xx response. See docs/declared_workloads.md.
60
+ def upsert(key:, workload: nil, name: nil, reporter: nil)
61
+ body = { name: name, reporter: reporter, **workload_params(workload) }.compact
62
+ request_json(:put, by_key_url("upsert", key), ERROR_NOUN, body: body)
63
+ end
64
+
65
+ private
66
+
67
+ def workload_params(workload)
68
+ return {} if workload.nil?
69
+ unless workload.is_a?(Hash)
70
+ raise ArgumentError, "upsert: workload must be a Hash with :id or :key, got #{workload.class}"
71
+ end
72
+
73
+ { workload_id: workload[:id] || workload["id"], workload_key: workload[:key] || workload["key"] }
74
+ end
59
75
  end
60
76
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coolhand
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -3,15 +3,17 @@
3
3
  require_relative "api_service"
4
4
  require_relative "pagination"
5
5
  require_relative "read_query"
6
+ require_relative "write_requests"
6
7
 
7
8
  module Coolhand
8
9
  # Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
9
10
  WorkloadSearchResult = Struct.new(:workloads, :pagination, keyword_init: true)
10
11
 
11
- # Read-only. Requires the client's **private** key. There is no per-id endpoint.
12
- # See docs/workload-search.md.
12
+ # Lists workloads and upserts them by key. Requires the client's **private** key. There is no per-id
13
+ # endpoint. See docs/workload-search.md and docs/declared_workloads.md.
13
14
  class WorkloadService < ApiService
14
15
  include ReadQuery
16
+ include WriteRequests
15
17
 
16
18
  ERROR_NOUN = "Workload"
17
19
 
@@ -34,13 +36,15 @@ module Coolhand
34
36
  per: per
35
37
  }.compact
36
38
 
37
- workloads, response = get_json_with_headers(list_url(query), ERROR_NOUN)
38
- raise Error, "#{ERROR_NOUN} response was not a JSON array" unless workloads.is_a?(Array)
39
+ fetch_list(query, WorkloadSearchResult, :workloads, page: page, per: per)
40
+ end
39
41
 
40
- WorkloadSearchResult.new(
41
- workloads: workloads,
42
- pagination: Pagination.from_headers(response, items: workloads, page: page, per: per)
43
- ).freeze
42
+ # Creates the workload with this `key`, or updates the existing one. The key is immutable; a new
43
+ # `name` renames the display name. Returns the workload Hash (the server answers 201 on create,
44
+ # 200 on update). Raises {Coolhand::HttpError} on a non-2xx response. See docs/declared_workloads.md.
45
+ def upsert(key:, name: nil, description: nil, reporter: nil)
46
+ body = { name: name, description: description, reporter: reporter }.compact
47
+ request_json(:put, by_key_url("upsert", key), ERROR_NOUN, body: body)
44
48
  end
45
49
  end
46
50
  end
@@ -13,6 +13,8 @@ module Coolhand
13
13
  # `api_key`, `apply_headers` and `format_error_body` from there and the shared HTTP helpers from
14
14
  # {ReadRequests}.
15
15
  module WriteRequests
16
+ DOT_SEGMENTS = [".", ".."].freeze
17
+
16
18
  protected
17
19
 
18
20
  # Returns the parsed body, or nil for an empty one (204).
@@ -26,6 +28,18 @@ module Coolhand
26
28
  response.body.to_s.strip.empty? ? nil : parse_json_body(response.body, noun)
27
29
  end
28
30
 
31
+ # `<endpoint>/by_key/<key>` for the upsert routes, which are globs: a key like "Parser#call/squish:L2"
32
+ # keeps its slashes and each segment is escaped, so `#` or `?` in a key cannot retarget the request.
33
+ def by_key_url(method_name, key)
34
+ trimmed = key.is_a?(String) ? key.strip : ""
35
+ segments = trimmed.split("/", -1)
36
+ if trimmed.empty? || segments.any? { |segment| segment.empty? || DOT_SEGMENTS.include?(segment) }
37
+ raise Error, "#{method_name}: key must be a non-empty string without empty or dot path segments"
38
+ end
39
+
40
+ URI.parse("#{api_endpoint}/by_key/#{segments.map { |segment| escape_path_segment(segment) }.join('/')}")
41
+ end
42
+
29
43
  private
30
44
 
31
45
  def perform_write(verb, url, noun, body)
data/lib/coolhand.rb CHANGED
@@ -10,6 +10,9 @@ require_relative "coolhand/errors"
10
10
  require_relative "coolhand/version"
11
11
  require_relative "coolhand/configuration"
12
12
  require_relative "coolhand/collector"
13
+ require_relative "coolhand/declared_context"
14
+ require_relative "coolhand/declarable"
15
+ require_relative "coolhand/rails_auto_declare"
13
16
  require_relative "coolhand/log_queue"
14
17
  require_relative "coolhand/base_interceptor"
15
18
  require_relative "coolhand/net_http_interceptor"
@@ -21,6 +24,8 @@ require_relative "coolhand/optimization_feedback_link_service"
21
24
  require_relative "coolhand/workload_service"
22
25
  require_relative "coolhand/log_service"
23
26
  require_relative "coolhand/webhook_interceptor"
27
+ # Only when Rails is already loaded; the gem has no Rails dependency.
28
+ require_relative "coolhand/railtie" if defined?(Rails::Railtie)
24
29
 
25
30
  # The main module for the Coolhand gem.
26
31
  # It provides the configuration interface and initializes the patching.
@@ -35,6 +40,7 @@ module Coolhand
35
40
  def reset_configuration!
36
41
  @configuration = Configuration.new
37
42
  Thread.current[:coolhand_capture_override] = nil
43
+ DeclaredContext.clear!
38
44
  end
39
45
 
40
46
  # Provides a block to configure the gem.
@@ -74,20 +80,25 @@ module Coolhand
74
80
  end
75
81
  end
76
82
 
77
- def without_capture
78
- previous = Thread.current[:coolhand_capture_override]
79
- Thread.current[:coolhand_capture_override] = false
80
- yield
81
- ensure
82
- Thread.current[:coolhand_capture_override] = previous
83
+ def without_capture(&block)
84
+ override_capture(false, &block)
83
85
  end
84
86
 
85
- def with_capture
86
- previous = Thread.current[:coolhand_capture_override]
87
- Thread.current[:coolhand_capture_override] = true
88
- yield
89
- ensure
90
- Thread.current[:coolhand_capture_override] = previous
87
+ def with_capture(&block)
88
+ override_capture(true, &block)
89
+ end
90
+
91
+ # Declares the workload/template that LLM calls captured inside the block belong to. Nested
92
+ # blocks override per field (`reporter`, `workload`, `template`), innermost wins. `workload` and
93
+ # `template` are Hashes with `id:` (an existing record) or `key:` (find-or-created), plus optional
94
+ # `name:` (and `description:` on a workload). See docs/declared_workloads.md.
95
+ #
96
+ # Example:
97
+ # Coolhand.with_context(reporter: "squishling", workload: { key: "Parser#call" }) { client.chat(...) }
98
+ def with_context(reporter: nil, workload: nil, template: nil, &block)
99
+ raise ArgumentError, "Coolhand.with_context requires a block" unless block
100
+
101
+ DeclaredContext.with(reporter: reporter, workload: workload, template: template, &block)
91
102
  end
92
103
 
93
104
  # Blocks until log requests queued by async logging have been sent, or the timeout (in
@@ -149,5 +160,16 @@ module Coolhand
149
160
 
150
161
  true
151
162
  end
163
+
164
+ private
165
+
166
+ # Forces capture on or off for the block on this thread, restoring the previous override after.
167
+ def override_capture(enabled)
168
+ previous = Thread.current[:coolhand_capture_override]
169
+ Thread.current[:coolhand_capture_override] = enabled
170
+ yield
171
+ ensure
172
+ Thread.current[:coolhand_capture_override] = previous
173
+ end
152
174
  end
153
175
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coolhand
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Carroll
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-10-14 00:00:00.000000000 Z
12
+ date: 2026-10-18 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: base64
@@ -47,6 +47,7 @@ files:
47
47
  - SECURITY.md
48
48
  - docs/anthropic.md
49
49
  - docs/configuration.md
50
+ - docs/declared_workloads.md
50
51
  - docs/elevenlabs.md
51
52
  - docs/feedback-links.md
52
53
  - docs/feedback.md
@@ -60,6 +61,8 @@ files:
60
61
  - lib/coolhand/base_interceptor.rb
61
62
  - lib/coolhand/collector.rb
62
63
  - lib/coolhand/configuration.rb
64
+ - lib/coolhand/declarable.rb
65
+ - lib/coolhand/declared_context.rb
63
66
  - lib/coolhand/default_exclude_api_patterns.yml
64
67
  - lib/coolhand/default_intercept_addresses.yml
65
68
  - lib/coolhand/default_intercept_path_patterns.yml
@@ -74,8 +77,11 @@ files:
74
77
  - lib/coolhand/open_ai/webhook_validator.rb
75
78
  - lib/coolhand/optimization_feedback_link_service.rb
76
79
  - lib/coolhand/pagination.rb
80
+ - lib/coolhand/rails_auto_declare.rb
81
+ - lib/coolhand/railtie.rb
77
82
  - lib/coolhand/read_query.rb
78
83
  - lib/coolhand/read_requests.rb
84
+ - lib/coolhand/response_warnings.rb
79
85
  - lib/coolhand/template_service.rb
80
86
  - lib/coolhand/version.rb
81
87
  - lib/coolhand/vertex/batch_result_processor.rb