railwatch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +122 -0
  3. data/CHANGELOG.md +462 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +226 -0
  6. data/app/controllers/railwatch/beacon_controller.rb +254 -0
  7. data/config/routes.rb +5 -0
  8. data/docs/ai-and-mcp.md +227 -0
  9. data/docs/configuration.md +931 -0
  10. data/docs/faq.md +230 -0
  11. data/docs/getting-started.md +279 -0
  12. data/docs/records.md +834 -0
  13. data/docs/replacing-nightwatch.md +216 -0
  14. data/docs/replacing-sentry.md +573 -0
  15. data/docs/security.md +94 -0
  16. data/docs/self-hosting.md +60 -0
  17. data/docs/source-maps.md +60 -0
  18. data/docs/testing.md +175 -0
  19. data/docs/troubleshooting.md +319 -0
  20. data/lib/generators/railwatch/install/install_generator.rb +280 -0
  21. data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
  22. data/lib/generators/railwatch/install/templates/post-deploy +98 -0
  23. data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
  24. data/lib/railwatch/attachments.rb +83 -0
  25. data/lib/railwatch/backtrace.rb +158 -0
  26. data/lib/railwatch/buffer.rb +122 -0
  27. data/lib/railwatch/clock.rb +25 -0
  28. data/lib/railwatch/configuration.rb +334 -0
  29. data/lib/railwatch/console.rb +48 -0
  30. data/lib/railwatch/context.rb +125 -0
  31. data/lib/railwatch/controller_helpers.rb +21 -0
  32. data/lib/railwatch/current.rb +32 -0
  33. data/lib/railwatch/engine.rb +144 -0
  34. data/lib/railwatch/execution.rb +367 -0
  35. data/lib/railwatch/faraday.rb +73 -0
  36. data/lib/railwatch/health.rb +188 -0
  37. data/lib/railwatch/job_tracing.rb +49 -0
  38. data/lib/railwatch/middleware/request.rb +289 -0
  39. data/lib/railwatch/minitest.rb +43 -0
  40. data/lib/railwatch/patches/inertia.rb +34 -0
  41. data/lib/railwatch/patches/net_http.rb +102 -0
  42. data/lib/railwatch/patches/rake_task.rb +88 -0
  43. data/lib/railwatch/patches/runner_command.rb +120 -0
  44. data/lib/railwatch/patches.rb +43 -0
  45. data/lib/railwatch/profiler.rb +270 -0
  46. data/lib/railwatch/record.rb +119 -0
  47. data/lib/railwatch/redactor.rb +67 -0
  48. data/lib/railwatch/release_detector.rb +97 -0
  49. data/lib/railwatch/reporter.rb +539 -0
  50. data/lib/railwatch/rspec.rb +139 -0
  51. data/lib/railwatch/sampler.rb +17 -0
  52. data/lib/railwatch/secret_safety.rb +62 -0
  53. data/lib/railwatch/sessions.rb +162 -0
  54. data/lib/railwatch/source_maps.rb +59 -0
  55. data/lib/railwatch/spec_helper.rb +147 -0
  56. data/lib/railwatch/sql_normalizer.rb +398 -0
  57. data/lib/railwatch/subscribers/base.rb +54 -0
  58. data/lib/railwatch/subscribers/broadcasts.rb +107 -0
  59. data/lib/railwatch/subscribers/cache.rb +107 -0
  60. data/lib/railwatch/subscribers/deprecations.rb +26 -0
  61. data/lib/railwatch/subscribers/exceptions.rb +304 -0
  62. data/lib/railwatch/subscribers/jobs.rb +282 -0
  63. data/lib/railwatch/subscribers/logs.rb +137 -0
  64. data/lib/railwatch/subscribers/mail.rb +42 -0
  65. data/lib/railwatch/subscribers/notifications.rb +36 -0
  66. data/lib/railwatch/subscribers/process_info.rb +98 -0
  67. data/lib/railwatch/subscribers/queries.rb +183 -0
  68. data/lib/railwatch/subscribers/requests.rb +94 -0
  69. data/lib/railwatch/subscribers/storage.rb +35 -0
  70. data/lib/railwatch/subscribers/users.rb +159 -0
  71. data/lib/railwatch/subscribers/views.rb +54 -0
  72. data/lib/railwatch/subscribers.rb +34 -0
  73. data/lib/railwatch/transport/http.rb +208 -0
  74. data/lib/railwatch/version.rb +5 -0
  75. data/lib/railwatch.rb +550 -0
  76. data/lib/tasks/railwatch_tasks.rake +289 -0
  77. data/llms.txt +38 -0
  78. metadata +157 -0
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
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Railwatch::Engine.routes.draw do
4
+ post "beacon", to: "beacon#create"
5
+ end