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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9e80ca205e8ad9435148ba6b185c3de1796f8ffe489f1208876f38e21686181
4
- data.tar.gz: '09028109bb2da10a48db010d19f126a5e406bcbe50d2991ebcc303e4c4d74318'
3
+ metadata.gz: 3cd11714c7a290f5f6717f3b8f239b72b669fce39a2797a59fcb862595925778
4
+ data.tar.gz: 147d3416d6d047a6c87b8ccdb9d2b67b8e2a6058f881956241f65dff0ea239c5
5
5
  SHA512:
6
- metadata.gz: 75c81cb580e0948629948f8be59ccaf81e175239e2d268ae7b8cf9f5f6a42073e930881fc2ac5fe7700806c925684f9defd297f47673a2b2470754d688858554
7
- data.tar.gz: 8603393804ca3cdd96c5406fb313b1dca1ff7b378c780b7b42765c14d2b76c4e2d0895f8766c0a50fcbe14e0ea5f9ec1381fb7d330737d4ab50b950c76e84e56
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
 
@@ -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
@@ -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
@@ -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}" }
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "0.8.7"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -538,6 +538,7 @@
538
538
  "response_size": 2,
539
539
  "error": null,
540
540
  "response_body": null,
541
+ "in_transaction": false,
541
542
  "source": "/spec/railwatch/wire_fixtures_spec.rb"
542
543
  },
543
544
  "storage_op": {
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.8.7
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