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
data/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Railwatch
|
|
2
|
+
|
|
3
|
+
First-class monitoring for Rails. One gem instruments requests, jobs,
|
|
4
|
+
scheduled tasks, commands, queries, exceptions, cache, mail, broadcasts,
|
|
5
|
+
outgoing HTTP, storage, views, and logs, links them into one trace per
|
|
6
|
+
execution, and ships them to Railwatch Cloud for about half a millisecond
|
|
7
|
+
per request plus tens of microseconds per query, with zero writes to your
|
|
8
|
+
database.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
bundle add railwatch # 1. add the public gem
|
|
14
|
+
bin/rails generate railwatch:install --prompt-token # 2. hidden token input plus app wiring
|
|
15
|
+
bin/rails railwatch:doctor # 3. check every piece is wired up after restart
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The gem, its Ruby namespace, and its require path share one name:
|
|
19
|
+
`railwatch`, `Railwatch::*`, `require "railwatch"`. For a self-hosted
|
|
20
|
+
deployment or an unreleased revision, use the Git source instead:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem "railwatch", github: "Rebulk/railwatch"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The generator writes `config/initializers/railwatch.rb`, mounts the beacon
|
|
27
|
+
engine, and — where the app already has them — adds a Kamal `post-deploy`
|
|
28
|
+
hook, the Inertia browser client with its `startRailwatch()` call, and
|
|
29
|
+
`require "railwatch/rspec"` in `spec/rails_helper.rb`. `railwatch:doctor` prints
|
|
30
|
+
a ✓/✗ checklist of all of it and exits non-zero if the token is missing or
|
|
31
|
+
the ingest host is unreachable.
|
|
32
|
+
|
|
33
|
+
Pass `--prompt-token` (and `--url=` when self-hosting) to read the token
|
|
34
|
+
without echo or process-argument exposure. The generator writes it to `.env`
|
|
35
|
+
only when Git confirms that file is ignored; otherwise it points you to Rails
|
|
36
|
+
credentials or your deployment secret manager without printing the value. Add
|
|
37
|
+
`--kamal-secrets` to wire `RAILWATCH_TOKEN` through `.kamal/secrets` and
|
|
38
|
+
`config/deploy.yml`. It finishes by running `railwatch:doctor` for you.
|
|
39
|
+
`bin/rails railwatch:token` says where to get a token; `bin/rails railwatch:mcp`
|
|
40
|
+
prints ready-to-paste MCP client configuration.
|
|
41
|
+
|
|
42
|
+
## Documentation
|
|
43
|
+
|
|
44
|
+
- [Getting started](docs/getting-started.md) — five-minute install for a
|
|
45
|
+
Rails 8 app, the three optional lines, and deploying with Kamal, Docker,
|
|
46
|
+
Heroku, Render, or none of them.
|
|
47
|
+
- [Configuration](docs/configuration.md) — every option and `RAILWATCH_*`
|
|
48
|
+
variable, field by field.
|
|
49
|
+
- [Record types](docs/records.md) — every record Railwatch ships and every
|
|
50
|
+
attribute on it, sourced from the code that builds it.
|
|
51
|
+
- [Testing](docs/testing.md) — the RSpec and Minitest matchers, and a CI
|
|
52
|
+
performance gate.
|
|
53
|
+
- [AI assistants and MCP](docs/ai-and-mcp.md) — connecting Claude Code,
|
|
54
|
+
Cursor, VS Code, or Zed to your production data.
|
|
55
|
+
- [Replacing Sentry](docs/replacing-sentry.md) — a step-by-step migration,
|
|
56
|
+
option by option and call site by call site.
|
|
57
|
+
- [Coming from Laravel Nightwatch](docs/replacing-nightwatch.md) — the
|
|
58
|
+
record-type mapping and the sampling model, for Laravel people.
|
|
59
|
+
- [Self-hosting](docs/self-hosting.md) — pointing the gem at your own
|
|
60
|
+
Railwatch Cloud.
|
|
61
|
+
- [Troubleshooting](docs/troubleshooting.md) — every failure mode, paired
|
|
62
|
+
with the `railwatch:doctor` line it shows up as.
|
|
63
|
+
- [FAQ](docs/faq.md) — overhead numbers, retention, PII posture, SQLite.
|
|
64
|
+
- [Security](docs/security.md) — transport, capture defaults, the browser
|
|
65
|
+
beacon, and application responsibilities.
|
|
66
|
+
|
|
67
|
+
AI coding agents working on an app that uses Railwatch: [`llms.txt`](llms.txt)
|
|
68
|
+
and [`AGENTS.md`](AGENTS.md).
|
|
69
|
+
|
|
70
|
+
Configuration lives in `config/initializers/railwatch.rb`; every option has
|
|
71
|
+
a `RAILWATCH_*` environment variable. Sampling is decided once per execution:
|
|
72
|
+
a sampled-in request ships its whole tree, a sampled-out one ships nothing
|
|
73
|
+
except unhandled exceptions.
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
Railwatch.configure do |c|
|
|
77
|
+
c.sample = { requests: 0.1, jobs: 1.0 }
|
|
78
|
+
c.user { |u| { id: u.id, name: u.name, email: u.email } }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class ReportsController < ApplicationController
|
|
82
|
+
railwatch_sample 0.01, only: :index
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Railwatch.ignore { ExpensiveSync.run }
|
|
86
|
+
Railwatch.context(tenant: org.slug, plan: org.plan)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Time any block of your own code as a `span` on the surrounding request,
|
|
90
|
+
job, or command — the block's value is returned untouched:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
Railwatch.span("pdf.render", template: "invoice", pages: 12) { renderer.call }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
When a span isn't enough to say where the time went, Railwatch can attach a
|
|
97
|
+
real stack profile to an execution. Add `gem "vernier"` (Ruby ≥ 3.2) or
|
|
98
|
+
`gem "stackprof"` to the Gemfile and set `c.profile_sample = 0.01` to
|
|
99
|
+
profile 1% of executions, or `c.profile_slow_ms = 500` alongside
|
|
100
|
+
`c.tail_sample_slow_ms` to profile the slow ones. The collapsed stacks
|
|
101
|
+
ship as their own `profile` record, gzipped, and the request or job it
|
|
102
|
+
belongs to is marked `profiled`. Off by default, and one Float comparison
|
|
103
|
+
per execution while it stays off.
|
|
104
|
+
|
|
105
|
+
Sampling can also be decided at the *end* of an execution instead of the
|
|
106
|
+
start: set `c.tail_sample_slow_ms = 500` (or call `Railwatch.keep!`) and a
|
|
107
|
+
head-sampled-out request that turns out to be slow, or to have raised,
|
|
108
|
+
ships its whole tree anyway. Outgoing HTTP carries a W3C `traceparent`,
|
|
109
|
+
and an inbound one is adopted, so a trace spans services.
|
|
110
|
+
|
|
111
|
+
Inertia apps get real page-visit timing by calling `startRailwatch()` from the
|
|
112
|
+
generated `app/frontend/lib/railwatch.ts`. Server-side rendering is timed
|
|
113
|
+
automatically wherever `inertia_rails` SSR is already enabled — no extra
|
|
114
|
+
configuration needed.
|
|
115
|
+
|
|
116
|
+
Outgoing HTTP made through Faraday is instrumented by adding
|
|
117
|
+
`Railwatch::Faraday` to the connection's middleware stack (`Net::HTTP` is
|
|
118
|
+
already covered globally, with no setup); any other client can be wrapped
|
|
119
|
+
with `Railwatch.instrument_outgoing`:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
Faraday.new(url) { |f| f.use Railwatch::Faraday }
|
|
123
|
+
Railwatch.instrument_outgoing(:get, url) { http_client.get(url) }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Testing
|
|
127
|
+
|
|
128
|
+
Production browser errors can be resolved to original source files using
|
|
129
|
+
[private source-map uploads](docs/source-maps.md).
|
|
130
|
+
|
|
131
|
+
The same instrumentation runs in your test suite, so a spec can hold a hot
|
|
132
|
+
path to a query budget and CI can fail the pull request that regresses it:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
expect { get "/widgets" }.to have_railwatch_queries(at_most: 6)
|
|
136
|
+
expect { get "/widgets" }.not_to have_railwatch_n_plus_one
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Failures list the offending SQL. Set-up, every matcher (RSpec and Minitest),
|
|
140
|
+
and a CI performance-gate recipe are in [`docs/testing.md`](docs/testing.md).
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
Every attribute, the full public facade, sampling, redaction/rejection,
|
|
145
|
+
transport/buffering behavior, the overhead gate, and the Kamal deploy hook
|
|
146
|
+
are documented field-by-field in [`docs/configuration.md`](docs/configuration.md).
|
|
147
|
+
Every record type Railwatch ships — `request`, `job_attempt`, `query`,
|
|
148
|
+
`exception`, and the rest — is documented field-by-field, sourced directly
|
|
149
|
+
from the code that builds it, in [`docs/records.md`](docs/records.md).
|
|
150
|
+
|
|
151
|
+
## Replacing Sentry
|
|
152
|
+
|
|
153
|
+
Railwatch subscribes to `Rails.error` on install
|
|
154
|
+
(`Rails.error.subscribe`), so any existing `Rails.error.report` or
|
|
155
|
+
`Rails.error.handle` call — which is how Sentry's own Rails integration
|
|
156
|
+
is normally wired in — is captured with no code changes. An unhandled
|
|
157
|
+
exception bypasses the execution buffer: it is enqueued immediately and
|
|
158
|
+
wakes the in-memory reporter without doing network I/O on the application
|
|
159
|
+
thread. Delivery is still asynchronous and memory-only, so a hard kill,
|
|
160
|
+
OOM, or process exit after the shutdown deadline can lose it.
|
|
161
|
+
|
|
162
|
+
What differs from a dedicated error tracker: exceptions aren't reported in
|
|
163
|
+
isolation — each one is linked (`execution_id`/`trace_id`) to the request,
|
|
164
|
+
job, or command it happened inside, alongside every query, cache read,
|
|
165
|
+
outgoing request, and log line from that same execution. There's no
|
|
166
|
+
separate error-tracking SDK/config to maintain — `severity`, `handled`,
|
|
167
|
+
and `context` all come from the same `Railwatch.configure` block and
|
|
168
|
+
`Railwatch.context` calls used for everything else the gem instruments.
|
|
169
|
+
|
|
170
|
+
### Coming from Sentry
|
|
171
|
+
|
|
172
|
+
| Sentry | Railwatch |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `dsn:` | `RAILWATCH_TOKEN` (+ `RAILWATCH_INGEST_URL` for a self-hosted platform). |
|
|
175
|
+
| `environment:` | `config.environment` — defaults to `Rails.env`, set it only to report under a different name. |
|
|
176
|
+
| `release:` | `config.deploy` — auto-detected from the deploy platform or Git checkout. Stamped on every record. |
|
|
177
|
+
| `traces_sample_rate:` / `profiles_sample_rate:` | `config.sample`, a rate per execution kind (`requests`, `jobs`, `commands`, `scheduled_tasks`, `exceptions`), decided once per execution rather than per event. Per-route: `railwatch_sample 0.01, only: :index`. |
|
|
178
|
+
| `excluded_exceptions:` | `config.ignored_exceptions` — same default list, plus every named ancestor is matched, not just the exact class. |
|
|
179
|
+
| `before_send:` / `before_send_transaction:` | `Railwatch.before_ingest { \|batch\| ... }` for the whole outgoing batch; `Railwatch.redact_queries`/`redact_logs`/... to scrub one record type in place; `Railwatch.reject_queries`/`reject_logs`/... to drop records by predicate. |
|
|
180
|
+
| `fingerprint` / grouping rules | `Railwatch.fingerprint { \|error, default\| ... }` globally, `def railwatch_fingerprint` on your own error class, or `Railwatch.report(error, fingerprint: [...])` per call. The literal `:default` splices in the parts Railwatch would have hashed, like Sentry's `{{ default }}`. |
|
|
181
|
+
| `include_local_variables:` | `config.capture_exception_locals`. |
|
|
182
|
+
| `config.rails.active_job_report_on_retry_error` | `config.capture_job_retry_errors` (off by default). |
|
|
183
|
+
| `send_default_pii:` | Deliberately split: `config.capture_request_payload` for params, `config.capture_job_arguments` for job arguments, `config.capture_response_body_on_error` for what a failing upstream sent back, `config.redact_headers`/`redact_params` for what's scrubbed, and the `Railwatch.user { ... }` block for who. There is no single "send everything" switch. |
|
|
184
|
+
| Breadcrumbs | Not a separate concept — every query, cache read, outgoing request, log line, and view render is already a first-class record linked to its execution by `execution_id`/`trace_id`. The execution *is* the breadcrumb trail, and it's queryable. |
|
|
185
|
+
| `Sentry.capture_message` | `Railwatch.report(error, ...)` for an exception; plain `Rails.logger` for a message — log lines at or above `config.log_level` become `log` records automatically. |
|
|
186
|
+
| `Sentry.set_user` | `Railwatch.user { ... }` (a resolver block, evaluated per execution). |
|
|
187
|
+
| `Sentry.set_tags` / `set_context` / `set_extras` | `Railwatch.context(key: value)` — serialized onto the parent record and every exception. |
|
|
188
|
+
| `Sentry.with_child_span` | `Railwatch.span("name") { ... }`. |
|
|
189
|
+
| `Sentry.add_attachment` | `Railwatch.attach("payload.json", data)` — a String, `Pathname`, or IO, gzipped on the wire and capped at `config.max_attachment_bytes`. `exception:` files it against that error's issue, and `Railwatch.report(error, attachments: { "payload.json" => data })` captures and attaches in one call. |
|
|
190
|
+
| `Sentry.capture_check_in` (cron monitoring) | Automatic: Solid Queue recurring tasks become `scheduled_task` records with `task_key`, `schedule`, and `drift`. Nothing to instrument. |
|
|
191
|
+
| `config.rails.report_rescued_exceptions` | `config.capture_rescued_exceptions` (on by default). |
|
|
192
|
+
| Rack `X-Request-Start` queue time | Automatic: `queue_time` on every `request` record. |
|
|
193
|
+
| `auto_session_tracking:` (release health) | Automatic: `session` records from the browser client and the request middleware, keyed on `config.deploy` as the release. `config.track_sessions` turns both off. |
|
|
194
|
+
|
|
195
|
+
These mappings cover the Rails-server migration path. Browser Replay,
|
|
196
|
+
native/mobile SDKs, some direct worker and scheduler entry points, and
|
|
197
|
+
Sentry's broader managed integration catalog are not equivalent today.
|
|
198
|
+
Use the supported-workload matrix in
|
|
199
|
+
[`docs/replacing-sentry.md`](docs/replacing-sentry.md) before removing
|
|
200
|
+
Sentry from an application that depends on those capabilities.
|
|
201
|
+
|
|
202
|
+
`config.deploy` checks `RAILWATCH_DEPLOY`, `KAMAL_VERSION`, common Git and
|
|
203
|
+
platform environment variables, `REVISION`, then `.git/HEAD`. Full 40-character
|
|
204
|
+
SHAs are consistently shortened to 12 characters. Set
|
|
205
|
+
`RAILWATCH_DETECT_DEPLOY=false` (or `c.detect_deploy = false`) to keep only the
|
|
206
|
+
explicit `RAILWATCH_DEPLOY` and Kamal defaults.
|
|
207
|
+
|
|
208
|
+
To report an exception manually (the `Rails.error.report`-equivalent):
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
Railwatch.report(error, handled: true, context: { order_id: order.id })
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`severity` defaults to `:warning` when `handled: true`, `:error`
|
|
215
|
+
otherwise. See the `exception` section of
|
|
216
|
+
[`docs/records.md`](docs/records.md) for the full field list, and
|
|
217
|
+
[`docs/configuration.md`](docs/configuration.md) for redaction
|
|
218
|
+
(`Railwatch.redact_exceptions`), `capture_exception_source`, and
|
|
219
|
+
`Railwatch.on_unrecoverable` (Railwatch watching its own internal failures).
|
|
220
|
+
|
|
221
|
+
## Development
|
|
222
|
+
|
|
223
|
+
```sh
|
|
224
|
+
bundle install
|
|
225
|
+
bundle exec rspec
|
|
226
|
+
```
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Receives Inertia visit timings and JavaScript errors from the browser
|
|
5
|
+
# client (app/frontend/lib/railwatch.ts). Mounted at /railwatch/beacon.
|
|
6
|
+
class BeaconController < ActionController::API
|
|
7
|
+
# Core Web Vitals ceilings. Anything beyond these is a broken clock or a
|
|
8
|
+
# forged beacon, not a page load, so it is clamped rather than stored.
|
|
9
|
+
MAX_METRIC_MS = 120_000
|
|
10
|
+
MAX_CLS = 100.0
|
|
11
|
+
|
|
12
|
+
MAX_REQUEST_BYTES = 256 * 1024
|
|
13
|
+
MAX_VISITS = 50
|
|
14
|
+
MAX_ERRORS = 50
|
|
15
|
+
MAX_ERROR_MESSAGE = 1024
|
|
16
|
+
MAX_ERROR_STACK = 8192
|
|
17
|
+
MAX_BREADCRUMBS = 20
|
|
18
|
+
MAX_CRUMB_TEXT = 500
|
|
19
|
+
MAX_CONTEXT_KEYS = 20
|
|
20
|
+
MAX_CONTEXT_VALUE = 4096
|
|
21
|
+
CRUMB_KINDS = %w[console click navigate].freeze
|
|
22
|
+
|
|
23
|
+
RATE_LIMIT_WINDOW = 60 # seconds
|
|
24
|
+
|
|
25
|
+
before_action :limit_payload, :throttle, if: -> { Railwatch.config.beacon_enabled }
|
|
26
|
+
|
|
27
|
+
def create
|
|
28
|
+
return head :no_content unless Railwatch.config.beacon_enabled
|
|
29
|
+
|
|
30
|
+
visits = params[:visits].is_a?(Array) ? params[:visits].first(MAX_VISITS).filter_map { |visit| object(visit) } : []
|
|
31
|
+
user_id = Subscribers::Users.resolve_beacon_id(request)
|
|
32
|
+
tenant = beacon_tenant(params[:tenant])
|
|
33
|
+
record_session(params[:session], visits, user_id, tenant)
|
|
34
|
+
record_errors(params[:errors], user_id, tenant)
|
|
35
|
+
visits.each do |v|
|
|
36
|
+
Railwatch.record(:visit,
|
|
37
|
+
group: Record.group_hash(safe_string(v["component"], 255)),
|
|
38
|
+
timestamp: safe_float(v["started_at"]) / 1000.0,
|
|
39
|
+
component: safe_string(v["component"], 255),
|
|
40
|
+
url: safe_string(v["url"], 2048),
|
|
41
|
+
method: safe_string(v["method"], 10),
|
|
42
|
+
duration: (safe_float(v["duration_ms"]) * 1000).round,
|
|
43
|
+
status: safe_string(v["status"], 20),
|
|
44
|
+
partial: v["partial"] ? true : false,
|
|
45
|
+
only: array(v["only"]).first(50).map { |value| safe_string(value, 255) },
|
|
46
|
+
props_bytes: safe_integer(v["props_bytes"]),
|
|
47
|
+
lcp: metric_ms(v["lcp"]),
|
|
48
|
+
cls: cumulative_layout_shift(v["cls"]),
|
|
49
|
+
inp: metric_ms(v["inp"]),
|
|
50
|
+
ttfb: metric_ms(v["ttfb"]),
|
|
51
|
+
user: user_id,
|
|
52
|
+
tenant: tenant,
|
|
53
|
+
user_agent: safe_string(request.user_agent, 256))
|
|
54
|
+
end
|
|
55
|
+
head :no_content
|
|
56
|
+
rescue StandardError => error
|
|
57
|
+
# The endpoint is unauthenticated and its payload is untrusted. A bad
|
|
58
|
+
# shape must not turn into an exception in the customer application.
|
|
59
|
+
Railwatch.debug { "discarded invalid beacon payload: #{error.class}: #{error.message}" }
|
|
60
|
+
head :no_content
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# The beacon takes no credential and keeps every browser error it is
|
|
66
|
+
# sent, so a client that is not the page -- a script, a bored visitor
|
|
67
|
+
# with curl -- could otherwise fill the app's quota with junk issues.
|
|
68
|
+
# Same shape as Rails' rate_limit (a counter per client IP in the app's
|
|
69
|
+
# cache store), read from config at request time so an initializer can
|
|
70
|
+
# raise or disable it. A store that cannot count (NullStore) fails open:
|
|
71
|
+
# the beacon keeps working, just unthrottled.
|
|
72
|
+
def throttle
|
|
73
|
+
limit = Railwatch.config.beacon_rate_limit.to_i
|
|
74
|
+
return unless limit.positive?
|
|
75
|
+
|
|
76
|
+
count = cache_store.increment("railwatch:beacon:#{request.remote_ip}", 1, expires_in: RATE_LIMIT_WINDOW)
|
|
77
|
+
return unless count && count > limit
|
|
78
|
+
|
|
79
|
+
response.set_header("Retry-After", RATE_LIMIT_WINDOW.to_s)
|
|
80
|
+
head :too_many_requests
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def limit_payload
|
|
84
|
+
head :content_too_large if request.raw_post.bytesize > MAX_REQUEST_BYTES
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The browser half of release health: one `session` record per beacon
|
|
88
|
+
# flush, never more, whatever the flush carried. The first one the client
|
|
89
|
+
# sends has no duration yet and opens the session; every later flush
|
|
90
|
+
# beats it along; the pagehide flush closes it with `ended`.
|
|
91
|
+
def record_session(session, visits, user_id, tenant)
|
|
92
|
+
session = object(session)
|
|
93
|
+
return unless session
|
|
94
|
+
|
|
95
|
+
id = safe_string(session["id"], 64)
|
|
96
|
+
return if id.empty?
|
|
97
|
+
|
|
98
|
+
duration_ms = session["duration_ms"]
|
|
99
|
+
Railwatch.record(:session,
|
|
100
|
+
group: Record.group_hash(id),
|
|
101
|
+
id: id,
|
|
102
|
+
source: "browser",
|
|
103
|
+
status: duration_ms.nil? ? "started" : "ok",
|
|
104
|
+
started_at: safe_float(session["started_at"]) / 1000.0,
|
|
105
|
+
duration: duration_ms.nil? ? nil : (safe_float(duration_ms) * 1000).round,
|
|
106
|
+
visits: visits.size,
|
|
107
|
+
errors: visits.count { |v| v["status"] == "error" },
|
|
108
|
+
ended: session["ended"] ? true : false,
|
|
109
|
+
user: user_id,
|
|
110
|
+
tenant: tenant)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# One exception record per JavaScript error the page threw, filed the
|
|
114
|
+
# same way the Ruby side files one: the browser's stack parsed into
|
|
115
|
+
# frames, and a default fingerprint of class, top in-app frame, and the
|
|
116
|
+
# message with its variable data removed, so a browser issue groups and
|
|
117
|
+
# regresses like any other. Nothing here may raise on a payload the app
|
|
118
|
+
# did not write: an entry that is not a hash, or carries no error name,
|
|
119
|
+
# is dropped rather than answered with a 500.
|
|
120
|
+
def record_errors(errors, user_id, tenant)
|
|
121
|
+
errors = errors.is_a?(Array) ? errors.first(MAX_ERRORS) : []
|
|
122
|
+
return if errors.empty?
|
|
123
|
+
|
|
124
|
+
# A browser error is unhandled by definition, so it has to survive the
|
|
125
|
+
# beacon request's own head sampling decision the way an unhandled
|
|
126
|
+
# Ruby exception does.
|
|
127
|
+
Railwatch.keep!
|
|
128
|
+
origin = request.base_url
|
|
129
|
+
session_id = session_id_from(params[:session])
|
|
130
|
+
errors.each do |error|
|
|
131
|
+
error = object(error)
|
|
132
|
+
next unless error
|
|
133
|
+
name = safe_string(error["name"], 255)
|
|
134
|
+
next if name.empty?
|
|
135
|
+
record_error(error, name, origin, session_id, user_id, tenant)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The tenant the browser says it was looking at. The beacon does not go
|
|
140
|
+
# through whatever path or subdomain the app scopes tenants by, so an
|
|
141
|
+
# app that scopes them at all resolves nothing here -- the client's hint
|
|
142
|
+
# (startRailwatch({ tenant })) fills that gap, and never overrides a
|
|
143
|
+
# tenant the server did resolve for itself.
|
|
144
|
+
def beacon_tenant(hint)
|
|
145
|
+
Context.current_tenant || (hint.is_a?(String) ? safe_string(hint, 255).presence : nil)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The tab's session id, so a browser error can be lined up with the
|
|
149
|
+
# session it crashed. Shaped like every other read off this payload:
|
|
150
|
+
# anything but a hash carrying an id yields no id at all.
|
|
151
|
+
def session_id_from(session)
|
|
152
|
+
session = object(session)
|
|
153
|
+
session ? safe_string(session["id"], 64) : ""
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def record_error(error, name, origin, session_id, user_id, tenant)
|
|
157
|
+
message = safe_string(error["message"], MAX_ERROR_MESSAGE)
|
|
158
|
+
frames = Backtrace.js_frames(safe_string(error["stack"], MAX_ERROR_STACK), origin: origin)
|
|
159
|
+
top = Subscribers::Exceptions.top_frame(frames)
|
|
160
|
+
parts = Subscribers::Exceptions.cap_fingerprint(
|
|
161
|
+
[ name, top[:file], top[:line], Subscribers::Exceptions.normalize_message(message) ])
|
|
162
|
+
Railwatch.record(:exception,
|
|
163
|
+
group: Record.group_hash(*parts),
|
|
164
|
+
timestamp: safe_float(error["at"]).positive? ? safe_float(error["at"]) / 1000.0 : nil,
|
|
165
|
+
class: name,
|
|
166
|
+
message: message,
|
|
167
|
+
handled: false,
|
|
168
|
+
severity: "error",
|
|
169
|
+
source: "browser",
|
|
170
|
+
file: top[:file],
|
|
171
|
+
line: top[:line],
|
|
172
|
+
frames: frames,
|
|
173
|
+
context: Context.serialized_with(error_context(error["context"]).merge(browser: {
|
|
174
|
+
url: safe_string(error["url"], 2048).presence,
|
|
175
|
+
component: safe_string(error["component"], 255).presence,
|
|
176
|
+
visit: safe_string(error["visit"], 2048).presence,
|
|
177
|
+
session: session_id.presence,
|
|
178
|
+
user_agent: safe_string(request.user_agent, 256).presence,
|
|
179
|
+
breadcrumbs: breadcrumbs(error["breadcrumbs"]).presence
|
|
180
|
+
}.compact)),
|
|
181
|
+
fingerprint: parts,
|
|
182
|
+
fingerprint_source: "default",
|
|
183
|
+
user: user_id,
|
|
184
|
+
tenant: tenant)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# What the user did in the run-up to the crash, kept only where each
|
|
188
|
+
# entry is the shape the client writes. A garbled or forged trail is
|
|
189
|
+
# dropped entry by entry rather than failing the beacon.
|
|
190
|
+
def breadcrumbs(raw)
|
|
191
|
+
return [] unless raw.is_a?(Array)
|
|
192
|
+
raw.first(MAX_BREADCRUMBS).filter_map do |crumb|
|
|
193
|
+
crumb = object(crumb)
|
|
194
|
+
next unless crumb
|
|
195
|
+
kind = safe_string(crumb["kind"], 20)
|
|
196
|
+
next unless CRUMB_KINDS.include?(kind)
|
|
197
|
+
text = safe_string(crumb["text"], MAX_CRUMB_TEXT)
|
|
198
|
+
next if text.empty?
|
|
199
|
+
{ at: safe_float(crumb["at"]), kind: kind, text: text }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Whatever the app handed Railwatch's reportError -- a React error
|
|
204
|
+
# boundary's component stack, most often. Flattened to strings so a
|
|
205
|
+
# cyclic or enormous object cannot ride in on it.
|
|
206
|
+
def error_context(raw)
|
|
207
|
+
raw = object(raw)
|
|
208
|
+
return {} unless raw
|
|
209
|
+
raw.first(MAX_CONTEXT_KEYS).to_h do |key, value|
|
|
210
|
+
[ safe_string(key, 64), safe_string(value, MAX_CONTEXT_VALUE) ]
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Web vitals only ride along on the initial-load visit, and only from
|
|
215
|
+
# browsers that support the entry type behind them, so every one of these
|
|
216
|
+
# is nil far more often than not.
|
|
217
|
+
def metric_ms(value)
|
|
218
|
+
return nil if value.nil? || value == ""
|
|
219
|
+
safe_float(value).round.clamp(0, MAX_METRIC_MS)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def cumulative_layout_shift(value)
|
|
223
|
+
return nil if value.nil? || value == ""
|
|
224
|
+
safe_float(value).clamp(0.0, MAX_CLS).round(4)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def object(value)
|
|
228
|
+
value = value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
|
|
229
|
+
value if value.is_a?(Hash)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def array(value)
|
|
233
|
+
value.is_a?(Array) ? value : []
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def safe_string(value, length)
|
|
237
|
+
value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)[0, length]
|
|
238
|
+
rescue StandardError
|
|
239
|
+
""
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def safe_float(value)
|
|
243
|
+
Float(value, exception: false).to_f
|
|
244
|
+
rescue StandardError
|
|
245
|
+
0.0
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def safe_integer(value)
|
|
249
|
+
Integer(value, exception: false).to_i
|
|
250
|
+
rescue StandardError
|
|
251
|
+
0
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|