railwatch 0.1.3 → 0.1.4
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/CHANGELOG.md +26 -0
- data/docs/records.md +16 -1
- data/lib/railwatch/subscribers/llm.rb +149 -4
- data/lib/railwatch/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0b78b98999447f64098f912cdfdcbb6202c9331ac4e07988fb350080d084f79c
|
|
4
|
+
data.tar.gz: e230f37a0a728c381fc4b84849bc0db762ad14e2b9c37aa5802850b97cba7271
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 27c7659440a7e1592b99d10ad2a409c711a2b5a44cafee9a4707ff8672e0952fcf6819ad5b98c96f4e9d25ae6e1c5b86193ebb70a74b52904f8aee808977c72d
|
|
7
|
+
data.tar.gz: c1101b7933f5703f4dfd7469b07bf9ac3f39cfaee7bd2a9d451e30126921c33a0326c5976d8605e7dce39f13b10b8d65fd8052007051775bc9e46f0b5cbb6368
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.4 (2026-09-15)
|
|
4
|
+
|
|
5
|
+
- `llm_call` records what the call carried and how it was configured, not
|
|
6
|
+
just what it cost. `finish_reason` shows when an answer was cut off
|
|
7
|
+
(`max_tokens`) or filtered, which previously read exactly like a complete
|
|
8
|
+
one. `attachments` and `attachment_types` show that a call carried two
|
|
9
|
+
images and a PDF, so a document read is no longer indistinguishable from
|
|
10
|
+
an expensive prompt -- on a document-reading call the attachments are most
|
|
11
|
+
of the input tokens. `params` holds the settings that produced the answer
|
|
12
|
+
(temperature, max_output_tokens, tool_choice, thinking, caching, whether a
|
|
13
|
+
schema was used, and the per-operation ones) so a surprising result can be
|
|
14
|
+
reproduced. `tools` names what the model could reach; `tool_call_id` joins
|
|
15
|
+
a tool call to the turn that asked for it.
|
|
16
|
+
- `provider_request_id` is read from the response headers (`request-id`,
|
|
17
|
+
`x-request-id`, `x-amzn-requestid`). It is the only key that joins a
|
|
18
|
+
Railwatch record to the provider's own record of the same call, and it is
|
|
19
|
+
what a provider support ticket asks for.
|
|
20
|
+
- `cost_reported` distinguishes a price the provider stated from one
|
|
21
|
+
estimated against the model registry.
|
|
22
|
+
- `provider_options` is filtered twice before it is stored: through the
|
|
23
|
+
app's own parameter filter, and again against the credential-name matcher
|
|
24
|
+
that catches `X-Api-Key` on a header. The default parameter filter is
|
|
25
|
+
password-shaped, so an `api_key` passed per call went through it
|
|
26
|
+
untouched. Attachment filenames stay behind `capture_llm_content`, since
|
|
27
|
+
a filename is business data rather than metadata.
|
|
28
|
+
|
|
3
29
|
## 0.1.3 (2026-09-15)
|
|
4
30
|
|
|
5
31
|
- LLM calls are recorded from RubyLLM's own instrumentation. Every model
|
data/docs/records.md
CHANGED
|
@@ -512,7 +512,13 @@ not a dependency — an app without it never emits these. Requires RubyLLM
|
|
|
512
512
|
|
|
513
513
|
The model call also appears as an `outgoing_request`, since it is an HTTP
|
|
514
514
|
call like any other. The two are different grains on purpose: the
|
|
515
|
-
`outgoing_request` is the HTTP truth, the `llm_call` is what it cost.
|
|
515
|
+
`outgoing_request` is the HTTP truth, the `llm_call` is what it cost. That
|
|
516
|
+
difference is useful: RubyLLM retries through Faraday, so one `llm_call`
|
|
517
|
+
with several `outgoing_request` rows against it in the same execution is a
|
|
518
|
+
call that was retried. Over a window, `outgoing_requests - llm_calls` to the
|
|
519
|
+
same provider host is the number of *extra attempts*, not a rate -- the
|
|
520
|
+
share of calls that were retried needs counting the calls with more than one
|
|
521
|
+
request against them, which the execution id supports.
|
|
516
522
|
|
|
517
523
|
**Token counts and cost differ by RubyLLM version.** 1.16 reports token
|
|
518
524
|
counts and no cost at all. 2.0 reports both, from its usage ledger, and
|
|
@@ -556,6 +562,15 @@ so cost is always complete.
|
|
|
556
562
|
| `workflow_step_id` | Step identifier within the workflow (2.0+). |
|
|
557
563
|
| `workflow_step_name` | Step name (2.0+). |
|
|
558
564
|
| `workflow_step_parent_id` | Enclosing step, for nested steps — what reconstructs the tree (2.0+). |
|
|
565
|
+
| `finish_reason` | Why the model stopped: `stop`, `max_tokens`, `tool_calls`, `content_filter`, or whatever the provider spelled it. `max_tokens` means the answer was cut off -- without this a truncated extraction reads exactly like a complete one. |
|
|
566
|
+
| `provider_request_id` | The provider's own id for the request, read from the response headers (`request-id`, `x-request-id`, `x-amzn-requestid`). The only key that joins this record to the provider's side of it, and what a support ticket asks for. |
|
|
567
|
+
| `tools` | Comma-separated names of the tools the model could reach, first 50. `tool_count` says how many; retracing needs which. |
|
|
568
|
+
| `cost_reported` | Whether the provider priced the call itself, or the amount is an estimate from the model registry. Null on gems or operations that report no cost. |
|
|
569
|
+
| `attachments` | How many files the last user turn carried. Absent when it carried none. Only the last turn is measured: earlier turns were counted by the calls that sent them. |
|
|
570
|
+
| `attachment_types` | What they were, by category and count, e.g. `imagex2,pdf`. Categories are RubyLLM's: image, pdf, audio, video, text, document, unknown. On a document-reading call the attachments are most of the input tokens, so without this an expensive scan is indistinguishable from an expensive prompt. |
|
|
571
|
+
| `attachment_names` | Filenames, only when `config.capture_llm_content` is on. A filename like `ACME_invoice_88231.pdf` is business data, not metadata, so it follows the same switch as prompts. |
|
|
572
|
+
| `params` | JSON of the settings that produced the answer, so a surprising one can be reproduced: `temperature`, `max_output_tokens`, `tool_choice`, `tool_call_limit`, `thinking`, `caching`, `citations`, whether a `schema` was used, plus the per-operation ones (`dimensions`, `task_type`, `size`, `count`, `voice`, `format`, `language`, `pages`, `document_count`, `top_n`), `server_tools` and the provider's `server_tool_use` counters. `provider_options` is included, filtered twice: through the app's own parameter filter, and again against the credential-name matcher that catches `X-Api-Key` on a header -- an `api_key` passed per call sails straight through a password-shaped filter. For `operation: "tool"` this holds the tool result's class instead. |
|
|
573
|
+
| `tool_call_id` | The provider's id for a tool invocation, for joining a tool call to the assistant turn that asked for it. |
|
|
559
574
|
| `prompt` | Last user turn, only when `config.capture_llm_content` is on (off by default). Capped at 4 KiB of bytes. |
|
|
560
575
|
| `completion` | The reply, same condition and cap. For `operation: "tool"` these two hold the tool's arguments and result instead. |
|
|
561
576
|
|
|
@@ -67,7 +67,13 @@ module Railwatch
|
|
|
67
67
|
streaming: p[:streaming] == true,
|
|
68
68
|
message_count: p[:message_count],
|
|
69
69
|
tool_count: Array(p[:tools]).size,
|
|
70
|
+
tools: tool_names(p),
|
|
70
71
|
cost_nanos: cost_nanos(p),
|
|
72
|
+
cost_reported: cost_reported(p),
|
|
73
|
+
finish_reason: finish_reason(p),
|
|
74
|
+
provider_request_id: provider_request_id(p),
|
|
75
|
+
params: params(operation, p),
|
|
76
|
+
**attachments(p),
|
|
71
77
|
**tokens(p),
|
|
72
78
|
**workflow(p),
|
|
73
79
|
**outcome(p),
|
|
@@ -103,6 +109,8 @@ module Railwatch
|
|
|
103
109
|
provider: p[:provider].to_s,
|
|
104
110
|
model: p[:model].to_s,
|
|
105
111
|
tool_name: tool_name[0, 255],
|
|
112
|
+
tool_call_id: p[:tool_call_id]&.to_s&.slice(0, 128),
|
|
113
|
+
params: ({ result_class: p[:result_class].to_s[0, 128] } if p[:result_class]),
|
|
106
114
|
duration: micros(event),
|
|
107
115
|
**workflow(p),
|
|
108
116
|
**outcome(p),
|
|
@@ -110,6 +118,145 @@ module Railwatch
|
|
|
110
118
|
completion: content(p[:result_content]))
|
|
111
119
|
end
|
|
112
120
|
|
|
121
|
+
# Why the model stopped. :max_tokens means the answer was cut off --
|
|
122
|
+
# a truncated extraction reads exactly like a complete one without
|
|
123
|
+
# this, which is the failure most worth being able to see.
|
|
124
|
+
def finish_reason(payload)
|
|
125
|
+
response = payload[:response]
|
|
126
|
+
return nil unless response.respond_to?(:finish_reason)
|
|
127
|
+
|
|
128
|
+
response.finish_reason&.to_s&.slice(0, 32)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Message#raw is the Faraday response (protocol.rb hands it in), so the
|
|
132
|
+
# provider's own request id is in its headers. It is what a provider
|
|
133
|
+
# support ticket asks for, and the only key that joins our record to
|
|
134
|
+
# theirs.
|
|
135
|
+
REQUEST_ID_HEADERS = %w[request-id x-request-id x-amzn-requestid].freeze
|
|
136
|
+
|
|
137
|
+
def provider_request_id(payload)
|
|
138
|
+
raw = payload[:response]
|
|
139
|
+
raw = raw.raw if raw.respond_to?(:raw)
|
|
140
|
+
headers = raw.respond_to?(:headers) ? raw.headers : nil
|
|
141
|
+
return nil unless headers.respond_to?(:[])
|
|
142
|
+
|
|
143
|
+
REQUEST_ID_HEADERS.each do |name|
|
|
144
|
+
value = headers[name]
|
|
145
|
+
return value.to_s[0, 128] if value.present?
|
|
146
|
+
end
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Which tools the model could reach on this call. tool_count alone says
|
|
151
|
+
# how many; retracing needs which.
|
|
152
|
+
def tool_names(payload)
|
|
153
|
+
names = Array(payload[:tools]).map(&:to_s)
|
|
154
|
+
names.empty? ? nil : names.first(50).join(",")[0, 1024]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Whether the provider priced the call itself, or we estimated it from
|
|
158
|
+
# the registry. The difference matters when a total is queried against
|
|
159
|
+
# an invoice.
|
|
160
|
+
def cost_reported(payload)
|
|
161
|
+
tokens = payload[:tokens]
|
|
162
|
+
return nil unless tokens.respond_to?(:reported_cost)
|
|
163
|
+
# No cost means no provenance to report. false would claim the
|
|
164
|
+
# registry priced it, which is the same false certainty cost_nanos
|
|
165
|
+
# avoids by being nil rather than zero.
|
|
166
|
+
return nil if cost_nanos(payload).nil?
|
|
167
|
+
|
|
168
|
+
!tokens.reported_cost.nil?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# What the call carried besides text. Images and PDFs are most of the
|
|
172
|
+
# input tokens on a document-reading call, and without this an
|
|
173
|
+
# expensive scan is indistinguishable from an expensive prompt.
|
|
174
|
+
# Only the last user turn is measured: earlier turns were counted by
|
|
175
|
+
# the calls that sent them, and walking the whole history would both
|
|
176
|
+
# double-count and cost O(messages) on every call.
|
|
177
|
+
def attachments(payload)
|
|
178
|
+
message = last_user_message(payload)
|
|
179
|
+
list = message.respond_to?(:attachments) ? Array(message.attachments) : []
|
|
180
|
+
return {} if list.empty?
|
|
181
|
+
|
|
182
|
+
types = list.filter_map { |a| a.type.to_s if a.respond_to?(:type) }.tally
|
|
183
|
+
.sort_by { |_, n| -n }.map { |type, n| n > 1 ? "#{type}x#{n}" : type }.join(",")
|
|
184
|
+
{ attachments: list.size, attachment_types: types[0, 128],
|
|
185
|
+
attachment_names: content(list.filter_map { |a| a.filename if a.respond_to?(:filename) }.join(", ")) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The knobs that change what a call costs and what it returns, so a
|
|
189
|
+
# surprising result can be reproduced with the settings that produced
|
|
190
|
+
# it. Provider options go through the app's own parameter filter: they
|
|
191
|
+
# are request configuration, but an app can put anything in them.
|
|
192
|
+
COMMON_PARAMS = %i[temperature max_output_tokens tool_choice tool_call_limit
|
|
193
|
+
thinking caching citations dimensions task_type size count
|
|
194
|
+
voice format language pages document_count top_n].freeze
|
|
195
|
+
|
|
196
|
+
def params(operation, payload)
|
|
197
|
+
out = {}
|
|
198
|
+
COMMON_PARAMS.each do |key|
|
|
199
|
+
value = payload[key]
|
|
200
|
+
next if value.nil?
|
|
201
|
+
# false is kept, not dropped: `caching` defaults to nil, so
|
|
202
|
+
# caching: false is a deliberate choice, and reproducing a call
|
|
203
|
+
# needs the settings it ran with. RubyLLM does not distinguish a
|
|
204
|
+
# boolean that was set from one that defaulted, so record both
|
|
205
|
+
# rather than guess which mattered.
|
|
206
|
+
out[key] = value.is_a?(Numeric) || [ true, false ].include?(value) ? value : value.to_s[0, 128]
|
|
207
|
+
end
|
|
208
|
+
out[:schema] = true if payload[:schema]
|
|
209
|
+
out[:server_tools] = Array(payload[:server_tools]).map(&:to_s).first(20) if payload[:server_tools].present?
|
|
210
|
+
if (usage = payload[:tokens]).respond_to?(:server_tool_use) && usage.server_tool_use.present?
|
|
211
|
+
out[:server_tool_use] = usage.server_tool_use
|
|
212
|
+
end
|
|
213
|
+
if (options = payload[:provider_options]).is_a?(Hash) && !options.empty?
|
|
214
|
+
out[:provider_options] = provider_options(options)
|
|
215
|
+
end
|
|
216
|
+
out[:operation] = operation unless out.empty?
|
|
217
|
+
out.empty? ? nil : out
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Two filters, because one is not enough here. The app's parameter
|
|
221
|
+
# filter defaults to password-shaped names only, and provider_options
|
|
222
|
+
# is the one place in this payload where a per-request credential
|
|
223
|
+
# plausibly lives -- an api_key passed per call sails straight through
|
|
224
|
+
# a password filter. The redactor's credential-name matcher (the same
|
|
225
|
+
# one that catches X-Api-Key on a header) closes that.
|
|
226
|
+
def provider_options(options)
|
|
227
|
+
redact_credentials(Railwatch.redactor.params(options.transform_keys(&:to_s)))
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Recursive, because provider options nest: extra_headers carrying an
|
|
231
|
+
# authorization value is a hash inside the hash, and a top-level scan
|
|
232
|
+
# walks straight past it.
|
|
233
|
+
def redact_credentials(value, depth = 0)
|
|
234
|
+
return value if depth > 4
|
|
235
|
+
|
|
236
|
+
case value
|
|
237
|
+
when Hash
|
|
238
|
+
value.each_with_object({}) do |(key, item), out|
|
|
239
|
+
# The matcher is written for header names, which are hyphenated;
|
|
240
|
+
# provider options are Ruby-ish and use underscores, so api_key
|
|
241
|
+
# would sail past a pattern expecting api-key.
|
|
242
|
+
out[key] = if Railwatch.redactor.redact_header?(key.to_s.tr("_", "-"))
|
|
243
|
+
Redactor::FILTERED
|
|
244
|
+
else
|
|
245
|
+
redact_credentials(item, depth + 1)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
when Array then value.map { |item| redact_credentials(item, depth + 1) }
|
|
249
|
+
else value
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def last_user_message(payload)
|
|
254
|
+
messages = payload[:input_messages]
|
|
255
|
+
return nil unless messages.respond_to?(:reverse_each)
|
|
256
|
+
|
|
257
|
+
messages.reverse_each.find { |m| m.respond_to?(:role) && m.role.to_s == "user" }
|
|
258
|
+
end
|
|
259
|
+
|
|
113
260
|
# 2.0 sends a RubyLLM::Tokens; 1.16 sends bare counts on the event.
|
|
114
261
|
def tokens(payload)
|
|
115
262
|
counts = payload[:tokens]
|
|
@@ -161,11 +308,9 @@ module Railwatch
|
|
|
161
308
|
# The last thing the app asked, which is the half of a conversation
|
|
162
309
|
# worth seeing next to a cost. Earlier turns are the app's own records.
|
|
163
310
|
def prompt_text(payload)
|
|
164
|
-
|
|
165
|
-
return payload[:input] || payload[:prompt] || payload[:query] unless messages.respond_to?(:reverse_each)
|
|
311
|
+
return payload[:input] || payload[:prompt] || payload[:query] unless payload[:input_messages].respond_to?(:reverse_each)
|
|
166
312
|
|
|
167
|
-
|
|
168
|
-
message_text(last)
|
|
313
|
+
message_text(last_user_message(payload))
|
|
169
314
|
end
|
|
170
315
|
|
|
171
316
|
def message_text(message)
|
data/lib/railwatch/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: railwatch
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cole Robertson
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-16 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rails
|