railwatch 0.1.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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +122 -0
  3. data/CHANGELOG.md +462 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +226 -0
  6. data/app/controllers/railwatch/beacon_controller.rb +254 -0
  7. data/config/routes.rb +5 -0
  8. data/docs/ai-and-mcp.md +227 -0
  9. data/docs/configuration.md +931 -0
  10. data/docs/faq.md +230 -0
  11. data/docs/getting-started.md +279 -0
  12. data/docs/records.md +834 -0
  13. data/docs/replacing-nightwatch.md +216 -0
  14. data/docs/replacing-sentry.md +573 -0
  15. data/docs/security.md +94 -0
  16. data/docs/self-hosting.md +60 -0
  17. data/docs/source-maps.md +60 -0
  18. data/docs/testing.md +175 -0
  19. data/docs/troubleshooting.md +319 -0
  20. data/lib/generators/railwatch/install/install_generator.rb +280 -0
  21. data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
  22. data/lib/generators/railwatch/install/templates/post-deploy +98 -0
  23. data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
  24. data/lib/railwatch/attachments.rb +83 -0
  25. data/lib/railwatch/backtrace.rb +158 -0
  26. data/lib/railwatch/buffer.rb +122 -0
  27. data/lib/railwatch/clock.rb +25 -0
  28. data/lib/railwatch/configuration.rb +334 -0
  29. data/lib/railwatch/console.rb +48 -0
  30. data/lib/railwatch/context.rb +125 -0
  31. data/lib/railwatch/controller_helpers.rb +21 -0
  32. data/lib/railwatch/current.rb +32 -0
  33. data/lib/railwatch/engine.rb +144 -0
  34. data/lib/railwatch/execution.rb +367 -0
  35. data/lib/railwatch/faraday.rb +73 -0
  36. data/lib/railwatch/health.rb +188 -0
  37. data/lib/railwatch/job_tracing.rb +49 -0
  38. data/lib/railwatch/middleware/request.rb +289 -0
  39. data/lib/railwatch/minitest.rb +43 -0
  40. data/lib/railwatch/patches/inertia.rb +34 -0
  41. data/lib/railwatch/patches/net_http.rb +102 -0
  42. data/lib/railwatch/patches/rake_task.rb +88 -0
  43. data/lib/railwatch/patches/runner_command.rb +120 -0
  44. data/lib/railwatch/patches.rb +43 -0
  45. data/lib/railwatch/profiler.rb +270 -0
  46. data/lib/railwatch/record.rb +119 -0
  47. data/lib/railwatch/redactor.rb +67 -0
  48. data/lib/railwatch/release_detector.rb +97 -0
  49. data/lib/railwatch/reporter.rb +539 -0
  50. data/lib/railwatch/rspec.rb +139 -0
  51. data/lib/railwatch/sampler.rb +17 -0
  52. data/lib/railwatch/secret_safety.rb +62 -0
  53. data/lib/railwatch/sessions.rb +162 -0
  54. data/lib/railwatch/source_maps.rb +59 -0
  55. data/lib/railwatch/spec_helper.rb +147 -0
  56. data/lib/railwatch/sql_normalizer.rb +398 -0
  57. data/lib/railwatch/subscribers/base.rb +54 -0
  58. data/lib/railwatch/subscribers/broadcasts.rb +107 -0
  59. data/lib/railwatch/subscribers/cache.rb +107 -0
  60. data/lib/railwatch/subscribers/deprecations.rb +26 -0
  61. data/lib/railwatch/subscribers/exceptions.rb +304 -0
  62. data/lib/railwatch/subscribers/jobs.rb +282 -0
  63. data/lib/railwatch/subscribers/logs.rb +137 -0
  64. data/lib/railwatch/subscribers/mail.rb +42 -0
  65. data/lib/railwatch/subscribers/notifications.rb +36 -0
  66. data/lib/railwatch/subscribers/process_info.rb +98 -0
  67. data/lib/railwatch/subscribers/queries.rb +183 -0
  68. data/lib/railwatch/subscribers/requests.rb +94 -0
  69. data/lib/railwatch/subscribers/storage.rb +35 -0
  70. data/lib/railwatch/subscribers/users.rb +159 -0
  71. data/lib/railwatch/subscribers/views.rb +54 -0
  72. data/lib/railwatch/subscribers.rb +34 -0
  73. data/lib/railwatch/transport/http.rb +208 -0
  74. data/lib/railwatch/version.rb +5 -0
  75. data/lib/railwatch.rb +550 -0
  76. data/lib/tasks/railwatch_tasks.rake +289 -0
  77. data/llms.txt +38 -0
  78. metadata +157 -0
@@ -0,0 +1,931 @@
1
+ # Configuration
2
+
3
+ Everything below lives on `Railwatch::Configuration` (`lib/railwatch/configuration.rb`),
4
+ set via `Railwatch.configure { |c| ... }` in `config/initializers/railwatch.rb`
5
+ (created by `bin/rails generate railwatch:install`). Every setting has a
6
+ `RAILWATCH_*` env var default; explicit values set in the initializer always
7
+ win over the env var.
8
+
9
+ ## Core
10
+
11
+ | Attribute | Env var | Default | Meaning |
12
+ |---|---|---|---|
13
+ | `enabled` | `RAILWATCH_ENABLED` | `true` | Master switch. `Railwatch.enabled?` is also `false` whenever `token` is blank, so setting only `RAILWATCH_TOKEN` is enough to turn Railwatch on. |
14
+ | `token` | `RAILWATCH_TOKEN` | nil | Bearer token for `/ingest`. Required. |
15
+ | `ingest_url` | `RAILWATCH_INGEST_URL` | `https://railwatch.rebulk.com` | Platform base URL. Point at a self-hosted instance to override. |
16
+ | `allow_http` | `RAILWATCH_ALLOW_HTTP` | `false` | Permit a non-loopback plain HTTP ingest URL. HTTPS is required by default; `localhost`, `127.0.0.1`, and `::1` remain available for local self-hosted development. |
17
+ | `deploy` | `RAILWATCH_DEPLOY` | auto-detected (order below), then nil | Version tag stamped on every record and used by `railwatch:deploy`. Full 40-character SHAs are shortened to 12 characters. |
18
+ | `detect_deploy` | `RAILWATCH_DETECT_DEPLOY` | `true` | Detect deploys beyond `RAILWATCH_DEPLOY` and `KAMAL_VERSION`. Set false when the app deliberately reports no inferred deploy. |
19
+ | `server` | `RAILWATCH_SERVER` | `KAMAL_HOST`, else `Socket.gethostname` | Host stamped on every record. Under Kamal the container hostname carries a per-deploy container id, so the Kamal host wins; it is what the post-deploy hook registers as an expected server, which is what silent-host detection compares against. |
20
+ | `environment` | — | resolved lazily from `Rails.env` | Set `c.environment = "staging"` to report under a name other than the actual Rails env. |
21
+ | `ignored_request_paths` | `RAILWATCH_IGNORED_REQUEST_PATHS` (comma-separated) | `/up,/railwatch/beacon` | Exact request paths that bypass Railwatch's request execution entirely. In Ruby configuration, `Regexp` entries are also supported. Setting the env var replaces the defaults; append with `c.ignored_request_paths += ["/healthz"]` to keep them. |
22
+ | `beacon_rate_limit` | `RAILWATCH_BEACON_RATE_LIMIT` | `120` | Beacon POSTs accepted per client IP per minute before `POST /railwatch/beacon` answers 429. The beacon is unauthenticated and keeps every browser error it is sent, so this is what stops a script from spending the app's event quota. Counted in the app's cache store; `0` turns it off. |
23
+
24
+ `Railwatch.enabled?` delegates to `config.enabled?`, which is `@enabled &&
25
+ token.present?` — there is no separate "is configured" check elsewhere.
26
+
27
+ Deploy detection stops at the first value found: `RAILWATCH_DEPLOY`,
28
+ `KAMAL_VERSION`, `GIT_REV`, `GIT_SHA`, `SOURCE_VERSION`,
29
+ `HEROKU_SLUG_COMMIT`, `RENDER_GIT_COMMIT`, the tag from `FLY_IMAGE_REF`,
30
+ `VERCEL_GIT_COMMIT_SHA`, `CI_COMMIT_SHA`, `GITHUB_SHA`, a Capistrano
31
+ `REVISION` file, then `.git/HEAD` (including loose and packed refs). Git is
32
+ never run as a subprocess. An initializer assignment to `config.deploy`
33
+ always wins.
34
+
35
+ The request middleware also recognizes a reporter's own `POST /ingest` when
36
+ the configured ingest endpoint runs in the instrumented application. It
37
+ bypasses that request only when the
38
+ method, bearer token, configured ingest path, and public scheme/host/port all
39
+ match; an unrelated application route named `/ingest` remains observable.
40
+ Rack's normalized forwarded origin is used so this works behind a trusted
41
+ TLS-terminating proxy with `Forwarded` or `X-Forwarded-*` headers.
42
+
43
+ ## Sampling
44
+
45
+ `sample` is a hash of rate per execution kind, each `0.0`–`1.0`, decided
46
+ once per execution (`Railwatch::Sampler.decide`, `lib/railwatch/sampler.rb`) —
47
+ not per record. A sampled-in execution ships every child record it
48
+ buffered; a sampled-out one ships nothing except an unhandled exception
49
+ (governed by its own `exceptions` rate, decided once and memoized per
50
+ execution — see `docs/records.md`'s `exception` section).
51
+
52
+ | Key | Env var | Default |
53
+ |---|---|---|
54
+ | `requests` | `RAILWATCH_REQUEST_SAMPLE_RATE` | `1.0` |
55
+ | `jobs` | `RAILWATCH_JOB_SAMPLE_RATE` | `1.0` |
56
+ | `commands` | `RAILWATCH_COMMAND_SAMPLE_RATE` | `1.0` |
57
+ | `scheduled_tasks` | `RAILWATCH_SCHEDULED_TASK_SAMPLE_RATE` | `1.0` |
58
+ | `channels` | `RAILWATCH_CHANNEL_SAMPLE_RATE` | `1.0` |
59
+ | `exceptions` | `RAILWATCH_EXCEPTION_SAMPLE_RATE` | `1.0` |
60
+
61
+ Set as a whole hash: `c.sample = { requests: 0.1, jobs: 1.0 }` — keys you
62
+ omit keep their default (`config.sample_rate` falls back to `1.0` for an
63
+ unset kind).
64
+
65
+ **Per-route overrides**, from `ControllerHelpers`
66
+ (`lib/railwatch/controller_helpers.rb`), included into every controller:
67
+
68
+ ```ruby
69
+ class ReportsController < ApplicationController
70
+ railwatch_sample 0.01, only: :index # before_action wrapping Railwatch.sample(rate)
71
+ railwatch_never_sample only: :health # before_action wrapping Railwatch.dont_sample
72
+ end
73
+ ```
74
+
75
+ Both accept the same options as `before_action` (`only:`, `except:`, ...).
76
+ Programmatically: `Railwatch.sample(rate)` re-rolls the current execution's
77
+ sampling decision; `Railwatch.dont_sample` forces it off; `Railwatch.sampling?`
78
+ reads the current decision.
79
+
80
+ ### Tail-based sampling
81
+
82
+ Head sampling decides at the *start* of an execution, before anything is
83
+ known about it — cheap, but it throws away exactly the slow requests you
84
+ wanted to see. Tail sampling keeps buffering a head-sampled-out
85
+ execution's child records and decides at the *end*, once the duration and
86
+ outcome are known.
87
+
88
+ | Attribute | Env var | Default | Meaning |
89
+ |---|---|---|---|
90
+ | `tail_sample_slow_ms` | `RAILWATCH_TAIL_SAMPLE_SLOW_MS` | nil (off) | Keep a head-sampled-out execution that ran at least this many milliseconds. |
91
+
92
+ With it set (or after `Railwatch.keep!`), a head-sampled-out execution
93
+ ships its whole tree when it ran at least `tail_sample_slow_ms`, when
94
+ `Railwatch.keep!` was called, or when it raised an unhandled exception
95
+ (subject to the `exceptions` rate); otherwise the buffered records are
96
+ discarded at the end and nothing ships. Such a tree's parent record
97
+ carries `tail_sampled: true`, so a tail-kept execution is
98
+ distinguishable from a head-sampled one.
99
+
100
+ ```ruby
101
+ c.sample = { requests: 0.05 } # keep 5% of requests...
102
+ c.tail_sample_slow_ms = 500 # ...plus every request slower than 500ms
103
+ Railwatch.keep! # keep this one, whatever the head decision was
104
+ ```
105
+
106
+ **The trade-off is memory**: with tail sampling on, every sampled-out
107
+ execution buffers its child records (queries, logs, cache events, ...)
108
+ for its lifetime instead of discarding them as they happen, capped at
109
+ `Execution::MAX_RECORDS` (10,000) per execution. With it off — the
110
+ default — `Execution#recording?` is false for a sampled-out execution and
111
+ nothing is built or buffered at all, which is the cheapest path and
112
+ exactly the behaviour Railwatch had before. `Railwatch.keep!` can only keep
113
+ records made *after* the call unless tail sampling was already on: what
114
+ was never buffered can't be resurrected.
115
+
116
+ ### Failure context
117
+
118
+ Tail sampling buys diagnosability for sampled-out executions with the
119
+ memory to buffer *every* one of them. Failure context is the same trade
120
+ on a much shorter leash: keep a bounded ring of a head-sampled-out
121
+ execution's most recent child records, and ship it only if that
122
+ execution reports an unhandled exception.
123
+
124
+ | Attribute | Env var | Default | Meaning |
125
+ |---|---|---|---|
126
+ | `failure_context` | `RAILWATCH_FAILURE_CONTEXT` | `0` (off) | How many child records a head-sampled-out execution keeps, so an unhandled exception can ship what led up to it. |
127
+
128
+ ```ruby
129
+ c.sample = { requests: 0.05 } # keep 5% of requests...
130
+ c.failure_context = 200 # ...and the last 200 records of any that fails
131
+ ```
132
+
133
+ With this set, a head-sampled-out request, job attempt, scheduled task,
134
+ or command buffers its child records in a ring of that many. If it
135
+ reports an unhandled exception — the same policy that decides whether
136
+ the exception itself ships, i.e. subject to the `exceptions` rate — the
137
+ ring is promoted, and the parent, the exception, and the retained
138
+ children all ship together, with `tail_sampled: true` on the parent. If
139
+ it completes normally the ring is discarded at the end and nothing ships,
140
+ exactly as before.
141
+
142
+ Nothing else promotes a ring. `exceptions: 0`, an exception in
143
+ `ignored_exceptions`, an exception `Railwatch.report`s as handled or that a
144
+ controller's `rescue_from` swallowed, one reported inside
145
+ `Railwatch.ignore` / between `Railwatch.pause` and `Railwatch.resume`, and an
146
+ interactive `bin/rails runner`'s error all leave the sampled-out
147
+ execution shipping exactly what it shipped before the ring existed
148
+ (nothing, or the lone parent record that gives an unhandled exception
149
+ somewhere to hang). `Railwatch.sample(1.0)` and `Railwatch.keep!` still work
150
+ from inside the execution, and now ship the ring's contents with it
151
+ rather than only what followed the call.
152
+
153
+ **The cost** is that a sampled-out execution builds and buffers child
154
+ records again — the ring bounds how many are *kept*, not how many are
155
+ built — so this is a fraction of what tail sampling costs, but it is not
156
+ free, which is why it is off by default. There is no separate byte
157
+ limit: every record type is already truncated where it is built (SQL at
158
+ 16 KB, exception messages at 4 KB, attributes at 200 bytes), so
159
+ `failure_context` records is also the memory bound, and overflow
160
+ increments the same dropped-record counter tail sampling uses, reported
161
+ with the batch rather than swallowed.
162
+
163
+ `failure_context` and `tail_sample_slow_ms` are independent. With both
164
+ set, tail sampling's larger buffer wins for the whole execution: it keeps
165
+ everything, up to `Execution::MAX_RECORDS`, and promotes on duration as
166
+ well as on failure.
167
+
168
+ ### Profiling
169
+
170
+ Sampling and tail sampling say *which* executions ship; profiling says
171
+ which of them also ship a stack profile — where the time inside a slow
172
+ request or job actually went (`docs/records.md`'s `profile` record).
173
+
174
+ The backend is an optional dependency the app installs itself, because
175
+ neither belongs in every Gemfile:
176
+
177
+ ```ruby
178
+ gem "vernier" # Ruby >= 3.2, preferred
179
+ gem "stackprof" # anywhere else
180
+ ```
181
+
182
+ With neither installed, `Railwatch::Profiler.available?` is false and every
183
+ option below is inert.
184
+
185
+ | Attribute | Env var | Default | Meaning |
186
+ |---|---|---|---|
187
+ | `profile_sample` | `RAILWATCH_PROFILE_SAMPLE_RATE` | `0.0` (off) | Fraction of sampled-in executions to profile, rolled once per execution. |
188
+ | `profile_slow_ms` | `RAILWATCH_PROFILE_SLOW_MS` | nil (off) | Also ship a profile for any tail-buffering execution that ran at least this many milliseconds. |
189
+ | `profile_interval_us` | `RAILWATCH_PROFILE_INTERVAL_US` | `1000` | Sampling interval in microseconds. |
190
+ | `profiler` | `RAILWATCH_PROFILER` | nil (auto) | Pin a backend: `vernier` or `stackprof`. Auto prefers vernier when both are installed. |
191
+
192
+ The two triggers are different bargains:
193
+
194
+ - **`profile_sample`** decides at the *start*, like head sampling. A
195
+ profiler runs for that fraction of executions and every profile it takes
196
+ is shipped. Cheap and predictable — 1% of requests pay for a profiler,
197
+ 99% pay for one `Random.rand`.
198
+ - **`profile_slow_ms`** can't know an execution is slow until it is over,
199
+ so it profiles *every* tail-buffering execution from its first line and
200
+ throws away the ones that turn out to be fast. That means it only works
201
+ together with `tail_sample_slow_ms` (nothing tail-buffers without it),
202
+ and **the CPU cost is paid on every execution, not just the slow ones**
203
+ — the profiler's sampling thread runs throughout, and the stack table it
204
+ builds is held for the execution's lifetime. Raise
205
+ `profile_interval_us` if that shows up in your latency; a 5000µs
206
+ interval still resolves a 500ms request perfectly well.
207
+
208
+ ```ruby
209
+ c.sample = { requests: 1.0 }
210
+ c.tail_sample_slow_ms = 500 # keep every request slower than 500ms...
211
+ c.profile_slow_ms = 500 # ...and profile it
212
+ c.profile_sample = 0.01 # plus a profile of 1% of everything else
213
+ ```
214
+
215
+ Both backends are process-global, so there is one profiler per process:
216
+ an execution that starts while another is being profiled simply isn't
217
+ profiled. In the Rails `test` env profiling is skipped entirely unless
218
+ `profile_sample` is explicitly non-zero, so a suite that inherits the
219
+ app's `RAILWATCH_*` environment doesn't start a real profiler on every
220
+ example.
221
+
222
+ ## Distributed tracing
223
+
224
+ Railwatch propagates W3C trace context, so a request that fans out to
225
+ other Railwatch-instrumented services shows up as one trace.
226
+
227
+ | Attribute | Env var | Default | Meaning |
228
+ |---|---|---|---|
229
+ | `propagate_traces` | `RAILWATCH_PROPAGATE_TRACES` | `true` | Send a `traceparent` header on outgoing Net::HTTP and `Railwatch::Faraday` requests. |
230
+ | `trace_propagation_hosts` | `RAILWATCH_TRACE_PROPAGATION_HOSTS` (comma-separated) | nil (every host) | Allow list of hostnames. An entry starting with `.` matches as a suffix (`.services.example.com` matches `api.services.example.com`); anything else must match the host exactly. |
231
+
232
+ Outgoing: `traceparent: 00-<trace_id>-<execution_id[0,16]>-<flags>`, with
233
+ flags `01` when the execution is sampled and `00` when it isn't — a
234
+ sampled-out execution still propagates, it just says so. A `traceparent`
235
+ the app set itself is never overwritten.
236
+
237
+ Inbound: the Rack middleware parses `HTTP_TRACEPARENT` and adopts its
238
+ trace id and parent id for this execution. A header the W3C spec calls
239
+ invalid is ignored and the execution starts its own trace: wrong lengths or
240
+ non-hex characters, the forbidden version `ff`, an all-zero trace id, an
241
+ all-zero parent id, and anything trailing the flags on version `00`. A
242
+ future version may append fields after the flags, which are accepted and
243
+ never interpreted as long as they are dash-delimited, so a newer upstream
244
+ still links to this service instead of losing the trace. If the upstream
245
+ flags say the trace is sampled, the downstream execution is kept
246
+ (`Railwatch.keep!`, above) whatever its own head decision was — otherwise
247
+ the trace would have a hole exactly where this service should be.
248
+
249
+ ## Ignoring whole record types
250
+
251
+ `ignore` drops a record type before it's ever built — cheaper than
252
+ filtering after the fact, and the only way to stop the highest-volume
253
+ types (`query`, `cache_event`, `log`) at the source.
254
+
255
+ | Value | Env var |
256
+ |---|---|
257
+ | `:queries` | `RAILWATCH_IGNORE_QUERIES` |
258
+ | `:cache_events` | `RAILWATCH_IGNORE_CACHE_EVENTS` |
259
+ | `:mail` | `RAILWATCH_IGNORE_MAIL` |
260
+ | `:broadcasts` | `RAILWATCH_IGNORE_BROADCASTS` |
261
+ | `:notifications` | `RAILWATCH_IGNORE_NOTIFICATIONS` |
262
+ | `:outgoing_requests` | `RAILWATCH_IGNORE_OUTGOING_REQUESTS` |
263
+ | `:storage_ops` | `RAILWATCH_IGNORE_STORAGE_OPS` |
264
+ | `:view_renders` | `RAILWATCH_IGNORE_VIEW_RENDERS` |
265
+ | `:logs` | `RAILWATCH_IGNORE_LOGS` |
266
+ | `:transactions` | `RAILWATCH_IGNORE_TRANSACTIONS` |
267
+ | `:deprecations` | `RAILWATCH_IGNORE_DEPRECATIONS` |
268
+ | `:sessions` | `RAILWATCH_IGNORE_SESSIONS` |
269
+
270
+ ```ruby
271
+ c.ignore = [:cache_events, :transactions]
272
+ ```
273
+
274
+ Setting an unknown type raises `ArgumentError` immediately (this is
275
+ validated at assignment, not silently dropped). Note `query` and
276
+ `n_plus_one` records both key off `:queries`; `notification` off
277
+ `:notifications`; see `Railwatch::PLURALS` in `lib/railwatch.rb` for the full
278
+ singular-to-plural mapping used everywhere ignore/redact/reject hooks key
279
+ by plural.
280
+
281
+ ## Redaction
282
+
283
+ Two built-in filters, both string lists, both merged with what the app
284
+ already hides:
285
+
286
+ | Attribute | Env var | Default |
287
+ |---|---|---|
288
+ | `redact_headers` | `RAILWATCH_REDACT_HEADERS` (comma-separated) | `Authorization,Cookie,Set-Cookie,Proxy-Authorization,X-CSRF-Token,X-XSRF-TOKEN` |
289
+ | `redact_params` | `RAILWATCH_REDACT_PARAMS` (comma-separated) | `password,password_confirmation,authenticity_token,_token` |
290
+
291
+ `redact_params` is merged with `Rails.application.config.filter_parameters`
292
+ at first use (`Railwatch::Redactor#param_filter`), so anything the app
293
+ already scrubs from its own logs is scrubbed here too, with no extra
294
+ config. Request params are only captured at all when
295
+ `capture_request_payload` is on, and even then only for a request that
296
+ raised an exception (see `request` in `docs/records.md`).
297
+
298
+ Header names containing a credential-shaped segment (`api-key`, `access-key`,
299
+ `private-key`, `auth`, `bearer`, `credential`, `hmac`, `jwt`, `token`,
300
+ `secret`, or `signature`) are always masked as a safe default, even when they
301
+ arrive as concatenated Rack aliases such as `X-AuthToken`, `X-ApiToken`,
302
+ `X-AccessToken`, `X-ClientToken`, `X-SessionToken`, `X-RefreshToken`,
303
+ `X-SecretKey`, `X-HmacSignature`, or `X-CSRFToken`. Add application-specific
304
+ aliases to `redact_headers`; ordinary diagnostic headers remain available.
305
+
306
+ **Per-field redaction blocks** run after a record is built, before it's
307
+ buffered — the block receives and can mutate the record hash in place:
308
+
309
+ ```ruby
310
+ Railwatch.redact_queries { |q| q[:sql] = q[:sql].gsub(/email = '[^']+'/, "email = '?'") }
311
+ Railwatch.redact_requests { |r| ... }
312
+ Railwatch.redact_exceptions { |e| ... }
313
+ Railwatch.redact_cache_events { |c| ... }
314
+ Railwatch.redact_commands { |c| ... }
315
+ Railwatch.redact_mail { |m| ... }
316
+ Railwatch.redact_outgoing_requests { |o| ... }
317
+ Railwatch.redact_logs { |l| ... }
318
+ ```
319
+
320
+ A redactor that raises drops the record entirely (logged via
321
+ `Railwatch.debug`, never raised into app code).
322
+
323
+ ## Rejection
324
+
325
+ Drop a record entirely based on its content — for the record types that
326
+ don't have a matching `redact_*`:
327
+
328
+ ```ruby
329
+ Railwatch.reject_queries { |q| q[:sql].include?("solid_queue") }
330
+ Railwatch.reject_cache_events { |c| ... }
331
+ Railwatch.reject_mail { |m| ... }
332
+ Railwatch.reject_notifications { |n| ... }
333
+ Railwatch.reject_broadcasts { |b| ... }
334
+ Railwatch.reject_outgoing_requests { |r| r[:host] == "127.0.0.1" }
335
+ Railwatch.reject_enqueued_jobs { |j| ... }
336
+ Railwatch.reject_logs { |l| ... }
337
+ ```
338
+
339
+ `Railwatch.reject_cache_keys(prefixes)` is a shortcut that appends to
340
+ `config.ignored_cache_key_prefixes`, matched by `Configuration.match_cache_key?`:
341
+ a `Regexp` matches as-is; a `String` starting with `^` (or containing
342
+ another regex metacharacter) is compiled as one; a `String` ending in `*`
343
+ matches as a prefix; anything else must match the key exactly.
344
+
345
+ ```ruby
346
+ Railwatch.reject_cache_keys %w[session: rack::attack* ^feature_flag_\d+$]
347
+ ```
348
+
349
+ A rejector block returning truthy drops the record before it's buffered;
350
+ a raising rejector is treated as "don't reject" (fails open, logged via
351
+ `Railwatch.debug`).
352
+
353
+ ## before_ingest
354
+
355
+ Runs once per batch, right before it's POSTed — the last chance to
356
+ inspect or drop records as a group (redact/reject hooks above run
357
+ per-record, earlier, at record-build time):
358
+
359
+ ```ruby
360
+ Railwatch.before_ingest { |batch| batch.size < 10_000 } # return false to drop the whole batch
361
+ Railwatch.before_ingest { |batch| batch.reject { |r| r[:t] == "log" } } # return an Array to replace it
362
+ ```
363
+
364
+ Multiple hooks chain; any hook returning `false` drops the batch and
365
+ skips remaining hooks (`Railwatch.run_before_ingest`, `lib/railwatch.rb`).
366
+
367
+ ## Buffering, flushing, transport
368
+
369
+ One background thread per process (`Railwatch::Reporter`,
370
+ `lib/railwatch/reporter.rb`), re-armed after fork so each Puma cluster
371
+ worker / Solid Queue forked worker gets its own. Never touches the app
372
+ database.
373
+
374
+ | Attribute | Env var | Default | Meaning |
375
+ |---|---|---|---|
376
+ | `buffer_size` | `RAILWATCH_BUFFER_SIZE` | `10000` | Max buffered records (`Railwatch::Buffer`). Oldest is dropped (and counted) when full — never blocks the request thread. Keep it at or above `Execution::MAX_RECORDS` (10,000): a kept execution's whole tree is written here at once when it ends, and a queue smaller than the tree drops the tree's own oldest records first. |
377
+ | `buffer_bytes` | `RAILWATCH_BUFFER_BYTES` | `16777216` (16 MiB) | Estimated payload memory the reporter queue may hold. A record count alone does not bound memory: 10,000 records is a few megabytes of ordinary telemetry, or a gigabyte of captured attachments. Oldest records are dropped (and counted) under byte pressure, same as under count pressure. |
378
+ | `execution_buffer_bytes` | `RAILWATCH_EXECUTION_BUFFER_BYTES` | `8388608` (8 MiB) | The same ceiling for one execution's buffered tree, before it finishes. A normal execution keeps its earliest records; a failure-context ring keeps its latest. |
379
+ | `batch_bytes` | `RAILWATCH_BATCH_BYTES` | `8388608` (8 MiB) | Uncompressed NDJSON bytes in one ingest request. A queue holding more than this is delivered as several batches — the tail is kept for the next flush, not dropped. |
380
+ | `backpressure` | `RAILWATCH_BACKPRESSURE` | `true` | Adapt every execution kind's effective sample rate when the reporter buffer reaches its high-water mark or ingest is in retry backoff. |
381
+ | `backpressure_high_water` | `RAILWATCH_BACKPRESSURE_HIGH_WATER` | `0.8` | Fraction of either `buffer_size` or `buffer_bytes` that signals pressure. Values must be greater than `0.0` and at most `1.0`; invalid values use the default. |
382
+ | `flush_interval` | `RAILWATCH_FLUSH_INTERVAL` | `2.0` (seconds) | Background thread wakes and flushes on this cadence even if the buffer never fills. |
383
+ | `flush_threshold` | `RAILWATCH_FLUSH_THRESHOLD` | `500` | A `write` that pushes the buffer past this size wakes the thread immediately instead of waiting for the next interval. |
384
+ | `connect_timeout` | `RAILWATCH_CONNECT_TIMEOUT` | `1.0` (seconds) | TCP connect timeout for the ingest POST. |
385
+ | `timeout` | `RAILWATCH_TIMEOUT` | `3.0` (seconds) | Read/write timeout for the ingest POST. |
386
+ | `shutdown_timeout` | `RAILWATCH_SHUTDOWN_TIMEOUT` | `2.0` (seconds) | Deadline for the reporter thread to deliver retained records during `at_exit`. A deployment drain timeout must be longer than this. |
387
+
388
+ Delivery (`Railwatch::Transport::Http`, `lib/railwatch/transport/http.rb`):
389
+ gzip NDJSON POST to `{ingest_url}/ingest`, one retry on a raised error or
390
+ a 5xx within each delivery attempt. If that still fails — or ingest returns
391
+ 402, 408, or 429 — the immutable batch and its prior drop count are retained
392
+ for retry. Every newly formed batch gets an `X-Railwatch-Batch-Id` UUID which is
393
+ reused for the immediate HTTP retry and every later reporter retry; the
394
+ platform can therefore return the first committed result without inserting
395
+ the payload twice. Records written while a request is in flight collect in a
396
+ separate bounded buffer, so they never change the retained request's identity.
397
+ At most one retained batch plus one live buffer are held in memory. The
398
+ reporter retries with jittered exponential backoff (one second up to 60
399
+ seconds); it does not busy-loop. A 401 marks the transport
400
+ permanently unauthorized (no further HTTP attempts for the process's
401
+ lifetime); it and other permanent client rejections are reported through
402
+ `on_unrecoverable`. Delivery never raises into app code.
403
+
404
+ HTTPS connections explicitly use OpenSSL `VERIFY_PEER`, and redirects are not
405
+ followed. Plain HTTP is refused unless the host is loopback or
406
+ `RAILWATCH_ALLOW_HTTP=true`; `railwatch:doctor` reports the policy and boot logs a
407
+ warning when an insecure URL is refused.
408
+
409
+ On every reporter flush tick, adaptive backpressure doubles a process-local
410
+ sample divisor while either buffer ceiling is at least 80% full or the retry
411
+ ladder is active, up to 8x. Clear ticks halve it back toward 1x. Eight is
412
+ enough to create room after three pressured ticks and recovers in three clear
413
+ ticks; a 16x ceiling would preserve less telemetry and take longer to recover.
414
+ The sampler reads the reporter's Float without locking the request path; the
415
+ reporter is its only writer, an ivar assignment is atomic, and one stale read
416
+ only affects one probabilistic decision. Set `backpressure` false to keep the
417
+ factor at 1. The current value is sent as
418
+ `X-Railwatch-Backpressure-Factor` whenever it is greater than 1.
419
+
420
+ `Railwatch.flush` forces an immediate flush (also called by the `command`
421
+ patches after a rake task/runner invocation finishes, so short-lived
422
+ processes don't lose their last batch to the flush interval). An unhandled
423
+ exception (`Railwatch.record_now` → `Reporter#write_now`) enqueues the record
424
+ and asks for an urgent flush; it never performs network I/O or a timeout
425
+ cycle on the application thread. Urgent means within a quarter of a second
426
+ (`Reporter::URGENT_FLUSH_DELAY`), not instantly: during an exception storm
427
+ every request would otherwise wake the reporter for a handful of records,
428
+ and a burst that produced 4,000 records went out as 400 POSTs of ten. A
429
+ lone exception still ships inside that window; a storm coalesces into full
430
+ batches, and a buffer that crosses `flush_threshold` flushes at once
431
+ regardless.
432
+
433
+ During shutdown the reporter immediately attempts any retained batch and
434
+ keeps retrying within `shutdown_timeout`. If the deadline expires, the batch
435
+ remains accounted for in memory and `on_unrecoverable` receives the unsent
436
+ record/drop counts. The buffer is deliberately memory-only: a hard kill or
437
+ process exit after that deadline cannot preserve records for the next boot.
438
+
439
+ ## Query and view thresholds
440
+
441
+ | Attribute | Env var | Default | Meaning |
442
+ |---|---|---|---|
443
+ | `slow_query_threshold_ms` | `RAILWATCH_SLOW_QUERY_MS` | `5.0` | Above this, a query's source location is resolved fresh instead of reused from the group cache (see `query` in `docs/records.md`). |
444
+ | `n_plus_one_threshold` | `RAILWATCH_N_PLUS_ONE_THRESHOLD` | `5` | Same query group repeating this many times in one execution fires one `n_plus_one` record. |
445
+ | `max_view_renders_per_execution` | — (code only) | `20` | Caps stored `view_render` records per execution; all renders still count toward the parent's `view_renders` counter regardless of the cap. |
446
+ | `capture_query_explain` | `RAILWATCH_CAPTURE_QUERY_EXPLAIN` | `false` | Attach the adapter's own query plan to slow `SELECT`s as the `query` record's `explain` field. **Its own privacy decision, independent of `capture_sql_values`:** the EXPLAIN runs on the raw statement (a plan of normalized SQL would be meaningless), and a plan can echo literal predicate values — Postgres prints them in `Filter` and `Index Cond` lines. Leave it off if that matters. The EXPLAIN runs on the same connection the query just used, with Railwatch paused so it never records itself, and is rate-limited to one per query shape per process per 10 minutes. Off by default: it doubles the round trips for the queries it fires on. |
447
+ | `explain_threshold_ms` | `RAILWATCH_EXPLAIN_THRESHOLD_MS` | `100.0` | Minimum query duration before `capture_query_explain` will explain it. |
448
+ | `capture_sql_values` | `RAILWATCH_CAPTURE_SQL_VALUES` | `false` | Send the raw adapter SQL in `query.sql`. Off by default: a `query` record carries the normalized statement shape — placeholders and structure kept, string/numeric/hex/dollar-quoted literals and SQL comments removed — because SQL literals routinely contain email addresses, tokens, and other customer data. Active Record's separate structured binds are never sent in either mode. Normalization follows each dialect's *default* backslash-escaping rule (MySQL escapes, PostgreSQL does not, `E''` does, SQLite does not); a session running `NO_BACKSLASH_ESCAPES` or `standard_conforming_strings = off` is not visible in the notification, and a hand-written statement mixing that mode with a backslash before a quote can leave part of the statement's text in the shape. |
449
+
450
+ ## Process health
451
+
452
+ | Attribute | Env var | Default | Meaning |
453
+ |---|---|---|---|
454
+ | `health_interval` | `RAILWATCH_HEALTH_INTERVAL` | `15.0` | Seconds between `health` records (Puma thread pool, Active Record pool, Solid Queue backlog — see `health` in `docs/records.md`). One background thread per web/worker process; never runs in a console, a rake task, or the `test` env. |
455
+
456
+ Railwatch re-arms the reporter, sampler, and profiler after `fork` (one
457
+ `ActiveSupport::ForkTracker` callback, Rails' own `Process._fork` hook), so
458
+ clustered Puma workers and forked Solid Queue workers each get a fresh
459
+ buffer, transport policy state, process record, health thread, and profiler
460
+ slot. The child never flushes records or drop accounting inherited from its
461
+ parent, and no `on_worker_boot` configuration is needed.
462
+
463
+ The `process` record is written from `config.after_initialize`, after the
464
+ app's own initializers, so `boot_seconds` covers them. A Puma master that
465
+ preloads the app runs those initializers too, so it writes its own
466
+ `process` record and starts its own reporter, health, and session threads
467
+ before forking; Puma prints "Detected N Thread(s) started in app boot"
468
+ for them. That is advisory: the threads it is warning about are exactly
469
+ the ones the fork callback replaces in every worker. The Rake and
470
+ `bin/rails runner` patches are installed from the engine's `rake_tasks`
471
+ and `runner` hooks, which only a rake or runner process fires, so a web or
472
+ worker boot does not require rake or railties' runner command.
473
+
474
+ A numeric `RAILWATCH_*` value that is not a number (`RAILWATCH_BUFFER_SIZE=12px`)
475
+ falls back to the default documented in the tables above rather than being
476
+ coerced to `0`.
477
+
478
+ ### Release health
479
+
480
+ `session` records count sessions per deploy, which is what the platform's
481
+ crash-free session and crash-free user rates are computed from — the
482
+ `deploy` on every record *is* the release.
483
+
484
+ | Attribute | Env var | Default | Meaning |
485
+ |---|---|---|---|
486
+ | `track_sessions` | `RAILWATCH_TRACK_SESSIONS` | `true` | Master switch for both session sources. Off means the request middleware does nothing extra and no flusher thread is started. |
487
+ | `session_flush_interval` | `RAILWATCH_SESSION_FLUSH_INTERVAL` | `60.0` | Seconds between server-session flushes. One background thread per web process, re-armed after `fork` exactly like the health sampler, and flushed once more on shutdown. |
488
+ | `session_timeout` | `RAILWATCH_SESSION_TIMEOUT` | `1800.0` | Seconds a server session may sit idle before it ships with `ended` and is forgotten. |
489
+
490
+ There are two sources, and they meet on the same id:
491
+
492
+ - **The browser client** (`app/frontend/lib/railwatch.ts`, installed by
493
+ `railwatch:install`) mints one id per tab in `sessionStorage` and mirrors it
494
+ into a `railwatch_session` cookie. It rides along on the beacon flushes the
495
+ client already sends for visits, so this costs no extra requests. This is
496
+ the primary source for a web app, and it is what makes session duration
497
+ mean "how long the tab was open".
498
+ - **The request middleware** aggregates, in memory, every request that either
499
+ resolves a user or carries that cookie (or an `X-Railwatch-Session` header) —
500
+ the only source for an API-only app, and the only one that can see an
501
+ unhandled exception, which is what makes a session `crashed`.
502
+
503
+ When a browser session's requests carry the cookie both sources produce
504
+ records under the same id and the platform dedupes them.
505
+
506
+ ## Vendor noise defaults
507
+
508
+ Framework/vendor activity excluded by default so a fresh install isn't
509
+ dominated by Rails' own housekeeping:
510
+
511
+ | Attribute | Env var | Default | Affects |
512
+ |---|---|---|---|
513
+ | `capture_default_vendor_commands` | `RAILWATCH_CAPTURE_DEFAULT_VENDOR_COMMANDS` | `false` | `Configuration::DEFAULT_VENDOR_COMMANDS`: `db:migrate`, `db:schema:load`, `db:schema:dump`, `db:seed`, `db:prepare`, `assets:precompile`, `assets:clobber`, `tmp:cache:clear`, `log:clear`. |
514
+ | `capture_default_vendor_cache_keys` | `RAILWATCH_CAPTURE_DEFAULT_VENDOR_CACHE_KEYS` | `false` | `Configuration::DEFAULT_VENDOR_CACHE_KEYS`: `rack::attack`, `flipper`, `solid_cable`, `active_storage`, `migration_`, `schema_cache` prefixes. |
515
+ | `capture_framework_events` | `RAILWATCH_CAPTURE_FRAMEWORK_EVENTS` | `false` | Rails 8.1 structured `Rails.event` events under `action_controller.*`, `active_record.*`, etc. — already redundant with the `request`/`job_attempt` records, so off by default. |
516
+
517
+ `ignored_cache_key_prefixes` (code only, no env var — use
518
+ `Railwatch.reject_cache_keys` above) is separate from these vendor
519
+ defaults and always applies.
520
+
521
+ ## Interactive sessions: console and runner
522
+
523
+ An engineer poking at production from a shell is not the application failing.
524
+ Sentry never hooked `bin/rails console` at all, and Railwatch keeps that
525
+ behaviour — while making sure a deployed script still reports.
526
+
527
+ | Attribute | Env var | Default | Meaning |
528
+ |---|---|---|---|
529
+ | `capture_console` | `RAILWATCH_CAPTURE_CONSOLE` | `false` | When `false`, a `bin/rails console` process captures nothing — no exceptions, queries, or logs — starts no reporter/health/session thread, and sends no `process` or `health` record. Set it to `true` for the rare "trace what I'm about to do in here" session. Detected from `Rails::Console`, which railties defines before the app boots (`lib/railwatch/console.rb`). |
530
+ | `interactive_runner_paths` | `RAILWATCH_INTERACTIVE_RUNNER_PATHS` (comma-separated) | `Configuration::DEFAULT_INTERACTIVE_RUNNER_PATHS`: `/tmp/`, `/var/tmp/` | Scratch roots. A `bin/rails runner` given a `.rb` file under one of these is treated as hand-written (typed in a shell inside a container) rather than deployed. |
531
+
532
+ `bin/rails runner` is classified by **where the code came from**, which is
533
+ the only thing that separates a typo from a cron job:
534
+
535
+ | Invocation | Treated as | Result |
536
+ |---|---|---|
537
+ | `rails runner -` | interactive | `command` record with `interactive: true`, no exception reported |
538
+ | `rails runner 'Some.code'` | interactive | same |
539
+ | `rails runner /tmp/probe.rb` | interactive | same (a `.rb` file under `interactive_runner_paths`) |
540
+ | `rails runner script/nightly.rb` | deployed | `command` record and the exception, as before |
541
+
542
+ An interactive run is still recorded: the `command` record ships with its
543
+ `exit_code`, duration, and `exception_preview`, so you can see that someone
544
+ ran something and that it died — it just doesn't open an issue. Rake tasks
545
+ and Solid Queue jobs are never interactive.
546
+
547
+ ## Exception source and request payload
548
+
549
+ | Attribute | Env var | Default | Meaning |
550
+ |---|---|---|---|
551
+ | `capture_exception_source` | `RAILWATCH_CAPTURE_EXCEPTION_SOURCE_CODE` | `true` | Send source snippet lines surrounding each in-application exception frame to Railwatch Cloud. This is on by default for crash context; disable it when source disclosure is outside the application's telemetry policy. |
552
+ | `capture_exception_locals` | `RAILWATCH_CAPTURE_EXCEPTION_LOCALS` | `false` | Snapshot the raising frame's local variables (up to 25, values truncated to 200 chars, run through the same filter as request params) onto each exception, like Sentry's locals panel. Installs a `TracePoint(:raise)`; opt in per environment. |
553
+ | `capture_request_payload` | `RAILWATCH_CAPTURE_REQUEST_PAYLOAD` | `false` | Capture (redacted) request params — only for a request that raised, never otherwise. |
554
+ | `capture_job_arguments` | `RAILWATCH_CAPTURE_JOB_ARGUMENTS` | `false` | Add the job's real arguments (`job.serialize["arguments"]`) to each `job_attempt`/`scheduled_task` record, capped at 8 KiB of JSON. Hash arguments run through the same filter as request params. Off by default because job arguments routinely carry PII; `arguments_preview` (argument *shapes* only) is always on regardless. |
555
+ | `capture_job_retry_errors` | `RAILWATCH_CAPTURE_JOB_RETRY_ERRORS` | `false` | Capture the exception that caused an Active Job `retry_on` retry as handled, warning-level exception telemetry. Off by default because retries are usually expected and capturing them can flood the issues list. The retry log line is recorded either way. |
556
+ | `capture_response_body_on_error` | `RAILWATCH_CAPTURE_RESPONSE_BODY_ON_ERROR` | `false` | Add the first 4 KiB of the response body to an `outgoing_request` record when the response was an error (status ≥ 400, or the call raised). A JSON object body is filtered like request params and re-serialized; anything else is stored as it arrived. Off by default — a third party's error body is arbitrary data you didn't write. |
557
+ | `ignored_exceptions` | `RAILWATCH_IGNORED_EXCEPTIONS` (comma-separated) | `Configuration::DEFAULT_IGNORED_EXCEPTIONS` | Class names never captured, handled or not. The default list is Sentry's Rails-relevant exclusions plus `SignalException` (a SIGTERM/SIGINT ending a process is a shutdown, not an error; rake and runner also close their command record with exit code 128+signal instead of reporting). Matched against the error's class *and every named ancestor*, so your own subclass of a listed error is ignored too. Setting the env var replaces the default list; append instead with `c.ignored_exceptions += ["MyApp::Expected"]`. |
558
+ | `capture_rescued_exceptions` | `RAILWATCH_CAPTURE_RESCUED_EXCEPTIONS` | `true` | Capture exceptions a controller swallows with `rescue_from` (Rails' `rescue_from_callback.action_controller` notification) as `handled: true`, `severity: :warning`, `source: "action_controller.rescue_from"`. Sentry calls this `report_rescued_exceptions`. |
559
+
560
+ `DEFAULT_IGNORED_EXCEPTIONS` is the Rails-relevant subset of Sentry's own
561
+ `excluded_exceptions` defaults — routine 4xx plumbing rather than
562
+ application bugs:
563
+
564
+ `ActionController::BadRequest`, `ActionController::InvalidAuthenticityToken`,
565
+ `ActionController::RoutingError`, `ActionController::UnknownFormat`,
566
+ `ActionController::UnknownHttpMethod`,
567
+ `ActionDispatch::Http::MimeNegotiation::InvalidType`,
568
+ `ActionDispatch::Http::Parameters::ParseError`,
569
+ `ActiveRecord::RecordNotFound`, `Puma::HttpParserError`,
570
+ `Puma::HttpParserError501`, `Rack::QueryParser::InvalidParameterError`,
571
+ `Rack::QueryParser::ParameterTypeError`.
572
+
573
+ Note that Rails never reports an exception that has a `rescue_response`
574
+ (`ActiveRecord::RecordNotFound` → 404) to `Rails.error` in the first
575
+ place, so several of these are belt-and-braces for the paths that *do*
576
+ reach Railwatch — jobs, `Railwatch.report`, and `rescue_from`.
577
+
578
+ ## Logging
579
+
580
+ | Attribute | Env var | Default |
581
+ |---|---|---|
582
+ | `log_level` | `RAILWATCH_LOG_LEVEL` | `:info` |
583
+
584
+ Only `Rails.logger` lines at or above this level become `log` records.
585
+ Rails' own per-request/job noise (`"Started GET"`, `"Processing by"`,
586
+ `"Rendered"`, etc.) is filtered regardless of level, since the
587
+ `request`/`job_attempt` records already carry that information.
588
+ Message text is otherwise shipped as written and is not parsed for embedded
589
+ secrets. Keep secrets out of logs, use `Railwatch.redact_logs` for an
590
+ application-specific scrub, or disable log records with
591
+ `RAILWATCH_IGNORE_LOGS=true`.
592
+
593
+ ## User resolution
594
+
595
+ ```ruby
596
+ c.user { |user| { id: user.id, name: user.name, email: user.email } }
597
+ ```
598
+
599
+ Default (no block set): reads `Current.user` (authentication-zero /
600
+ Rails 8 auth generator convention) if defined, else Warden's `env["warden"].user`
601
+ (Devise). The resolved id is memoized per user per process-hour so a
602
+ `user` record ships once, not once per request (`Railwatch::Subscribers::Users`,
603
+ `docs/records.md`'s `user` section).
604
+
605
+ Ids are tenant-scoped: with a tenant bound, `1` is recorded as `acme:1`. It
606
+ does not matter whether the tenant binds before or after the user is
607
+ resolved — an app that resolves the user in one `before_action` and the
608
+ tenant in the next still gets `acme:1`, on the records already buffered as
609
+ well as the ones after. Return an already-scoped value from the block (an
610
+ external id, or `"#{org.slug}:#{user.id}"`) and it is left alone.
611
+
612
+ A request resolves its user at the end, but a job enqueued mid-action needs
613
+ one immediately, so `JobTracing#serialize` resolves the enqueuing
614
+ execution's user and tenant and puts those two identifier strings into the
615
+ Active Job payload (`railwatch_user`/`railwatch_tenant`). The worker restores
616
+ them before the attempt records anything, so a `job_attempt` and every
617
+ child record under it are attributed to the person whose request enqueued
618
+ the job rather than to a worker process that has no signed-in user — and a
619
+ job that enqueues a job passes the same identity on. Nothing but the two
620
+ strings crosses the queue; no model is serialized or hydrated. Payloads
621
+ carry the keys only when there is something to carry, and a payload without
622
+ them falls back to local resolution, so a queue drained across a deploy
623
+ keeps working. See `docs/records.md`'s `job_attempt` section for retries,
624
+ scheduled jobs, and the cardinality note.
625
+
626
+ ```ruby
627
+ c.beacon_user { |request| Session.find_by(id: request.cookie_jar.signed[:session_token])&.user }
628
+ ```
629
+
630
+ Who is behind a browser beacon (visits, browser sessions, JavaScript
631
+ errors). The beacon is handled by the gem's engine controller, outside your
632
+ `ApplicationController`, so an app that authenticates in a `before_action`
633
+ -- a signed session cookie looked up per request -- has not run it when the
634
+ beacon arrives, and `Current.user` is nil there. Give Railwatch the same
635
+ lookup; it hands the result to the `user` block above. Not needed when
636
+ `Current.user` is set in middleware or by Warden.
637
+
638
+ ## Tenant / context
639
+
640
+ ```ruby
641
+ Railwatch.context(tenant: org.slug, plan: org.plan)
642
+ ```
643
+
644
+ Writes through to `ActiveSupport::ExecutionContext`, `Rails.error.set_context`,
645
+ and `Rails.event.set_context` in one call (`Railwatch::Context.set`,
646
+ `lib/railwatch/context.rb`) — so context set for Railwatch also shows up
647
+ anywhere else Rails' own context stores are read. Serialized onto every
648
+ record's `context` field, through the same `ActiveSupport::ParameterFilter`
649
+ that redacts request params (`c.redact_params` plus Rails'
650
+ `config.filter_parameters`) — so a token or password put in context is
651
+ `[FILTERED]` on the wire, not written verbatim onto every record made while
652
+ it was set. A context over 64KB is rebuilt smaller rather than cut: whole
653
+ values are kept while they fit, an oversized string value ends with
654
+ `[TRUNCATED]`, anything that still does not fit is dropped, and the result
655
+ carries `"_railwatch_truncated": true`. It is always parseable JSON — the
656
+ previous behaviour sliced the encoded string at 64KB, which produced a
657
+ fragment the platform could not read at all. `tenant` specifically is
658
+ auto-detected with no explicit `Railwatch.context` call needed when the app
659
+ uses `activerecord-tenanted` (`ActiveRecord::Base.current_tenant`) or
660
+ `TenantRecord` (`TenantRecord.current_tenant`) — `Context.current_tenant`
661
+ checks both. The tenant is re-read while it is still nil, so a tenant bound
662
+ *inside* the execution (activerecord-tenanted's `TenantSelector` middleware
663
+ sits under Railwatch's, as do `around_action`s and a job's `with_tenant`
664
+ block) still lands on the request/job record and every child made after
665
+ the bind. Records made before the bind (a `before_action` that loads the
666
+ user, say) keep `tenant: nil`.
667
+
668
+ ## Inertia: beacon and SSR
669
+
670
+ `beacon_enabled` (`RAILWATCH_BEACON`, default `true`) gates
671
+ `POST /railwatch/beacon`, mounted by the install generator
672
+ (`mount Railwatch::Engine, at: "/railwatch"`) — see `visit` and `exception` in
673
+ `docs/records.md` for the full field lists and client batching behavior.
674
+ The same beacon carries visit timing, Core Web Vitals, browser sessions,
675
+ and every JavaScript error the page throws; turning `beacon_enabled` off
676
+ turns off all four. The endpoint takes no credential, so it is throttled
677
+ per client IP (`beacon_rate_limit`, default 120 a minute, `0` to disable);
678
+ a client past the limit gets a 429 with `Retry-After` and nothing from that
679
+ POST is recorded. Client setup: call `startRailwatch()` (generated at
680
+ `app/frontend/lib/railwatch.ts`) from your Inertia entrypoint.
681
+
682
+ `startRailwatch` takes three optional settings, none of which has a
683
+ server-side equivalent — they are decisions about the browser the code is
684
+ running in:
685
+
686
+ ```ts
687
+ startRailwatch({
688
+ // Messages never worth an issue, added to the defaults (both
689
+ // "ResizeObserver loop ..." messages). Strings match anywhere in the
690
+ // message; regexes are tested against it.
691
+ ignoreErrors: [/Failed to fetch dynamically imported module/],
692
+ // Scripts whose failures are not this app's, matched against the top
693
+ // stack frame's URL and added to the defaults (/extensions\//i,
694
+ // /^chrome:\/\//i, /^moz-extension:\/\//i). A frame from any origin
695
+ // other than the app's own is dropped regardless.
696
+ denyUrls: [/analytics\./],
697
+ // Only for apps that scope tenants by path or subdomain: the beacon
698
+ // posts to /railwatch/beacon, outside that scoping, so the server cannot
699
+ // resolve the tenant itself. Read on every flush. A tenant the server
700
+ // does resolve (`Context.current_tenant`) always wins.
701
+ tenant: () => /^\/orgs\/([^/]+)/.exec(location.pathname)?.[1],
702
+ })
703
+ ```
704
+
705
+ The same file exports two more things. `railwatchRootOptions()` returns
706
+ React 19's `onCaughtError`/`onUncaughtError` root options —
707
+ `createRoot(el, railwatchRootOptions())` — which is what reports an error a
708
+ boundary caught, since React only sends those to `console.error` outside
709
+ a development build. `reportError(error, context?)` reports an error the
710
+ app caught itself, and is how a React 18 boundary's `componentDidCatch`
711
+ does the same thing. See
712
+ [`docs/replacing-sentry.md`](replacing-sentry.md) for what is and is not
713
+ captured versus `@sentry/react`.
714
+
715
+ SSR timing needs no configuration: `Railwatch::Patches::Inertia` prepends
716
+ `InertiaRails::Renderer#ssr_render` whenever `inertia_rails` SSR is
717
+ enabled, and the resulting `ssr_ms` lands on the `request` record's
718
+ `inertia` field automatically.
719
+
720
+ ## Manual reporting and instrumentation
721
+
722
+ ```ruby
723
+ Railwatch.report(error, handled: true, context: { order_id: order.id })
724
+ Railwatch.ignore { ExpensiveSync.run } # pause recording for the block, restored after
725
+ Railwatch.instrument_outgoing(:get, url) { http_client.get(url) } # for HTTP clients without a dedicated patch
726
+ ```
727
+
728
+ `Railwatch.report` defaults `severity` to `:warning` when `handled: true`,
729
+ `:error` otherwise, and tags `source: "railwatch.manual"`.
730
+ `Railwatch.instrument_outgoing` records an `outgoing_request` only if the
731
+ block's return value responds to `#status` — for Faraday-alike client
732
+ objects that aren't Net::HTTP and don't already go through
733
+ `Railwatch::Faraday` middleware.
734
+
735
+ ### Fingerprinting
736
+
737
+ How an exception is bucketed into an issue. The default is class + top
738
+ in-app frame + normalized message (see
739
+ [`docs/records.md`](records.md)'s `exception` section for what
740
+ normalization removes). Three ways to override it, in precedence order:
741
+
742
+ ```ruby
743
+ # 1. Per call, when you already know the bucket.
744
+ Railwatch.report(error, fingerprint: [ "payments", gateway.name ])
745
+
746
+ # 2. On your own error class, so every raise site agrees.
747
+ class PaymentError < StandardError
748
+ def railwatch_fingerprint = [ "payments", gateway ]
749
+ end
750
+
751
+ # 3. Globally, in an initializer (one block; Sentry's before_send fingerprint).
752
+ Railwatch.fingerprint do |error, default|
753
+ error.is_a?(Faraday::Error) ? [ "upstream", error.response_status, :default ] : nil
754
+ end
755
+ ```
756
+
757
+ The block is called with the error and `default` — the Array of parts
758
+ Railwatch would have hashed (`[class, file, line, normalized message]`). It
759
+ returns an Array of strings/symbols/numbers; the literal `:default`
760
+ splices those default parts in wherever you put it (Sentry's
761
+ `{{ default }}`). Parts are stringified, empty ones dropped, and the
762
+ result capped at 10 parts of 200 chars. Returning nil or an empty Array —
763
+ or raising — falls back to the default, so a bad resolver can never lose
764
+ an exception. Every `exception` record carries the parts it was hashed on
765
+ (`fingerprint`) and where they came from (`fingerprint_source`), and an
766
+ attachment filed against the error (`Railwatch.attach(..., exception:)`)
767
+ follows the same rule, so it lands on the same issue.
768
+
769
+ ### Attachments
770
+
771
+ Ship an arbitrary blob — the payload that failed to parse, a rendered PDF,
772
+ the webhook body a customer swears they sent — as its own `attachment`
773
+ record (Sentry's `Sentry.add_attachment`):
774
+
775
+ ```ruby
776
+ Railwatch.attach("payload.json", request.raw_post) # a String
777
+ Railwatch.attach("invoice.pdf", Rails.root.join("tmp/invoice.pdf")) # a Pathname, or any IO
778
+ Railwatch.attach("payload.json", body, content_type: "text/plain") # override the guessed type
779
+ Railwatch.attach("payload.json", body, exception: error) # file it against an issue
780
+ Railwatch.report(error, attachments: { "payload.json" => body }) # capture + attach in one call
781
+ ```
782
+
783
+ `content_type` defaults to whatever Marcel makes of the name's extension
784
+ (`application/octet-stream` if it can't tell). Passing `exception:` sets
785
+ the record's `exception_group_hash` to the same group hash the `exception`
786
+ record is filed under, so the platform shows the attachment on that issue.
787
+ An attachment made inside a recording execution belongs to it; made with
788
+ nothing executing, it ships standalone. Returns nil and records nothing
789
+ when Railwatch is disabled or the payload is empty.
790
+
791
+ | Attribute | Env var | Default | Meaning |
792
+ |---|---|---|---|
793
+ | `max_attachment_bytes` | `RAILWATCH_MAX_ATTACHMENT_BYTES` | `1048576` (1 MiB) | Payloads longer than this are cut to the cap and the record is flagged `truncated: true`. Files and IOs are read with at most cap + 1 bytes rather than read whole and then sliced. `bytes` on the record is always the stored size. Data is gzipped and base64-encoded on the wire, so the cap is on the *original* bytes, not what ships. |
794
+
795
+ ## on_unrecoverable
796
+
797
+ ```ruby
798
+ Railwatch.on_unrecoverable { |error| Rails.error.report(error, handled: true) }
799
+ ```
800
+
801
+ Called whenever Railwatch rescues one of its own internal errors, ingest
802
+ permanently rejects a batch, or shutdown expires with retained records that
803
+ could not be sent. Retryable delivery failures stay buffered and do not fire
804
+ the callback on every attempt. With no callback registered, this falls back
805
+ to `Railwatch.debug` (stderr, gated on `RAILWATCH_DEBUG`, never `Rails.logger` —
806
+ so gem-internal failures can never themselves become `log` records).
807
+
808
+ ## Faraday
809
+
810
+ Opt in per connection (only needed for a non-default Faraday adapter;
811
+ the default adapter is Net::HTTP, already covered globally):
812
+
813
+ ```ruby
814
+ Faraday.new(url) { |f| f.use Railwatch::Faraday }
815
+ ```
816
+
817
+ ## debug
818
+
819
+ | Attribute | Env var | Default |
820
+ |---|---|---|
821
+ | `debug` | `RAILWATCH_DEBUG` | `false` |
822
+
823
+ Internal diagnostics to stderr (`warn`, prefixed `[railwatch]`) — deliberately
824
+ not `Rails.logger`, so turning this on can't create a feedback loop of
825
+ `log` records about Railwatch's own failures.
826
+
827
+ ## Public facade — full method list
828
+
829
+ Mirrors Laravel Nightwatch's facade shape. All on the `Railwatch` module
830
+ (`lib/railwatch.rb`) unless noted:
831
+
832
+ `configure`, `config`, `enabled?`, `sample(rate)`, `dont_sample`,
833
+ `keep!`, `sampling?`, `span(name, **attributes) { }`, `ignore { }` / `pause` / `resume` / `paused?` (pause/resume
834
+ are the ignore block's building blocks — nestable), `record(type, **fields)`,
835
+ `report(error, ..., attachments: {}, fingerprint: [])`, `attach(name, data, ...)`, `context(**attrs)`, `user(&block)`,
836
+ `fingerprint(&block)`, `redact_*`,
837
+ `reject_*`, `reject_cache_keys`, `before_ingest`, `on_unrecoverable`,
838
+ `instrument_outgoing`, `flush`, `debug { }`.
839
+
840
+ ## Rake tasks
841
+
842
+ Ship with the gem via Rails::Engine's default `lib/tasks` convention
843
+ (`lib/tasks/railwatch_tasks.rake`):
844
+
845
+ - **`railwatch:status`** — pings `{ingest_url}/ingest/ping` with the
846
+ configured token; aborts if `RAILWATCH_TOKEN` is unset or the ping fails.
847
+ - **`railwatch:doctor`** — prints a ✓/✗ checklist of the whole install: token,
848
+ ingest URL, `GET /ingest/ping`, `Railwatch::Middleware::Request` in the
849
+ middleware stack, the mounted engine's beacon route, `config.deploy` and
850
+ its environment, `REVISION`, Git, or initializer source, sample rates,
851
+ ignored record types, the Kamal
852
+ `post-deploy` hook, `app/frontend/lib/railwatch.ts`, and whether
853
+ `railwatch/rspec` (or `railwatch/minitest`) is required by the test helper.
854
+ The last five are informational; it exits non-zero only when the token is
855
+ missing or the ping fails.
856
+ - **`railwatch:deploy[ref,name,url]`** — POSTs `{deploy, ref, name, url,
857
+ server, timestamp, performer, destination, service, commits}` to
858
+ `{ingest_url}/ingest/deploys`. `deploy` comes from `config.deploy`; aborts
859
+ if that's unset. `ref` defaults to `git rev-parse HEAD` when not passed.
860
+ `performer`/`destination`/`service` come from `KAMAL_PERFORMER`,
861
+ `KAMAL_DESTINATION`, and `KAMAL_SERVICE`. `commits` is up to 50
862
+ `{sha, author, message, at}` objects, newest first, from `git log` — empty
863
+ inside an app container, which has no `.git`, which is why the hook below
864
+ posts from the deployer instead.
865
+
866
+ ## Kamal integration
867
+
868
+ `bin/rails generate railwatch:install` writes `.kamal/hooks/post-deploy` (only
869
+ if `config/deploy.yml` already exists). It no-ops when `RAILWATCH_TOKEN` isn't
870
+ set, and never fails a deploy — every network call ends in `|| true`.
871
+
872
+ The hook runs on the **deployer machine**, not in a container, which is the
873
+ whole point: that's where the git history lives and where Kamal exports its
874
+ [`KAMAL_*` variables](https://kamal-deploy.org/docs/hooks/overview/)
875
+ (`KAMAL_VERSION`, `KAMAL_HOSTS`, `KAMAL_PERFORMER`, `KAMAL_DESTINATION`,
876
+ `KAMAL_SERVICE`, `KAMAL_RECORDED_AT`, `KAMAL_COMMAND`, `KAMAL_SUBCOMMAND`,
877
+ `KAMAL_ROLE`). With `curl`, `ruby`, and `RAILWATCH_INGEST_URL` all present it
878
+ POSTs directly, twice:
879
+
880
+ 1. `POST $RAILWATCH_INGEST_URL/ingest/deploys` — `{deploy, ref, name, url,
881
+ server, timestamp, performer, destination, service, commits}`, where
882
+ `commits` is up to 50 `{sha, author, message, at}` objects built from
883
+ `git log -n 50 --format='%H%x1f%an%x1f%s%x1f%cI'` piped through a one-line
884
+ `ruby -rjson -e`. This is what lets the platform show a diff of what
885
+ actually shipped. `name` is `KAMAL_SERVICE_VERSION`; set the optional
886
+ `RAILWATCH_DEPLOY_URL` to link the marker at a CI run or release page.
887
+ 2. `POST $RAILWATCH_INGEST_URL/ingest/kamal` — `{version, hosts, roles,
888
+ performer, destination, service, recorded_at, command, subcommand}`, with
889
+ `hosts` split out of the comma-separated `KAMAL_HOSTS`. The platform uses
890
+ this to know which servers should be reporting.
891
+
892
+ Without `curl`/`ruby`, or without `RAILWATCH_INGEST_URL`, it falls back to the
893
+ original behaviour — `bin/kamal app exec --primary --reuse "bin/rails
894
+ railwatch:deploy[$KAMAL_VERSION]"` — which records the same deploy minus the
895
+ commit list.
896
+
897
+ `config.deploy` itself auto-detects `KAMAL_VERSION` (and the other release
898
+ sources listed under Core) with no configuration needed even without this
899
+ hook — the hook's job is the deploy marker, the commit diff, and the server
900
+ inventory.
901
+
902
+ ## Overhead gate
903
+
904
+ `bench/overhead.rb` boots the dummy app on SQLite, drives three request
905
+ shapes (no queries; 20 uncached queries; the N+1 widgets page) with
906
+ Railwatch's subscribers unsubscribed and then subscribed, alternating every
907
+ batch, and fails (exit 1) if instrumentation adds more than the per-shape
908
+ budget in `LIMITS` (CPU time on the request thread, not wall — stable
909
+ under CI load — plus an allocation count). It also fails if the log
910
+ capture has made `Rails.logger.debug?` true. Run it with `bundle exec ruby
911
+ bench/overhead.rb`. Measured on a shared box the gem adds ~0.4ms fixed per
912
+ request plus 40–80µs per real query; the limits leave headroom for slower
913
+ CI hosts without letting a real regression through unnoticed. The numbers
914
+ and how they were taken are in [`docs/faq.md`](faq.md).
915
+
916
+ ## Testing your own app against Railwatch
917
+
918
+ ```ruby
919
+ # spec/rails_helper.rb
920
+ require "railwatch/rspec"
921
+ ```
922
+
923
+ `railwatch_records(type = nil)` flushes and returns buffered records (as
924
+ built hashes, filtered to `type` if given) without a real network call —
925
+ backed by `Railwatch::SpecHelper::MemoryTransport`, swapped in for
926
+ `Railwatch.reporter` on first use. `require "railwatch/rspec"` also includes
927
+ `Railwatch::SpecHelper` everywhere and adds the block matchers
928
+ (`have_railwatch_queries`, `have_railwatch_n_plus_one`, ...) documented in
929
+ [`testing.md`](testing.md); `require "railwatch/minitest"` is the Minitest
930
+ equivalent. `require "railwatch/spec_helper"` on its own, plus your own
931
+ `config.include Railwatch::SpecHelper`, still works.