railwatch 0.8.7 → 0.9.1

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: 5e6d353ab28ad688da0cd8f6cce5795101736d0e4ff9ca754aac49ba307a6614
4
+ data.tar.gz: 51558d4fa45d0f53a33c426e844ab4312704d2a82a6438070c1ad8bdcb5bfc6a
5
5
  SHA512:
6
- metadata.gz: 75c81cb580e0948629948f8be59ccaf81e175239e2d268ae7b8cf9f5f6a42073e930881fc2ac5fe7700806c925684f9defd297f47673a2b2470754d688858554
7
- data.tar.gz: 8603393804ca3cdd96c5406fb313b1dca1ff7b378c780b7b42765c14d2b76c4e2d0895f8766c0a50fcbe14e0ea5f9ec1381fb7d330737d4ab50b950c76e84e56
6
+ metadata.gz: 498984621aca78d24b668d15d8d77366b826335d7143b9e9e1ececfb8feab881a287dadb3ddae809c87597940cac05a34e1728ffdb9ecce073d4e4ffdabdcd46
7
+ data.tar.gz: 0e03f832ba5fc1d5a52168136252d5d789837d4a29f675a824f7d0577a5eb1f66cc35cc7d1f86f470d6afc7ebfc4d97978b3542b974de71a542ac92079e273d6
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,25 @@
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.1 (2026-10-10)
10
+
11
+ - Sampling (or reporter backpressure, which divides every rate by up to 8)
12
+ no longer drops a scheduled task's `scheduled_task` record: a sampled-out
13
+ run still queues it, alone, and only its child records are dropped. The
14
+ record is the run's check-in, and a sampled-out run read as "missed its
15
+ run" for a task that ran on time. Once queued it is subject to the same
16
+ reporter-buffer and delivery limits as any other record.
17
+
18
+ ## 0.9.0 (2026-10-08)
19
+
20
+ - An `outgoing_request` now says whether it was made inside a database
21
+ transaction (`in_transaction`), like Isolator: the transaction and its
22
+ locks wait on the other service, and a rollback cannot undo the call. Only
23
+ transactions the execution began count, so transactional test fixtures do
24
+ not. New `have_railwatch_http_in_transaction` matcher and
25
+ `refute_railwatch_http_in_transaction` assertion, and a telemetry migration
26
+ adding the column to `outgoing_requests`.
27
+
9
28
  ## 0.8.7 (2026-10-06)
10
29
 
11
30
  - 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
@@ -50,7 +50,9 @@ proxy with `Forwarded` or `X-Forwarded-*` headers.
50
50
  is decided once per execution, not per record, by
51
51
  `Railwatch::Sampler.decide` in `lib/railwatch/sampler.rb`. A sampled-in
52
52
  execution ships every child record it buffered. A sampled-out one ships
53
- nothing except an unhandled exception. That exception is governed by its
53
+ nothing except an unhandled exception. A sampled-out scheduled task also
54
+ ships its `scheduled_task` record alone: that record is the run's check-in,
55
+ and without it the missed-run check would flag a task that ran. That exception is governed by its
54
56
  own `exceptions` rate, decided once and memoized per execution. See
55
57
  `docs/records.md`'s `exception` section.
56
58
 
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.1"
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
@@ -161,6 +161,12 @@ module Railwatch
161
161
  # unless the tail decision (tail_keep?) rescues the whole tree, which is
162
162
  # also how a failure-context ring is promoted.
163
163
  #
164
+ # A scheduled_task parent ships whatever the sampling decision, alone
165
+ # when sampled out: it is the run's check-in, and the missed-run detector
166
+ # reads its absence as "the scheduler did not run this". Sampling it out
167
+ # (backpressure divides the rate by up to 8) raised "missed its run" for
168
+ # tasks that ran on time. One small record per run is the price.
169
+ #
164
170
  # The parent's fields may be given as a block instead of keywords. It is
165
171
  # called only when a parent is actually going to ship, so a caller whose
166
172
  # fields are expensive to assemble (the request middleware: an
@@ -178,7 +184,7 @@ module Railwatch
178
184
  # either way: a profiler left running would outlive the execution.
179
185
  profiled = exe.profiler_handle && ship_profile(exe, shipping)
180
186
  parent = nil
181
- if parent_type && (shipping || exe.exception_sampled)
187
+ if parent_type && (shipping || exe.exception_sampled || parent_type == :scheduled_task)
182
188
  fields.merge!(yield) if block_given?
183
189
  group ||= fields.delete(:group)
184
190
  fields[:tail_sampled] = true if tail
@@ -197,7 +203,8 @@ module Railwatch
197
203
  reporter.write(parent) if parent
198
204
  elsif parent
199
205
  # Head-sampled out, but an unhandled exception rolled the exceptions
200
- # sample in: the exception needs its parent, and only its parent.
206
+ # sample in (the exception needs its parent, and only its parent), or
207
+ # it is a scheduled task's check-in, which always ships.
201
208
  reporter.write(parent)
202
209
  end
203
210
  parent
@@ -389,7 +396,8 @@ module Railwatch
389
396
  host = (URI(url.to_s).host rescue nil)
390
397
  record(:outgoing_request, group: Record.group_hash(host, method.to_s.upcase),
391
398
  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)
399
+ url: url.to_s[0, 2048], duration: Clock.micros_since(start), status_code: result.status.to_i,
400
+ in_transaction: execution&.in_transaction? || false)
393
401
  end
394
402
  result
395
403
  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.1
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