railwatch 1.4.0 → 1.5.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 +4 -4
- data/CHANGELOG.md +27 -0
- data/app/controllers/railwatch/beacon_controller.rb +15 -6
- data/docs/configuration.md +33 -6
- data/docs/getting-started.md +19 -3
- data/docs/records.md +28 -10
- data/docs/troubleshooting.md +1 -1
- data/lib/generators/railwatch/install/install_generator.rb +18 -10
- data/lib/generators/railwatch/install/templates/railwatch.ts +245 -35
- data/lib/railwatch/configuration.rb +22 -1
- data/lib/railwatch/middleware/request.rb +3 -1
- data/lib/railwatch/sessions.rb +63 -3
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/wire_fixtures.json +7 -1
- data/lib/railwatch.rb +16 -1
- data/lib/tasks/railwatch_tasks.rake +5 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd59ae7a65fbd673b2069d096802144c04277a08379fa42285d32677ad5502c6
|
|
4
|
+
data.tar.gz: 49ff48d1ec8340609c23c12a117ae1e381ac938f7fbdfb6aa914b7e33505a25c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9c45c3639f2787b16b9104afed286ff712bfb63028e13fd83eda44323544ddb9a156c60566287d5465d6daaeffb46b4a8379addb8a51ebf90ab178f9c17a41d4
|
|
7
|
+
data.tar.gz: 31fe3a1492400bf823ef7dbd04ee22a45edda63349168032b93a3f8e429525cf9cb205b15b96eb6238819a6386916457a75ed3f4de52164227c39e39c8a1a576
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,33 @@
|
|
|
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.5.0 (2026-10-28)
|
|
10
|
+
|
|
11
|
+
- The browser session is one per browser, shared by every tab, as in GA4
|
|
12
|
+
and Amplitude: kept in `localStorage`, it ends after 30 minutes with no
|
|
13
|
+
activity in any tab or 24 hours after it started. Each tab also has its
|
|
14
|
+
own id. Every visit record carries `session_id` and `tab_id`, and every
|
|
15
|
+
request record carries the `session_id` from the `X-Railwatch-Session`
|
|
16
|
+
header or `railwatch_session` cookie and the `tab_id` from
|
|
17
|
+
`X-Railwatch-Tab`, which the client now sends on same-origin Inertia
|
|
18
|
+
visits. A browser error's `context.browser` carries the `session` and
|
|
19
|
+
`tab` it was thrown in, stamped when it fired, and both are dropped
|
|
20
|
+
rather than cut when they are not shaped like an id. A browser's first
|
|
21
|
+
page load, which arrives before the client has set its cookie, is handed
|
|
22
|
+
one by the request middleware (successful HTML `GET`s only, never a
|
|
23
|
+
publicly cacheable one, and only when the browser client is installed:
|
|
24
|
+
`browser_client`), so that page is in the session's journey and no
|
|
25
|
+
longer opens a second `user:<id>` session in release health. A closing
|
|
26
|
+
tab no longer marks the session `ended`: the browser's other tabs may
|
|
27
|
+
still be in it. Apps that copied the client
|
|
28
|
+
(`app/frontend/lib/railwatch.ts`) need the same change in their copy:
|
|
29
|
+
`bin/rails generate railwatch:install --force --no-doctor` updates it.
|
|
30
|
+
- `railwatch:install`, `railwatch:doctor` and the `browser_client`
|
|
31
|
+
detection find the app's frontend sources in ViteRuby's `sourceCodeDir`,
|
|
32
|
+
else in `app/frontend` or `app/javascript`. They only looked in
|
|
33
|
+
`app/frontend` before, so an `app/javascript` app got no client and no
|
|
34
|
+
entrypoint wiring.
|
|
35
|
+
|
|
9
36
|
## 1.4.0 (2026-10-26)
|
|
10
37
|
|
|
11
38
|
- 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,12 +207,19 @@ module Railwatch
|
|
|
206
207
|
Context.current_tenant || (hint.is_a?(String) ? safe_string(hint, 255).presence : nil)
|
|
207
208
|
end
|
|
208
209
|
|
|
209
|
-
# The
|
|
210
|
-
#
|
|
211
|
-
#
|
|
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
|
-
|
|
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)
|
|
@@ -235,7 +243,8 @@ module Railwatch
|
|
|
235
243
|
url: safe_string(error["url"], 2048).presence,
|
|
236
244
|
component: safe_string(error["component"], 255).presence,
|
|
237
245
|
visit: safe_string(error["visit"], 2048).presence,
|
|
238
|
-
session: session_id
|
|
246
|
+
session: session_id,
|
|
247
|
+
tab: Sessions.browser_id(error["tab_id"]),
|
|
239
248
|
user_agent: safe_string(request.user_agent, 256).presence,
|
|
240
249
|
breadcrumbs: breadcrumbs(error["breadcrumbs"]).presence
|
|
241
250
|
}.compact)),
|
data/docs/configuration.md
CHANGED
|
@@ -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
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
`
|
|
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
|
data/docs/getting-started.md
CHANGED
|
@@ -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
|
-
- `
|
|
61
|
-
|
|
62
|
-
|
|
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
|
@@ -104,6 +104,8 @@ boundaries. The `action`/`render` boundaries come from
|
|
|
104
104
|
| `payload` | Filtered request params — only captured when `config.capture_request_payload` is on **and** the request raised an exception (never for successful requests). |
|
|
105
105
|
| `user_agent` | Truncated to 256 chars. |
|
|
106
106
|
| `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. |
|
|
107
|
+
| `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. |
|
|
108
|
+
| `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
109
|
| `profiled` | `true` when a `profile` record shipped for this request; absent otherwise. |
|
|
108
110
|
|
|
109
111
|
### `job_attempt`
|
|
@@ -396,8 +398,10 @@ the normalized message. So browser errors group, split, merge, resolve,
|
|
|
396
398
|
and regress through the same Issue machinery.
|
|
397
399
|
|
|
398
400
|
`context` carries a `browser` key with the page `url`, the Inertia
|
|
399
|
-
`component`, the `visit` the error happened in if any, the
|
|
400
|
-
`session` id
|
|
401
|
+
`component`, the `visit` the error happened in if any, the browser
|
|
402
|
+
`session` id and the `tab` id it was thrown in, both stamped by the client
|
|
403
|
+
when the error fired (the flush's session stands in for a client that
|
|
404
|
+
sends none; each at most 64 of `[A-Za-z0-9_-]`, absent otherwise), the `user_agent`, and up to 20 `breadcrumbs`. Each
|
|
401
405
|
breadcrumb is `{at, kind, text}`, with `kind` being `console`, `click`,
|
|
402
406
|
or `navigate`. The breadcrumbs are the trail the client recorded before
|
|
403
407
|
the crash. Anything the app passed as `reportError(error, context)` is
|
|
@@ -776,6 +780,8 @@ entirely if `config.beacon_enabled` is off.
|
|
|
776
780
|
| `user` | Resolved server-side from the beacon request's session/cookies, same resolver as every other record. |
|
|
777
781
|
| `tenant` | Current tenant context. |
|
|
778
782
|
| `user_agent` | Truncated to 256 chars. |
|
|
783
|
+
| `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. |
|
|
784
|
+
| `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
785
|
|
|
780
786
|
The **initial page load** is reported as a visit too, even though Inertia
|
|
781
787
|
never routed it. It has `method` `"GET"`, `status` `"success"`, and
|
|
@@ -799,13 +805,25 @@ per deploy and reports crash-free rates from them. Two sources produce
|
|
|
799
805
|
the same record:
|
|
800
806
|
|
|
801
807
|
- **Browser**, `source: "browser"`. The client,
|
|
802
|
-
`app/frontend/lib/railwatch.ts`,
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
808
|
+
`app/frontend/lib/railwatch.ts`, keeps one session per browser, shared
|
|
809
|
+
by every tab, the way GA4 and Amplitude do: a 16-hex id in
|
|
810
|
+
`localStorage` (`railwatch.session`, with `railwatch.session.at` when it
|
|
811
|
+
started and `railwatch.session.seen` for its last activity in any tab).
|
|
812
|
+
Activity is a page load, an Inertia visit the user waits on, and a tab
|
|
813
|
+
coming back into view. After 30 minutes with no activity in any tab, or
|
|
814
|
+
24 hours after it started, the next activity mints a new id. When
|
|
815
|
+
`localStorage` throws the session lives in `sessionStorage`, and when
|
|
816
|
+
that throws too, in memory; a new tab still joins the session through
|
|
817
|
+
the cookie. A browser's first page load gets its id from the server, in
|
|
818
|
+
that cookie, and the client adopts it when no live session is stored.
|
|
819
|
+
The client mirrors the id into a `railwatch_session` cookie that lapses
|
|
820
|
+
after 30 minutes, renewed on every activity, and sends it with every
|
|
821
|
+
beacon flush. `Railwatch::BeaconController` writes at most one `session` record
|
|
822
|
+
per flush. The first, with no `duration_ms` yet, opens the session.
|
|
823
|
+
Later ones beat it along. Closing a tab does not end the session, since
|
|
824
|
+
other tabs may still be in it; a tab that sees the session time out
|
|
825
|
+
sends one last beat with `ended`, and a session no tab comes back to
|
|
826
|
+
ends only by the platform's own timeout.
|
|
809
827
|
- **Server**, `source: "server"`. `lib/railwatch/sessions.rb` aggregates,
|
|
810
828
|
per process, every request that resolves a user or carries that cookie
|
|
811
829
|
or an `X-Railwatch-Session` header. A background thread ships one
|
|
@@ -830,7 +848,7 @@ halves of one session rather than counting it twice.
|
|
|
830
848
|
| `requests` | Requests in this session so far (server only). |
|
|
831
849
|
| `visits` | Inertia visits in this beacon flush (browser only). |
|
|
832
850
|
| `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. |
|
|
851
|
+
| `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
852
|
| `user` | Resolved user id, when there is one. |
|
|
835
853
|
|
|
836
854
|
### `process`
|
data/docs/troubleshooting.md
CHANGED
|
@@ -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` | `
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
74
|
-
copy_file Railwatch.browser_client_path.to_s, "
|
|
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
|
|
83
|
+
return unless frontend_dir
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
35
|
-
//
|
|
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
|
}
|
|
@@ -90,14 +97,26 @@ const DEFAULT_DENY_URLS = [
|
|
|
90
97
|
]
|
|
91
98
|
|
|
92
99
|
const SESSION_ID_KEY = "railwatch.session"
|
|
100
|
+
const SESSION_SEEN_KEY = "railwatch.session.seen"
|
|
93
101
|
const SESSION_STARTED_KEY = "railwatch.session.at"
|
|
102
|
+
const TAB_KEY = "railwatch.tab"
|
|
103
|
+
// The GA4/Amplitude rules: a session is over after 30 minutes with no
|
|
104
|
+
// activity in any tab (config.session_timeout's default, which the server
|
|
105
|
+
// half times out on too), and after 24 hours whatever happens.
|
|
106
|
+
const SESSION_TIMEOUT_MS = 30 * 60 * 1000
|
|
107
|
+
const SESSION_CAP_MS = 24 * 60 * 60 * 1000
|
|
108
|
+
// Railwatch::Sessions reads these, ahead of the cookie.
|
|
109
|
+
const SESSION_HEADER = "X-Railwatch-Session"
|
|
110
|
+
const TAB_HEADER = "X-Railwatch-Tab"
|
|
94
111
|
|
|
95
112
|
const queue: Visit[] = []
|
|
96
113
|
const errors: JsError[] = []
|
|
97
114
|
const crumbs: Crumb[] = []
|
|
98
115
|
let current: Visit | null = null
|
|
99
116
|
let initial: Visit | null = null
|
|
100
|
-
|
|
117
|
+
// `seen` is the session's last activity in any tab, as far as this one knows.
|
|
118
|
+
let session: { id: string; started_at: number; seen: number } | null = null
|
|
119
|
+
let tab = ""
|
|
101
120
|
// The Inertia page component the user is on, so an error that fires between
|
|
102
121
|
// visits still says which screen it broke.
|
|
103
122
|
let component: string | undefined
|
|
@@ -170,12 +189,13 @@ function post(payload: {
|
|
|
170
189
|
}).catch(() => undefined)
|
|
171
190
|
}
|
|
172
191
|
|
|
173
|
-
function flush(
|
|
174
|
-
if (queue.length === 0 && errors.length === 0 && !
|
|
192
|
+
function flush(force = false) {
|
|
193
|
+
if (queue.length === 0 && errors.length === 0 && !force) return
|
|
194
|
+
touch(false)
|
|
175
195
|
post({
|
|
176
196
|
visits: queue.splice(0, queue.length),
|
|
177
197
|
errors: errors.splice(0, errors.length),
|
|
178
|
-
session: beat(
|
|
198
|
+
session: beat(),
|
|
179
199
|
tenant: tenant(),
|
|
180
200
|
})
|
|
181
201
|
}
|
|
@@ -199,40 +219,220 @@ function randomId() {
|
|
|
199
219
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
|
|
200
220
|
}
|
|
201
221
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
222
|
+
interface Store {
|
|
223
|
+
getItem(key: string): string | null
|
|
224
|
+
setItem(key: string, value: string): void
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Where the session lives: localStorage, so every tab of the browser shares
|
|
228
|
+
// one; sessionStorage when localStorage throws (storage disabled, a
|
|
229
|
+
// sandboxed frame), which narrows the session to the tab; memory when both
|
|
230
|
+
// throw, which narrows it to the page. The tab id lives in sessionStorage,
|
|
231
|
+
// which survives a reload of the tab and nothing else, or in memory.
|
|
232
|
+
let sessions: Store = memoryStore()
|
|
233
|
+
let tabs: Store = memoryStore()
|
|
234
|
+
|
|
235
|
+
function storage(kind: "localStorage" | "sessionStorage"): Store | undefined {
|
|
236
|
+
try {
|
|
237
|
+
const store = window[kind]
|
|
238
|
+
store.setItem("railwatch.probe", "1")
|
|
239
|
+
store.removeItem("railwatch.probe")
|
|
240
|
+
return store
|
|
241
|
+
} catch {
|
|
242
|
+
// Disabled, or denied to this frame. The caller tries the next one.
|
|
243
|
+
return undefined
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function memoryStore(): Store {
|
|
248
|
+
const values = new Map<string, string>()
|
|
249
|
+
return {
|
|
250
|
+
getItem: (key) => values.get(key) ?? null,
|
|
251
|
+
setItem: (key, value) => void values.set(key, value),
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// A full store throws on write. The session then lasts as long as this
|
|
256
|
+
// page's memory of it, which is all a write could have bought.
|
|
257
|
+
function save(store: Store, key: string, value: string) {
|
|
258
|
+
try {
|
|
259
|
+
store.setItem(key, value)
|
|
260
|
+
} catch {
|
|
261
|
+
// Quota exceeded.
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Mirrors the id into a cookie so every request the browser makes carries
|
|
266
|
+
// it, full page loads included -- that is what lets the server side of the
|
|
267
|
+
// session (Railwatch::Sessions) join the browser side under one id. It
|
|
268
|
+
// lapses with the session's idle timeout, so the first page load after one
|
|
269
|
+
// does not carry the dead id; every activity in any tab renews it.
|
|
270
|
+
function remember(id: string) {
|
|
207
271
|
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
272
|
// Railwatch::Sessions::COOKIE reads this name to stitch server requests to the browser session.
|
|
217
|
-
document.cookie = `railwatch_session=${id}; path=/; SameSite=Lax`
|
|
218
|
-
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
273
|
+
document.cookie = `railwatch_session=${id}; path=/; max-age=${SESSION_TIMEOUT_MS / 1000}; SameSite=Lax`
|
|
274
|
+
} catch {
|
|
275
|
+
// A sandboxed frame has no cookies. Visits still carry the headers.
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// A store that passed the probe can still throw later: the user turns
|
|
280
|
+
// storage off, or the browser revokes it. From then on the page keeps its
|
|
281
|
+
// own copy, seeded with the session it is in, so losing storage neither
|
|
282
|
+
// stops the client nor ends the session.
|
|
283
|
+
function read(key: string): string | null {
|
|
284
|
+
try {
|
|
285
|
+
return sessions.getItem(key)
|
|
286
|
+
} catch {
|
|
287
|
+
sessions = memoryStore()
|
|
288
|
+
if (session) {
|
|
289
|
+
sessions.setItem(SESSION_ID_KEY, session.id)
|
|
290
|
+
sessions.setItem(SESSION_STARTED_KEY, String(session.started_at))
|
|
291
|
+
sessions.setItem(SESSION_SEEN_KEY, String(session.seen))
|
|
292
|
+
}
|
|
293
|
+
return sessions.getItem(key)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function readTab(): string | null {
|
|
298
|
+
try {
|
|
299
|
+
return tabs.getItem(TAB_KEY)
|
|
227
300
|
} catch {
|
|
228
|
-
|
|
229
|
-
|
|
301
|
+
tabs = memoryStore()
|
|
302
|
+
return null
|
|
230
303
|
}
|
|
231
304
|
}
|
|
232
305
|
|
|
233
|
-
function
|
|
306
|
+
function startSession() {
|
|
307
|
+
sessions =
|
|
308
|
+
storage("localStorage") ?? storage("sessionStorage") ?? memoryStore()
|
|
309
|
+
tabs = storage("sessionStorage") ?? memoryStore()
|
|
310
|
+
tab = readTab() ?? randomId()
|
|
311
|
+
save(tabs, TAB_KEY, tab)
|
|
312
|
+
touch(true)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Brings this tab's session up to date with the browser's, joining the one
|
|
316
|
+
// another tab opened meanwhile. `active` is the user doing something -- the
|
|
317
|
+
// page loading, a visit they wait on, the tab coming back into view -- which
|
|
318
|
+
// keeps the session alive, or opens a new one when the last went 30 minutes
|
|
319
|
+
// without activity in any tab or is 24 hours old. Anything else (a
|
|
320
|
+
// background poll, a prefetch, a flush) only joins a live session, so a tab
|
|
321
|
+
// left polling cannot hold its session open until the cap.
|
|
322
|
+
function touch(active: boolean) {
|
|
323
|
+
const now = Date.now()
|
|
324
|
+
const id = read(SESSION_ID_KEY)
|
|
325
|
+
const seen = Number(read(SESSION_SEEN_KEY)) || 0
|
|
326
|
+
const startedAt = Number(read(SESSION_STARTED_KEY)) || 0
|
|
327
|
+
const live = id !== null && !expired(startedAt, seen, now)
|
|
328
|
+
if (live && id === session?.id) session.seen = Math.max(session.seen, seen)
|
|
329
|
+
else if (live) begin(id, startedAt, seen, false)
|
|
330
|
+
else if (active) begin(minted(id) ?? randomId(), now, now, true)
|
|
331
|
+
if (!active || !session) return
|
|
332
|
+
session.seen = now
|
|
333
|
+
save(sessions, SESSION_SEEN_KEY, String(now))
|
|
334
|
+
remember(session.id)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The id the server put in the cookie for this browser's first page load
|
|
338
|
+
// (Railwatch::Sessions.mint), so the session starts with that request rather
|
|
339
|
+
// than after it. Not the session that just expired, whose id the cookie
|
|
340
|
+
// can still hold.
|
|
341
|
+
function minted(expiredId: string | null): string | undefined {
|
|
342
|
+
try {
|
|
343
|
+
const id = /(?:^|;\s*)railwatch_session=([A-Za-z0-9_-]{1,64})(?:;|$)/.exec(
|
|
344
|
+
document.cookie,
|
|
345
|
+
)?.[1]
|
|
346
|
+
return id && id !== expiredId && id !== session?.id ? id : undefined
|
|
347
|
+
} catch {
|
|
348
|
+
// A sandboxed frame has no cookies.
|
|
349
|
+
return undefined
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function expired(startedAt: number, seen: number, now: number) {
|
|
354
|
+
return now - seen >= SESSION_TIMEOUT_MS || now - startedAt >= SESSION_CAP_MS
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// The session this tab is in, while it is live. A background visit after it
|
|
358
|
+
// timed out belongs to no session: it is neither tagged nor recorded as one.
|
|
359
|
+
function liveSession(): string | undefined {
|
|
360
|
+
if (!session || expired(session.started_at, session.seen, Date.now()))
|
|
361
|
+
return undefined
|
|
362
|
+
return session.id
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Moves this tab into a session. The one it was in is over -- a session is
|
|
366
|
+
// only replaced once it has timed out or reached the cap -- so this tab
|
|
367
|
+
// says so with a last, `ended` beat. Closing a tab sends no such thing: the
|
|
368
|
+
// browser's other tabs may still be in the session, and nothing a closing
|
|
369
|
+
// tab knows says otherwise. A session no tab comes back to ends where the
|
|
370
|
+
// server half's does, on its own timeout.
|
|
371
|
+
function begin(id: string, startedAt: number, seen: number, minted: boolean) {
|
|
372
|
+
if (session)
|
|
373
|
+
post({
|
|
374
|
+
visits: [],
|
|
375
|
+
errors: [],
|
|
376
|
+
session: {
|
|
377
|
+
id: session.id,
|
|
378
|
+
started_at: session.started_at,
|
|
379
|
+
duration_ms: session.seen - session.started_at,
|
|
380
|
+
ended: true,
|
|
381
|
+
},
|
|
382
|
+
tenant: tenant(),
|
|
383
|
+
})
|
|
384
|
+
session = { id, started_at: startedAt, seen }
|
|
385
|
+
if (!minted) return
|
|
386
|
+
save(sessions, SESSION_ID_KEY, id)
|
|
387
|
+
save(sessions, SESSION_STARTED_KEY, String(startedAt))
|
|
388
|
+
save(sessions, SESSION_SEEN_KEY, String(seen))
|
|
389
|
+
// No duration on the first beat: that is what opens the session.
|
|
390
|
+
post({
|
|
391
|
+
visits: [],
|
|
392
|
+
errors: [],
|
|
393
|
+
session: { id, started_at: startedAt },
|
|
394
|
+
tenant: tenant(),
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// While the session is live it runs to now, whichever tab is beating it. One
|
|
399
|
+
// this tab has watched time out ran to its last activity, and is `ended`.
|
|
400
|
+
function beat(): Session | undefined {
|
|
234
401
|
if (!session) return undefined
|
|
235
|
-
|
|
402
|
+
const now = Date.now()
|
|
403
|
+
const ended = expired(session.started_at, session.seen, now)
|
|
404
|
+
return {
|
|
405
|
+
id: session.id,
|
|
406
|
+
started_at: session.started_at,
|
|
407
|
+
duration_ms: (ended ? session.seen : now) - session.started_at,
|
|
408
|
+
ended,
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Inertia sends a visit with whatever headers the visit object holds once
|
|
413
|
+
// `before` has fired, so setting them here tags every visit, prefetch, poll
|
|
414
|
+
// and partial reload with the session and the tab -- which is how a request
|
|
415
|
+
// record joins its journey under the tab it came from. Only for the page's
|
|
416
|
+
// own origin: a custom header makes a cross-origin request preflight, and
|
|
417
|
+
// another origin is not this app's server anyway. The headers object is
|
|
418
|
+
// replaced, not written into, because it may be the app's own options. The
|
|
419
|
+
// prefetch cache compares headers; a prefetch and the click it serves are
|
|
420
|
+
// tagged alike, so the prefetch still serves.
|
|
421
|
+
function tag(visit: { url: unknown; headers?: Record<string, string> }) {
|
|
422
|
+
const id = liveSession()
|
|
423
|
+
if (!id) return
|
|
424
|
+
try {
|
|
425
|
+
if (new URL(String(visit.url), location.href).origin !== location.origin)
|
|
426
|
+
return
|
|
427
|
+
} catch {
|
|
428
|
+
// Not a URL Inertia could visit either.
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
visit.headers = {
|
|
432
|
+
...visit.headers,
|
|
433
|
+
[SESSION_HEADER]: id,
|
|
434
|
+
[TAB_HEADER]: tab,
|
|
435
|
+
}
|
|
236
436
|
}
|
|
237
437
|
|
|
238
438
|
// --- Breadcrumbs -------------------------------------------------------
|
|
@@ -349,6 +549,8 @@ function capture(
|
|
|
349
549
|
component,
|
|
350
550
|
url: location.pathname + location.search,
|
|
351
551
|
visit: current?.url,
|
|
552
|
+
session_id: liveSession(),
|
|
553
|
+
tab_id: tab,
|
|
352
554
|
breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : undefined,
|
|
353
555
|
context,
|
|
354
556
|
}
|
|
@@ -755,6 +957,8 @@ function initialVisit(): Visit | null {
|
|
|
755
957
|
duration_ms: duration,
|
|
756
958
|
status: "success",
|
|
757
959
|
...ttfbPhases(nav),
|
|
960
|
+
session_id: liveSession(),
|
|
961
|
+
tab_id: tab,
|
|
758
962
|
}
|
|
759
963
|
}
|
|
760
964
|
|
|
@@ -774,7 +978,7 @@ function finalize() {
|
|
|
774
978
|
}
|
|
775
979
|
// Still flushes on every later hide: a tab can be backgrounded, brought
|
|
776
980
|
// back, navigated some more, and then closed. Each of those carries a
|
|
777
|
-
//
|
|
981
|
+
// session beat, which the platform dedupes by session id.
|
|
778
982
|
flush(true)
|
|
779
983
|
}
|
|
780
984
|
|
|
@@ -798,7 +1002,10 @@ export function startRailwatch(options: RailwatchOptions = {}) {
|
|
|
798
1002
|
// final there.
|
|
799
1003
|
router.on("before", (event) => {
|
|
800
1004
|
const v = event.detail.visit
|
|
801
|
-
if (
|
|
1005
|
+
if (event.defaultPrevented) return
|
|
1006
|
+
touch(waiting(v))
|
|
1007
|
+
tag(v)
|
|
1008
|
+
if (!waiting(v)) return
|
|
802
1009
|
const request = (foregroundRequest = { id: idOf(v, "id") })
|
|
803
1010
|
queueMicrotask(() => {
|
|
804
1011
|
if (event.defaultPrevented && foregroundRequest === request)
|
|
@@ -817,6 +1024,8 @@ export function startRailwatch(options: RailwatchOptions = {}) {
|
|
|
817
1024
|
method: v.method,
|
|
818
1025
|
partial: Boolean(v.only?.length || v.except?.length),
|
|
819
1026
|
only: v.only,
|
|
1027
|
+
session_id: liveSession(),
|
|
1028
|
+
tab_id: tab,
|
|
820
1029
|
}
|
|
821
1030
|
crumb("navigate", `${v.method.toUpperCase()} ${current.url}`)
|
|
822
1031
|
})
|
|
@@ -847,6 +1056,7 @@ export function startRailwatch(options: RailwatchOptions = {}) {
|
|
|
847
1056
|
timer ??= window.setInterval(flush, 5000)
|
|
848
1057
|
document.addEventListener("visibilitychange", () => {
|
|
849
1058
|
if (document.visibilityState === "hidden") finalize()
|
|
1059
|
+
else touch(true)
|
|
850
1060
|
})
|
|
851
1061
|
window.addEventListener("pagehide", finalize)
|
|
852
1062
|
}
|
|
@@ -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
|
|
@@ -202,7 +203,8 @@ module Railwatch
|
|
|
202
203
|
payload: payload,
|
|
203
204
|
queue_time: queue_time(env, exe),
|
|
204
205
|
user_agent: req.user_agent.to_s[0, 256],
|
|
205
|
-
files: env["railwatch.files"] || uploaded_files_fallback(req, env)
|
|
206
|
+
files: env["railwatch.files"] || uploaded_files_fallback(req, env),
|
|
207
|
+
**Sessions.browser_fields(env)
|
|
206
208
|
}
|
|
207
209
|
end
|
|
208
210
|
|
data/lib/railwatch/sessions.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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)
|
data/lib/railwatch/version.rb
CHANGED
|
@@ -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
|
@@ -607,13 +607,28 @@ require "railwatch/subscribers"
|
|
|
607
607
|
|
|
608
608
|
module Railwatch
|
|
609
609
|
# The Inertia browser client, relative to the gem root. The install
|
|
610
|
-
# generator copies it into a host's
|
|
610
|
+
# generator copies it into a host's frontend source directory, at lib/.
|
|
611
611
|
BROWSER_CLIENT = "lib/generators/railwatch/install/templates/railwatch.ts"
|
|
612
|
+
# Where a Rails app keeps its Vite sources when ViteRuby does not say:
|
|
613
|
+
# vite_ruby's default, then the jsbundling/rails_vite one.
|
|
614
|
+
FRONTEND_DIRS = %w[app/frontend app/javascript].freeze
|
|
612
615
|
|
|
613
616
|
def self.browser_client_path
|
|
614
617
|
File.expand_path("../#{BROWSER_CLIENT}", __dir__)
|
|
615
618
|
end
|
|
616
619
|
|
|
620
|
+
# The app's frontend source directory, relative to `root`: ViteRuby's
|
|
621
|
+
# configured sourceCodeDir when ViteRuby is loaded, else the first of
|
|
622
|
+
# FRONTEND_DIRS that exists, else nil. The install generator copies the
|
|
623
|
+
# client into it, and Configuration#browser_client? looks for it there.
|
|
624
|
+
def self.frontend_dir(root)
|
|
625
|
+
root = Pathname(root)
|
|
626
|
+
configured = defined?(ViteRuby) && (ViteRuby.config.source_code_dir rescue nil)
|
|
627
|
+
return configured.to_s if configured.present? && root.join(configured.to_s).directory?
|
|
628
|
+
|
|
629
|
+
FRONTEND_DIRS.find { |dir| root.join(dir).directory? }
|
|
630
|
+
end
|
|
631
|
+
|
|
617
632
|
# One realistic wire record per record type, exactly as the gem's own
|
|
618
633
|
# subscribers emit it, generated by spec/railwatch/wire_fixtures_spec.rb.
|
|
619
634
|
# 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
|
-
|
|
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("
|
|
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
|
|
160
|
+
"no entrypoint in #{frontend}/entrypoints calls startRailwatch()"
|
|
160
161
|
else
|
|
161
|
-
"no
|
|
162
|
+
"no #{frontend}/entrypoints (only needed for Inertia visit timing)"
|
|
162
163
|
end)
|
|
163
164
|
|
|
164
165
|
backend = Railwatch::Profiler.backend
|