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,573 @@
1
+ # Replacing Sentry
2
+
3
+ A step-by-step migration from `sentry-ruby` / `sentry-rails` to Railwatch.
4
+ Railwatch is a genuine alternative for the Rails server workloads in the
5
+ matrix below; it is not a drop-in replacement for every product Sentry
6
+ sells. Keep both SDKs enabled during an evaluation if your application
7
+ depends on a conditional row.
8
+
9
+ Install Railwatch first ([`getting-started.md`](getting-started.md)); you
10
+ can run both for a day if you want to compare, since neither knows about
11
+ the other.
12
+
13
+ ## Decide whether Railwatch covers your workload
14
+
15
+ Nightwatch parity and Sentry parity are different targets. Laravel
16
+ Nightwatch is an application-monitoring product built around framework
17
+ executions; Railwatch deliberately maps that model onto Rails. Sentry is a
18
+ broader, multi-language managed platform with browser replay, native/mobile
19
+ SDKs, a large integration catalog, and generic tracing. Those broader
20
+ capabilities are not implied by Rails/Nightwatch parity.
21
+
22
+ | Workload or capability | Status | Boundary |
23
+ |---|---|---|
24
+ | Rails HTML/API request performance and errors | Supported | Controller/action, route, SQL, cache, render, mail, HTTP, storage, logs, and exceptions share one execution. Work performed while a streaming Rack body enumerates is tracked by [#22](https://github.com/Rebulk/railwatch/issues/22). |
25
+ | Handled and unhandled Ruby exceptions | Supported | Rails.error, Rack, Active Job, manual `Railwatch.report`, grouping, context, attachments, issue lifecycle, and regression detection. Delivery is memory-buffered, not a durable crash spool. |
26
+ | Active Job | Supported | Works above Solid Queue, Sidekiq, GoodJob, and other Active Job adapters. Direct `Sidekiq::Worker` and non-Solid Queue schedulers are tracked by [#29](https://github.com/Rebulk/railwatch/issues/29). |
27
+ | Recurring/scheduled work | Conditional | Solid Queue recurring tasks include schedule and drift. Other schedulers need [#29](https://github.com/Rebulk/railwatch/issues/29). |
28
+ | Action Cable channel actions | Conditional | Broadcast/transmit records exist; a complete action parent and child lifecycle is tracked by [#15](https://github.com/Rebulk/railwatch/issues/15). |
29
+ | Distributed Rails traces | Conditional | Outgoing propagation and request/job linking exist. Upstream-sampled W3C continuity is tracked by [#16](https://github.com/Rebulk/railwatch/issues/16). This is not a general OpenTelemetry collector. |
30
+ | Ruby profiling | Conditional | Requires `vernier` or `stackprof`; there is no profiler bundled into the SDK. |
31
+ | Browser monitoring | Partial | The optional Inertia client reports visits, Web Vitals, browser errors, and breadcrumbs. Session Replay, native/mobile SDKs, and Sentry's full browser/source-map workflow are outside the currently released Rails-server replacement. |
32
+ | Runtime compatibility | Narrow today | The currently proved pair is Ruby 3.4 + Rails 8.1. A maintained compatibility matrix and any safe lowering of requirements are tracked by [#26](https://github.com/Rebulk/railwatch/issues/26). |
33
+ | Managed integrations and operations | Partial | Railwatch Cloud supports its documented email, Slack, and webhook paths plus self-hosting. It does not promise Sentry's broader integration catalog. |
34
+ | SQL value privacy | Supported by default | Query records carry normalized SQL shapes without literal values, and Active Record binds are never sent. Raw SQL and query plans are separate opt-ins; either can contain values. |
35
+
36
+ For a Rails 8.1 application whose work enters through Rack and Active Job,
37
+ and which does not require Replay, native/mobile monitoring, or Sentry's
38
+ managed integration catalog, Railwatch is a useful and valid server-side
39
+ replacement. Evaluate the conditional rows against your own production
40
+ entry points before removing Sentry.
41
+
42
+ Exception delivery has the same process boundary as the rest of Railwatch's
43
+ reporter. `Railwatch.record_now` skips the execution buffer, enqueues the
44
+ record, and asks the reporter for an urgent flush (within a quarter of a
45
+ second, so an exception storm ships as full batches rather than one POST
46
+ per request); it does not synchronously POST on the application thread. The reporter retries during graceful shutdown,
47
+ but its buffer is memory-only. A hard kill, OOM, or exit after the shutdown
48
+ deadline can lose records. See [Buffering, flushing, and
49
+ transport](configuration.md#buffering-flushing-transport).
50
+
51
+ ## 1. Remove the gem
52
+
53
+ ```ruby
54
+ # Gemfile — delete both
55
+ gem "sentry-ruby"
56
+ gem "sentry-rails"
57
+ ```
58
+
59
+ ```sh
60
+ bundle install
61
+ rm config/initializers/sentry.rb
62
+ ```
63
+
64
+ Then grep for what's left: `rg 'Sentry\.' app lib config` — every call
65
+ site is rewritten below. Delete `SENTRY_DSN` from your secrets and
66
+ deploy config once the app boots without it.
67
+
68
+ ## 2. Port the initializer
69
+
70
+ `config/initializers/railwatch.rb` (written by the install generator) is
71
+ where every option from `Sentry.init` lands. The mapping:
72
+
73
+ - **`dsn:`** becomes `RAILWATCH_TOKEN`, one token per environment, created
74
+ in Railwatch Cloud. Self-hosting adds `RAILWATCH_INGEST_URL`. The token is
75
+ also the on/off switch: with it blank, Railwatch installs nothing.
76
+ - **`environment:`** becomes `c.environment`, which defaults to
77
+ `Rails.env` — set it only to report under a different name.
78
+ - **`release:`** becomes `c.deploy`, which auto-detects the release from
79
+ `RAILWATCH_DEPLOY`, the deploy platform, `REVISION`, or the Git checkout in
80
+ the order documented in [`configuration.md`](configuration.md#core). It is
81
+ stamped on every record, and it is also the release for release health,
82
+ below.
83
+ - **`traces_sample_rate:`** becomes `c.sample`, a rate per execution kind
84
+ rather than one global number: `requests`, `jobs`, `commands`,
85
+ `scheduled_tasks`, `exceptions`. The decision is made once per
86
+ execution, not per event, so a sampled-in request ships its whole tree
87
+ and a sampled-out one ships nothing but an unhandled exception. Per
88
+ route, use the `railwatch_sample` / `railwatch_never_sample` controller
89
+ macros.
90
+ - **`profiles_sample_rate:`** becomes `c.profile_sample` (and/or
91
+ `c.profile_slow_ms`) — see step 6, since it also needs a profiler gem.
92
+ - **`excluded_exceptions:`** becomes `c.ignored_exceptions`, which starts
93
+ from the Rails-relevant subset of Sentry's own default list. Railwatch
94
+ matches the error's class *and every named ancestor*, so your own
95
+ subclass of a listed error is ignored too. Assigning replaces the list;
96
+ `+=` extends it.
97
+ - **`include_local_variables:`** becomes `c.capture_exception_locals`.
98
+ - **`send_default_pii:`** has no single equivalent, deliberately. It is
99
+ split into `c.capture_request_payload` (params, and only on a request
100
+ that raised), `c.capture_job_arguments`,
101
+ `c.capture_response_body_on_error`, the `c.redact_headers` /
102
+ `c.redact_params` lists, and the `c.user { }` resolver for who the
103
+ user is. There is no "send everything" switch.
104
+ - **`config.rails.report_rescued_exceptions`** becomes
105
+ `c.capture_rescued_exceptions`, on by default.
106
+ - **`config.rails.active_job_report_on_retry_error`** becomes
107
+ `c.capture_job_retry_errors`, off by default because retries are usually
108
+ expected and capturing them can flood the issues list.
109
+ - **`before_send:`** becomes `Railwatch.before_ingest` plus the
110
+ `redact_*` / `reject_*` hooks — see step 8.
111
+ - **Rack `X-Request-Start` queue time** needs no setting: it is parsed
112
+ into `queue_time` on every `request` record.
113
+
114
+ A worked initializer, roughly what a `Sentry.init` block turns into:
115
+
116
+ ```ruby
117
+ # config/initializers/railwatch.rb
118
+ Railwatch.configure do |c|
119
+ c.token = ENV["RAILWATCH_TOKEN"] # was dsn:
120
+ c.environment = ENV["APP_ENV"] || Rails.env # was environment:
121
+ c.deploy = ENV["GIT_REV"] # was release:
122
+
123
+ # was traces_sample_rate: 0.1
124
+ c.sample = { requests: 0.1, jobs: 1.0, commands: 1.0,
125
+ scheduled_tasks: 1.0, channels: 1.0, exceptions: 1.0 }
126
+ # ...but keep every slow or failing request regardless
127
+ c.tail_sample_slow_ms = 500
128
+ # ...or, for failures only and a fraction of the memory: the last 200
129
+ # child records of any sampled-out execution that raises
130
+ c.failure_context = 200
131
+
132
+ # was excluded_exceptions: [...] (the Sentry defaults are already here)
133
+ c.ignored_exceptions += %w[MyApp::Expected]
134
+
135
+ # was include_local_variables: true
136
+ c.capture_exception_locals = true
137
+
138
+ # was config.rails.active_job_report_on_retry_error: true
139
+ c.capture_job_retry_errors = true
140
+
141
+ # was send_default_pii: false, unpacked
142
+ c.capture_request_payload = false
143
+ c.capture_job_arguments = false
144
+ c.redact_headers += %w[X-Api-Key]
145
+ c.redact_params += %w[ssn]
146
+
147
+ # was Sentry.set_user / config.user
148
+ c.user { |user| { id: user.id, name: user.name, email: user.email } }
149
+ end
150
+ ```
151
+
152
+ Railwatch also masks credential-shaped header names (`api-key`, `access-key`,
153
+ `private-key`, `auth`, `bearer`, `credential`, `hmac`, `jwt`, `token`,
154
+ `secret`, and `signature`) automatically, including concatenated Rack aliases
155
+ such as `X-AuthToken`, `X-ApiToken`, `X-AccessToken`, `X-ClientToken`,
156
+ `X-SessionToken`, `X-RefreshToken`, `X-SecretKey`, `X-HmacSignature`, and
157
+ `X-CSRFToken`. During migration, add application-specific aliases that do not
158
+ use those names to `c.redact_headers`.
159
+
160
+ `Rails.error` needs no wiring: Railwatch subscribes to it on install, so
161
+ every `Rails.error.report` / `Rails.error.handle` call already in the app
162
+ — which is how `sentry-rails` itself is normally hooked up — keeps
163
+ working unchanged.
164
+
165
+ ## 3. Rewrite the call sites
166
+
167
+ | Sentry call | Railwatch |
168
+ |---|---|
169
+ | `Sentry.capture_exception(e)` | `Railwatch.report(e)` |
170
+ | `Sentry.capture_message("...")` | `Rails.logger.warn("...")` |
171
+ | `Sentry.set_user(id: ...)` | `Railwatch.user { \|u\| ... }` (once, in the initializer) |
172
+ | `Sentry.set_tags(...)` / `set_context(...)` / `set_extras(...)` | `Railwatch.context(...)` |
173
+ | `Sentry.with_scope { }` / `configure_scope { }` | `Railwatch.context(...)` inside the block; `Railwatch.ignore { }` where the scope existed to suppress |
174
+
175
+ ```ruby
176
+ # before
177
+ Sentry.capture_exception(e)
178
+ Sentry.capture_exception(e, extra: { order_id: order.id })
179
+
180
+ # after
181
+ Railwatch.report(e)
182
+ Railwatch.report(e, context: { order_id: order.id })
183
+ ```
184
+
185
+ `Railwatch.report` defaults `severity` to `:warning` when `handled: true`
186
+ (the default) and `:error` otherwise, and tags the exception
187
+ `source: "railwatch.manual"`.
188
+
189
+ **Messages.** There is no `capture_message`. Log it: every `Rails.logger`
190
+ line at or above `c.log_level` (default `:info`) becomes a `log` record,
191
+ linked to the execution it happened in and searchable on the Logs page.
192
+ Rails' own per-request noise (`Started GET`, `Processing by`, `Rendered`)
193
+ is filtered out regardless of level, because the `request` record already
194
+ carries it.
195
+
196
+ ```ruby
197
+ Sentry.capture_message("cache rebuilt", level: :info)
198
+ Rails.logger.info("cache rebuilt")
199
+ ```
200
+
201
+ **User.** `Sentry.set_user` scattered through controllers becomes one
202
+ resolver block, evaluated per execution:
203
+
204
+ ```ruby
205
+ c.user { |user| { id: user.id, name: user.name, email: user.email } }
206
+ ```
207
+
208
+ With no block set, the default reads `Current.user` if defined, else
209
+ Warden's `env["warden"].user` — so a Rails 8 auth-generator or Devise app
210
+ needs nothing at all.
211
+
212
+ **Tags, context, extras.** All three collapse into one call, which
213
+ writes through to `ActiveSupport::ExecutionContext`,
214
+ `Rails.error.set_context`, and `Rails.event.set_context` at the same
215
+ time:
216
+
217
+ ```ruby
218
+ Railwatch.context(tenant: org.slug, plan: org.plan, feature: :new_checkout)
219
+ ```
220
+
221
+ Context is serialized onto the parent record and every exception in the
222
+ execution. `tenant` is special: it is picked up automatically from
223
+ `ActiveRecord::Base.current_tenant` / `TenantRecord.current_tenant` when
224
+ the app uses `activerecord-tenanted`, and it drives the Tenants page.
225
+
226
+ **Scopes.** A `with_scope` that added data becomes a `Railwatch.context`
227
+ call inside the same block — there is no scope stack to push and pop,
228
+ because context is per execution and an execution is already the unit.
229
+ A `with_scope` that existed to *suppress* reporting becomes
230
+ `Railwatch.ignore { }`, which pauses recording for the block and restores
231
+ it afterwards (nestable, via `Railwatch.pause` / `Railwatch.resume`).
232
+
233
+ ## 4. Breadcrumbs
234
+
235
+ There is no breadcrumb API, and nothing to port. Every query, cache
236
+ read, outgoing HTTP call, log line, view render, mail delivery, and
237
+ broadcast in an execution is already its own record, linked to that
238
+ execution by `execution_id` and to the wider trace by `trace_id`. The
239
+ execution detail page shows them as a waterfall in the order they
240
+ happened, with durations — which is the breadcrumb trail Sentry
241
+ approximates, except it is complete, timed, and queryable rather than a
242
+ capped ring buffer of strings.
243
+
244
+ If you were manually adding breadcrumbs to mark progress through your own
245
+ code, that is a span (next section), not a breadcrumb.
246
+
247
+ ## 5. Spans
248
+
249
+ ```ruby
250
+ # before
251
+ Sentry.with_child_span(op: "pdf.render") { renderer.call }
252
+
253
+ # after
254
+ Railwatch.span("pdf.render", template: "invoice", pages: 12) { renderer.call }
255
+ ```
256
+
257
+ The block's value is returned untouched. Keyword arguments become the
258
+ span's attributes (up to 25, each truncated to 200 characters and run
259
+ through the same parameter filter as request params). The span records
260
+ its own duration and a `status` of `"ok"` or `"failed"`, counts toward
261
+ the parent's `spans` counter, and shows up in the execution waterfall
262
+ alongside the queries it contains. When Railwatch is disabled or nothing
263
+ is recording, the block still runs — `Railwatch.span` is never a behaviour
264
+ change.
265
+
266
+ ## 6. Profiling
267
+
268
+ `profiles_sample_rate:` becomes two settings and one gem. Railwatch does
269
+ not vendor a profiler; add the backend you want:
270
+
271
+ ```ruby
272
+ gem "vernier" # Ruby >= 3.2, preferred
273
+ gem "stackprof" # anywhere else
274
+ ```
275
+
276
+ ```ruby
277
+ c.profile_sample = 0.01 # profile 1% of sampled-in executions
278
+ c.profile_slow_ms = 500 # plus every tail-kept execution over 500ms
279
+ c.tail_sample_slow_ms = 500 # ...which profile_slow_ms requires
280
+ ```
281
+
282
+ With neither gem installed, `Railwatch::Profiler.available?` is false and
283
+ both settings are inert. `profile_sample` decides at the start of an
284
+ execution and is cheap. `profile_slow_ms` cannot know an execution is
285
+ slow until it ends, so it profiles every tail-buffering execution and
286
+ discards the fast ones — the CPU cost is paid on all of them, which is
287
+ why it only works together with `tail_sample_slow_ms`. Raise
288
+ `c.profile_interval_us` (default 1000) if that shows up in latency.
289
+
290
+ Profiles ship as their own `profile` record — collapsed stacks, gzipped —
291
+ and the request or job is marked `profiled`. The platform renders them as
292
+ a flamegraph on the Profiles page and inline on the execution.
293
+
294
+ ## 7. Attachments
295
+
296
+ ```ruby
297
+ # before
298
+ Sentry.add_attachment(filename: "payload.json", bytes: request.raw_post)
299
+
300
+ # after
301
+ Railwatch.attach("payload.json", request.raw_post)
302
+ Railwatch.attach("invoice.pdf", Rails.root.join("tmp/invoice.pdf"))
303
+ Railwatch.attach("payload.json", body, exception: error)
304
+ Railwatch.report(error, attachments: { "payload.json" => body })
305
+ ```
306
+
307
+ Data can be a String, a `Pathname`, or any IO. `content_type` is guessed
308
+ from the extension and can be passed explicitly. Passing `exception:`
309
+ (or using `Railwatch.report(..., attachments:)`) files the attachment
310
+ against that error's issue, using the same fingerprint the exception
311
+ itself was grouped by. Payloads are gzipped on the wire and capped at
312
+ `c.max_attachment_bytes` (1 MiB by default); over the cap the record is
313
+ flagged `truncated: true` rather than dropped.
314
+
315
+ ## 8. before_send
316
+
317
+ `before_send` did three different jobs. Railwatch splits them, so each one
318
+ runs at the cheapest point:
319
+
320
+ ```ruby
321
+ # Scrub one record type in place, at build time.
322
+ Railwatch.redact_queries { |q| q[:sql] = q[:sql].gsub(/email = '[^']+'/, "email = '?'") }
323
+
324
+ # Drop records by predicate, at build time.
325
+ Railwatch.reject_outgoing_requests { |r| r[:host] == "127.0.0.1" }
326
+
327
+ # Inspect or drop a whole batch, right before it's POSTed.
328
+ Railwatch.before_ingest { |batch| batch.size < 10_000 }
329
+ ```
330
+
331
+ The `redact_*` hooks — exactly these eight, one per record type that has
332
+ one — receive the record hash and mutate it in place:
333
+ `redact_requests`, `redact_queries`, `redact_exceptions`,
334
+ `redact_cache_events`, `redact_commands`, `redact_mail`,
335
+ `redact_outgoing_requests`, `redact_logs`. A redactor that raises drops
336
+ that record rather than the batch.
337
+
338
+ The `reject_*` hooks — exactly these eight — return truthy to drop the
339
+ record: `reject_queries`, `reject_cache_events`, `reject_mail`,
340
+ `reject_notifications`, `reject_broadcasts`, `reject_outgoing_requests`,
341
+ `reject_enqueued_jobs`, `reject_logs`. A rejector that raises fails open
342
+ (the record is kept). `Railwatch.reject_cache_keys(patterns)` is the
343
+ shortcut for cache keys specifically.
344
+
345
+ `Railwatch.before_ingest` runs per batch; returning `false` drops the whole
346
+ batch, returning an Array replaces it. Multiple hooks chain.
347
+
348
+ To drop a whole record type before it is ever built — cheaper than any
349
+ hook — use `c.ignore = [:cache_events, :view_renders]`.
350
+
351
+ ## 9. Fingerprints
352
+
353
+ Sentry's `fingerprint` and grouping rules become one block, or a
354
+ per-call argument:
355
+
356
+ ```ruby
357
+ Railwatch.fingerprint do |error, default|
358
+ error.is_a?(Faraday::Error) ? [ "upstream", error.response_status, :default ] : nil
359
+ end
360
+
361
+ Railwatch.report(error, fingerprint: [ "billing", "stripe-timeout" ])
362
+ ```
363
+
364
+ The block is called with the error and `default` — the array of parts
365
+ Railwatch would otherwise have hashed (class, file, line, normalized
366
+ message). Return an array of strings; return nil to fall back to the
367
+ default grouping, so a resolver that doesn't recognise an error can just
368
+ say so. The literal `:default` splices the default parts in wherever you
369
+ put it, like Sentry's `{{ default }}`. A per-call `fingerprint:` wins
370
+ over the global block, and an error class of your own can define
371
+ `railwatch_fingerprint` so every raise site agrees.
372
+
373
+ ## 10. Release health
374
+
375
+ Automatic, and there is nothing to port. `session` records come from two
376
+ places — the browser client (`startRailwatch()`, one session per tab) and
377
+ a per-user server-side fallback in the request middleware, which is also
378
+ the only source that can see an unhandled exception and mark a session
379
+ `crashed`. Both key on the same id when the browser cookie is present,
380
+ so a session seen from both ends is deduped rather than double counted.
381
+
382
+ The release is `c.deploy`. A deploy is a release: no separate release
383
+ concept, no `Sentry.configure_scope { |s| s.set_release }`. Crash-free
384
+ session and crash-free user rates are shown per release on the platform's
385
+ Releases page.
386
+
387
+ ```ruby
388
+ c.track_sessions = false # RAILWATCH_TRACK_SESSIONS — turns both sources off
389
+ c.session_flush_interval = 60.0 # seconds between server-session flushes
390
+ c.session_timeout = 1800.0 # idle seconds before a server session ends
391
+ ```
392
+
393
+ ## 11. Browser errors (`@sentry/react`)
394
+
395
+ Delete `@sentry/react` too. The browser client the generator installs
396
+ (`app/frontend/lib/railwatch.ts`) reports JavaScript errors on the same
397
+ beacon it already uses for visit timing and Core Web Vitals — one
398
+ transport, one batch, one flush on `pagehide` or every 5s. There is no
399
+ second SDK to load and no second quota.
400
+
401
+ ```ts
402
+ // app/frontend/entrypoints/application.ts
403
+ import { startRailwatch } from "@/lib/railwatch"
404
+
405
+ startRailwatch({
406
+ // Added to the defaults, not instead of them.
407
+ ignoreErrors: [/Failed to fetch dynamically imported module/],
408
+ denyUrls: [/analytics\./],
409
+ // Only if the app scopes tenants by path or subdomain: the beacon posts
410
+ // to /railwatch/beacon, which is outside that scoping, so the server
411
+ // cannot work the tenant out for itself.
412
+ tenant: () => /^\/orgs\/([^/]+)/.exec(location.pathname)?.[1],
413
+ })
414
+ ```
415
+
416
+ | Sentry | Railwatch |
417
+ |---|---|
418
+ | `Sentry.init({ dsn })` | `startRailwatch()`. There is no DSN: the beacon posts to the app's own origin and the *server* decides whether to record it (`c.beacon_enabled`, `RAILWATCH_TOKEN`). The gate you already have on whether `startRailwatch()` runs at all is the only gate. |
419
+ | `release` | Automatic. The record is stamped with `c.deploy`, the same release the server records carry, so a browser issue and a server issue from one deploy line up without a matching pair of settings to get wrong. |
420
+ | `environment` | Automatic — the ingest token identifies the environment. |
421
+ | `ignoreErrors` | `startRailwatch({ ignoreErrors })`. Strings match anywhere in the message; regexes are tested against it. Both `ResizeObserver` messages are ignored by default. |
422
+ | `denyUrls` | `startRailwatch({ denyUrls })`, matched against the top stack frame's URL. `/extensions\//i`, `/^chrome:\/\//i`, and `/^moz-extension:\/\//i` are denied by default, **and** any frame from an origin that isn't the app's own is dropped — extensions, injected widgets, tag managers. |
423
+ | `Sentry.setUser` | Server-side. The beacon is a same-origin POST carrying the session cookie, so the server resolves the user the same way it does for a request (`Railwatch.user`) when `Current.user` or Warden is set by middleware; an app that authenticates in a `before_action` gives Railwatch the same lookup with `c.beacon_user { \|request\| ... }`. Nothing the browser sends names the user, so it cannot be forged. |
424
+ | `Sentry.setTag("org", …)` | `startRailwatch({ tenant })`, and `Railwatch.context(...)` for everything else. |
425
+ | `Sentry.captureException(e)` | `reportError(e)`. |
426
+ | `Sentry.captureMessage(text)` | `reportError(new Error(text))` — Railwatch has one shape for a browser problem, not two. |
427
+ | Breadcrumbs (automatic) | Automatic: the last 20 of console errors/warnings, clicks, and Inertia navigations ride along on every error and are shown on the issue page. Click crumbs record the element, never an input's value. |
428
+ | `Sentry.ErrorBoundary` | Your own boundary plus `railwatchRootOptions()` (React 19) or `reportError` (React 18) — see below. |
429
+ | `tracesSampleRate`, `replaysSessionSampleRate` | No equivalent. Railwatch reports visit timing and Core Web Vitals instead of browser traces, and does not record sessions. |
430
+
431
+ ### What is and is not captured
432
+
433
+ Captured: uncaught errors (`window.onerror` / the `error` event),
434
+ unhandled promise rejections, Inertia's request-failed event (`exception`
435
+ on Inertia 2, `networkError` on Inertia 3 — where a dropped connection
436
+ lands as an axios `Network Error`; reported only while the user is waiting
437
+ on a visit, meaning one that shows Inertia's progress bar or loads a page's
438
+ deferred props — a background poll, `router.reload`, or prefetch that drops
439
+ its connection has failed nothing the user did, since the page keeps what
440
+ it has and the next tick refreshes it; pass `showProgress: true` to have a
441
+ particular refresh reported), Inertia's non-Inertia-response event
442
+ (`invalid` on Inertia 2, `httpException` on Inertia 3 — the server answered
443
+ a visit with a 403 page, a login redirect, a proxy error page; an Inertia
444
+ response that merely carries a 4xx status, such as a form re-rendered with
445
+ validation errors at 422, is the app working and is not reported), and
446
+ anything the app hands to `reportError`.
447
+
448
+ Not captured: browser traces and session replay; failed resource loads
449
+ (a 404 on an `<img>` or `<script>`); errors from a cross-origin script,
450
+ which the browser reports as a bare `"Script error."` with no stack and
451
+ Railwatch drops as not the app's to fix; and anything thrown before
452
+ `startRailwatch()` runs.
453
+
454
+ Minified frames are shown as the browser named them
455
+ (`assets/index-Bq1x9K.js:41`) — Railwatch does not yet upload source maps,
456
+ so a production frame does not link to a line in your repository.
457
+
458
+ ### Error boundaries: keep them, and wire the root
459
+
460
+ An error boundary is not what gets an error reported, and outside a
461
+ development build React does **not** hand a caught error back to
462
+ `window.onerror`. React 18 stops at `componentDidCatch`; React 19 routes
463
+ it to the root's `onCaughtError`, whose default is `console.error`. So a
464
+ boundary on a plain `startRailwatch()` app silently swallows every render
465
+ error it catches. Two lines fix that.
466
+
467
+ **React 19** — pass Railwatch's root options where you create the root:
468
+
469
+ ```tsx
470
+ import { createRoot } from "react-dom/client"
471
+ import { railwatchRootOptions, startRailwatch } from "@/lib/railwatch"
472
+
473
+ createRoot(el, railwatchRootOptions()).render(<App {...props} />)
474
+ startRailwatch()
475
+ ```
476
+
477
+ That covers `onCaughtError` (the one React would otherwise only log) and
478
+ `onUncaughtError` (which would reach the window listener anyway, but this
479
+ way it arrives with the component stack attached). `onRecoverableError`
480
+ is deliberately left alone: React's default already routes a hydration
481
+ mismatch through `window.reportError`, so it reaches Railwatch without help,
482
+ and overriding it would take React's own console warning away from
483
+ whoever is debugging one.
484
+
485
+ **React 18**, whose roots take no error options — report from the
486
+ boundary, which is also where the component stack lives:
487
+
488
+ ```tsx
489
+ import { reportError } from "@/lib/railwatch"
490
+
491
+ class MapErrorBoundary extends Component<Props, State> {
492
+ static getDerivedStateFromError() {
493
+ return { hasError: true }
494
+ }
495
+ componentDidCatch(error: Error, info: ErrorInfo) {
496
+ reportError(error, { componentStack: info.componentStack })
497
+ }
498
+ render() {
499
+ return this.state.hasError ? <Fallback /> : this.props.children
500
+ }
501
+ }
502
+ ```
503
+
504
+ Either way, keep the boundaries for what they are for — rendering a
505
+ fallback instead of a blank screen. Everything passed as `reportError`'s
506
+ second argument lands in the exception's `context` on the issue page,
507
+ flattened to strings.
508
+
509
+ ## 12. Console and runner sessions
510
+
511
+ Sentry never hooked `bin/rails console`: sentry-rails has no console railtie
512
+ block, so an engineer's typo at a production prompt was never an issue.
513
+ Railwatch subscribes to far more than Sentry did — every query, every log line,
514
+ `Rails.error` — and starts reporter/health/session threads at boot, so it has
515
+ to say this out loud rather than inherit it by accident. It does: a console
516
+ process **captures nothing, starts no thread, and sends no `process` or
517
+ `health` record**. Turn that off with `c.capture_console = true`
518
+ (`RAILWATCH_CAPTURE_CONSOLE=1`) when you actually want to trace a console
519
+ session.
520
+
521
+ `bin/rails runner` is the case that needs a rule rather than a switch.
522
+ sentry-rails installs an `at_exit` hook for every runner process and reports
523
+ whatever killed it, tagged `source: "runner"`. That is right for a *deployed*
524
+ script and wrong for a *typed* one — and on this app the typed ones dominated:
525
+ four of fifteen unresolved issues were a human poking at production (a
526
+ misspelled attribute, a tenant slug that did not exist, an `unless … next`
527
+ that did not parse). So the filter is **not** "source == runner", which would
528
+ silence exactly the runner errors worth waking up for. The line is where the
529
+ code came from, and the argument says it:
530
+
531
+ | Invocation | railties runs | Treated as | Reported? |
532
+ |---|---|---|---|
533
+ | `rails runner -` | `eval($stdin.read, …, "stdin")` | interactive | no |
534
+ | `rails runner 'Some.code'` | `eval(code_or_file, …)` | interactive | no |
535
+ | `rails runner /tmp/probe.rb` | `Kernel.load` | interactive (scratch path) | no |
536
+ | `rails runner script/nightly.rb` | `Kernel.load` | deployed | **yes** |
537
+
538
+ Anything that is not a `.rb` file was typed. A `.rb` file is deployed unless
539
+ it sits under `config.interactive_runner_paths` (`/tmp/`, `/var/tmp/`) —
540
+ deliberately two literal temp roots rather than "outside `Rails.root`",
541
+ because a scheduled script going silent is the failure this must never cause.
542
+ Rake tasks, Solid Queue jobs, and recurring tasks are never interactive.
543
+
544
+ An interactive run is still *recorded*: its `command` record ships with
545
+ `interactive: true`, the `exit_code`, the duration, and the
546
+ `exception_preview`, so the run is visible on the platform without opening an
547
+ issue. Only the exception is withheld.
548
+
549
+ If your app carried an app-side version of this (a `Railwatch.before_ingest`
550
+ hook matching `rails runner …` previews, or `sentry_runner_noise.rb` under
551
+ `before_send`), delete it — this is the gem's job now.
552
+
553
+ ## What Railwatch does that Sentry doesn't
554
+
555
+ | | |
556
+ |---|---|
557
+ | Execution-linked everything | Every query, cache read, log line, mail, broadcast, storage op, view render, and outgoing request is a record linked to the request/job/task it happened in, and shown as one waterfall. Not a sample of spans — all of it, for the executions that ship. |
558
+ | Query and N+1 detection | The same normalized query repeating past `n_plus_one_threshold` in one execution becomes an `n_plus_one` record with the app line that issued it; the platform turns that into a concrete `includes` or counter-cache suggestion, and can attach the adapter's own query plan. |
559
+ | Scheduled-task drift | Solid Queue recurring tasks report `task_key`, `schedule`, and `drift` (scheduled vs actual start) with nothing to instrument — no cron check-in calls to add or forget. |
560
+ | Rails surfaces Sentry has no record for | `cache_event`, `mail`, `broadcast`, `notification`, `storage_op`, `view_render`, `transaction`, `deprecation`, `enqueued_job`. |
561
+ | Inertia visit timing | Real browser page-visit duration, prop byte size, partial reloads, SSR time, and Core Web Vitals, from a client the generator installs. |
562
+ | Spec matchers as a CI gate | `have_railwatch_queries`, `have_railwatch_n_plus_one`, `have_railwatch_outgoing_requests` fail the pull request that regresses a hot path. |
563
+ | Zero app-DB writes | The gem holds records in memory and ships them from a background thread; a bench gate asserts no `INSERT`/`UPDATE`/`DELETE` ever originates in `lib/railwatch`. This is why it is safe on single-writer SQLite. |
564
+ | One SQLite database per environment | The platform stores each monitored environment's telemetry in its own database file, which makes retention pruning, backup, and restore per-environment operations. |
565
+ | An MCP server | AI assistants can ask what broke after the last deploy, list slow routes, read an execution's timeline, and search logs ([`ai-and-mcp.md`](ai-and-mcp.md)). |
566
+
567
+ ## See also
568
+
569
+ - [`configuration.md`](configuration.md) — every option in full.
570
+ - [`records.md`](records.md) — the `exception` record's field list,
571
+ including `fingerprint` and `fingerprint_source`.
572
+ - [`troubleshooting.md`](troubleshooting.md) — if nothing arrives after
573
+ the cutover.
data/docs/security.md ADDED
@@ -0,0 +1,94 @@
1
+ # Client-side security review
2
+
3
+ This review covers the open-source gem that runs in a customer's Rails
4
+ application. Railwatch Cloud is a separate service and is outside this review.
5
+
6
+ ## Transport
7
+
8
+ `Railwatch::Transport::Http` sends gzip NDJSON with `Net::HTTP`. HTTPS explicitly
9
+ sets OpenSSL's `VERIFY_PEER`; a spec pins that setting. The transport does not
10
+ implement redirect handling, so a redirect response is treated as a permanent
11
+ delivery failure and its target is never followed.
12
+
13
+ Plain HTTP is refused unless the ingest host is `localhost`, `127.0.0.1`, or
14
+ `::1`, or `RAILWATCH_ALLOW_HTTP=true` is explicitly set. Refusal does not raise
15
+ into application code: delivery returns a failed result, `railwatch:doctor`
16
+ reports the policy, and Rails logs a warning during boot. Deploy markers and
17
+ source-map uploads enforce the same URL policy.
18
+
19
+ ## Data capture and redaction
20
+
21
+ The defaults are defined in `Railwatch::Configuration` and covered by
22
+ configuration and record specs:
23
+
24
+ - Header names in `redact_headers` are filtered case-insensitively. A broader
25
+ `SENSITIVE_HEADER_NAME` expression also filters credential-shaped names such
26
+ as API keys, tokens, secrets, signatures, and authorization headers.
27
+ - Parameter filtering combines `redact_params` with the host application's
28
+ `Rails.application.config.filter_parameters`.
29
+ - `capture_sql_values` is false. Query records contain normalized SQL shapes;
30
+ Active Record binds are not sent.
31
+ - `capture_request_payload` is false. When enabled, filtered request params are
32
+ attached only to a request that raised, never to a successful request.
33
+ - `capture_exception_locals` is false. When enabled, at most 25 locals are
34
+ converted with `inspect`, truncated to 200 characters, and parameter
35
+ filtered. `Locals.inspect_value` rescues an `inspect` implementation that
36
+ raises; this behavior is covered by an exception-record spec.
37
+ - `capture_exception_source` is true. Source lines surrounding in-application
38
+ backtrace frames are sent to Railwatch Cloud. Disable it if source context is
39
+ outside the application's telemetry policy.
40
+
41
+ Log record messages are sent exactly as supplied to `Rails.logger`. Railwatch
42
+ does not attempt to parse and partially filter `key=value` text because doing
43
+ so would be incomplete and could give a false assurance. Applications must not
44
+ log secrets; `Railwatch.redact_logs` can implement an application-specific scrub,
45
+ and `Railwatch.reject_logs` or `RAILWATCH_IGNORE_LOGS=true` can omit log records.
46
+
47
+ ## Browser beacon
48
+
49
+ `POST /railwatch/beacon` is the gem's only inbound unauthenticated endpoint. It
50
+ is rate-limited per client IP through the Rails cache (120 requests per minute
51
+ by default), limited to a 256 KiB body, and capped at 50 visits and 50 errors
52
+ per request. Nested error stacks, messages, breadcrumbs, context, visit partial
53
+ keys, session ids, URLs, and other strings have count or length ceilings.
54
+
55
+ Specs verify that an oversized body is rejected, unexpected top-level and
56
+ nested scalar/array/object shapes are ignored, malformed UTF-8 does not produce
57
+ a 500, and accepted strings are converted to valid UTF-8. Payload-processing
58
+ errors are discarded with a debug diagnostic rather than raised into the host
59
+ application. Rack and Rails still parse the request before controller code, so
60
+ operators should also enforce an HTTP request-body limit at the reverse proxy
61
+ for protection before application allocation.
62
+
63
+ The endpoint intentionally has no authenticity token: it receives browser
64
+ telemetry without exposing the application's ingest token. Rate limiting is
65
+ quota-abuse mitigation, not authentication. Applications can disable it with
66
+ `beacon_enabled = false` when browser telemetry is not used.
67
+
68
+ ## Wire-data use
69
+
70
+ A source audit found no wire value passed to `eval`, `instance_eval`,
71
+ `constantize`, dynamic `send`, a shell command, backticks, or a file-path read.
72
+ The gem's backtick use runs a fixed `git log` command for deploy metadata. File
73
+ reads use fixed application/configuration paths or exception backtrace paths;
74
+ browser-provided frame paths are never read from disk. Ingest responses are
75
+ JSON-parsed and validated as acknowledgement counts only.
76
+
77
+ ## Token handling
78
+
79
+ The install generator accepts hidden prompt or stdin input and never prints a
80
+ token value. Diagnostics show only a short prefix and total length. The
81
+ generator writes a token to `.env` only when Git confirms the file is ignored,
82
+ and specs assert that command output omits the complete token. Tokens remain an
83
+ application/deployment secret and must not be committed.
84
+
85
+ ## Application responsibilities
86
+
87
+ - Review `redact_headers`, `redact_params`, and Rails `filter_parameters` for
88
+ application-specific credentials and personal data.
89
+ - Keep secrets out of exception messages, log text, source files, tenant ids,
90
+ user resolvers, and custom context.
91
+ - Use HTTPS in production and keep TLS verification enabled.
92
+ - Put a request-body limit at the reverse proxy when the public beacon is
93
+ enabled, and choose a shared cache if rate limits must span processes.
94
+ - Review every opt-in capture setting before enabling it in production.