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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41585458ddfaf2036a184f5afca05be24749ed73ba54bb41f38ca7e881694ac9
4
- data.tar.gz: f4bd36a26ec1eb80ee729596c116015f3159f6ecb4306d64ebce9ae3f1eb4335
3
+ metadata.gz: 0b78b98999447f64098f912cdfdcbb6202c9331ac4e07988fb350080d084f79c
4
+ data.tar.gz: e230f37a0a728c381fc4b84849bc0db762ad14e2b9c37aa5802850b97cba7271
5
5
  SHA512:
6
- metadata.gz: 67197b5696dd6d041e8f8a311ac7b5ca6b9f989347dd1c45b542e5cc3a5ad70207af7c507d135c721a522755dde589b8bbe16c912e163bf7796ed2bb6a6fcbed
7
- data.tar.gz: e3e3746abd37ebfec16b09dae18d67f8ba1ef425b1a4571c805102f313eb93dca19558d64ba3212d18fb7eba4e7cbdc18c670f264cc9288ca65d3fea81b919c7
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
- messages = payload[:input_messages]
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
- last = messages.reverse_each.find { |m| m.respond_to?(:role) && m.role.to_s == "user" }
168
- message_text(last)
313
+ message_text(last_user_message(payload))
169
314
  end
170
315
 
171
316
  def message_text(message)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.4"
5
5
  end
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.3
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-15 00:00:00.000000000 Z
10
+ date: 2026-09-16 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails