coolhand 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.harness.md +49 -14
- data/CHANGELOG.md +48 -0
- data/README.md +45 -4
- data/docs/configuration.md +27 -2
- data/docs/feedback-links.md +48 -0
- data/docs/log-search.md +102 -0
- data/docs/openai.md +1 -1
- data/docs/template-search.md +46 -4
- data/docs/vertex.md +3 -0
- data/docs/workload-search.md +116 -0
- data/lib/coolhand/api_service.rb +4 -3
- data/lib/coolhand/base_interceptor.rb +42 -7
- data/lib/coolhand/configuration.rb +2 -1
- data/lib/coolhand/default_exclude_api_patterns.yml +11 -0
- data/lib/coolhand/default_intercept_addresses.yml +54 -0
- data/lib/coolhand/errors.rb +2 -1
- data/lib/coolhand/log_queue.rb +179 -0
- data/lib/coolhand/log_service.rb +67 -0
- data/lib/coolhand/net_http_interceptor.rb +144 -109
- data/lib/coolhand/open_ai/batch_result_processor.rb +30 -29
- data/lib/coolhand/open_ai/webhook_validator.rb +2 -1
- data/lib/coolhand/optimization_feedback_link_service.rb +85 -0
- data/lib/coolhand/read_query.rb +61 -0
- data/lib/coolhand/read_requests.rb +26 -13
- data/lib/coolhand/template_service.rb +13 -29
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/vertex/batch_result_processor.rb +9 -1
- data/lib/coolhand/workload_service.rb +46 -0
- data/lib/coolhand/write_requests.rb +47 -0
- data/lib/coolhand.rb +33 -0
- metadata +11 -2
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Reading Workloads (Search)
|
|
2
|
+
|
|
3
|
+
`Coolhand::WorkloadService#search_workloads` lists your workloads, the groups of prompt templates that
|
|
4
|
+
make up one task or agent, optionally with a cost and performance `metrics` rollup per workload. It
|
|
5
|
+
wraps `GET /api/v2/workloads`. It is read-only, and there is no per-workload endpoint.
|
|
6
|
+
|
|
7
|
+
**Auth:** requires your **private** API key. The public key is write-only on this API and is rejected
|
|
8
|
+
exactly like an invalid key.
|
|
9
|
+
|
|
10
|
+
**IDs:** a workload's `:id` is its **hashid**, a String. It is what the `workload_id` filter on
|
|
11
|
+
`search_templates` and `search_logs` expects.
|
|
12
|
+
|
|
13
|
+
**Scoping:** the client is always derived from your API key. There is deliberately no `client_id`
|
|
14
|
+
keyword, so passing one raises `ArgumentError`.
|
|
15
|
+
|
|
16
|
+
## `search_workloads`
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
require "coolhand"
|
|
20
|
+
|
|
21
|
+
Coolhand.configure do |config|
|
|
22
|
+
config.api_key = ENV.fetch("COOLHAND_PRIVATE_API_KEY")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
result = Coolhand.workload_service.search_workloads(
|
|
26
|
+
include_metrics: true,
|
|
27
|
+
since: Time.utc(2026, 9, 1),
|
|
28
|
+
until: Time.utc(2026, 10, 1),
|
|
29
|
+
per: 50
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
result.workloads.each do |workload|
|
|
33
|
+
puts "#{workload[:name]}: $#{workload[:metrics][:total_cost]}"
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Keywords
|
|
38
|
+
|
|
39
|
+
All optional, and named exactly like the wire parameters.
|
|
40
|
+
|
|
41
|
+
| keyword | type | notes |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `search` | String | Case-insensitive substring match on the workload name. |
|
|
44
|
+
| `include_archived` | Boolean | Defaults to `false` server-side. |
|
|
45
|
+
| `include_system` | Boolean | `Unmatched`, `Embedding Requests` and the like. Defaults to `false` server-side. |
|
|
46
|
+
| `include_templates` | Boolean | Adds each workload's active templates and their routing patterns as `:templates`. |
|
|
47
|
+
| `include_metrics` | Boolean | Adds a `:metrics` rollup across all of the workload's templates. |
|
|
48
|
+
| `days_back` | Integer | Rolling metrics window ending now (server default 28, max 365). Ignored when `since` is given. |
|
|
49
|
+
| `since` | Time, DateTime, Date or String | Metrics window start, inclusive. Overrides `days_back`. |
|
|
50
|
+
| `until` | Time, DateTime, Date or String | Metrics window end, exclusive. Defaults to now. |
|
|
51
|
+
| `page` | Integer | 1-based. |
|
|
52
|
+
| `per` | Integer | Default 25, max 100, both enforced server-side. This gem only sends `per`, never its `per_page` alias. |
|
|
53
|
+
|
|
54
|
+
`false` and `0` are sent as given; only an unset (`nil`) keyword is left off the request.
|
|
55
|
+
|
|
56
|
+
### Windows: `since` and `until`
|
|
57
|
+
|
|
58
|
+
`Time`, `DateTime` and `Date` values are serialised as UTC ISO8601 (a `Date` as a date alone, which the
|
|
59
|
+
server reads as midnight UTC). A String is passed through untouched, so the server stays the one
|
|
60
|
+
validator. Anything else raises `ArgumentError` before a request is made.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
search_workloads(include_metrics: true, since: "2026-09-01") # date alone: midnight UTC
|
|
64
|
+
search_workloads(include_metrics: true, since: "2026-09-01T12:00:00+02:00") # the + is encoded as %2B for you
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Server-side rules, which only apply when `include_metrics` is set:
|
|
68
|
+
|
|
69
|
+
- A missing offset means UTC. `since` is inclusive and `until` is exclusive.
|
|
70
|
+
- With `since`, `days_back` is ignored and `metrics[:days_back]` is `nil`. With only `until`, the window
|
|
71
|
+
is `days_back` long ending there.
|
|
72
|
+
- The prior window behind `error_rate_change` is the same length, immediately before the window.
|
|
73
|
+
- A malformed value, a `since` not before `until`, or a window over 365 days is a `422` with the error on
|
|
74
|
+
the `since` or `until` key.
|
|
75
|
+
|
|
76
|
+
### Return value
|
|
77
|
+
|
|
78
|
+
A `Coolhand::WorkloadSearchResult`, a `Struct` with `#workloads` and `#pagination`. Rows are plain
|
|
79
|
+
Hashes with Symbol keys, handed back as the API rendered them so a field the server adds later reaches
|
|
80
|
+
you.
|
|
81
|
+
|
|
82
|
+
| field | type | notes |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `:id` | String | Hashid. |
|
|
85
|
+
| `:name` | String | |
|
|
86
|
+
| `:description` | String or nil | |
|
|
87
|
+
| `:archived`, `:system`, `:merged` | Boolean | |
|
|
88
|
+
| `:template_count` | Integer | Active templates. |
|
|
89
|
+
| `:draft_template_count` | Integer | |
|
|
90
|
+
| `:log_count` | Integer | Every log on any of its templates, all generators, so it can exceed the sum of the templates' own `log_count`. |
|
|
91
|
+
| `:last_activity` | String or nil | ISO-8601 UTC. |
|
|
92
|
+
| `:templates` | Array | Only with `include_templates`. Each has `:id`, `:name`, `:status`, `:user_prompt_pattern`, `:system_prompt_pattern`. |
|
|
93
|
+
| `:metrics` | Hash | Only with `include_metrics`. See below. |
|
|
94
|
+
|
|
95
|
+
Rows are ordered by name. `pagination` is a `Coolhand::Pagination` built from the `X-Page`,
|
|
96
|
+
`X-Per-Page`, `X-Total-Count` and `X-Total-Pages` response headers, which this endpoint always sends,
|
|
97
|
+
never from the length of the array.
|
|
98
|
+
|
|
99
|
+
### `metrics`
|
|
100
|
+
|
|
101
|
+
The same object `search_templates` returns per template, computed by the same SQL as the dashboard, so
|
|
102
|
+
tiered pricing, cached-token discounts and reasoning tokens are applied. See
|
|
103
|
+
[Metrics](template-search.md#metrics) for every field.
|
|
104
|
+
|
|
105
|
+
## Errors
|
|
106
|
+
|
|
107
|
+
The read methods raise where the write methods log and return `nil`. `Coolhand::HttpError` carries
|
|
108
|
+
`#status` and `#body` (`{"errors": {"<field>": ["msg"]}}` on a `422`).
|
|
109
|
+
|
|
110
|
+
| status | when |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `401` | No API key, an invalid key, or the public key. |
|
|
113
|
+
| `422` | A bad `days_back`, `since` or `until`. Only checked when `include_metrics` is set. |
|
|
114
|
+
| `504` | An aggregate exceeded the server's statement timeout. Retryable: narrow with `search` or a smaller `per`. |
|
|
115
|
+
|
|
116
|
+
`Coolhand::Error` covers no API key configured, a transport failure, and a body that is not a JSON array.
|
data/lib/coolhand/api_service.rb
CHANGED
|
@@ -80,9 +80,10 @@ module Coolhand
|
|
|
80
80
|
uri = URI.parse(@api_endpoint)
|
|
81
81
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
82
82
|
http.use_ssl = (uri.scheme == "https")
|
|
83
|
-
# Bound worst-case latency: this call happens inline in
|
|
84
|
-
# request's path
|
|
85
|
-
#
|
|
83
|
+
# Bound worst-case latency: with async_logging off this call happens inline in
|
|
84
|
+
# the intercepted request's path (otherwise it ties up the background log
|
|
85
|
+
# worker), so a slow/unreachable Coolhand backend must not hang for Ruby's
|
|
86
|
+
# ~60s Net::HTTP defaults.
|
|
86
87
|
http.open_timeout = 5
|
|
87
88
|
http.read_timeout = 5
|
|
88
89
|
|
|
@@ -11,7 +11,7 @@ module Coolhand
|
|
|
11
11
|
# (cookie, set-cookie), and future/unknown providers using a
|
|
12
12
|
# similarly-named header. Shared with LoggerService so the two logging
|
|
13
13
|
# paths (interceptor + webhook forwarding) stay consistent.
|
|
14
|
-
SENSITIVE_HEADER_PATTERN = /key|token|secret|signature|
|
|
14
|
+
SENSITIVE_HEADER_PATTERN = /key|token|secret|signature|auth|passw|credential|bearer|jwt|session|cookie/i
|
|
15
15
|
|
|
16
16
|
def sanitize_headers(headers)
|
|
17
17
|
return {} if headers.nil?
|
|
@@ -110,7 +110,42 @@ module Coolhand
|
|
|
110
110
|
|
|
111
111
|
modified ? uri.to_s : url
|
|
112
112
|
rescue URI::InvalidURIError
|
|
113
|
-
|
|
113
|
+
# Fail closed: an unparseable URL can't be checked param-by-param, so drop everything
|
|
114
|
+
# that could carry a credential (userinfo, query, fragment) rather than pass it through.
|
|
115
|
+
url.to_s.sub(%r{//[^/?#]*@}, "//REDACTED@").split(/[?#]/, 2).first
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Key names inside an Azure OpenAI "On Your Data" data_sources/dataSources entry that carry
|
|
119
|
+
# live datastore credentials in the request body, where header/query-param sanitization never
|
|
120
|
+
# looks. Matched against a separator-stripped, lowercased key name so both `connection_string`
|
|
121
|
+
# and `connectionString` (Cosmos/Mongo, embeds `AccountKey=...`) and `encoded_api_key`
|
|
122
|
+
# (Elasticsearch) are caught — not just exact or snake_case-only names.
|
|
123
|
+
SENSITIVE_BODY_KEY_PATTERN = /key|token|secret|passw|pwd|credential|bearer|jwt|connectionstring|signature/i
|
|
124
|
+
|
|
125
|
+
def sanitize_body(body)
|
|
126
|
+
return body unless body.is_a?(Hash)
|
|
127
|
+
|
|
128
|
+
sanitized = body.dup
|
|
129
|
+
sanitized.each_key do |key|
|
|
130
|
+
next unless key.to_s.delete("_-").casecmp?("datasources")
|
|
131
|
+
|
|
132
|
+
sanitized[key] = redact_sensitive_body_values(sanitized[key])
|
|
133
|
+
end
|
|
134
|
+
sanitized
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def redact_sensitive_body_values(node)
|
|
138
|
+
case node
|
|
139
|
+
when Hash
|
|
140
|
+
node.each_with_object({}) do |(k, v), acc|
|
|
141
|
+
normalized = k.to_s.gsub(/[_\-\s]/, "").downcase
|
|
142
|
+
acc[k] = normalized.match?(SENSITIVE_BODY_KEY_PATTERN) ? "[REDACTED]" : redact_sensitive_body_values(v)
|
|
143
|
+
end
|
|
144
|
+
when Array
|
|
145
|
+
node.map { |v| redact_sensitive_body_values(v) }
|
|
146
|
+
else
|
|
147
|
+
node
|
|
148
|
+
end
|
|
114
149
|
end
|
|
115
150
|
|
|
116
151
|
def send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:, response_headers:,
|
|
@@ -121,7 +156,7 @@ module Coolhand
|
|
|
121
156
|
method: method.to_s.downcase,
|
|
122
157
|
url: sanitize_url(url),
|
|
123
158
|
headers: sanitize_headers(request_headers),
|
|
124
|
-
request_body: request_body,
|
|
159
|
+
request_body: sanitize_body(request_body),
|
|
125
160
|
response_headers: sanitize_headers(response_headers),
|
|
126
161
|
response_body: response_body,
|
|
127
162
|
status_code: status_code,
|
|
@@ -134,10 +169,10 @@ module Coolhand
|
|
|
134
169
|
|
|
135
170
|
request_data = { raw_request: raw_request }
|
|
136
171
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
172
|
+
LogQueue.submit do
|
|
173
|
+
Coolhand::ApiService.new.send_llm_request_log(request_data)
|
|
174
|
+
Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
|
|
175
|
+
end
|
|
141
176
|
rescue StandardError => e
|
|
142
177
|
Coolhand.log "❌ Error sending complete request log: #{e.message}"
|
|
143
178
|
end
|
|
@@ -29,7 +29,7 @@ module Coolhand
|
|
|
29
29
|
DEFAULT_MAX_CAPTURED_BODY_BYTES = 1_000_000
|
|
30
30
|
|
|
31
31
|
attr_accessor :api_key, :environment, :silent, :debug_mode, :capture, :exclude_api_patterns, :enabled,
|
|
32
|
-
:max_captured_body_bytes, :webhook_replay_tolerance_seconds, :webhook_id_store
|
|
32
|
+
:max_captured_body_bytes, :webhook_replay_tolerance_seconds, :webhook_id_store, :async_logging
|
|
33
33
|
attr_reader :intercept_addresses, :intercept_path_patterns, :base_url
|
|
34
34
|
|
|
35
35
|
def initialize
|
|
@@ -44,6 +44,7 @@ module Coolhand
|
|
|
44
44
|
@capture = true
|
|
45
45
|
@exclude_api_patterns = DEFAULT_EXCLUDE_API_PATTERNS.dup
|
|
46
46
|
@enabled = true
|
|
47
|
+
@async_logging = true
|
|
47
48
|
@max_captured_body_bytes = DEFAULT_MAX_CAPTURED_BODY_BYTES
|
|
48
49
|
@webhook_replay_tolerance_seconds = 300
|
|
49
50
|
@webhook_id_store = Coolhand::OpenAi::WebhookIdStore.new
|
|
@@ -8,3 +8,14 @@
|
|
|
8
8
|
# Users can disable: c.exclude_api_patterns = []
|
|
9
9
|
|
|
10
10
|
- "/batchPredictionJobs/"
|
|
11
|
+
# Azure OpenAI control-plane paths (not inference) — both spellings are needed
|
|
12
|
+
# since substring matching is contiguous: "/openai/files" does not match
|
|
13
|
+
# "/openai/v1/files".
|
|
14
|
+
- "/openai/files"
|
|
15
|
+
- "/openai/batches"
|
|
16
|
+
- "/openai/fine_tuning"
|
|
17
|
+
- "/openai/models"
|
|
18
|
+
- "/openai/v1/files"
|
|
19
|
+
- "/openai/v1/batches"
|
|
20
|
+
- "/openai/v1/fine_tuning"
|
|
21
|
+
- "/openai/v1/models"
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
# should be captured and forwarded as an llm_request_log. A single "*" in an
|
|
5
5
|
# entry matches exactly one host label — e.g. "bedrock-runtime.*.amazonaws.com"
|
|
6
6
|
# matches "bedrock-runtime.us-east-1.amazonaws.com" but nothing else.
|
|
7
|
+
# An entry may also pin a port and/or a path prefix: "host:port", "host/path",
|
|
8
|
+
# or "host:port/path" (the path match is on a segment boundary) — useful for
|
|
9
|
+
# multi-service hosts where unrelated APIs (e.g. Azure Speech, Vision, Language)
|
|
10
|
+
# share a domain with the LLM inference path.
|
|
7
11
|
#
|
|
8
12
|
# Users can extend defaults: c.intercept_addresses << "my.custom.api.com"
|
|
9
13
|
# Users can override entirely: c.intercept_addresses = ["only.this.com"]
|
|
@@ -20,5 +24,55 @@
|
|
|
20
24
|
- "aiplatform.googleapis.com"
|
|
21
25
|
- "gateway.ai.cloudflare.com"
|
|
22
26
|
- "bedrock-runtime.*.amazonaws.com"
|
|
27
|
+
- "api.deepseek.com"
|
|
28
|
+
- "api.mistral.ai"
|
|
29
|
+
- "api.perplexity.ai"
|
|
30
|
+
- "api.x.ai"
|
|
31
|
+
# Cohere is path-scoped, never host-wide: the server only ingests v2 chat and
|
|
32
|
+
# v1/v2 embed, and its other endpoints (v1 chat, rerank, tokenize, classify...)
|
|
33
|
+
# have different envelopes that would be recorded as empty "successes".
|
|
34
|
+
- "api.cohere.com/v2/chat"
|
|
35
|
+
- "api.cohere.com/v1/embed"
|
|
36
|
+
- "api.cohere.com/v2/embed"
|
|
37
|
+
- "api.cohere.ai/v2/chat"
|
|
38
|
+
- "api.cohere.ai/v1/embed"
|
|
39
|
+
- "api.cohere.ai/v2/embed"
|
|
40
|
+
# TypeSafe Jev (a "System One" model, not an LLM) — only /v1/systemone is ingested.
|
|
41
|
+
- "api.typesafe.ai/v1/systemone"
|
|
42
|
+
# Ollama (self-hosted) has no fixed host, so an entry must pin BOTH the default
|
|
43
|
+
# port (11434) and the path — a bare "/api/chat" would capture any app's own
|
|
44
|
+
# unrelated chat endpoint. "*" matches one host label (localhost, an "ollama"
|
|
45
|
+
# compose service, a bare hostname); a LAN IP or dotted host needs its own
|
|
46
|
+
# entry, e.g. "192.168.1.5:11434/api/chat".
|
|
47
|
+
- "*:11434/api/chat"
|
|
48
|
+
- "*:11434/api/generate"
|
|
49
|
+
- "*:11434/api/embed"
|
|
50
|
+
- "*:11434/api/embeddings"
|
|
51
|
+
- "127.0.0.1:11434/api/chat"
|
|
52
|
+
- "127.0.0.1:11434/api/generate"
|
|
53
|
+
- "127.0.0.1:11434/api/embed"
|
|
54
|
+
- "127.0.0.1:11434/api/embeddings"
|
|
23
55
|
- "openrouter.ai"
|
|
24
56
|
- "opencode.ai"
|
|
57
|
+
# Azure OpenAI dedicated hosts (commercial, US Gov, China/21Vianet)
|
|
58
|
+
- "openai.azure.com"
|
|
59
|
+
- "openai.azure.us"
|
|
60
|
+
- "openai.azure.cn"
|
|
61
|
+
# Azure AI Services / Foundry multi-service hosts — path-anchored so Speech,
|
|
62
|
+
# Vision, Language and Content Safety on the same host are NOT captured
|
|
63
|
+
- "cognitiveservices.azure.com/openai/"
|
|
64
|
+
- "cognitiveservices.azure.com/models/"
|
|
65
|
+
- "cognitiveservices.azure.us/openai/"
|
|
66
|
+
- "cognitiveservices.azure.us/models/"
|
|
67
|
+
- "cognitiveservices.azure.cn/openai/"
|
|
68
|
+
- "cognitiveservices.azure.cn/models/"
|
|
69
|
+
- "services.ai.azure.com/openai/"
|
|
70
|
+
- "services.ai.azure.com/models/"
|
|
71
|
+
- "services.ai.azure.us/openai/"
|
|
72
|
+
- "services.ai.azure.us/models/"
|
|
73
|
+
# Serverless / MaaS + Azure ML managed online endpoints. inference.ml.azure.*
|
|
74
|
+
# is deliberately unanchored — see docs/configuration.md for why.
|
|
75
|
+
- "inference.ai.azure.com"
|
|
76
|
+
- "models.ai.azure.com"
|
|
77
|
+
- "inference.ml.azure.com"
|
|
78
|
+
- "inference.ml.azure.us"
|
data/lib/coolhand/errors.rb
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module Coolhand
|
|
4
4
|
class Error < StandardError; end
|
|
5
5
|
|
|
6
|
-
# Raised by the gem's
|
|
6
|
+
# Raised by the gem's raising API methods (template reads, feedback links) when the Coolhand API
|
|
7
|
+
# answers with a non-2xx status.
|
|
7
8
|
#
|
|
8
9
|
# `status` is carried so callers can branch on it (404 vs retryable 504) without matching the
|
|
9
10
|
# message text.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Runs log-shipping work on background threads so a slow or unreachable Coolhand backend can't
|
|
5
|
+
# add latency to the host app's own LLM calls.
|
|
6
|
+
#
|
|
7
|
+
# Work is spread over two lanes, each with its own bounded queue and worker thread: `:default`
|
|
8
|
+
# for the one-log-per-intercepted-call sends, and `:batch` for whole-batch jobs (download +
|
|
9
|
+
# one send per item) that can run for minutes. Keeping them apart stops a long batch from
|
|
10
|
+
# delaying — or, once the queue fills, evicting — the real-time logs.
|
|
11
|
+
#
|
|
12
|
+
# Each queue is bounded (oldest job dropped when full), restarts itself in a forked child, and
|
|
13
|
+
# is flushed at process exit. Work runs inline instead when `config.async_logging` is false,
|
|
14
|
+
# when `config.debug_mode` is on (debug output should appear in order), or when the caller is
|
|
15
|
+
# already a worker thread (a queued job's own sends don't re-enter a queue).
|
|
16
|
+
module LogQueue
|
|
17
|
+
MAX_QUEUE_SIZE = 1000
|
|
18
|
+
DEFAULT_FLUSH_TIMEOUT = 5
|
|
19
|
+
|
|
20
|
+
# One bounded FIFO plus the thread that drains it.
|
|
21
|
+
class Lane
|
|
22
|
+
attr_reader :thread
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@wakeup = ConditionVariable.new
|
|
27
|
+
@idle = ConditionVariable.new
|
|
28
|
+
@jobs = []
|
|
29
|
+
@thread = nil
|
|
30
|
+
@pid = Process.pid
|
|
31
|
+
@busy = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def enqueue(job)
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
restart_after_fork
|
|
37
|
+
drop_oldest if @jobs.size >= MAX_QUEUE_SIZE
|
|
38
|
+
@jobs << job
|
|
39
|
+
ensure_worker
|
|
40
|
+
@wakeup.signal
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# True once every queued job has finished, false if the timeout elapsed first.
|
|
45
|
+
def flush(deadline)
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
# A forked child's copy of the queue belongs to the parent's worker; nothing to wait on.
|
|
48
|
+
return true if @pid != Process.pid
|
|
49
|
+
# A dead worker will never drain the queue, so don't wait on it.
|
|
50
|
+
return @jobs.empty? unless @thread&.alive?
|
|
51
|
+
|
|
52
|
+
while @jobs.any? || @busy
|
|
53
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
54
|
+
return false unless remaining.positive?
|
|
55
|
+
|
|
56
|
+
@idle.wait(@mutex, remaining)
|
|
57
|
+
end
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reset!
|
|
63
|
+
thread = nil
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
thread = @thread
|
|
66
|
+
@jobs.clear
|
|
67
|
+
@thread = nil
|
|
68
|
+
@busy = false
|
|
69
|
+
@wakeup.broadcast
|
|
70
|
+
@idle.broadcast
|
|
71
|
+
end
|
|
72
|
+
thread&.join(1)
|
|
73
|
+
thread&.kill if thread&.alive?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def drop_oldest
|
|
79
|
+
@jobs.shift
|
|
80
|
+
Coolhand.log "⚠️ Log queue full (#{MAX_QUEUE_SIZE}); dropping the oldest pending log"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A forked child inherits the queue's memory but not its worker thread.
|
|
84
|
+
def restart_after_fork
|
|
85
|
+
return if @pid == Process.pid
|
|
86
|
+
|
|
87
|
+
@pid = Process.pid
|
|
88
|
+
@jobs = []
|
|
89
|
+
@thread = nil
|
|
90
|
+
@busy = false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def ensure_worker
|
|
94
|
+
return if @thread&.alive?
|
|
95
|
+
|
|
96
|
+
@thread = Thread.new { work }
|
|
97
|
+
@thread.report_on_exception = false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def work
|
|
101
|
+
loop do
|
|
102
|
+
job = next_job
|
|
103
|
+
break unless job
|
|
104
|
+
|
|
105
|
+
begin
|
|
106
|
+
run(job)
|
|
107
|
+
ensure
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
@busy = false
|
|
110
|
+
@idle.broadcast
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def next_job
|
|
117
|
+
@mutex.synchronize do
|
|
118
|
+
@wakeup.wait(@mutex) while @jobs.empty? && @thread == Thread.current
|
|
119
|
+
return nil unless @thread == Thread.current
|
|
120
|
+
|
|
121
|
+
@busy = true
|
|
122
|
+
@jobs.shift
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def run(job)
|
|
127
|
+
job.call
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
Coolhand.log "❌ Error in background log job: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
LANES = { default: Lane.new, batch: Lane.new }.freeze
|
|
134
|
+
|
|
135
|
+
@at_exit_mutex = Mutex.new
|
|
136
|
+
@at_exit_registered = false
|
|
137
|
+
|
|
138
|
+
class << self
|
|
139
|
+
def submit(lane: :default, &job)
|
|
140
|
+
return yield unless async?
|
|
141
|
+
|
|
142
|
+
register_at_exit
|
|
143
|
+
LANES.fetch(lane).enqueue(job)
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Blocks until every queued job has finished or the timeout elapses. Returns true when
|
|
148
|
+
# every lane drained in time.
|
|
149
|
+
def flush(timeout = DEFAULT_FLUSH_TIMEOUT)
|
|
150
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
151
|
+
LANES.values.map { |lane| lane.flush(deadline) }.all?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Testing-only: discard queued work and stop the workers.
|
|
155
|
+
def reset!
|
|
156
|
+
LANES.each_value(&:reset!)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def async?
|
|
162
|
+
Coolhand.configuration.async_logging && !Coolhand.configuration.debug_mode && !worker_thread?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def worker_thread?
|
|
166
|
+
LANES.each_value.any? { |lane| lane.thread == Thread.current }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def register_at_exit
|
|
170
|
+
@at_exit_mutex.synchronize do
|
|
171
|
+
return if @at_exit_registered
|
|
172
|
+
|
|
173
|
+
@at_exit_registered = true
|
|
174
|
+
at_exit { flush }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "api_service"
|
|
4
|
+
require_relative "pagination"
|
|
5
|
+
require_relative "read_query"
|
|
6
|
+
|
|
7
|
+
module Coolhand
|
|
8
|
+
# Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
|
|
9
|
+
LogSearchResult = Struct.new(:logs, :pagination, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
# Reads logs back out of Coolhand. The write side is {LoggerService}. Requires the client's
|
|
12
|
+
# **private** key. See docs/log-search.md.
|
|
13
|
+
class LogService < ApiService
|
|
14
|
+
include ReadQuery
|
|
15
|
+
|
|
16
|
+
ERROR_NOUN = "Log"
|
|
17
|
+
BLANK_ID_MESSAGE = "get_log: id must be a non-empty log hashid"
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
super("v2/llm_request_logs")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Filters, cost ordering, error semantics and server behaviour: docs/log-search.md.
|
|
24
|
+
def search_logs(template_id: nil, workload_id: nil, system_prompt_contains: nil, user_prompt_contains: nil,
|
|
25
|
+
model: nil, source_api: nil, source_api_result: nil, source_application: nil, project_path: nil,
|
|
26
|
+
unmatched_only: nil, days_back: nil, since: nil, until: nil, min_cost: nil, order: nil,
|
|
27
|
+
include_prompts: nil, include_total: nil, sort: nil, page: nil, per: nil)
|
|
28
|
+
query = {
|
|
29
|
+
template_id: template_id,
|
|
30
|
+
workload_id: workload_id,
|
|
31
|
+
system_prompt_contains: system_prompt_contains,
|
|
32
|
+
user_prompt_contains: user_prompt_contains,
|
|
33
|
+
model: model,
|
|
34
|
+
source_api: source_api,
|
|
35
|
+
source_api_result: source_api_result,
|
|
36
|
+
source_application: source_application,
|
|
37
|
+
project_path: project_path,
|
|
38
|
+
unmatched_only: unmatched_only,
|
|
39
|
+
days_back: days_back,
|
|
40
|
+
**window_params(since, binding.local_variable_get(:until)),
|
|
41
|
+
min_cost: min_cost,
|
|
42
|
+
order: order,
|
|
43
|
+
include_prompts: include_prompts,
|
|
44
|
+
include_total: include_total,
|
|
45
|
+
"q[s]": sort,
|
|
46
|
+
page: page,
|
|
47
|
+
per: per
|
|
48
|
+
}.compact
|
|
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
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The full log, with `cost` and `cost_breakdown`. Both are nil when the log has no tokens or its
|
|
60
|
+
# model has no pricing.
|
|
61
|
+
def get_log(id, section: nil, max_chars: nil, search_query: nil, include_thinking: nil)
|
|
62
|
+
query = { section: section, max_chars: max_chars, search_query: search_query,
|
|
63
|
+
include_thinking: include_thinking }.compact
|
|
64
|
+
get_json(resource_url(id, BLANK_ID_MESSAGE, query), ERROR_NOUN)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|