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.
- checksums.yaml +7 -0
- data/AGENTS.md +122 -0
- data/CHANGELOG.md +462 -0
- data/MIT-LICENSE +20 -0
- data/README.md +226 -0
- data/app/controllers/railwatch/beacon_controller.rb +254 -0
- data/config/routes.rb +5 -0
- data/docs/ai-and-mcp.md +227 -0
- data/docs/configuration.md +931 -0
- data/docs/faq.md +230 -0
- data/docs/getting-started.md +279 -0
- data/docs/records.md +834 -0
- data/docs/replacing-nightwatch.md +216 -0
- data/docs/replacing-sentry.md +573 -0
- data/docs/security.md +94 -0
- data/docs/self-hosting.md +60 -0
- data/docs/source-maps.md +60 -0
- data/docs/testing.md +175 -0
- data/docs/troubleshooting.md +319 -0
- data/lib/generators/railwatch/install/install_generator.rb +280 -0
- data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
- data/lib/generators/railwatch/install/templates/post-deploy +98 -0
- data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
- data/lib/railwatch/attachments.rb +83 -0
- data/lib/railwatch/backtrace.rb +158 -0
- data/lib/railwatch/buffer.rb +122 -0
- data/lib/railwatch/clock.rb +25 -0
- data/lib/railwatch/configuration.rb +334 -0
- data/lib/railwatch/console.rb +48 -0
- data/lib/railwatch/context.rb +125 -0
- data/lib/railwatch/controller_helpers.rb +21 -0
- data/lib/railwatch/current.rb +32 -0
- data/lib/railwatch/engine.rb +144 -0
- data/lib/railwatch/execution.rb +367 -0
- data/lib/railwatch/faraday.rb +73 -0
- data/lib/railwatch/health.rb +188 -0
- data/lib/railwatch/job_tracing.rb +49 -0
- data/lib/railwatch/middleware/request.rb +289 -0
- data/lib/railwatch/minitest.rb +43 -0
- data/lib/railwatch/patches/inertia.rb +34 -0
- data/lib/railwatch/patches/net_http.rb +102 -0
- data/lib/railwatch/patches/rake_task.rb +88 -0
- data/lib/railwatch/patches/runner_command.rb +120 -0
- data/lib/railwatch/patches.rb +43 -0
- data/lib/railwatch/profiler.rb +270 -0
- data/lib/railwatch/record.rb +119 -0
- data/lib/railwatch/redactor.rb +67 -0
- data/lib/railwatch/release_detector.rb +97 -0
- data/lib/railwatch/reporter.rb +539 -0
- data/lib/railwatch/rspec.rb +139 -0
- data/lib/railwatch/sampler.rb +17 -0
- data/lib/railwatch/secret_safety.rb +62 -0
- data/lib/railwatch/sessions.rb +162 -0
- data/lib/railwatch/source_maps.rb +59 -0
- data/lib/railwatch/spec_helper.rb +147 -0
- data/lib/railwatch/sql_normalizer.rb +398 -0
- data/lib/railwatch/subscribers/base.rb +54 -0
- data/lib/railwatch/subscribers/broadcasts.rb +107 -0
- data/lib/railwatch/subscribers/cache.rb +107 -0
- data/lib/railwatch/subscribers/deprecations.rb +26 -0
- data/lib/railwatch/subscribers/exceptions.rb +304 -0
- data/lib/railwatch/subscribers/jobs.rb +282 -0
- data/lib/railwatch/subscribers/logs.rb +137 -0
- data/lib/railwatch/subscribers/mail.rb +42 -0
- data/lib/railwatch/subscribers/notifications.rb +36 -0
- data/lib/railwatch/subscribers/process_info.rb +98 -0
- data/lib/railwatch/subscribers/queries.rb +183 -0
- data/lib/railwatch/subscribers/requests.rb +94 -0
- data/lib/railwatch/subscribers/storage.rb +35 -0
- data/lib/railwatch/subscribers/users.rb +159 -0
- data/lib/railwatch/subscribers/views.rb +54 -0
- data/lib/railwatch/subscribers.rb +34 -0
- data/lib/railwatch/transport/http.rb +208 -0
- data/lib/railwatch/version.rb +5 -0
- data/lib/railwatch.rb +550 -0
- data/lib/tasks/railwatch_tasks.rake +289 -0
- data/llms.txt +38 -0
- 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.
|