@steve31415/baselib 3.53.0 → 3.54.1

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,20 @@ 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
+ * `start_ms`, `sent_ms`, `first_byte_ms` and `end_ms` are ms since navigation
15
+ * start; `dns_ms` and `connect_ms` are durations. A request the browser (or an
16
+ * extension in it) held before sending shows as `sent_ms - start_ms` well over
17
+ * `dns_ms + connect_ms`; a cold network shows in those two; `bytes` 0 means
18
+ * served from cache.
19
+ */
20
+ export declare function lastResourceBefore(entries: readonly PerformanceResourceTiming[], initiatorType: string, before: number): Record<string, string | number | undefined> | undefined;
21
+ /**
22
+ * Flat breakdown fields for one metric (all durations in whole ms). Kept to a
23
+ * fixed, small set of columns per metric — never keyed by data.
24
+ */
25
+ export declare function breakdownFields(m: MetricWithAttribution): Record<string, unknown>;
9
26
  export declare function initRum(opts?: RumOptions): void;
package/dist/rum.js CHANGED
@@ -4,12 +4,159 @@
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
+ * `start_ms`, `sent_ms`, `first_byte_ms` and `end_ms` are ms since navigation
39
+ * start; `dns_ms` and `connect_ms` are durations. A request the browser (or an
40
+ * extension in it) held before sending shows as `sent_ms - start_ms` well over
41
+ * `dns_ms + connect_ms`; a cold network shows in those two; `bytes` 0 means
42
+ * served from cache.
43
+ */
44
+ export function lastResourceBefore(entries, initiatorType, before) {
45
+ let pick;
46
+ for (const e of entries) {
47
+ if (e.initiatorType !== initiatorType || e.responseEnd <= 0 || e.responseEnd > before)
48
+ continue;
49
+ let sameOrigin = false;
50
+ try {
51
+ sameOrigin = new URL(e.name, location.href).origin === location.origin;
52
+ }
53
+ catch {
54
+ sameOrigin = false;
55
+ }
56
+ if (!sameOrigin)
57
+ continue;
58
+ if (!pick || e.responseEnd > pick.responseEnd)
59
+ pick = e;
60
+ }
61
+ if (!pick)
62
+ return undefined;
63
+ const positive = (n) => (n > 0 ? n : undefined);
64
+ const span = (from, to) => (from > 0 && to >= from ? to - from : undefined);
65
+ return {
66
+ path: resourcePath(pick.name),
67
+ start_ms: ms(pick.startTime),
68
+ sent_ms: ms(positive(pick.requestStart)),
69
+ dns_ms: ms(span(pick.domainLookupStart, pick.domainLookupEnd)),
70
+ connect_ms: ms(span(pick.connectStart, pick.connectEnd)),
71
+ first_byte_ms: ms(positive(pick.responseStart)),
72
+ end_ms: ms(pick.responseEnd),
73
+ bytes: pick.transferSize,
74
+ protocol: pick.nextHopProtocol || undefined,
75
+ };
76
+ }
77
+ function prefixed(prefix, fields) {
78
+ const out = {};
79
+ if (!fields)
80
+ return out;
81
+ for (const [k, v] of Object.entries(fields))
82
+ if (v !== undefined)
83
+ out[`${prefix}_${k}`] = v;
84
+ return out;
85
+ }
86
+ function resourceEntries() {
87
+ try {
88
+ return performance.getEntriesByType('resource');
89
+ }
90
+ catch {
91
+ return [];
92
+ }
93
+ }
94
+ /**
95
+ * Flat breakdown fields for one metric (all durations in whole ms). Kept to a
96
+ * fixed, small set of columns per metric — never keyed by data.
97
+ */
98
+ export function breakdownFields(m) {
99
+ const fields = {};
100
+ switch (m.name) {
101
+ case 'TTFB': {
102
+ const a = m.attribution;
103
+ const nav = a.navigationEntry;
104
+ Object.assign(fields, {
105
+ waiting_ms: ms(a.waitingDuration),
106
+ cache_ms: ms(a.cacheDuration),
107
+ dns_ms: ms(a.dnsDuration),
108
+ connect_ms: ms(a.connectionDuration),
109
+ request_ms: ms(a.requestDuration),
110
+ protocol: nav?.nextHopProtocol || undefined,
111
+ });
112
+ break;
113
+ }
114
+ case 'FCP': {
115
+ const a = m.attribution;
116
+ Object.assign(fields, {
117
+ ttfb_ms: ms(a.timeToFirstByte),
118
+ after_ttfb_ms: ms(a.firstByteToFCP),
119
+ load_state: a.loadState,
120
+ });
121
+ const entries = resourceEntries();
122
+ Object.assign(fields, prefixed('script', lastResourceBefore(entries, 'script', m.value)));
123
+ Object.assign(fields, prefixed('fetch', lastResourceBefore(entries, 'fetch', m.value)));
124
+ break;
125
+ }
126
+ case 'LCP': {
127
+ const a = m.attribution;
128
+ Object.assign(fields, {
129
+ ttfb_ms: ms(a.timeToFirstByte),
130
+ resource_delay_ms: ms(a.resourceLoadDelay),
131
+ resource_ms: ms(a.resourceLoadDuration),
132
+ render_delay_ms: ms(a.elementRenderDelay),
133
+ target: clip(a.target),
134
+ });
135
+ break;
136
+ }
137
+ case 'INP': {
138
+ const a = m.attribution;
139
+ Object.assign(fields, {
140
+ input_delay_ms: ms(a.inputDelay),
141
+ processing_ms: ms(a.processingDuration),
142
+ presentation_ms: ms(a.presentationDelay),
143
+ interaction_type: a.interactionType,
144
+ target: clip(a.interactionTarget),
145
+ load_state: a.loadState,
146
+ });
147
+ break;
148
+ }
149
+ case 'CLS': {
150
+ const a = m.attribution;
151
+ Object.assign(fields, { target: clip(a.largestShiftTarget), load_state: a.loadState });
152
+ break;
153
+ }
154
+ }
155
+ for (const k of Object.keys(fields))
156
+ if (fields[k] === undefined)
157
+ delete fields[k];
158
+ return fields;
159
+ }
13
160
  export function initRum(opts = {}) {
14
161
  const app = opts.app ?? metaContent('pw-log-app') ?? 'unknown';
15
162
  const token = opts.token ?? metaContent('pw-log-token');
@@ -18,6 +165,7 @@ export function initRum(opts = {}) {
18
165
  const buildId = opts.buildId ?? metaContent('pw-build-id');
19
166
  const dataset = opts.dataset ?? 'rum';
20
167
  const baseUrl = (opts.baseUrl ?? 'https://api.axiom.co').replace(/\/$/, '');
168
+ const device = deviceLabel(navigator.userAgent, navigator.maxTouchPoints ?? 0);
21
169
  const shipper = new LogShipper({
22
170
  url: `${baseUrl}/v1/datasets/${dataset}/ingest`,
23
171
  token,
@@ -25,6 +173,13 @@ export function initRum(opts = {}) {
25
173
  intervalMs: 5000,
26
174
  });
27
175
  const report = (m) => {
176
+ let breakdown = {};
177
+ try {
178
+ breakdown = breakdownFields(m);
179
+ }
180
+ catch {
181
+ // Diagnostics only: a browser quirk in attribution never costs the metric.
182
+ }
28
183
  shipper.enqueue({
29
184
  _time: new Date().toISOString(),
30
185
  app,
@@ -41,7 +196,9 @@ export function initRum(opts = {}) {
41
196
  metric_id: m.id,
42
197
  navigation_type: m.navigationType,
43
198
  path: location.pathname,
199
+ device,
44
200
  ...(buildId ? { build_id: buildId } : {}),
201
+ ...breakdown,
45
202
  });
46
203
  };
47
204
  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.1",
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",