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 +4 -4
- data/CHANGELOG.md +51 -0
- data/app/controllers/railwatch/beacon_controller.rb +17 -7
- data/docs/configuration.md +39 -7
- data/docs/getting-started.md +19 -3
- data/docs/records.md +45 -16
- data/docs/replacing-sentry.md +1 -1
- data/docs/troubleshooting.md +1 -1
- data/lib/generators/railwatch/install/install_generator.rb +18 -10
- data/lib/generators/railwatch/install/templates/railwatch.ts +256 -38
- data/lib/railwatch/backtrace.rb +35 -0
- data/lib/railwatch/configuration.rb +22 -1
- data/lib/railwatch/middleware/request.rb +34 -1
- data/lib/railwatch/sessions.rb +63 -3
- data/lib/railwatch/subscribers/exceptions.rb +19 -1
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/wire_fixtures.json +7 -1
- data/lib/railwatch.rb +28 -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: 7d52990b8f664355823660082054f5f886d9ec4f3391936984182cf8526141b3
|
|
4
|
+
data.tar.gz: 39936e0eba72d4be5223c20948e01717be021cccaaf2819ccf58156f86b76b4a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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)
|
|
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,
|
|
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
|
|
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)),
|
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
|
|
@@ -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
|
|
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
|
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
|
@@ -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`.
|
|
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
|
|
336
|
-
|
|
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
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
400
|
-
`session` id
|
|
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`,
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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`
|
data/docs/replacing-sentry.md
CHANGED
|
@@ -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. |
|
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
|
}
|
|
@@ -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,
|
|
81
|
-
//
|
|
82
|
-
|
|
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
|
-
|
|
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(
|
|
174
|
-
if (queue.length === 0 && errors.length === 0 && !
|
|
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(
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
|
|
304
|
+
tabs = memoryStore()
|
|
305
|
+
return null
|
|
230
306
|
}
|
|
231
307
|
}
|
|
232
308
|
|
|
233
|
-
function
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
}
|
data/lib/railwatch/backtrace.rb
CHANGED
|
@@ -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
|
|
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)
|
|
@@ -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
|
|
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
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|