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,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.
|