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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 072de4fbe3a2b0854d5e00b3b41b033a901d05d9f681f8e6083ae9c6882e4751
4
- data.tar.gz: f24164cbf663302b6df3e302aadf59e7328316a4e6d1c86d1b8b5ad49ee6f85f
3
+ metadata.gz: af26db042e24211ffde4ad4e82dd90a7bc27e37c0bc9f74674947a30109b0036
4
+ data.tar.gz: 8c8033a1fa3f4b97142dd6b3593cf4bcc7fa16d2bea97fed6d580ee1584133de
5
5
  SHA512:
6
- metadata.gz: 9d3907a21539f08dd1e71d4eb83e0305d749673a51e9eb4d8404e4cff2924665d0c0dd28c5f4e8d62b50174ff0618f7e43d41a5984cd52b24a1d6fe63745dae7
7
- data.tar.gz: 1bc7449ec96e9c85432d9f75746a28e4f5152ba71b97d3d32e6b7a9ee4b53044abdf34281a78bf4bf546436b0b8168376b51635daf640fe396fd853163cde638
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
- safe_float(value).round.clamp(0, MAX_METRIC_MS)
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
- // for the initial page load, plus every JavaScript error the page throws
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
5
5
  end
@@ -605,6 +605,10 @@
605
605
  "cls": null,
606
606
  "inp": null,
607
607
  "ttfb": null,
608
+ "redirect_ms": null,
609
+ "redirect_count": null,
610
+ "connect_ms": null,
611
+ "server_ms": null,
608
612
  "user": null,
609
613
  "tenant": null,
610
614
  "user_agent": ""
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railwatch
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cole Robertson