railwatch 1.2.0 → 1.3.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 +14 -0
- data/app/controllers/railwatch/beacon_controller.rb +33 -1
- data/docs/records.md +10 -2
- data/lib/generators/railwatch/install/templates/railwatch.ts +61 -2
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/wire_fixtures.json +4 -0
- 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: af26db042e24211ffde4ad4e82dd90a7bc27e37c0bc9f74674947a30109b0036
|
|
4
|
+
data.tar.gz: 8c8033a1fa3f4b97142dd6b3593cf4bcc7fa16d2bea97fed6d580ee1584133de
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1ab758eed04fbcc9088989d5c411c88e0e80d8c3fbc08195c2908f04fcf38a21ad6b9ed8ec3ab8363081fcad59886f1abff8efb93bd6abfaeb156bada0bb66dc
|
|
7
|
+
data.tar.gz: 81e0e6e114d46a70619e21ae97816f451b83ccee7ee3f8e8aad8f2ccce8bd487beb31bfdea4cbbdf467357da7e7c4ae1b32dad69eae317e55083755897f3aa23
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,20 @@
|
|
|
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.3.0 (2026-10-25)
|
|
10
|
+
|
|
11
|
+
- The initial-load visit splits its TTFB into `redirect_ms`,
|
|
12
|
+
`connect_ms` and `server_ms`, which add up to `ttfb`, plus
|
|
13
|
+
`redirect_count`. TTFB is measured from navigation start, so it holds
|
|
14
|
+
every redirect before the page: a 1.2 s TTFB on a route the server
|
|
15
|
+
answers in 100 ms was a redirect chain, and nothing said so.
|
|
16
|
+
`redirect_ms` (navigation start to `fetchStart`) is still measured when
|
|
17
|
+
a hop is cross-origin, where browsers report `redirectCount` 0; the
|
|
18
|
+
client then leaves `redirect_count` out rather than claim no redirect.
|
|
19
|
+
The beacon accepts and clamps the four fields. Receivers must store them
|
|
20
|
+
for them to show anywhere. Apps that copied the client
|
|
21
|
+
(`app/frontend/lib/railwatch.ts`) need the same change in their copy.
|
|
22
|
+
|
|
9
23
|
## 1.2.0 (2026-10-25)
|
|
10
24
|
|
|
11
25
|
- Executions send only the counters that fired. Every parent record
|
|
@@ -10,6 +10,8 @@ module Railwatch
|
|
|
10
10
|
# forged beacon, not a page load, so it is clamped rather than stored.
|
|
11
11
|
MAX_METRIC_MS = 120_000
|
|
12
12
|
MAX_CLS = 100.0
|
|
13
|
+
# Browsers stop following a redirect chain at 20 (Fetch's limit).
|
|
14
|
+
MAX_REDIRECTS = 20
|
|
13
15
|
|
|
14
16
|
MAX_REQUEST_BYTES = 256 * 1024
|
|
15
17
|
MAX_VISITS = 50
|
|
@@ -50,6 +52,7 @@ module Railwatch
|
|
|
50
52
|
cls: cumulative_layout_shift(v["cls"]),
|
|
51
53
|
inp: metric_ms(v["inp"]),
|
|
52
54
|
ttfb: metric_ms(v["ttfb"]),
|
|
55
|
+
**ttfb_phases(v),
|
|
53
56
|
user: user_id,
|
|
54
57
|
tenant: tenant,
|
|
55
58
|
user_agent: safe_string(request.user_agent, 256))
|
|
@@ -274,9 +277,38 @@ module Railwatch
|
|
|
274
277
|
# Web vitals only ride along on the initial-load visit, and only from
|
|
275
278
|
# browsers that support the entry type behind them, so every one of these
|
|
276
279
|
# is nil far more often than not.
|
|
280
|
+
# A value that is not a finite number ("1e309" parses to Infinity, which
|
|
281
|
+
# #round refuses) is nil, like a missing one: raising here would drop the
|
|
282
|
+
# whole beacon at the rescue below, every other visit in it included.
|
|
277
283
|
def metric_ms(value)
|
|
284
|
+
finite_ms(value)&.round&.clamp(0, MAX_METRIC_MS)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# The initial load's TTFB split into phases. They add up to ttfb, so if
|
|
288
|
+
# clamping any of them (or ttfb itself) would break that sum, the split is
|
|
289
|
+
# dropped rather than stored inconsistent: ttfb stays, the phases are nil.
|
|
290
|
+
def ttfb_phases(visit)
|
|
291
|
+
raw = %w[redirect_ms connect_ms server_ms].to_h { |key| [ key, finite_ms(visit[key]) ] }
|
|
292
|
+
phases = raw.transform_values { |ms| ms&.round&.clamp(0, MAX_METRIC_MS) }
|
|
293
|
+
total = finite_ms(visit["ttfb"])
|
|
294
|
+
clamped = raw.any? { |key, ms| ms && ms.round != phases[key] }
|
|
295
|
+
over = total && total.round > MAX_METRIC_MS
|
|
296
|
+
phases = phases.transform_values { nil } if clamped || over
|
|
297
|
+
phases.transform_keys(&:to_sym).merge(redirect_count: redirect_count(visit["redirect_count"]))
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def finite_ms(value)
|
|
301
|
+
return nil if value.nil? || value == ""
|
|
302
|
+
float = safe_float(value)
|
|
303
|
+
float.finite? ? float : nil
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# How many redirects the browser SHOWED. It hides them when any hop was
|
|
307
|
+
# cross-origin, so the client sends nothing then rather than 0, and a
|
|
308
|
+
# nil here must stay nil: "none" and "hidden" are different answers.
|
|
309
|
+
def redirect_count(value)
|
|
278
310
|
return nil if value.nil? || value == ""
|
|
279
|
-
|
|
311
|
+
safe_integer(value).clamp(0, MAX_REDIRECTS)
|
|
280
312
|
end
|
|
281
313
|
|
|
282
314
|
def cumulative_layout_shift(value)
|
data/docs/records.md
CHANGED
|
@@ -768,7 +768,11 @@ entirely if `config.beacon_enabled` is off.
|
|
|
768
768
|
| `lcp` | Largest Contentful Paint, integer ms, clamped to 0–120000. Initial load only. |
|
|
769
769
|
| `cls` | Cumulative Layout Shift — the largest session window, float rounded to 4 decimals, clamped to 0–100. Initial load only. |
|
|
770
770
|
| `inp` | Interaction to Next Paint, integer ms, clamped to 0–120000. The slowest interaction, not the spec's high percentile. Initial load only. |
|
|
771
|
-
| `ttfb` | Time to First Byte from navigation timing's `responseStart`, integer ms, clamped to 0–120000. Initial load only. |
|
|
771
|
+
| `ttfb` | Time to First Byte from navigation timing's `responseStart`, integer ms, clamped to 0–120000. Initial load only. Includes every redirect before the page. |
|
|
772
|
+
| `redirect_ms` | The part of `ttfb` before the final request started: navigation start to `fetchStart` (`workerStart` when a service worker started first). Every redirect hop, plus a few ms of the browser's own work. Still measured when a hop is cross-origin, when the browser hides the count. Integer ms, clamped to 0–120000. Initial load only. |
|
|
773
|
+
| `redirect_count` | Redirects the browser **showed** (`redirectCount`), clamped to 0–20. Nil when it showed none, which is either no redirect or a cross-origin hop it hid: never read nil as "no redirect", read `redirect_ms`. Initial load only. |
|
|
774
|
+
| `connect_ms` | `ttfb` from the final request's start to `requestStart`: service worker startup, HTTP cache, DNS, TCP and TLS. Integer ms, clamped to 0–120000. Initial load only. |
|
|
775
|
+
| `server_ms` | `ttfb` from `requestStart` to `responseStart`: the network round trip plus the server's time. Integer ms, clamped to 0–120000. Initial load only. |
|
|
772
776
|
| `user` | Resolved server-side from the beacon request's session/cookies, same resolver as every other record. |
|
|
773
777
|
| `tenant` | Current tenant context. |
|
|
774
778
|
| `user_agent` | Truncated to 256 chars. |
|
|
@@ -781,7 +785,11 @@ never routed it. It has `method` `"GET"`, `status` `"success"`, and
|
|
|
781
785
|
four Core Web Vitals. It is held back until the page is first hidden, on
|
|
782
786
|
`visibilitychange`/`pagehide`, so those numbers are final when it ships.
|
|
783
787
|
Every vital is nil on a browser that doesn't support the
|
|
784
|
-
`PerformanceObserver` entry type behind it.
|
|
788
|
+
`PerformanceObserver` entry type behind it. `redirect_ms`, `connect_ms`
|
|
789
|
+
and `server_ms` add up to `ttfb` exactly (the boundaries are rounded
|
|
790
|
+
before subtracting); all three are nil when the browser's timestamps are
|
|
791
|
+
missing or out of order, and when clamping `ttfb` or any phase to the
|
|
792
|
+
120,000 ms cap would leave a split that no longer adds up.
|
|
785
793
|
|
|
786
794
|
### `session`
|
|
787
795
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Railwatch browser client for Inertia. Reports each visit's duration,
|
|
2
2
|
// component, and prop payload size to /railwatch/beacon so the platform can
|
|
3
|
-
// show real page-load timing, plus Core Web Vitals (LCP, CLS, INP, TTFB
|
|
4
|
-
//
|
|
3
|
+
// show real page-load timing, plus Core Web Vitals (LCP, CLS, INP, TTFB,
|
|
4
|
+
// with TTFB split into redirect / connection / server time) for the
|
|
5
|
+
// initial page load, plus every JavaScript error the page throws
|
|
5
6
|
// with the breadcrumb trail that led to it.
|
|
6
7
|
// No dependencies -- every metric comes from PerformanceObserver or the
|
|
7
8
|
// navigation timing entry, and every API is feature-detected, so browsers
|
|
@@ -23,6 +24,11 @@ interface Visit {
|
|
|
23
24
|
cls?: number
|
|
24
25
|
inp?: number
|
|
25
26
|
ttfb?: number
|
|
27
|
+
// TTFB split into phases (initial load only; see ttfbPhases).
|
|
28
|
+
redirect_ms?: number
|
|
29
|
+
redirect_count?: number
|
|
30
|
+
connect_ms?: number
|
|
31
|
+
server_ms?: number
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
// The tab's session, for release health. sessionStorage scopes it to the
|
|
@@ -679,6 +685,58 @@ function pageComponent(): string | undefined {
|
|
|
679
685
|
}
|
|
680
686
|
}
|
|
681
687
|
|
|
688
|
+
// Where the initial load's TTFB went, in three phases that add up to it:
|
|
689
|
+
//
|
|
690
|
+
// redirect_ms navigation start -> the final request's start: every
|
|
691
|
+
// redirect hop, plus the browser's own few ms (so never
|
|
692
|
+
// quite 0).
|
|
693
|
+
// connect_ms -> requestStart: service worker startup, HTTP cache, DNS,
|
|
694
|
+
// TCP and TLS for the final request.
|
|
695
|
+
// server_ms -> responseStart: the network round trip and the server's
|
|
696
|
+
// time.
|
|
697
|
+
//
|
|
698
|
+
// The final request starts at fetchStart, or at workerStart when a service
|
|
699
|
+
// worker had to start first (the web-vitals library cuts there too).
|
|
700
|
+
//
|
|
701
|
+
// redirect_ms stays honest when a hop is cross-origin. The browser then
|
|
702
|
+
// zeroes redirectCount, redirectStart and redirectEnd (Navigation Timing 2),
|
|
703
|
+
// but fetchStart is still the final request's start (Fetch's "post-redirect
|
|
704
|
+
// start time"), so the hidden hops' time is still in it.
|
|
705
|
+
//
|
|
706
|
+
// redirect_count is sent only when the browser shows the redirects. A 0
|
|
707
|
+
// means "no redirect" or "hidden", and a hidden redirect must never be
|
|
708
|
+
// reported as no redirect.
|
|
709
|
+
//
|
|
710
|
+
// The boundaries are rounded before subtracting, so the three phases add up
|
|
711
|
+
// to the reported ttfb exactly. A browser whose timestamps are missing or
|
|
712
|
+
// out of order sends no phases rather than a wrong split.
|
|
713
|
+
function ttfbPhases(nav: PerformanceNavigationTiming): Partial<Visit> {
|
|
714
|
+
const fetchStart =
|
|
715
|
+
nav.workerStart > 0 && nav.workerStart < nav.fetchStart
|
|
716
|
+
? nav.workerStart
|
|
717
|
+
: nav.fetchStart
|
|
718
|
+
// Order is judged on the raw timestamps: rounding first would let a
|
|
719
|
+
// requestStart a hair before fetchStart through as a 0 ms phase.
|
|
720
|
+
const ordered =
|
|
721
|
+
nav.responseStart > 0 &&
|
|
722
|
+
fetchStart >= nav.startTime &&
|
|
723
|
+
nav.requestStart >= fetchStart &&
|
|
724
|
+
nav.responseStart >= nav.requestStart
|
|
725
|
+
if (!ordered) return {}
|
|
726
|
+
const [start, fetched, requested, responded] = [
|
|
727
|
+
nav.startTime,
|
|
728
|
+
fetchStart,
|
|
729
|
+
nav.requestStart,
|
|
730
|
+
nav.responseStart,
|
|
731
|
+
].map(Math.round)
|
|
732
|
+
return {
|
|
733
|
+
redirect_ms: fetched - start,
|
|
734
|
+
redirect_count: nav.redirectCount > 0 ? nav.redirectCount : undefined,
|
|
735
|
+
connect_ms: requested - fetched,
|
|
736
|
+
server_ms: responded - requested,
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
682
740
|
// The first page load is a visit too -- it just wasn't routed by Inertia, so
|
|
683
741
|
// the component name comes off the root element's serialized page object.
|
|
684
742
|
function initialVisit(): Visit | null {
|
|
@@ -696,6 +754,7 @@ function initialVisit(): Visit | null {
|
|
|
696
754
|
component,
|
|
697
755
|
duration_ms: duration,
|
|
698
756
|
status: "success",
|
|
757
|
+
...ttfbPhases(nav),
|
|
699
758
|
}
|
|
700
759
|
}
|
|
701
760
|
|
data/lib/railwatch/version.rb
CHANGED