@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 +17 -0
- package/dist/rum.js +158 -1
- package/dist/socket-age.js +13 -3
- package/package.json +1 -1
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);
|
package/dist/socket-age.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
92
|
+
openedAt = performance.now();
|
|
83
93
|
ageTimer = setTimeout(() => cut(ws), maxAgeMs);
|
|
84
94
|
ageTimer.unref?.();
|
|
85
95
|
},
|
package/package.json
CHANGED