railwatch 0.8.7 → 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 +4 -4
- data/AGENTS.md +1 -0
- data/CHANGELOG.md +10 -0
- data/app/models/railwatch/ingest/mapper.rb +1 -1
- data/db/railwatch_telemetry_migrate/20261008000000_add_in_transaction_to_outgoing_requests.rb +10 -0
- data/docs/records.md +1 -0
- data/docs/testing.md +15 -0
- data/lib/railwatch/execution.rb +19 -0
- data/lib/railwatch/faraday.rb +2 -1
- data/lib/railwatch/minitest.rb +7 -0
- data/lib/railwatch/patches/net_http.rb +1 -0
- data/lib/railwatch/rspec.rb +23 -0
- data/lib/railwatch/spec_helper.rb +1 -1
- data/lib/railwatch/subscribers/queries.rb +8 -0
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/wire_fixtures.json +1 -0
- data/lib/railwatch.rb +2 -1
- data/llms.txt +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3cd11714c7a290f5f6717f3b8f239b72b669fce39a2797a59fcb862595925778
|
|
4
|
+
data.tar.gz: 147d3416d6d047a6c87b8ccdb9d2b67b8e2a6058f881956241f65dff0ea239c5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 774a7cbf876e5220af985a97a2cc6919ebb91ff9ea7a38e414956b1c0cbf3cedb723cbc470baaa807fa7343295117576df15485923502d855212e83698bee592
|
|
7
|
+
data.tar.gz: c0d3106cd7c800cf938daf056ce9c279b10e5013af618b39e4e31411517dc578a4e71fd711b5a4158e3b4acdb76964476ca298377f792c0b8f40319ad13c877e
|
data/AGENTS.md
CHANGED
|
@@ -102,6 +102,7 @@ expect { Checkout.new(cart).total }.to record_railwatch_span("checkout.total")
|
|
|
102
102
|
expect { importer.run }.to record_railwatch_exception(ArgumentError)
|
|
103
103
|
expect { importer.run }.not_to record_railwatch_exceptions
|
|
104
104
|
expect { SyncCustomers.run }.to have_railwatch_outgoing_requests(at_most: 1)
|
|
105
|
+
expect { post "/orders", params: }.not_to have_railwatch_http_in_transaction
|
|
105
106
|
|
|
106
107
|
records = railwatch_capture { get "/widgets" } # everything the block produced
|
|
107
108
|
```
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
filled in by the release commit, which is also the only commit that
|
|
7
7
|
touches lib/railwatch/version.rb and Gemfile.lock. See CONTRIBUTING.md. -->
|
|
8
8
|
|
|
9
|
+
## 0.9.0 (2026-10-08)
|
|
10
|
+
|
|
11
|
+
- An `outgoing_request` now says whether it was made inside a database
|
|
12
|
+
transaction (`in_transaction`), like Isolator: the transaction and its
|
|
13
|
+
locks wait on the other service, and a rollback cannot undo the call. Only
|
|
14
|
+
transactions the execution began count, so transactional test fixtures do
|
|
15
|
+
not. New `have_railwatch_http_in_transaction` matcher and
|
|
16
|
+
`refute_railwatch_http_in_transaction` assertion, and a telemetry migration
|
|
17
|
+
adding the column to `outgoing_requests`.
|
|
18
|
+
|
|
9
19
|
## 0.8.7 (2026-10-06)
|
|
10
20
|
|
|
11
21
|
- A migration of the railwatch databases waits up to 60 s for SQLite's
|
|
@@ -216,7 +216,7 @@ module Railwatch
|
|
|
216
216
|
when "mail" then child(Telemetry::Mail, rec, %w[mailer subject to cc bcc attachments delivery_method perform_deliveries duration failed message_id])
|
|
217
217
|
when "broadcast" then child(Telemetry::Broadcast, rec, %w[kind stream channel action bytes duration failed])
|
|
218
218
|
when "notification" then child(Telemetry::Notification, rec, %w[notifier delivery_method channel duration failed])
|
|
219
|
-
when "outgoing_request" then child(Telemetry::OutgoingRequest, rec, %w[host method url duration status_code request_size response_size error source response_body])
|
|
219
|
+
when "outgoing_request" then child(Telemetry::OutgoingRequest, rec, %w[host method url duration status_code request_size response_size error source response_body in_transaction])
|
|
220
220
|
when "llm_call" then child(Telemetry::LlmCall, rec, %w[operation provider model response_model tool_name duration status error
|
|
221
221
|
streaming message_count tool_count input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens
|
|
222
222
|
cost_nanos workflow_id workflow_name workflow_step_id workflow_step_name workflow_step_parent_id prompt completion
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# An outgoing HTTP call made while the execution held a database transaction
|
|
4
|
+
# open: the transaction (and on SQLite with BEGIN IMMEDIATE, the writer lock)
|
|
5
|
+
# stays held for the whole round trip. Same meaning as queries.in_transaction.
|
|
6
|
+
class AddInTransactionToOutgoingRequests < ActiveRecord::Migration[8.1]
|
|
7
|
+
def change
|
|
8
|
+
add_column :outgoing_requests, :in_transaction, :boolean, default: false
|
|
9
|
+
end
|
|
10
|
+
end
|
data/docs/records.md
CHANGED
|
@@ -500,6 +500,7 @@ never double-recorded.
|
|
|
500
500
|
| `response_size` | Bytes, from `Content-Length` or body size. |
|
|
501
501
|
| `error` | `"Class: message"`, truncated to 255 chars, if the request raised. |
|
|
502
502
|
| `response_body` | First 4 KiB of the response body, but only when `config.capture_response_body_on_error` is on (off by default) *and* the response was an error. A body that parses as a JSON object is run through the same parameter filter as request params and re-serialized; anything else is stored as it arrived. nil in every other case — including a connection failure, where there is no response (on the Net::HTTP path a body is read only if Net::HTTP already buffered it, so a response being streamed through `read_body` is never consumed; on the Faraday path the body is taken only once a status came back, so an outgoing request payload can never be filed as a response). |
|
|
503
|
+
| `in_transaction` | True when the execution had a database transaction (or savepoint) of its own open for this call, so the transaction and its locks were held for the whole round trip and a rollback cannot undo what the call did. Only transactions the execution began count: one already open when it started, such as a test's transactional fixture, does not. Active Record begins a transaction at its first statement, so a `transaction do` block that has not yet run one is not open. A job performed inline inherits its parent's. The `have_railwatch_http_in_transaction` matcher fails a spec on it. |
|
|
503
504
|
| `source` | App-code call site (Net::HTTP path only). |
|
|
504
505
|
|
|
505
506
|
### `llm_call`
|
data/docs/testing.md
CHANGED
|
@@ -109,11 +109,26 @@ expect { SyncCustomers.run }.to have_railwatch_outgoing_requests(at_most: 1)
|
|
|
109
109
|
Same bounds as `have_railwatch_queries`. Failures list the method and URL of
|
|
110
110
|
every request the block made.
|
|
111
111
|
|
|
112
|
+
### `have_railwatch_http_in_transaction`
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
expect { post "/orders", params: }.not_to have_railwatch_http_in_transaction
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Railwatch's version of [Isolator](https://github.com/palkan/isolator): fails
|
|
119
|
+
when the block makes an outgoing HTTP call while a database transaction it
|
|
120
|
+
opened is still open. The transaction, and every lock it holds, waits on the
|
|
121
|
+
other service, and rolling back cannot undo what the call did. The negated
|
|
122
|
+
failure message lists each such call with the app line that made it. Only
|
|
123
|
+
transactions opened inside the block count, so a suite's transactional
|
|
124
|
+
fixtures never trip it.
|
|
125
|
+
|
|
112
126
|
## Minitest assertions
|
|
113
127
|
|
|
114
128
|
```ruby
|
|
115
129
|
assert_railwatch_queries(at_most: 5) { OrderSummary.new(order).to_h }
|
|
116
130
|
refute_railwatch_n_plus_one { get widgets_url }
|
|
131
|
+
refute_railwatch_http_in_transaction { post orders_url, params: }
|
|
117
132
|
assert_railwatch_span("checkout.total") { Checkout.new(cart).total }
|
|
118
133
|
```
|
|
119
134
|
|
data/lib/railwatch/execution.rb
CHANGED
|
@@ -132,6 +132,7 @@ module Railwatch
|
|
|
132
132
|
@record_limit = @failure_context ? Railwatch.config.failure_context : MAX_RECORDS
|
|
133
133
|
@byte_limit = Railwatch.config.execution_buffer_bytes
|
|
134
134
|
@transaction_statement_counts = Hash.new(0)
|
|
135
|
+
@open_transactions = 0
|
|
135
136
|
@allocations_start = GC.stat(:total_allocated_objects)
|
|
136
137
|
@gc_time_start = GC.stat(:time) if GC_TIME_SUPPORTED
|
|
137
138
|
end
|
|
@@ -306,6 +307,24 @@ module Railwatch
|
|
|
306
307
|
@transaction_statement_counts.delete(transaction_object_id) || 0
|
|
307
308
|
end
|
|
308
309
|
|
|
310
|
+
# Database transactions (and savepoints) this execution has BEGUN and not
|
|
311
|
+
# yet finished, so an outgoing HTTP call can say whether it held one open.
|
|
312
|
+
# Only transactions opened during the execution count: a test's
|
|
313
|
+
# transactional fixture is already open when the execution starts, and
|
|
314
|
+
# flagging every request spec would make the signal useless. A nested
|
|
315
|
+
# execution (perform_now inside a request) inherits its parent's.
|
|
316
|
+
def transaction_opened
|
|
317
|
+
@open_transactions += 1
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def transaction_closed
|
|
321
|
+
@open_transactions -= 1 if @open_transactions.positive?
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def in_transaction?
|
|
325
|
+
@open_transactions.positive? || (parent_execution&.in_transaction? || false)
|
|
326
|
+
end
|
|
327
|
+
|
|
309
328
|
def duration
|
|
310
329
|
Clock.micros_since(@started_mono)
|
|
311
330
|
end
|
data/lib/railwatch/faraday.rb
CHANGED
|
@@ -53,7 +53,8 @@ module Railwatch
|
|
|
53
53
|
url: Record.url_without_sensitive_components(url, limit: 2048),
|
|
54
54
|
duration: Clock.micros_since(start), status_code: env.status.to_i,
|
|
55
55
|
error: error && "#{error.class}: #{error.message}"[0, 255],
|
|
56
|
-
response_body: response_body(env)
|
|
56
|
+
response_body: response_body(env),
|
|
57
|
+
in_transaction: Railwatch.execution&.in_transaction? || false)
|
|
57
58
|
end
|
|
58
59
|
|
|
59
60
|
# Faraday threads one Env through the whole stack, and the adapter
|
data/lib/railwatch/minitest.rb
CHANGED
|
@@ -32,6 +32,13 @@ module Railwatch
|
|
|
32
32
|
"detected:#{SpecHelper.n_plus_one_lines(n_plus_ones)}"
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
def refute_railwatch_http_in_transaction(&block)
|
|
36
|
+
offending = railwatch_capture(&block).select { |r| r[:t] == "outgoing_request" && r[:in_transaction] }
|
|
37
|
+
assert offending.empty?,
|
|
38
|
+
"Expected no outgoing HTTP requests inside a database transaction, but the block made " \
|
|
39
|
+
"#{offending.size}:#{SpecHelper.outgoing_lines(offending)}"
|
|
40
|
+
end
|
|
41
|
+
|
|
35
42
|
def assert_railwatch_span(name, &block)
|
|
36
43
|
spans = railwatch_capture(&block).select { |r| r[:t] == "span" }
|
|
37
44
|
assert spans.any? { |s| s[:name] == name },
|
|
@@ -68,6 +68,7 @@ module Railwatch
|
|
|
68
68
|
response_size: response ? (response["Content-Length"]&.to_i || response.body&.bytesize rescue nil) : nil,
|
|
69
69
|
error: error && "#{error.class}: #{error.message}"[0, 255],
|
|
70
70
|
response_body: response_body(response, error),
|
|
71
|
+
in_transaction: Railwatch.execution&.in_transaction? || false,
|
|
71
72
|
source: Backtrace.caller_location(skip: 4))
|
|
72
73
|
rescue StandardError => e
|
|
73
74
|
Railwatch.debug { "outgoing request record failed: #{e.message}" }
|
data/lib/railwatch/rspec.rb
CHANGED
|
@@ -136,4 +136,27 @@ RSpec::Matchers.define :have_railwatch_outgoing_requests do |bounds|
|
|
|
136
136
|
end
|
|
137
137
|
end
|
|
138
138
|
|
|
139
|
+
# Isolator's check: an HTTP call made while a database transaction this
|
|
140
|
+
# block opened is still open holds that transaction (and its locks) for the
|
|
141
|
+
# whole round trip, and a rollback cannot undo what the call did.
|
|
142
|
+
RSpec::Matchers.define :have_railwatch_http_in_transaction do
|
|
143
|
+
supports_block_expectations
|
|
144
|
+
|
|
145
|
+
match do |block|
|
|
146
|
+
@offending = railwatch_capture(&block).select { |r| r[:t] == "outgoing_request" && r[:in_transaction] }
|
|
147
|
+
@offending.any?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
description { "make an outgoing HTTP request inside a database transaction" }
|
|
151
|
+
|
|
152
|
+
failure_message do
|
|
153
|
+
"expected the block to make an outgoing HTTP request inside a database transaction, but it made none"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
failure_message_when_negated do
|
|
157
|
+
"expected no outgoing HTTP requests inside a database transaction, but the block made " \
|
|
158
|
+
"#{@offending.size}:#{Railwatch::SpecHelper.outgoing_lines(@offending)}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
139
162
|
RSpec.configure { |config| config.include Railwatch::SpecHelper } if defined?(RSpec.configure)
|
|
@@ -115,7 +115,7 @@ module Railwatch
|
|
|
115
115
|
end
|
|
116
116
|
|
|
117
117
|
def outgoing_lines(records)
|
|
118
|
-
lines(records) { |r| "#{r[:method]} #{truncate(r[:url])}" }
|
|
118
|
+
lines(records) { |r| "#{r[:method]} #{truncate(r[:url])}#{" at #{r[:source]}" if r[:source]}" }
|
|
119
119
|
end
|
|
120
120
|
|
|
121
121
|
def exception_lines(records)
|
|
@@ -148,8 +148,16 @@ module Railwatch
|
|
|
148
148
|
end
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
+
# Fired when a transaction or savepoint actually sends BEGIN (Active
|
|
152
|
+
# Record opens them lazily), paired with transaction.active_record
|
|
153
|
+
# below on commit, rollback, or restart.
|
|
154
|
+
subscribe_payload("start_transaction.active_record") do |_payload|
|
|
155
|
+
execution&.transaction_opened
|
|
156
|
+
end
|
|
157
|
+
|
|
151
158
|
subscribe("transaction.active_record") do |event|
|
|
152
159
|
exe = execution
|
|
160
|
+
exe&.transaction_closed
|
|
153
161
|
exe&.count(:transactions)
|
|
154
162
|
p = event.payload
|
|
155
163
|
# Taken (and removed) whether or not a record is built: the
|
data/lib/railwatch/version.rb
CHANGED
data/lib/railwatch.rb
CHANGED
|
@@ -389,7 +389,8 @@ module Railwatch
|
|
|
389
389
|
host = (URI(url.to_s).host rescue nil)
|
|
390
390
|
record(:outgoing_request, group: Record.group_hash(host, method.to_s.upcase),
|
|
391
391
|
timestamp: started_at, host: host, method: method.to_s.upcase,
|
|
392
|
-
url: url.to_s[0, 2048], duration: Clock.micros_since(start), status_code: result.status.to_i
|
|
392
|
+
url: url.to_s[0, 2048], duration: Clock.micros_since(start), status_code: result.status.to_i,
|
|
393
|
+
in_transaction: execution&.in_transaction? || false)
|
|
393
394
|
end
|
|
394
395
|
result
|
|
395
396
|
end
|
data/llms.txt
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
- [Embedded mode](docs/embedded.md): the default install — the dashboard inside the app, its two SQLite databases, dashboard authentication, the Puma writer process, maintenance without a job worker, disk reclaim, and optional export to Railwatch Cloud.
|
|
27
27
|
- [Configuration](docs/configuration.md): every configuration option and its `RAILWATCH_*` environment variable, plus the full public facade, redaction, rejection, transport and buffering behaviour, and the rake tasks.
|
|
28
28
|
- [Record types](docs/records.md): every record type the gem ships and every attribute on it, including RubyLLM calls (tokens, cost, finish reason, tool calls, workflows), sourced from the code that builds it.
|
|
29
|
-
- [Testing](docs/testing.md): the RSpec and Minitest matchers (`have_railwatch_queries`, `have_railwatch_n_plus_one`, `record_railwatch_span`, `record_railwatch_exception`, `have_railwatch_outgoing_requests`) and a CI performance-gate recipe.
|
|
29
|
+
- [Testing](docs/testing.md): the RSpec and Minitest matchers (`have_railwatch_queries`, `have_railwatch_n_plus_one`, `record_railwatch_span`, `record_railwatch_exception`, `have_railwatch_outgoing_requests`, `have_railwatch_http_in_transaction`) and a CI performance-gate recipe.
|
|
30
30
|
- [Production source maps](docs/source-maps.md): hidden Vite maps, private upload before publishing assets, safe opt-in deletion, resolved browser stacks and default issue grouping.
|
|
31
31
|
- [AI assistants and MCP](docs/ai-and-mcp.md): Railwatch Cloud's MCP endpoint, how to get a token, paste-ready client configuration for Claude Code, Claude Desktop, Cursor, VS Code, and Zed, and every tool, prompt, and resource the server exposes.
|
|
32
32
|
- [Replacing Sentry](docs/replacing-sentry.md): a step-by-step migration — removing the gems, porting each option, rewriting each call site, breadcrumbs, spans, profiling, attachments, `before_send`, fingerprints, and release health.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: railwatch
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cole Robertson
|
|
@@ -406,6 +406,7 @@ files:
|
|
|
406
406
|
- db/railwatch_telemetry_migrate/20260922000000_add_covering_indexes_for_dashboard_aggregates.rb
|
|
407
407
|
- db/railwatch_telemetry_migrate/20260923000000_add_people_index.rb
|
|
408
408
|
- db/railwatch_telemetry_migrate/20260925000000_add_tenant_summary_index.rb
|
|
409
|
+
- db/railwatch_telemetry_migrate/20261008000000_add_in_transaction_to_outgoing_requests.rb
|
|
409
410
|
- docs/ai-and-mcp.md
|
|
410
411
|
- docs/configuration.md
|
|
411
412
|
- docs/embedded.md
|