@steve31415/baselib 3.53.0 → 3.54.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.
package/dist/rum.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type MetricWithAttribution } from 'web-vitals/attribution';
1
2
  export interface RumOptions {
2
3
  app?: string;
3
4
  dataset?: string;
@@ -6,4 +7,16 @@ export interface RumOptions {
6
7
  /** Defaults to the page's pw-build-id meta tag. */
7
8
  buildId?: string;
8
9
  }
10
+ /**
11
+ * The same-origin resource of one initiator type (`script`, `fetch`) that
12
+ * finished last before `before` (ms since navigation start) — for a single-page
13
+ * app, the entry script and the first data fetch that first paint waited on.
14
+ * Times are ms since navigation start; `bytes` 0 means served from cache.
15
+ */
16
+ export declare function lastResourceBefore(entries: readonly PerformanceResourceTiming[], initiatorType: string, before: number): Record<string, string | number | undefined> | undefined;
17
+ /**
18
+ * Flat breakdown fields for one metric (all durations in whole ms). Kept to a
19
+ * fixed, small set of columns per metric — never keyed by data.
20
+ */
21
+ export declare function breakdownFields(m: MetricWithAttribution): Record<string, unknown>;
9
22
  export declare function initRum(opts?: RumOptions): void;
package/dist/rum.js CHANGED
@@ -4,12 +4,150 @@
4
4
  // no-op. Metrics finalize on tab-hide, so the shipper flushes there with
5
5
  // keepalive fetch.
6
6
  //
7
+ // Each row also carries the reporting `device` and a few flat breakdown
8
+ // fields from web-vitals' attribution build, so a slow load can be split
9
+ // into its phases from the row alone (connection setup vs server wait vs
10
+ // the app's script and first data fetch) instead of being reconstructed by
11
+ // lining server request logs up against client timestamps (Reader, 2026-11-03:
12
+ // a 5 s /nyt load on the phone with 0.15 s of server time, explained only by
13
+ // that reconstruction).
14
+ //
7
15
  // Browser-only module — do not import server-side.
8
- import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
16
+ import { onCLS, onFCP, onINP, onLCP, onTTFB, } from 'web-vitals/attribution';
9
17
  import { LogShipper } from './log-core.js';
18
+ import { deviceLabel } from './log-browser.js';
10
19
  function metaContent(name) {
11
20
  return document.querySelector(`meta[name="${name}"]`)?.content || undefined;
12
21
  }
22
+ const ms = (n) => (n === undefined || !Number.isFinite(n) ? undefined : Math.round(n));
23
+ const clip = (s, max = 120) => (s === undefined ? undefined : s.slice(0, max));
24
+ /** Path (no origin, no query) of a same-origin resource URL, else the URL clipped. */
25
+ function resourcePath(url) {
26
+ try {
27
+ const u = new URL(url, location.href);
28
+ return u.origin === location.origin ? u.pathname : clip(url, 200);
29
+ }
30
+ catch {
31
+ return clip(url, 200);
32
+ }
33
+ }
34
+ /**
35
+ * The same-origin resource of one initiator type (`script`, `fetch`) that
36
+ * finished last before `before` (ms since navigation start) — for a single-page
37
+ * app, the entry script and the first data fetch that first paint waited on.
38
+ * Times are ms since navigation start; `bytes` 0 means served from cache.
39
+ */
40
+ export function lastResourceBefore(entries, initiatorType, before) {
41
+ let pick;
42
+ for (const e of entries) {
43
+ if (e.initiatorType !== initiatorType || e.responseEnd <= 0 || e.responseEnd > before)
44
+ continue;
45
+ let sameOrigin = false;
46
+ try {
47
+ sameOrigin = new URL(e.name, location.href).origin === location.origin;
48
+ }
49
+ catch {
50
+ sameOrigin = false;
51
+ }
52
+ if (!sameOrigin)
53
+ continue;
54
+ if (!pick || e.responseEnd > pick.responseEnd)
55
+ pick = e;
56
+ }
57
+ if (!pick)
58
+ return undefined;
59
+ return {
60
+ path: resourcePath(pick.name),
61
+ start_ms: ms(pick.startTime),
62
+ first_byte_ms: ms(pick.responseStart > 0 ? pick.responseStart : undefined),
63
+ end_ms: ms(pick.responseEnd),
64
+ bytes: pick.transferSize,
65
+ protocol: pick.nextHopProtocol || undefined,
66
+ };
67
+ }
68
+ function prefixed(prefix, fields) {
69
+ const out = {};
70
+ if (!fields)
71
+ return out;
72
+ for (const [k, v] of Object.entries(fields))
73
+ if (v !== undefined)
74
+ out[`${prefix}_${k}`] = v;
75
+ return out;
76
+ }
77
+ function resourceEntries() {
78
+ try {
79
+ return performance.getEntriesByType('resource');
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ }
85
+ /**
86
+ * Flat breakdown fields for one metric (all durations in whole ms). Kept to a
87
+ * fixed, small set of columns per metric — never keyed by data.
88
+ */
89
+ export function breakdownFields(m) {
90
+ const fields = {};
91
+ switch (m.name) {
92
+ case 'TTFB': {
93
+ const a = m.attribution;
94
+ const nav = a.navigationEntry;
95
+ Object.assign(fields, {
96
+ waiting_ms: ms(a.waitingDuration),
97
+ cache_ms: ms(a.cacheDuration),
98
+ dns_ms: ms(a.dnsDuration),
99
+ connect_ms: ms(a.connectionDuration),
100
+ request_ms: ms(a.requestDuration),
101
+ protocol: nav?.nextHopProtocol || undefined,
102
+ });
103
+ break;
104
+ }
105
+ case 'FCP': {
106
+ const a = m.attribution;
107
+ Object.assign(fields, {
108
+ ttfb_ms: ms(a.timeToFirstByte),
109
+ after_ttfb_ms: ms(a.firstByteToFCP),
110
+ load_state: a.loadState,
111
+ });
112
+ const entries = resourceEntries();
113
+ Object.assign(fields, prefixed('script', lastResourceBefore(entries, 'script', m.value)));
114
+ Object.assign(fields, prefixed('fetch', lastResourceBefore(entries, 'fetch', m.value)));
115
+ break;
116
+ }
117
+ case 'LCP': {
118
+ const a = m.attribution;
119
+ Object.assign(fields, {
120
+ ttfb_ms: ms(a.timeToFirstByte),
121
+ resource_delay_ms: ms(a.resourceLoadDelay),
122
+ resource_ms: ms(a.resourceLoadDuration),
123
+ render_delay_ms: ms(a.elementRenderDelay),
124
+ target: clip(a.target),
125
+ });
126
+ break;
127
+ }
128
+ case 'INP': {
129
+ const a = m.attribution;
130
+ Object.assign(fields, {
131
+ input_delay_ms: ms(a.inputDelay),
132
+ processing_ms: ms(a.processingDuration),
133
+ presentation_ms: ms(a.presentationDelay),
134
+ interaction_type: a.interactionType,
135
+ target: clip(a.interactionTarget),
136
+ load_state: a.loadState,
137
+ });
138
+ break;
139
+ }
140
+ case 'CLS': {
141
+ const a = m.attribution;
142
+ Object.assign(fields, { target: clip(a.largestShiftTarget), load_state: a.loadState });
143
+ break;
144
+ }
145
+ }
146
+ for (const k of Object.keys(fields))
147
+ if (fields[k] === undefined)
148
+ delete fields[k];
149
+ return fields;
150
+ }
13
151
  export function initRum(opts = {}) {
14
152
  const app = opts.app ?? metaContent('pw-log-app') ?? 'unknown';
15
153
  const token = opts.token ?? metaContent('pw-log-token');
@@ -18,6 +156,7 @@ export function initRum(opts = {}) {
18
156
  const buildId = opts.buildId ?? metaContent('pw-build-id');
19
157
  const dataset = opts.dataset ?? 'rum';
20
158
  const baseUrl = (opts.baseUrl ?? 'https://api.axiom.co').replace(/\/$/, '');
159
+ const device = deviceLabel(navigator.userAgent, navigator.maxTouchPoints ?? 0);
21
160
  const shipper = new LogShipper({
22
161
  url: `${baseUrl}/v1/datasets/${dataset}/ingest`,
23
162
  token,
@@ -25,6 +164,13 @@ export function initRum(opts = {}) {
25
164
  intervalMs: 5000,
26
165
  });
27
166
  const report = (m) => {
167
+ let breakdown = {};
168
+ try {
169
+ breakdown = breakdownFields(m);
170
+ }
171
+ catch {
172
+ // Diagnostics only: a browser quirk in attribution never costs the metric.
173
+ }
28
174
  shipper.enqueue({
29
175
  _time: new Date().toISOString(),
30
176
  app,
@@ -41,7 +187,9 @@ export function initRum(opts = {}) {
41
187
  metric_id: m.id,
42
188
  navigation_type: m.navigationType,
43
189
  path: location.pathname,
190
+ device,
44
191
  ...(buildId ? { build_id: buildId } : {}),
192
+ ...breakdown,
45
193
  });
46
194
  };
47
195
  onLCP(report);
@@ -54,11 +54,21 @@ function withAgeCap(events, path, maxAgeMs, graceMs, logger) {
54
54
  if (cutAt === null || logged)
55
55
  return;
56
56
  logged = true;
57
- logger.info('socket age cap', { path, ageMs: cutAt - openedAt, terminated });
57
+ logger.info('socket age cap', { path, ageMs: Math.round(cutAt - openedAt), terminated });
58
58
  };
59
59
  const cut = (ws) => {
60
60
  ageTimer = null;
61
- cutAt = Date.now();
61
+ // Node starts a timer's countdown from the event loop's cached time, not
62
+ // from the setTimeout call, so it can fire a little before `maxAgeMs` has
63
+ // really passed since onOpen (the age test saw 299 ms of a 300 ms cap,
64
+ // 2 runs in 30). Re-arm for the remainder: a socket is never cut young.
65
+ const remaining = openedAt + maxAgeMs - performance.now();
66
+ if (remaining > 0) {
67
+ ageTimer = setTimeout(() => cut(ws), remaining);
68
+ ageTimer.unref?.();
69
+ return;
70
+ }
71
+ cutAt = performance.now();
62
72
  try {
63
73
  ws.close(CLOSE_SERVICE_RESTART, 'socket age limit');
64
74
  }
@@ -79,7 +89,7 @@ function withAgeCap(events, path, maxAgeMs, graceMs, logger) {
79
89
  events.onOpen?.(evt, ws);
80
90
  if (ws.readyState !== WS_OPEN)
81
91
  return;
82
- openedAt = Date.now();
92
+ openedAt = performance.now();
83
93
  ageTimer = setTimeout(() => cut(ws), maxAgeMs);
84
94
  ageTimer.unref?.();
85
95
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.53.0",
3
+ "version": "3.54.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",