railwatch 1.4.0 → 1.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 954add09993af5d0756ed9ff1875061f25eed72867139116fd020a90b961627c
4
- data.tar.gz: 6fbb618b6eb38d867f9ea7648dfce28d7c46003e1ed9499303a3889de06373af
3
+ metadata.gz: 7d52990b8f664355823660082054f5f886d9ec4f3391936984182cf8526141b3
4
+ data.tar.gz: 39936e0eba72d4be5223c20948e01717be021cccaaf2819ccf58156f86b76b4a
5
5
  SHA512:
6
- metadata.gz: 88f0a50d4769269d565513d59b03567413424f6566da76d1056d08a1e734d7ce244bc609a06c8a25cd7651873ae1519af968a5f6708b225cb984cb764ab77387
7
- data.tar.gz: b882ad5f4cb5a5a3ed16975d1cab018fdd04a0e75be8677baa29882dcc61c56b561096568241e023843d687dc950324a16dedc20200dd4035f51f949535b20c5
6
+ metadata.gz: e3eda104f83588f1cc3c9c12670e9fc335378aa2cd0413e23a811faff4012513b36c0324c0e8cf6ebaae7c0062a5eb537d647a34482c04cf4b2e3fc7c1955ebc
7
+ data.tar.gz: 14f360b2772980266c9fd821e5abd8120af7015fa64d59fb29b21ab8e46ab05d070e5de54fc2d9f34c5fa3b99f3de88ac57d2f85fde1f78045986ce3b1c9a83c
data/CHANGELOG.md CHANGED
@@ -6,6 +6,57 @@
6
6
  filled in by the release commit, which is also the only commit that
7
7
  touches lib/railwatch/version.rb and Gemfile.lock. See CONTRIBUTING.md. -->
8
8
 
9
+ ## 1.6.0 (2026-10-29)
10
+
11
+ - An IPv6 address in an exception message is masked like an IPv4 one, so
12
+ "scrape trap followed from 2607:fb90:..." groups with "...from
13
+ 40.77.167.14" instead of opening an issue per address. Full and
14
+ `::`-compressed forms are both covered. A clock time (`12:30:45`) and a
15
+ constant path (`Foo::Bar`) are left alone.
16
+ - A browser error in a content-hashed bundle (`vite/assets/dist-DedfDSJv.js`)
17
+ is fingerprinted on the name without the hash (`vite/assets/dist.js`)
18
+ and without the line, so the same error no longer opens a new issue
19
+ every deploy. In a minified bundle the line moves with any change
20
+ before it. The stored frames keep the real file and line.
21
+ - The browser client ignores "Script error." by default. It is all a
22
+ browser reports for an error in a cross-origin script, with no stack.
23
+ - A file `ActionDispatch::Static` serves from `public/` (a 2xx or 304 for
24
+ a GET or HEAD no route matched) is no longer recorded as a `GET
25
+ unmatched` request, nor counted in its session. In rebulk-system that
26
+ was 3,168 of 36,131 requests a day and about 23 journey rows per page
27
+ load. Unmatched 404s are still recorded. Routed requests never touch
28
+ the filesystem for this.
29
+ - `startRailwatch()` does nothing when there is no `window`, so an
30
+ Inertia SSR entrypoint that calls it at its top level no longer crashes
31
+ with "document is not defined".
32
+
33
+ ## 1.5.0 (2026-10-28)
34
+
35
+ - The browser session is one per browser, shared by every tab, as in GA4
36
+ and Amplitude: kept in `localStorage`, it ends after 30 minutes with no
37
+ activity in any tab or 24 hours after it started. Each tab also has its
38
+ own id. Every visit record carries `session_id` and `tab_id`, and every
39
+ request record carries the `session_id` from the `X-Railwatch-Session`
40
+ header or `railwatch_session` cookie and the `tab_id` from
41
+ `X-Railwatch-Tab`, which the client now sends on same-origin Inertia
42
+ visits. A browser error's `context.browser` carries the `session` and
43
+ `tab` it was thrown in, stamped when it fired, and both are dropped
44
+ rather than cut when they are not shaped like an id. A browser's first
45
+ page load, which arrives before the client has set its cookie, is handed
46
+ one by the request middleware (successful HTML `GET`s only, never a
47
+ publicly cacheable one, and only when the browser client is installed:
48
+ `browser_client`), so that page is in the session's journey and no
49
+ longer opens a second `user:<id>` session in release health. A closing
50
+ tab no longer marks the session `ended`: the browser's other tabs may
51
+ still be in it. Apps that copied the client
52
+ (`app/frontend/lib/railwatch.ts`) need the same change in their copy:
53
+ `bin/rails generate railwatch:install --force --no-doctor` updates it.
54
+ - `railwatch:install`, `railwatch:doctor` and the `browser_client`
55
+ detection find the app's frontend sources in ViteRuby's `sourceCodeDir`,
56
+ else in `app/frontend` or `app/javascript`. They only looked in
57
+ `app/frontend` before, so an `app/javascript` app got no client and no
58
+ entrypoint wiring.
59
+
9
60
  ## 1.4.0 (2026-10-26)
10
61
 
11
62
  - In development each recorded request's response carries
@@ -55,6 +55,7 @@ module Railwatch
55
55
  inp: metric_ms(v["inp"]),
56
56
  ttfb: metric_ms(v["ttfb"]),
57
57
  **ttfb_phases(v),
58
+ **browser_fields(v),
58
59
  user: user_id,
59
60
  tenant: tenant,
60
61
  user_agent: safe_string(request.user_agent, 256))
@@ -193,7 +194,7 @@ module Railwatch
193
194
  next unless error
194
195
  name = safe_string(error["name"], 255)
195
196
  next if name.empty?
196
- record_error(error, name, origin, session_id, user_id, tenant)
197
+ record_error(error, name, origin, Sessions.browser_id(error["session_id"]) || session_id, user_id, tenant)
197
198
  end
198
199
  end
199
200
 
@@ -206,20 +207,28 @@ module Railwatch
206
207
  Context.current_tenant || (hint.is_a?(String) ? safe_string(hint, 255).presence : nil)
207
208
  end
208
209
 
209
- # The tab's session id, so a browser error can be lined up with the
210
- # session it crashed. Shaped like every other read off this payload:
211
- # anything but a hash carrying an id yields no id at all.
210
+ # The flush's session id, for an error that does not name its own (a
211
+ # client copied before errors carried one). Anything but a hash carrying
212
+ # an id shaped like one the client mints yields no id at all.
212
213
  def session_id_from(session)
213
214
  session = object(session)
214
- session ? safe_string(session["id"], 64) : ""
215
+ Sessions.browser_id(session["id"]) if session
216
+ end
217
+
218
+ # The session and tab each visit says it happened in, which can differ
219
+ # within one beacon: visits queued before the session timed out ship
220
+ # with the next session's beat. Omitted unless shaped like an id.
221
+ def browser_fields(visit)
222
+ { session_id: Sessions.browser_id(visit["session_id"]), tab_id: Sessions.browser_id(visit["tab_id"]) }.compact
215
223
  end
216
224
 
217
225
  def record_error(error, name, origin, session_id, user_id, tenant)
218
226
  message = safe_string(error["message"], MAX_ERROR_MESSAGE)
219
227
  frames = Backtrace.js_frames(safe_string(error["stack"], MAX_ERROR_STACK), origin: origin)
220
228
  top = Subscribers::Exceptions.top_frame(frames)
229
+ file, line = Backtrace.js_fingerprint_location(top)
221
230
  parts = Subscribers::Exceptions.cap_fingerprint(
222
- [ name, top[:file], top[:line], Subscribers::Exceptions.normalize_message(message) ])
231
+ [ name, file, line, Subscribers::Exceptions.normalize_message(message) ])
223
232
  Railwatch.record(:exception,
224
233
  group: Record.group_hash(*parts),
225
234
  timestamp: safe_float(error["at"]).positive? ? safe_float(error["at"]) / 1000.0 : nil,
@@ -235,7 +244,8 @@ module Railwatch
235
244
  url: safe_string(error["url"], 2048).presence,
236
245
  component: safe_string(error["component"], 255).presence,
237
246
  visit: safe_string(error["visit"], 2048).presence,
238
- session: session_id.presence,
247
+ session: session_id,
248
+ tab: Sessions.browser_id(error["tab_id"]),
239
249
  user_agent: safe_string(request.user_agent, 256).presence,
240
250
  breadcrumbs: breadcrumbs(error["breadcrumbs"]).presence
241
251
  }.compact)),
@@ -523,15 +523,21 @@ crash-free session and crash-free user rates are computed from. The
523
523
  | `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. |
524
524
  | `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. |
525
525
  | `session_timeout` | `RAILWATCH_SESSION_TIMEOUT` | `1800.0` | Seconds a server session may sit idle before it ships with `ended` and is forgotten. |
526
+ | `browser_client` | `RAILWATCH_BROWSER_CLIENT` | whether `lib/railwatch.ts` exists in the app's frontend source directory (ViteRuby's `sourceCodeDir`, else `app/frontend` or `app/javascript`) | Whether the app's pages run the browser client. When they do, a browser's first page load gets its session id from the server (below). Set it when the client lives somewhere else, or `false` to never set the cookie. |
526
527
 
527
528
  There are two sources, and they meet on the same id:
528
529
 
529
530
  - **The browser client** is `app/frontend/lib/railwatch.ts`, installed by
530
- `railwatch:install`. It mints one id per tab in `sessionStorage` and
531
- mirrors it into a `railwatch_session` cookie. It rides along on the
532
- beacon flushes the client already sends for visits, so this costs no
533
- extra requests. This is the primary source for a web app, and it is
534
- what makes session duration mean "how long the tab was open".
531
+ `railwatch:install`. It keeps one id per browser in `localStorage`,
532
+ shared by every tab and replaced after 30 minutes with no activity in
533
+ any tab or 24 hours after it started, and mirrors it into a
534
+ `railwatch_session` cookie. It also sends the id, with the tab's own
535
+ id, as `X-Railwatch-Session` and `X-Railwatch-Tab` on every Inertia
536
+ visit to the page's own origin. It rides along on the beacon flushes
537
+ the client already sends for visits, so this costs no extra requests.
538
+ This is the primary source for a web app, and it is what makes session
539
+ duration mean "how long the user was active". The client's 30 minutes
540
+ is fixed; it matches this setting's default.
535
541
  - **The request middleware** aggregates, in memory, every request that
536
542
  either resolves a user or carries that cookie or an
537
543
  `X-Railwatch-Session` header. It is the only source for an API-only
@@ -541,6 +547,25 @@ There are two sources, and they meet on the same id:
541
547
  When a browser session's requests carry the cookie both sources produce
542
548
  records under the same id and the platform dedupes them.
543
549
 
550
+ A browser's first page load arrives before the client has run, so it
551
+ carries no cookie. With `browser_client` on, the request middleware gives
552
+ it one: a successful `GET` whose response is an HTML document, that is not
553
+ an Inertia visit and carries no `X-Railwatch-Session` or cookie, gets
554
+ `Set-Cookie: railwatch_session=<new id>; path=/; max-age=1800;
555
+ samesite=lax` (plus `secure` over HTTPS, including behind a proxy that
556
+ sets `X-Forwarded-Proto`). That request's record carries the id, and so does its
557
+ server session when it resolved a user (an anonymous one opened no server
558
+ session before and still opens none; its browser opens the session with
559
+ the client's first beat).
560
+ The client adopts it when the browser has no live session of its own, so
561
+ that first page is the first entry of the session's journey and the
562
+ session is not counted twice (once under the id, once as `user:<id>`). It
563
+ is never set on a response whose `Cache-Control` says `public` or
564
+ `s-maxage`, since a shared cache would hand one visitor's cookie to every
565
+ visitor; on such a page the session starts with the client, as before.
566
+ Requests with neither cookie nor header, from an API client or an app
567
+ without the browser client, still key on `user:<id>`.
568
+
544
569
  ## Vendor noise defaults
545
570
 
546
571
  Framework/vendor activity excluded by default so a fresh install isn't
@@ -719,7 +744,9 @@ credential, so it is throttled per client IP by `beacon_rate_limit`,
719
744
  default 120 a minute, `0` to disable. A client past the limit gets a 429
720
745
  with `Retry-After` and nothing from that POST is recorded. Client setup:
721
746
  call `startRailwatch()` from your Inertia entrypoint. It is generated at
722
- `app/frontend/lib/railwatch.ts`.
747
+ `lib/railwatch.ts` in the app's frontend source directory, usually
748
+ `app/frontend/lib/railwatch.ts` (see Getting started for how the directory
749
+ is chosen and how to update the copy).
723
750
 
724
751
  `startRailwatch` takes three optional settings, none of which has a
725
752
  server-side equivalent. They are decisions about the browser the code is
@@ -728,7 +755,8 @@ running in:
728
755
  ```ts
729
756
  startRailwatch({
730
757
  // Messages never worth an issue, added to the defaults (both
731
- // "ResizeObserver loop ..." messages). Strings match anywhere in the
758
+ // "ResizeObserver loop ..." messages, and the bare "Script error." a
759
+ // browser gives for a cross-origin script). Strings match anywhere in the
732
760
  // message; regexes are tested against it.
733
761
  ignoreErrors: [/Failed to fetch dynamically imported module/],
734
762
  // Scripts whose failures are not this app's, matched against the top
@@ -754,6 +782,10 @@ does the same thing. See
754
782
  [`docs/replacing-sentry.md`](replacing-sentry.md) for what is and is not
755
783
  captured versus `@sentry/react`.
756
784
 
785
+ The client is safe to import from an SSR bundle: nothing in it touches
786
+ the DOM at import time, and `startRailwatch()` returns without doing
787
+ anything when there is no `window`.
788
+
757
789
  SSR timing needs no configuration. `Railwatch::Patches::Inertia` prepends
758
790
  `InertiaRails::Renderer#ssr_render` whenever `inertia_rails` SSR is
759
791
  enabled. The resulting `ssr_ms` lands on the `request` record's
@@ -57,9 +57,25 @@ bin/rails generate railwatch:install \
57
57
  beacon endpoint the browser client posts to (`POST /railwatch/beacon`).
58
58
  - `.kamal/hooks/post-deploy` — only if `config/deploy.yml` already
59
59
  exists.
60
- - `app/frontend/lib/railwatch.ts` plus the `startRailwatch()` call in your
61
- Inertia entrypoint — only if `app/frontend/` exists. If it can't find
62
- an entrypoint it prints the two lines to add.
60
+ - `lib/railwatch.ts` in the app's frontend source directory, plus the
61
+ `startRailwatch()` call in its Inertia entrypoint
62
+ (`entrypoints/inertia.tsx`, `.ts` or `.jsx`). The directory is ViteRuby's
63
+ `sourceCodeDir` when the app uses vite_ruby, else the first of
64
+ `app/frontend` and `app/javascript` that exists; with neither, nothing
65
+ is copied. If it can't find an entrypoint it prints the two lines to add.
66
+
67
+ The browser client is copied into the app, so upgrading the gem does not
68
+ update it. After an upgrade whose CHANGELOG mentions the client, run
69
+ `bin/rails generate railwatch:install --force --no-doctor`, or copy the
70
+ file by hand:
71
+
72
+ ```sh
73
+ cp "$(bundle exec ruby -e 'require "railwatch"; print Railwatch.browser_client_path')" app/frontend/lib/railwatch.ts # or app/javascript/lib/...
74
+ ```
75
+
76
+ Without `--force` the generator asks before overwriting a copy that
77
+ differs. Either way, review the diff if you have edited your copy. The
78
+ generator's other steps leave what they already wrote alone.
63
79
  - `require "railwatch/rspec"` in `spec/rails_helper.rb`, or
64
80
  `require "railwatch/minitest"` in `test/test_helper.rb`.
65
81
 
data/docs/records.md CHANGED
@@ -72,7 +72,10 @@ whether child records ship, not whether an error is visible.
72
72
  ### `request`
73
73
 
74
74
  Built by the outermost Rack middleware in
75
- `lib/railwatch/middleware/request.rb`. That middleware also owns the
75
+ `lib/railwatch/middleware/request.rb`. A file `ActionDispatch::Static`
76
+ served from `public/` (a GET or HEAD no route matched, answered 2xx or
77
+ 304, whose path names a file there or its precompressed `.br`/`.gz`) is not recorded. An unmatched request
78
+ for anything else, a 404 included, is. That middleware also owns the
76
79
  `middleware_before`/`action`/`render`/`middleware_after` stage
77
80
  boundaries. The `action`/`render` boundaries come from
78
81
  `start_processing.action_controller` and `render_template.action_view` in
@@ -104,6 +107,8 @@ boundaries. The `action`/`render` boundaries come from
104
107
  | `payload` | Filtered request params — only captured when `config.capture_request_payload` is on **and** the request raised an exception (never for successful requests). |
105
108
  | `user_agent` | Truncated to 256 chars. |
106
109
  | `files` | Array of `{name, size, content_type, error}` for each uploaded file in a multipart request (metadata only, never contents). A non-multipart request body is never parsed to fill this in. |
110
+ | `session_id` | The browser client's session id, from `X-Railwatch-Session` or else the `railwatch_session` cookie, so a full page load carries it too. On a browser's first page load, the id the middleware set in that cookie on this response (see Release health in configuration.md). Never the `"user:<id>"` key the server session falls back to. At most 64 of `[A-Za-z0-9_-]`; absent when missing or not that shape. |
111
+ | `tab_id` | The browser client's tab id, from `X-Railwatch-Tab`, which it sends only on Inertia visits: a full page load has none. Same validation, absent otherwise. |
107
112
  | `profiled` | `true` when a `profile` record shipped for this request; absent otherwise. |
108
113
 
109
114
  ### `job_attempt`
@@ -332,8 +337,9 @@ its own `exceptions` sample rate rolled once per execution. See
332
337
 
333
338
  The default fingerprint normalizes the message before hashing it, so one
334
339
  issue doesn't shatter into thousands. URLs, email addresses, UUIDs, ISO
335
- timestamps, IPv4 addresses, quoted strings, hex runs of six characters or
336
- more, and plain integers all become `?`. Whitespace collapses, and the
340
+ timestamps, IPv4 and IPv6 addresses (lower-case hex, full or compressed
341
+ with `::`), quoted strings, hex runs of six characters or more, and plain
342
+ integers all become `?`. Whitespace collapses, and the
337
343
  result is cut at 200 chars. For classes whose message is mostly the data
338
344
  that varied, only the message *prefix* is kept. The prefix runs up to the
339
345
  first `:` for `ActiveRecord::RecordNotFound`, `ActiveRecord::RecordInvalid`,
@@ -391,13 +397,22 @@ line with no location on it is dropped:
391
397
  | `in_app` | True when the script came from the app's own origin and is not under `node_modules/` or `vendor/`. |
392
398
 
393
399
  No source snippets: the file is on the client, not on the server. Frames
394
- are fingerprinted exactly like Ruby ones: class, top in-app frame, and
395
- the normalized message. So browser errors group, split, merge, resolve,
396
- and regress through the same Issue machinery.
400
+ are fingerprinted like Ruby ones: class, top in-app frame, and the
401
+ normalized message. So browser errors group, split, merge, resolve, and
402
+ regress through the same Issue machinery. One difference: when the top
403
+ frame's file name carries a bundler's content hash (`dist-DedfDSJv.js`,
404
+ `main.3f2a1b9c.js`, `app-5XDGVJ2T.mjs`; eight or more characters with a
405
+ digit, or mixed case that does not read as camelCase words, so
406
+ `checkout-PaymentForm.js` keeps its name), the fingerprint uses the name without the hash
407
+ (`dist.js`) and no line. Both change on every deploy of a minified
408
+ bundle, so keeping them would open a new issue each release. The frame
409
+ itself keeps the real file and line.
397
410
 
398
411
  `context` carries a `browser` key with the page `url`, the Inertia
399
- `component`, the `visit` the error happened in if any, the tab's
400
- `session` id, the `user_agent`, and up to 20 `breadcrumbs`. Each
412
+ `component`, the `visit` the error happened in if any, the browser
413
+ `session` id and the `tab` id it was thrown in, both stamped by the client
414
+ when the error fired (the flush's session stands in for a client that
415
+ sends none; each at most 64 of `[A-Za-z0-9_-]`, absent otherwise), the `user_agent`, and up to 20 `breadcrumbs`. Each
401
416
  breadcrumb is `{at, kind, text}`, with `kind` being `console`, `click`,
402
417
  or `navigate`. The breadcrumbs are the trail the client recorded before
403
418
  the crash. Anything the app passed as `reportError(error, context)` is
@@ -776,6 +791,8 @@ entirely if `config.beacon_enabled` is off.
776
791
  | `user` | Resolved server-side from the beacon request's session/cookies, same resolver as every other record. |
777
792
  | `tenant` | Current tenant context. |
778
793
  | `user_agent` | Truncated to 256 chars. |
794
+ | `session_id` | The browser session the visit happened in (see `session`), when the client sent one shaped like an id: at most 64 of `[A-Za-z0-9_-]`. Absent otherwise. |
795
+ | `tab_id` | The tab the visit happened in: a 16-hex id the client keeps in `sessionStorage` (`railwatch.tab`), so it survives a reload of the tab but not a new tab. "Duplicate tab" copies `sessionStorage`, so a duplicated tab shares its original's id until one of them is reloaded fresh. Same validation, absent otherwise. |
779
796
 
780
797
  The **initial page load** is reported as a visit too, even though Inertia
781
798
  never routed it. It has `method` `"GET"`, `status` `"success"`, and
@@ -799,13 +816,25 @@ per deploy and reports crash-free rates from them. Two sources produce
799
816
  the same record:
800
817
 
801
818
  - **Browser**, `source: "browser"`. The client,
802
- `app/frontend/lib/railwatch.ts`, mints a 16-hex id per tab in
803
- `sessionStorage` under the key `railwatch.session`, so it dies with the
804
- tab. It mirrors the id into a `railwatch_session` cookie and sends it
805
- with every beacon flush. `Railwatch::BeaconController` writes at most
806
- one `session` record per flush. The first, with no `duration_ms` yet,
807
- opens the session. Later ones beat it along, and the
808
- `pagehide`/`visibilitychange` flush closes it with `ended`.
819
+ `app/frontend/lib/railwatch.ts`, keeps one session per browser, shared
820
+ by every tab, the way GA4 and Amplitude do: a 16-hex id in
821
+ `localStorage` (`railwatch.session`, with `railwatch.session.at` when it
822
+ started and `railwatch.session.seen` for its last activity in any tab).
823
+ Activity is a page load, an Inertia visit the user waits on, and a tab
824
+ coming back into view. After 30 minutes with no activity in any tab, or
825
+ 24 hours after it started, the next activity mints a new id. When
826
+ `localStorage` throws the session lives in `sessionStorage`, and when
827
+ that throws too, in memory; a new tab still joins the session through
828
+ the cookie. A browser's first page load gets its id from the server, in
829
+ that cookie, and the client adopts it when no live session is stored.
830
+ The client mirrors the id into a `railwatch_session` cookie that lapses
831
+ after 30 minutes, renewed on every activity, and sends it with every
832
+ beacon flush. `Railwatch::BeaconController` writes at most one `session` record
833
+ per flush. The first, with no `duration_ms` yet, opens the session.
834
+ Later ones beat it along. Closing a tab does not end the session, since
835
+ other tabs may still be in it; a tab that sees the session time out
836
+ sends one last beat with `ended`, and a session no tab comes back to
837
+ ends only by the platform's own timeout.
809
838
  - **Server**, `source: "server"`. `lib/railwatch/sessions.rb` aggregates,
810
839
  per process, every request that resolves a user or carries that cookie
811
840
  or an `X-Railwatch-Session` header. A background thread ships one
@@ -830,7 +859,7 @@ halves of one session rather than counting it twice.
830
859
  | `requests` | Requests in this session so far (server only). |
831
860
  | `visits` | Inertia visits in this beacon flush (browser only). |
832
861
  | `errors` | Requests that 5xx'd or raised (server), or visits with status `"error"` in this flush (browser). |
833
- | `ended` | Whether this is the session's last record. |
862
+ | `ended` | Whether this is the session's last record. A browser session is only `ended` when a tab saw it time out, never because a tab closed. |
834
863
  | `user` | Resolved user id, when there is one. |
835
864
 
836
865
  ### `process`
@@ -421,7 +421,7 @@ startRailwatch({
421
421
  | `Sentry.init({ dsn })` | `startRailwatch()`. There is no DSN: the beacon posts to the app's own origin and the *server* decides whether to record it (`c.beacon_enabled`, and whether Railwatch is enabled). The gate you already have on whether `startRailwatch()` runs at all is the only gate. |
422
422
  | `release` | Automatic. The record is stamped with `c.deploy`, the same release the server records carry, so a browser issue and a server issue from one deploy line up without a matching pair of settings to get wrong. |
423
423
  | `environment` | Automatic — the ingest token identifies the environment. |
424
- | `ignoreErrors` | `startRailwatch({ ignoreErrors })`. Strings match anywhere in the message; regexes are tested against it. Both `ResizeObserver` messages are ignored by default. |
424
+ | `ignoreErrors` | `startRailwatch({ ignoreErrors })`. Strings match anywhere in the message; regexes are tested against it. Both `ResizeObserver` messages and the opaque cross-origin `Script error.` are ignored by default. |
425
425
  | `denyUrls` | `startRailwatch({ denyUrls })`, matched against the top stack frame's URL. `/extensions\//i`, `/^chrome:\/\//i`, and `/^moz-extension:\/\//i` are denied by default, **and** any frame from an origin that isn't the app's own is dropped — extensions, injected widgets, tag managers. |
426
426
  | `Sentry.setUser` | Server-side. The beacon is a same-origin POST carrying the session cookie, so the server resolves the user the same way it does for a request (`Railwatch.user`) when `Current.user` or Warden is set by middleware; an app that authenticates in a `before_action` gives Railwatch the same lookup with `c.beacon_user { \|request\| ... }`. Nothing the browser sends names the user, so it cannot be forged. |
427
427
  | `Sentry.setTag("org", …)` | `startRailwatch({ tenant })`, and `Railwatch.context(...)` for everything else. |
@@ -26,7 +26,7 @@ the install is broken.
26
26
  | `ignored record types` | Never fails; it prints what `c.ignore` is dropping. |
27
27
  | `interactive sessions` | Never fails; it prints whether consoles are captured and the runner scratch paths. |
28
28
  | `kamal post-deploy hook` | `.kamal/hooks/post-deploy` is missing or doesn't mention Railwatch. Only matters if you deploy with Kamal. |
29
- | `browser client` | `app/frontend/lib/railwatch.ts` isn't there. Only matters for Inertia visit timing. |
29
+ | `browser client` | `lib/railwatch.ts` isn't in the app's frontend source directory (ViteRuby's `sourceCodeDir`, else `app/frontend` or `app/javascript`). Only matters for Inertia visit timing. |
30
30
  | `browser client imported` | The client exists but nothing calls `startRailwatch()` — no `startRailwatch` found in `app/frontend/entrypoints`. Visits won't report. |
31
31
  | `profiler backend` | Neither `vernier` nor `stackprof` is installed, so `Railwatch::Profiler.available?` is false and the profiling settings are inert. |
32
32
  | `test matchers` | Neither `spec/rails_helper.rb` requires `railwatch/rspec` nor `test/test_helper.rb` requires `railwatch/minitest`. |
@@ -23,11 +23,12 @@ module Railwatch
23
23
  class_option :doctor, type: :boolean, default: true,
24
24
  desc: "Run railwatch:doctor when the install finishes."
25
25
 
26
- # Whichever of these exists is where `startRailwatch()` is added.
26
+ # Whichever of these exists, under the app's frontend source directory
27
+ # (Railwatch.frontend_dir), is where `startRailwatch()` is added.
27
28
  INERTIA_ENTRYPOINTS = %w[
28
- app/frontend/entrypoints/inertia.tsx
29
- app/frontend/entrypoints/inertia.ts
30
- app/frontend/entrypoints/inertia.jsx
29
+ entrypoints/inertia.tsx
30
+ entrypoints/inertia.ts
31
+ entrypoints/inertia.jsx
31
32
  ].freeze
32
33
 
33
34
  IMPORT_LINE = 'import { startRailwatch } from "@/lib/railwatch"'
@@ -68,19 +69,22 @@ module Railwatch
68
69
 
69
70
  # The one browser client (Railwatch.browser_client_path). Copied rather
70
71
  # than templated: there is nothing to interpolate, and a copy means the
71
- # host sees exactly what the gem tests.
72
+ # host sees exactly what the gem tests. Re-running the generator after
73
+ # a gem upgrade is how an app picks up a new client: Thor asks before
74
+ # overwriting a copy that differs (--force overwrites, --skip keeps it).
72
75
  def create_browser_client
73
- return unless File.directory?("app/frontend")
74
- copy_file Railwatch.browser_client_path.to_s, "app/frontend/lib/railwatch.ts"
76
+ return unless frontend_dir
77
+ copy_file Railwatch.browser_client_path.to_s, "#{frontend_dir}/lib/railwatch.ts"
75
78
  end
76
79
 
77
80
  # Adds the import and the call to the Inertia entrypoint, so page-visit
78
81
  # timing and Core Web Vitals report with no further editing.
79
82
  def start_browser_client
80
- return unless File.directory?("app/frontend")
83
+ return unless frontend_dir
81
84
 
82
- path = INERTIA_ENTRYPOINTS.find { |candidate| File.exist?(candidate) }
83
- return say(browser_client_instructions(INERTIA_ENTRYPOINTS.first), :yellow) unless path
85
+ candidates = INERTIA_ENTRYPOINTS.map { |entrypoint| "#{frontend_dir}/#{entrypoint}" }
86
+ path = candidates.find { |candidate| File.exist?(candidate) }
87
+ return say(browser_client_instructions(candidates.first), :yellow) unless path
84
88
 
85
89
  contents = File.read(path)
86
90
  return say_status(:identical, path, :blue) if contents.include?(START_CALL)
@@ -279,6 +283,10 @@ module Railwatch
279
283
  "The token value is never printed:\n#{lines.join("\n")}"
280
284
  end
281
285
 
286
+ def frontend_dir
287
+ @frontend_dir ||= Railwatch.frontend_dir(destination_root)
288
+ end
289
+
282
290
  def browser_client_instructions(path)
283
291
  "Add these two lines to #{path} to report Inertia visits and Core Web Vitals:\n" \
284
292
  " #{IMPORT_LINE}\n #{START_CALL}"
@@ -29,10 +29,13 @@ interface Visit {
29
29
  redirect_count?: number
30
30
  connect_ms?: number
31
31
  server_ms?: number
32
+ // The browser session and the tab the visit happened in, for the journey.
33
+ session_id?: string
34
+ tab_id?: string
32
35
  }
33
36
 
34
- // The tab's session, for release health. sessionStorage scopes it to the
35
- // tab and it dies with the tab, which is what a browser session is.
37
+ // The browser's session, for release health and the session journey, on the
38
+ // wire. `ended` only when this tab has seen it time out (see `begin`).
36
39
  interface Session {
37
40
  id: string
38
41
  started_at: number
@@ -50,6 +53,10 @@ interface JsError {
50
53
  component?: string
51
54
  url: string
52
55
  visit?: string
56
+ // The session and tab it was thrown in, taken when it was: another tab
57
+ // may move the session on before this one flushes.
58
+ session_id?: string
59
+ tab_id?: string
53
60
  breadcrumbs?: Crumb[]
54
61
  context?: Record<string, unknown>
55
62
  }
@@ -77,11 +84,14 @@ export interface RailwatchOptions {
77
84
  }
78
85
 
79
86
  // Browser noise that is never actionable: ResizeObserver fires from benign
80
- // layout thrash and the spec says to ignore it, and the extension URLs are
81
- // third-party code running in someone's browser that this app cannot fix.
82
- const DEFAULT_IGNORE_ERRORS = [
87
+ // layout thrash and the spec says to ignore it, "Script error." is all a
88
+ // browser says about an error in a cross-origin script (no stack, no file,
89
+ // no line), and the extension URLs are third-party code running in
90
+ // someone's browser that this app cannot fix.
91
+ const DEFAULT_IGNORE_ERRORS: (string | RegExp)[] = [
83
92
  "ResizeObserver loop limit exceeded",
84
93
  "ResizeObserver loop completed with undelivered notifications",
94
+ /^Script error\.?$/,
85
95
  ]
86
96
  const DEFAULT_DENY_URLS = [
87
97
  /extensions\//i,
@@ -90,14 +100,26 @@ const DEFAULT_DENY_URLS = [
90
100
  ]
91
101
 
92
102
  const SESSION_ID_KEY = "railwatch.session"
103
+ const SESSION_SEEN_KEY = "railwatch.session.seen"
93
104
  const SESSION_STARTED_KEY = "railwatch.session.at"
105
+ const TAB_KEY = "railwatch.tab"
106
+ // The GA4/Amplitude rules: a session is over after 30 minutes with no
107
+ // activity in any tab (config.session_timeout's default, which the server
108
+ // half times out on too), and after 24 hours whatever happens.
109
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000
110
+ const SESSION_CAP_MS = 24 * 60 * 60 * 1000
111
+ // Railwatch::Sessions reads these, ahead of the cookie.
112
+ const SESSION_HEADER = "X-Railwatch-Session"
113
+ const TAB_HEADER = "X-Railwatch-Tab"
94
114
 
95
115
  const queue: Visit[] = []
96
116
  const errors: JsError[] = []
97
117
  const crumbs: Crumb[] = []
98
118
  let current: Visit | null = null
99
119
  let initial: Visit | null = null
100
- let session: Session | null = null
120
+ // `seen` is the session's last activity in any tab, as far as this one knows.
121
+ let session: { id: string; started_at: number; seen: number } | null = null
122
+ let tab = ""
101
123
  // The Inertia page component the user is on, so an error that fires between
102
124
  // visits still says which screen it broke.
103
125
  let component: string | undefined
@@ -170,12 +192,13 @@ function post(payload: {
170
192
  }).catch(() => undefined)
171
193
  }
172
194
 
173
- function flush(ended = false) {
174
- if (queue.length === 0 && errors.length === 0 && !ended) return
195
+ function flush(force = false) {
196
+ if (queue.length === 0 && errors.length === 0 && !force) return
197
+ touch(false)
175
198
  post({
176
199
  visits: queue.splice(0, queue.length),
177
200
  errors: errors.splice(0, errors.length),
178
- session: beat(ended),
201
+ session: beat(),
179
202
  tenant: tenant(),
180
203
  })
181
204
  }
@@ -199,40 +222,220 @@ function randomId() {
199
222
  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
200
223
  }
201
224
 
202
- // Mints the tab's session on its first load, or picks up the one an earlier
203
- // page in this tab minted, and mirrors the id into a cookie so every request
204
- // the tab makes carries it -- that is what lets the server side of the
205
- // session (Railwatch::Sessions) join the browser side under one id.
206
- function startSession() {
225
+ interface Store {
226
+ getItem(key: string): string | null
227
+ setItem(key: string, value: string): void
228
+ }
229
+
230
+ // Where the session lives: localStorage, so every tab of the browser shares
231
+ // one; sessionStorage when localStorage throws (storage disabled, a
232
+ // sandboxed frame), which narrows the session to the tab; memory when both
233
+ // throw, which narrows it to the page. The tab id lives in sessionStorage,
234
+ // which survives a reload of the tab and nothing else, or in memory.
235
+ let sessions: Store = memoryStore()
236
+ let tabs: Store = memoryStore()
237
+
238
+ function storage(kind: "localStorage" | "sessionStorage"): Store | undefined {
239
+ try {
240
+ const store = window[kind]
241
+ store.setItem("railwatch.probe", "1")
242
+ store.removeItem("railwatch.probe")
243
+ return store
244
+ } catch {
245
+ // Disabled, or denied to this frame. The caller tries the next one.
246
+ return undefined
247
+ }
248
+ }
249
+
250
+ function memoryStore(): Store {
251
+ const values = new Map<string, string>()
252
+ return {
253
+ getItem: (key) => values.get(key) ?? null,
254
+ setItem: (key, value) => void values.set(key, value),
255
+ }
256
+ }
257
+
258
+ // A full store throws on write. The session then lasts as long as this
259
+ // page's memory of it, which is all a write could have bought.
260
+ function save(store: Store, key: string, value: string) {
261
+ try {
262
+ store.setItem(key, value)
263
+ } catch {
264
+ // Quota exceeded.
265
+ }
266
+ }
267
+
268
+ // Mirrors the id into a cookie so every request the browser makes carries
269
+ // it, full page loads included -- that is what lets the server side of the
270
+ // session (Railwatch::Sessions) join the browser side under one id. It
271
+ // lapses with the session's idle timeout, so the first page load after one
272
+ // does not carry the dead id; every activity in any tab renews it.
273
+ function remember(id: string) {
207
274
  try {
208
- const existing = sessionStorage.getItem(SESSION_ID_KEY)
209
- const id = existing ?? randomId()
210
- const startedAt =
211
- Number(sessionStorage.getItem(SESSION_STARTED_KEY)) || Date.now()
212
- if (!existing) {
213
- sessionStorage.setItem(SESSION_ID_KEY, id)
214
- sessionStorage.setItem(SESSION_STARTED_KEY, String(startedAt))
215
- }
216
275
  // Railwatch::Sessions::COOKIE reads this name to stitch server requests to the browser session.
217
- document.cookie = `railwatch_session=${id}; path=/; SameSite=Lax`
218
- session = { id, started_at: startedAt }
219
- // No duration on the first beat: that is what opens the session.
220
- if (!existing)
221
- post({
222
- visits: [],
223
- errors: [],
224
- session: { ...session },
225
- tenant: tenant(),
226
- })
276
+ document.cookie = `railwatch_session=${id}; path=/; max-age=${SESSION_TIMEOUT_MS / 1000}; SameSite=Lax`
277
+ } catch {
278
+ // A sandboxed frame has no cookies. Visits still carry the headers.
279
+ }
280
+ }
281
+
282
+ // A store that passed the probe can still throw later: the user turns
283
+ // storage off, or the browser revokes it. From then on the page keeps its
284
+ // own copy, seeded with the session it is in, so losing storage neither
285
+ // stops the client nor ends the session.
286
+ function read(key: string): string | null {
287
+ try {
288
+ return sessions.getItem(key)
289
+ } catch {
290
+ sessions = memoryStore()
291
+ if (session) {
292
+ sessions.setItem(SESSION_ID_KEY, session.id)
293
+ sessions.setItem(SESSION_STARTED_KEY, String(session.started_at))
294
+ sessions.setItem(SESSION_SEEN_KEY, String(session.seen))
295
+ }
296
+ return sessions.getItem(key)
297
+ }
298
+ }
299
+
300
+ function readTab(): string | null {
301
+ try {
302
+ return tabs.getItem(TAB_KEY)
227
303
  } catch {
228
- // sessionStorage is unavailable (private mode, storage disabled).
229
- // Everything else still reports; this tab just has no session.
304
+ tabs = memoryStore()
305
+ return null
230
306
  }
231
307
  }
232
308
 
233
- function beat(ended: boolean): Session | undefined {
309
+ function startSession() {
310
+ sessions =
311
+ storage("localStorage") ?? storage("sessionStorage") ?? memoryStore()
312
+ tabs = storage("sessionStorage") ?? memoryStore()
313
+ tab = readTab() ?? randomId()
314
+ save(tabs, TAB_KEY, tab)
315
+ touch(true)
316
+ }
317
+
318
+ // Brings this tab's session up to date with the browser's, joining the one
319
+ // another tab opened meanwhile. `active` is the user doing something -- the
320
+ // page loading, a visit they wait on, the tab coming back into view -- which
321
+ // keeps the session alive, or opens a new one when the last went 30 minutes
322
+ // without activity in any tab or is 24 hours old. Anything else (a
323
+ // background poll, a prefetch, a flush) only joins a live session, so a tab
324
+ // left polling cannot hold its session open until the cap.
325
+ function touch(active: boolean) {
326
+ const now = Date.now()
327
+ const id = read(SESSION_ID_KEY)
328
+ const seen = Number(read(SESSION_SEEN_KEY)) || 0
329
+ const startedAt = Number(read(SESSION_STARTED_KEY)) || 0
330
+ const live = id !== null && !expired(startedAt, seen, now)
331
+ if (live && id === session?.id) session.seen = Math.max(session.seen, seen)
332
+ else if (live) begin(id, startedAt, seen, false)
333
+ else if (active) begin(minted(id) ?? randomId(), now, now, true)
334
+ if (!active || !session) return
335
+ session.seen = now
336
+ save(sessions, SESSION_SEEN_KEY, String(now))
337
+ remember(session.id)
338
+ }
339
+
340
+ // The id the server put in the cookie for this browser's first page load
341
+ // (Railwatch::Sessions.mint), so the session starts with that request rather
342
+ // than after it. Not the session that just expired, whose id the cookie
343
+ // can still hold.
344
+ function minted(expiredId: string | null): string | undefined {
345
+ try {
346
+ const id = /(?:^|;\s*)railwatch_session=([A-Za-z0-9_-]{1,64})(?:;|$)/.exec(
347
+ document.cookie,
348
+ )?.[1]
349
+ return id && id !== expiredId && id !== session?.id ? id : undefined
350
+ } catch {
351
+ // A sandboxed frame has no cookies.
352
+ return undefined
353
+ }
354
+ }
355
+
356
+ function expired(startedAt: number, seen: number, now: number) {
357
+ return now - seen >= SESSION_TIMEOUT_MS || now - startedAt >= SESSION_CAP_MS
358
+ }
359
+
360
+ // The session this tab is in, while it is live. A background visit after it
361
+ // timed out belongs to no session: it is neither tagged nor recorded as one.
362
+ function liveSession(): string | undefined {
363
+ if (!session || expired(session.started_at, session.seen, Date.now()))
364
+ return undefined
365
+ return session.id
366
+ }
367
+
368
+ // Moves this tab into a session. The one it was in is over -- a session is
369
+ // only replaced once it has timed out or reached the cap -- so this tab
370
+ // says so with a last, `ended` beat. Closing a tab sends no such thing: the
371
+ // browser's other tabs may still be in the session, and nothing a closing
372
+ // tab knows says otherwise. A session no tab comes back to ends where the
373
+ // server half's does, on its own timeout.
374
+ function begin(id: string, startedAt: number, seen: number, minted: boolean) {
375
+ if (session)
376
+ post({
377
+ visits: [],
378
+ errors: [],
379
+ session: {
380
+ id: session.id,
381
+ started_at: session.started_at,
382
+ duration_ms: session.seen - session.started_at,
383
+ ended: true,
384
+ },
385
+ tenant: tenant(),
386
+ })
387
+ session = { id, started_at: startedAt, seen }
388
+ if (!minted) return
389
+ save(sessions, SESSION_ID_KEY, id)
390
+ save(sessions, SESSION_STARTED_KEY, String(startedAt))
391
+ save(sessions, SESSION_SEEN_KEY, String(seen))
392
+ // No duration on the first beat: that is what opens the session.
393
+ post({
394
+ visits: [],
395
+ errors: [],
396
+ session: { id, started_at: startedAt },
397
+ tenant: tenant(),
398
+ })
399
+ }
400
+
401
+ // While the session is live it runs to now, whichever tab is beating it. One
402
+ // this tab has watched time out ran to its last activity, and is `ended`.
403
+ function beat(): Session | undefined {
234
404
  if (!session) return undefined
235
- return { ...session, duration_ms: Date.now() - session.started_at, ended }
405
+ const now = Date.now()
406
+ const ended = expired(session.started_at, session.seen, now)
407
+ return {
408
+ id: session.id,
409
+ started_at: session.started_at,
410
+ duration_ms: (ended ? session.seen : now) - session.started_at,
411
+ ended,
412
+ }
413
+ }
414
+
415
+ // Inertia sends a visit with whatever headers the visit object holds once
416
+ // `before` has fired, so setting them here tags every visit, prefetch, poll
417
+ // and partial reload with the session and the tab -- which is how a request
418
+ // record joins its journey under the tab it came from. Only for the page's
419
+ // own origin: a custom header makes a cross-origin request preflight, and
420
+ // another origin is not this app's server anyway. The headers object is
421
+ // replaced, not written into, because it may be the app's own options. The
422
+ // prefetch cache compares headers; a prefetch and the click it serves are
423
+ // tagged alike, so the prefetch still serves.
424
+ function tag(visit: { url: unknown; headers?: Record<string, string> }) {
425
+ const id = liveSession()
426
+ if (!id) return
427
+ try {
428
+ if (new URL(String(visit.url), location.href).origin !== location.origin)
429
+ return
430
+ } catch {
431
+ // Not a URL Inertia could visit either.
432
+ return
433
+ }
434
+ visit.headers = {
435
+ ...visit.headers,
436
+ [SESSION_HEADER]: id,
437
+ [TAB_HEADER]: tab,
438
+ }
236
439
  }
237
440
 
238
441
  // --- Breadcrumbs -------------------------------------------------------
@@ -349,6 +552,8 @@ function capture(
349
552
  component,
350
553
  url: location.pathname + location.search,
351
554
  visit: current?.url,
555
+ session_id: liveSession(),
556
+ tab_id: tab,
352
557
  breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : undefined,
353
558
  context,
354
559
  }
@@ -755,6 +960,8 @@ function initialVisit(): Visit | null {
755
960
  duration_ms: duration,
756
961
  status: "success",
757
962
  ...ttfbPhases(nav),
963
+ session_id: liveSession(),
964
+ tab_id: tab,
758
965
  }
759
966
  }
760
967
 
@@ -774,11 +981,16 @@ function finalize() {
774
981
  }
775
982
  // Still flushes on every later hide: a tab can be backgrounded, brought
776
983
  // back, navigated some more, and then closed. Each of those carries a
777
- // final session beat, which the platform dedupes by session id.
984
+ // session beat, which the platform dedupes by session id.
778
985
  flush(true)
779
986
  }
780
987
 
988
+ // A no-op under server-side rendering: the Inertia SSR server runs the same
989
+ // entrypoint, with no window to measure and nothing to send a beacon from.
990
+ // Nothing above touches the DOM until this is called, so importing the
991
+ // module is safe there too.
781
992
  export function startRailwatch(options: RailwatchOptions = {}) {
993
+ if (typeof window === "undefined") return
782
994
  ignoreErrors = [...DEFAULT_IGNORE_ERRORS, ...(options.ignoreErrors ?? [])]
783
995
  denyUrls = [...DEFAULT_DENY_URLS, ...(options.denyUrls ?? [])]
784
996
  tenantOf = options.tenant
@@ -798,7 +1010,10 @@ export function startRailwatch(options: RailwatchOptions = {}) {
798
1010
  // final there.
799
1011
  router.on("before", (event) => {
800
1012
  const v = event.detail.visit
801
- if (!waiting(v) || event.defaultPrevented) return
1013
+ if (event.defaultPrevented) return
1014
+ touch(waiting(v))
1015
+ tag(v)
1016
+ if (!waiting(v)) return
802
1017
  const request = (foregroundRequest = { id: idOf(v, "id") })
803
1018
  queueMicrotask(() => {
804
1019
  if (event.defaultPrevented && foregroundRequest === request)
@@ -817,6 +1032,8 @@ export function startRailwatch(options: RailwatchOptions = {}) {
817
1032
  method: v.method,
818
1033
  partial: Boolean(v.only?.length || v.except?.length),
819
1034
  only: v.only,
1035
+ session_id: liveSession(),
1036
+ tab_id: tab,
820
1037
  }
821
1038
  crumb("navigate", `${v.method.toUpperCase()} ${current.url}`)
822
1039
  })
@@ -847,6 +1064,7 @@ export function startRailwatch(options: RailwatchOptions = {}) {
847
1064
  timer ??= window.setInterval(flush, 5000)
848
1065
  document.addEventListener("visibilitychange", () => {
849
1066
  if (document.visibilityState === "hidden") finalize()
1067
+ else touch(true)
850
1068
  })
851
1069
  window.addEventListener("pagehide", finalize)
852
1070
  }
@@ -110,6 +110,41 @@ module Railwatch
110
110
 
111
111
  MAX_JS_FRAMES = 50
112
112
 
113
+ # A bundler's content hash in a built file's name: Vite and Rollup's
114
+ # "dist-DedfDSJv.js" (base64url, so it can hold a "-" of its own:
115
+ # "dist-CMHaG-3i.js"), esbuild's "app-5XDGVJ2T.js", webpack's
116
+ # "main.3f2a1b9c.js". The name is greedy, so in "yard-canvas-BwMeIV6a.js"
117
+ # only the last segment that can be a hash is one.
118
+ JS_CONTENT_HASH = /\A(?<name>.+)[-.](?<hash>[A-Za-z0-9_-]{8,64})(?<ext>\.[mc]?js)\z/
119
+
120
+ # Where a browser error is filed for grouping. A content hash changes
121
+ # every deploy the bundle changes, so it is taken out of the file name,
122
+ # and so is the line: in a minified bundle the line is wherever the
123
+ # minifier wrapped (Vite writes the whole app on a line or two) and
124
+ # shifts with any change before it, which is why Sentry leaves the line
125
+ # out of a minified frame it has no source map for. The stored frames
126
+ # keep the real file and line; this is for the fingerprint alone.
127
+ def js_fingerprint_location(frame)
128
+ file = frame[:file]
129
+ match = file && JS_CONTENT_HASH.match(file)
130
+ return [ file, frame[:line] ] unless match && content_hash?(match[:hash])
131
+
132
+ [ "#{match[:name]}#{match[:ext]}", nil ]
133
+ end
134
+
135
+ # A word is not a hash: one has a digit, or mixes cases in a way no
136
+ # camelCase or PascalCase name does ("DedfDSJv"). So the "timeline" in
137
+ # "order-timeline.js" and the "PaymentForm" in "checkout-PaymentForm.js"
138
+ # stay part of the name. A hash that happens to read like words (about
139
+ # 1 in 100 of eight letters with no digit) is kept too, which costs that
140
+ # deploy a split issue, never a wrong merge.
141
+ CAMEL_WORDS = /\A[A-Za-z][a-z]+(?:[A-Z][a-z]{2,})+\z/
142
+
143
+ def content_hash?(segment)
144
+ segment.match?(/\d/) ||
145
+ (segment.match?(/[a-z]/) && segment.match?(/[A-Z]/) && !segment.match?(CAMEL_WORDS))
146
+ end
147
+
113
148
  # A browser stack, exactly as the engine wrote it, in the same frame
114
149
  # shape as a Ruby backtrace. `origin` is the app's own scheme and host: a
115
150
  # script served from it is the app's own, so its file is stored relative
@@ -86,7 +86,7 @@ module Railwatch
86
86
  :capture_job_arguments, :capture_job_retry_errors, :capture_response_body_on_error, :max_attachment_bytes,
87
87
  :track_sessions, :session_flush_interval, :session_timeout,
88
88
  :capture_console, :interactive_runner_paths, :ignored_request_paths,
89
- :capture_llm_content, :timing_headers
89
+ :capture_llm_content, :timing_headers, :browser_client
90
90
 
91
91
  attr_reader :deploy, :deploy_source, :detect_deploy, :user_resolver, :beacon_user_resolver,
92
92
  :fingerprint_resolver, :redactors, :rejectors, :before_ingest, :backpressure_high_water
@@ -245,6 +245,11 @@ module Railwatch
245
245
  # (#timing_headers?), since elsewhere they tell every visitor how the
246
246
  # app spends its time.
247
247
  @timing_headers = env_bool("RAILWATCH_TIMING_HEADERS", nil)
248
+ # Whether the app's pages run the browser client, which is what makes
249
+ # it safe to hand a first page load a session cookie (#browser_client?).
250
+ # nil: whether lib/railwatch.ts exists in the app's frontend source
251
+ # directory (Railwatch.frontend_dir), where railwatch:install puts it.
252
+ @browser_client = env_bool("RAILWATCH_BROWSER_CLIENT", nil)
248
253
  @user_resolver = nil
249
254
  @beacon_user_resolver = nil
250
255
  @fingerprint_resolver = nil
@@ -376,6 +381,22 @@ module Railwatch
376
381
  @timing_headers.nil? ? defined?(Rails) && Rails.env.development? : @timing_headers
377
382
  end
378
383
 
384
+ # Whether the browser client is installed (#browser_client). Looked for
385
+ # once, on the first request that asks, when Rails.root is known.
386
+ def browser_client?
387
+ return @browser_client unless @browser_client.nil?
388
+
389
+ @browser_client_installed = browser_client_installed? if @browser_client_installed.nil?
390
+ @browser_client_installed
391
+ end
392
+
393
+ def browser_client_installed?
394
+ return false unless defined?(Rails) && Rails.root
395
+
396
+ dir = Railwatch.frontend_dir(Rails.root)
397
+ dir ? Rails.root.join(dir, "lib/railwatch.ts").exist? : false
398
+ end
399
+
379
400
  # Matches Railwatch.reject_cache_keys entries and DEFAULT_VENDOR_CACHE_KEYS
380
401
  # against a cache key. A Regexp is used as-is. A String starting with "^"
381
402
  # or containing another regex metacharacter is compiled as a Regexp; a
@@ -64,6 +64,7 @@ module Railwatch
64
64
  begin
65
65
  status, headers, body = @app.call(env)
66
66
  server_timing(env, exe, headers) if timing && headers
67
+ Sessions.mint(env, status, headers) if headers
67
68
  rescue Exception => e # rubocop:disable Lint/RescueException
68
69
  Subscribers::Exceptions.capture(e, handled: false, severity: :error, source: "railwatch.middleware")
69
70
  raise
@@ -144,6 +145,8 @@ module Railwatch
144
145
  end
145
146
 
146
147
  def finish(env, exe, status, headers)
148
+ return Railwatch.discard_execution if static_file?(env, status)
149
+
147
150
  exe.finish_stages
148
151
  # Resolved here, once, for both the request record and the session
149
152
  # key below (the start_processing subscriber ran before the app's
@@ -164,6 +167,35 @@ module Railwatch
164
167
  nil
165
168
  end
166
169
 
170
+ # A file ActionDispatch::Static served from public/: a bundle, a
171
+ # stylesheet, a favicon. This middleware sits in front of Static, so
172
+ # without this every one of them is a "GET unmatched" request record
173
+ # -- about 23 per page load, a tenth of rebulk-system's requests --
174
+ # and a row in the session's journey. Only a success or a 304 is
175
+ # skipped: an unmatched 404 is a probe the security tools read.
176
+ #
177
+ # Static's 304 is a bare [304, {}, []], so the response cannot say a
178
+ # file answered it. The path can: it names a file under public/,
179
+ # looked up the way Static looks (unescaped, cleaned, then with
180
+ # ".html" or "/index.html", or only its precompressed ".br" or ".gz",
181
+ # which Static serves in its place). Every check before that one is an
182
+ # env or integer read, so a routed request never touches the
183
+ # filesystem.
184
+ def static_file?(env, status)
185
+ return false if env["railwatch.route"] || env["action_dispatch.route"]
186
+ return false unless status && (status.to_i.between?(200, 299) || status.to_i == 304)
187
+ return false unless env["REQUEST_METHOD"] == "GET" || env["REQUEST_METHOD"] == "HEAD"
188
+
189
+ path = Rack::Utils.unescape_path(env["PATH_INFO"].to_s.chomp("/"))
190
+ return false unless Rack::Utils.valid_path?(path)
191
+
192
+ file = File.join(Rails.public_path.to_s, Rack::Utils.clean_path_info(path))
193
+ [ file, "#{file}.html", File.join(file, "index.html"), "#{file}.br", "#{file}.gz" ]
194
+ .any? { |candidate| File.file?(candidate) }
195
+ rescue StandardError
196
+ false
197
+ end
198
+
167
199
  def parent_fields(env, exe, status, headers)
168
200
  req = ActionDispatch::Request.new(env)
169
201
  route = env["railwatch.route"] || {}
@@ -202,7 +234,8 @@ module Railwatch
202
234
  payload: payload,
203
235
  queue_time: queue_time(env, exe),
204
236
  user_agent: req.user_agent.to_s[0, 256],
205
- files: env["railwatch.files"] || uploaded_files_fallback(req, env)
237
+ files: env["railwatch.files"] || uploaded_files_fallback(req, env),
238
+ **Sessions.browser_fields(env)
206
239
  }
207
240
  end
208
241
 
@@ -26,6 +26,18 @@ module Railwatch
26
26
  # a tab carries the id its beacons already use.
27
27
  COOKIE = /(?:\A|;\s*)railwatch_session=([^;]+)/
28
28
  KEY_LIMIT = 64
29
+ # What the browser client mints for a session or a tab id. Anything else
30
+ # in a header, cookie, or beacon is not one, and is not recorded as one.
31
+ BROWSER_ID = /\A[A-Za-z0-9_-]{1,64}\z/
32
+ # The id Sessions.mint handed this request, read by key_for and
33
+ # browser_fields as if the browser had sent it.
34
+ MINTED = "railwatch.session_id"
35
+ # The client's idle timeout, which the cookie lapses with (railwatch.ts).
36
+ COOKIE_MAX_AGE = 30 * 60
37
+ HTML = "text/html"
38
+ # A response a shared cache may store and serve to anyone. One visitor's
39
+ # cookie on it would be every visitor's.
40
+ SHARED_CACHE = /\b(?:public|s-maxage)\b/i
29
41
 
30
42
  @mutex = Mutex.new
31
43
  @wakeup = ConditionVariable.new
@@ -78,13 +90,61 @@ module Railwatch
78
90
  end
79
91
 
80
92
  # The browser client's id when the request carries one -- so the two
81
- # halves of the same session share a key -- else the resolved user.
93
+ # halves of the same session share a key -- else the resolved user. A
94
+ # first page load the middleware minted an id for keys on that id, but
95
+ # only where it would have keyed on the user: an anonymous request with
96
+ # no cookie (a crawler, curl) opened no session before and opens none
97
+ # now. Its browser, if it runs the client, opens it with the first beat.
82
98
  def key_for(exe, env)
83
- id = env["HTTP_X_RAILWATCH_SESSION"] || COOKIE.match(env["HTTP_COOKIE"])&.[](1)
99
+ id = sent_id(env)
84
100
  return id[0, KEY_LIMIT] unless id.nil? || id.empty?
85
101
 
86
102
  user = exe.user_id
87
- user && "user:#{user}"[0, KEY_LIMIT]
103
+ return nil unless user
104
+ env[MINTED] || "user:#{user}"[0, KEY_LIMIT]
105
+ end
106
+
107
+ # The browser session and tab a request came from, for the request
108
+ # record: only what the browser client sent, never the user fallback
109
+ # above, which names a person rather than a session.
110
+ def browser_fields(env)
111
+ session = browser_id(sent_id(env) || env[MINTED])
112
+ tab = browser_id(env["HTTP_X_RAILWATCH_TAB"])
113
+ { session_id: session, tab_id: tab }.compact
114
+ end
115
+
116
+ def sent_id(env)
117
+ env["HTTP_X_RAILWATCH_SESSION"] || COOKIE.match(env["HTTP_COOKIE"])&.[](1)
118
+ end
119
+
120
+ # A browser's first page load carries no session cookie: the client sets
121
+ # it once the page runs, after this request is over. Without one the
122
+ # request would key on the user, a session no browser ever beats, and
123
+ # the journey would start a page late. So a page the client will run on
124
+ # -- a successful GET of an HTML document, not an Inertia visit, from an
125
+ # app with the client installed -- that arrives with no id gets one here,
126
+ # in the cookie the client reads and adopts. Never on a response a
127
+ # shared cache may keep (Cache-Control public or s-maxage).
128
+ def mint(env, status, headers)
129
+ return unless Railwatch.config.browser_client?
130
+ return unless env["REQUEST_METHOD"] == "GET" && status.to_i == 200
131
+ return if env["HTTP_X_INERTIA"] || sent_id(env)
132
+ return unless headers[Rack::CONTENT_TYPE].to_s.start_with?(HTML)
133
+ return if SHARED_CACHE.match?(headers[Rack::CACHE_CONTROL].to_s)
134
+
135
+ id = env[MINTED] = SecureRandom.hex(8)
136
+ # Secure over HTTPS (behind a TLS proxy too: ssl? reads
137
+ # X-Forwarded-Proto), so the id never rides a plain-HTTP request.
138
+ Rack::Utils.set_cookie_header!(headers, "railwatch_session",
139
+ { value: id, path: "/", max_age: COOKIE_MAX_AGE, same_site: :lax, secure: Rack::Request.new(env).ssl? })
140
+ rescue StandardError => e
141
+ # A frozen header hash: the client mints the id once the page runs.
142
+ env.delete(MINTED)
143
+ Railwatch.debug { "session cookie skipped: #{e.class}: #{e.message}" }
144
+ end
145
+
146
+ def browser_id(value)
147
+ value if value.is_a?(String) && BROWSER_ID.match?(value)
88
148
  end
89
149
 
90
150
  def emit(key, entry, ended)
@@ -248,15 +248,33 @@ module Railwatch
248
248
  error.class.ancestors.any? { |ancestor| (name = ancestor.name) && ignored.include?(name) }
249
249
  end
250
250
 
251
+ # An IPv6 address, full ("2607:fb90:...:74c7") or compressed around
252
+ # "::" ("2600:3c15::2000:22ff:febb:8bb9", "2a03:2880:f802:c::"). It
253
+ # has to carry a digit and stand alone, and it is lower case, as
254
+ # RFC 5952 and IPAddr write it: so a constant path like Foo::Bar or
255
+ # Abc1::Def stays, and a clock time like 12:30:45 has neither eight
256
+ # groups nor a "::".
257
+ IPV6 = /
258
+ (?<![\w:])
259
+ (?=[0-9a-f:]*\d)
260
+ (?:
261
+ [0-9a-f]{1,4}(?::[0-9a-f]{1,4}){7}
262
+ | (?:[0-9a-f]{1,4}(?::[0-9a-f]{1,4}){0,6})?::(?:[0-9a-f]{1,4}(?::[0-9a-f]{1,4}){0,6})?
263
+ )
264
+ (?![\w:])
265
+ /x
266
+
251
267
  # Variable data that would otherwise split one issue into thousands of
252
268
  # them. Applied in this order: a URL before the numbers inside it, a
253
- # quoted string before the id it quotes, hex before plain digits.
269
+ # quoted string before the id it quotes, an IPv6 address and hex before
270
+ # plain digits.
254
271
  MESSAGE_NOISE = [
255
272
  %r{\bhttps?://\S+}, # URLs
256
273
  /\b[^\s@]+@[^\s@]+\.[^\s@]+\b/, # email addresses
257
274
  /\h{8}-\h{4}-\h{4}-\h{4}-\h{12}/, # UUIDs
258
275
  /\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}\S*/, # ISO timestamps
259
276
  /\b\d{1,3}(?:\.\d{1,3}){3}\b/, # IPv4 addresses
277
+ IPV6, # IPv6, before hex and digits
260
278
  /(?<!\w)'[^']*'|"[^"]*"/, # quoted strings ("won't" is not one)
261
279
  /\b(?:0x)?\h{6,}\b/, # hex: digests, object addresses
262
280
  /\b\d+\b/ # plain integers
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "1.4.0"
4
+ VERSION = "1.6.0"
5
5
  end
@@ -57,12 +57,16 @@
57
57
  "headers": {
58
58
  "Host": "www.example.com",
59
59
  "Accept": "text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5",
60
+ "X-Railwatch-Session": "3f2a9c1e7b4d6a08",
61
+ "X-Railwatch-Tab": "9e8d7c6b5a4f3e21",
60
62
  "Cookie": "[FILTERED]"
61
63
  },
62
64
  "payload": null,
63
65
  "queue_time": null,
64
66
  "user_agent": "",
65
- "files": []
67
+ "files": [],
68
+ "session_id": "3f2a9c1e7b4d6a08",
69
+ "tab_id": "9e8d7c6b5a4f3e21"
66
70
  },
67
71
  "job_attempt": {
68
72
  "v": 1,
@@ -609,6 +613,8 @@
609
613
  "redirect_count": null,
610
614
  "connect_ms": null,
611
615
  "server_ms": null,
616
+ "session_id": "3f2a9c1e7b4d6a08",
617
+ "tab_id": "9e8d7c6b5a4f3e21",
612
618
  "user": null,
613
619
  "tenant": null,
614
620
  "user_agent": ""
data/lib/railwatch.rb CHANGED
@@ -199,6 +199,18 @@ module Railwatch
199
199
  Current.execution = exe&.parent_execution
200
200
  end
201
201
 
202
+ # Ends the execution and ships nothing of it, for one that turned out
203
+ # not to be worth a record at all (the request middleware's static
204
+ # files). A profile it started is stopped and thrown away.
205
+ def discard_execution
206
+ exe = Current.execution
207
+ return unless exe
208
+
209
+ Profiler.stop if exe.profiler_handle
210
+ Current.execution = exe.parent_execution
211
+ nil
212
+ end
213
+
202
214
  # --- record writing --------------------------------------------------------
203
215
 
204
216
  # Records that make sense without a parent execution (console, boot).
@@ -607,13 +619,28 @@ require "railwatch/subscribers"
607
619
 
608
620
  module Railwatch
609
621
  # The Inertia browser client, relative to the gem root. The install
610
- # generator copies it into a host's app/frontend/lib.
622
+ # generator copies it into a host's frontend source directory, at lib/.
611
623
  BROWSER_CLIENT = "lib/generators/railwatch/install/templates/railwatch.ts"
624
+ # Where a Rails app keeps its Vite sources when ViteRuby does not say:
625
+ # vite_ruby's default, then the jsbundling/rails_vite one.
626
+ FRONTEND_DIRS = %w[app/frontend app/javascript].freeze
612
627
 
613
628
  def self.browser_client_path
614
629
  File.expand_path("../#{BROWSER_CLIENT}", __dir__)
615
630
  end
616
631
 
632
+ # The app's frontend source directory, relative to `root`: ViteRuby's
633
+ # configured sourceCodeDir when ViteRuby is loaded, else the first of
634
+ # FRONTEND_DIRS that exists, else nil. The install generator copies the
635
+ # client into it, and Configuration#browser_client? looks for it there.
636
+ def self.frontend_dir(root)
637
+ root = Pathname(root)
638
+ configured = defined?(ViteRuby) && (ViteRuby.config.source_code_dir rescue nil)
639
+ return configured.to_s if configured.present? && root.join(configured.to_s).directory?
640
+
641
+ FRONTEND_DIRS.find { |dir| root.join(dir).directory? }
642
+ end
643
+
617
644
  # One realistic wire record per record type, exactly as the gem's own
618
645
  # subscribers emit it, generated by spec/railwatch/wire_fixtures_spec.rb.
619
646
  # A receiver tests its mapper against these instead of hand-writing what
@@ -144,21 +144,22 @@ namespace :railwatch do
144
144
  check.call(hook.exist? && hook.read.include?("railwatch"), "kamal post-deploy hook",
145
145
  hook.exist? ? hook.to_s : "not found (only needed when deploying with Kamal)")
146
146
 
147
- client = Rails.root.join("app/frontend/lib/railwatch.ts")
147
+ frontend = Railwatch.frontend_dir(Rails.root) || "app/frontend"
148
+ client = Rails.root.join(frontend, "lib/railwatch.ts")
148
149
  check.call(client.exist?, "browser client",
149
150
  client.exist? ? "#{client} (call startRailwatch() from your Inertia entrypoint)" : "not found (only needed for Inertia visit timing)")
150
151
 
151
152
  # The client file existing is not the same as it running: startRailwatch()
152
153
  # has to be called from an entrypoint or no visit is ever timed.
153
- entrypoints = Dir[Rails.root.join("app/frontend/entrypoints/*")].select { |f| File.file?(f) }
154
+ entrypoints = Dir[Rails.root.join(frontend, "entrypoints/*")].select { |f| File.file?(f) }
154
155
  started = entrypoints.select { |f| File.read(f).include?("startRailwatch") }
155
156
  check.call(started.any?, "browser client imported",
156
157
  if started.any?
157
158
  started.map { |f| Pathname.new(f).relative_path_from(Rails.root).to_s }.join(", ")
158
159
  elsif entrypoints.any?
159
- "no entrypoint in app/frontend/entrypoints calls startRailwatch()"
160
+ "no entrypoint in #{frontend}/entrypoints calls startRailwatch()"
160
161
  else
161
- "no app/frontend/entrypoints (only needed for Inertia visit timing)"
162
+ "no #{frontend}/entrypoints (only needed for Inertia visit timing)"
162
163
  end)
163
164
 
164
165
  backend = Railwatch::Profiler.backend
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railwatch
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cole Robertson