@tinvar/react 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/package.json +168 -4
- package/src/action-server.js +41 -0
- package/src/action.d.ts +68 -0
- package/src/action.js +167 -0
- package/src/bootstrap.js +153 -0
- package/src/channel.d.ts +46 -0
- package/src/channel.js +132 -0
- package/src/client-modules.d.ts +8 -0
- package/src/client-modules.js +88 -0
- package/src/client.d.ts +36 -0
- package/src/client.js +42 -0
- package/src/compose-route.d.ts +33 -0
- package/src/compose-route.js +107 -0
- package/src/csrf.d.ts +11 -0
- package/src/csrf.js +49 -0
- package/src/deferred-boundary.d.ts +61 -0
- package/src/deferred-boundary.js +646 -0
- package/src/deferred-groups.d.ts +24 -0
- package/src/deferred-groups.js +198 -0
- package/src/deferred-revalidation.d.ts +37 -0
- package/src/deferred-revalidation.js +108 -0
- package/src/deferred-triggers.d.ts +26 -0
- package/src/deferred-triggers.js +172 -0
- package/src/deferred.d.ts +24 -0
- package/src/deferred.jsx +100 -0
- package/src/document-load.js +95 -0
- package/src/endpoint.d.ts +87 -0
- package/src/endpoint.js +304 -0
- package/src/form-data.js +219 -0
- package/src/form.d.ts +177 -0
- package/src/form.js +615 -0
- package/src/forms.d.ts +4 -0
- package/src/forms.js +10 -0
- package/src/fragment-client.d.ts +70 -0
- package/src/fragment-client.js +510 -0
- package/src/head.d.ts +85 -0
- package/src/head.js +360 -0
- package/src/inbox-view.d.ts +17 -0
- package/src/inbox-view.js +105 -0
- package/src/inbox.d.ts +108 -0
- package/src/inbox.js +293 -0
- package/src/index.combined.d.ts +31 -0
- package/src/index.d.ts +5 -0
- package/src/index.js +29 -0
- package/src/index.server.d.ts +4 -0
- package/src/index.server.js +8 -0
- package/src/infinite-scroll.d.ts +108 -0
- package/src/infinite-scroll.js +715 -0
- package/src/layout-props.d.ts +30 -0
- package/src/layout-props.js +143 -0
- package/src/link-click.d.ts +3 -0
- package/src/link-click.js +68 -0
- package/src/link.d.ts +63 -0
- package/src/link.js +274 -0
- package/src/live.d.ts +65 -0
- package/src/live.js +977 -0
- package/src/merge-slot.d.ts +41 -0
- package/src/merge-slot.js +253 -0
- package/src/merge.d.ts +111 -0
- package/src/merge.js +149 -0
- package/src/once-slot.d.ts +14 -0
- package/src/once-slot.js +75 -0
- package/src/once-store.d.ts +26 -0
- package/src/once-store.js +211 -0
- package/src/once.d.ts +44 -0
- package/src/once.js +65 -0
- package/src/optimistic.d.ts +14 -0
- package/src/optimistic.js +159 -0
- package/src/optional.d.ts +10 -0
- package/src/optional.js +31 -0
- package/src/page-root.d.ts +56 -0
- package/src/page-root.js +455 -0
- package/src/payload.d.ts +15 -0
- package/src/payload.js +101 -0
- package/src/poll.d.ts +55 -0
- package/src/poll.js +224 -0
- package/src/precognition.js +190 -0
- package/src/prefetch-cache.d.ts +46 -0
- package/src/prefetch-cache.js +256 -0
- package/src/progress.d.ts +38 -0
- package/src/progress.js +243 -0
- package/src/remember-store.d.ts +50 -0
- package/src/remember-store.js +486 -0
- package/src/rescue.d.ts +10 -0
- package/src/rescue.js +30 -0
- package/src/resumable.js +822 -0
- package/src/resume-store.js +364 -0
- package/src/route.d.ts +50 -0
- package/src/route.js +227 -0
- package/src/router-hooks.js +234 -0
- package/src/router.d.ts +586 -0
- package/src/router.js +3555 -0
- package/src/segment.d.ts +36 -0
- package/src/segment.js +116 -0
- package/src/server.d.ts +71 -0
- package/src/server.js +242 -0
- package/src/submit.js +333 -0
- package/src/telemetry.d.ts +71 -0
- package/src/telemetry.js +851 -0
- package/src/testing.d.ts +89 -0
- package/src/testing.js +634 -0
- package/src/transfer.d.ts +48 -0
- package/src/transfer.js +135 -0
- package/src/transport.js +390 -0
- package/src/upload.d.ts +342 -0
- package/src/upload.js +1240 -0
- package/src/use-channel.d.ts +21 -0
- package/src/use-channel.js +51 -0
- package/src/use-form.d.ts +86 -0
- package/src/use-form.js +432 -0
- package/src/use-http.d.ts +56 -0
- package/src/use-http.js +248 -0
- package/src/use-inbox.d.ts +41 -0
- package/src/use-inbox.js +201 -0
- package/src/use-remember.d.ts +10 -0
- package/src/use-remember.js +132 -0
- package/src/when-mounted.d.ts +15 -0
- package/src/when-mounted.js +27 -0
- package/README.md +0 -3
package/src/telemetry.js
ADDED
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
// Client telemetry: how the page loaded, and what went wrong in its script,
|
|
2
|
+
// reported to the server's /_vitals and /_errors (Tinvar\Observability
|
|
3
|
+
// \TelemetryEndpoint).
|
|
4
|
+
//
|
|
5
|
+
// tinvar-spec.md, "Endpoints": "`/_vitals`, `/_errors` ... | `throttle:telemetry`,
|
|
6
|
+
// CSRF | 16 KB bodies; route names checked against the manifest". So each
|
|
7
|
+
// report is a same-origin POST carrying the CSRF header every other one
|
|
8
|
+
// carries, under 16 KB, naming its page by route name -- which the server
|
|
9
|
+
// holds to its route registry, and refuses when it is not one.
|
|
10
|
+
//
|
|
11
|
+
// The document's page is named by its page metadata's `route`: its route's
|
|
12
|
+
// name, or the view of a page with none (ADR 0129). Failing that, by its
|
|
13
|
+
// route token, whose payload is plain base64url JSON with the route name in
|
|
14
|
+
// its `r`. A page the router mounts is named by its route token, or its view
|
|
15
|
+
// where it has none. All of it is read without verifying, as the router
|
|
16
|
+
// reads the session binding beside it (bindingOf), since nothing here
|
|
17
|
+
// trusts it -- the server checks the name again. A page with no name at all
|
|
18
|
+
// reports nothing.
|
|
19
|
+
//
|
|
20
|
+
// A visitor with no session -- a static page's, which no PHP rendered --
|
|
21
|
+
// has no CSRF token, and reports with no cookie at all: the server takes a
|
|
22
|
+
// report that rides no session without one, since there is no session for
|
|
23
|
+
// a forged one to ride (ADR 0129).
|
|
24
|
+
//
|
|
25
|
+
// Vitals are measured with PerformanceObserver alone: LCP, FCP and TTFB as
|
|
26
|
+
// the document loaded, CLS as its largest burst of layout shifts, INP from
|
|
27
|
+
// event timing. Each page the reader is shown has its own: the document's,
|
|
28
|
+
// and then each page a client navigation mounts, whose CLS and INP are
|
|
29
|
+
// measured from its mount, and whose NAV is how long the navigation took,
|
|
30
|
+
// from the visit's start to the page's mount -- LCP, FCP and TTFB exist
|
|
31
|
+
// only for the document's load. A page's vitals are sent once, when it is
|
|
32
|
+
// left for the next, or the first time the tab is hidden, filed under that
|
|
33
|
+
// page's route and as a `document` or a `client` navigation.
|
|
34
|
+
//
|
|
35
|
+
// A static page carries its Content Security Policy in a meta element,
|
|
36
|
+
// which can name nowhere to report to: a browser ignores report-uri and
|
|
37
|
+
// report-to there. Its violations still reach the page as
|
|
38
|
+
// `securitypolicyviolation` events, and those of the meta element's policy
|
|
39
|
+
// are sent to /_reports, as the browser sends a header's, with no cookie.
|
|
40
|
+
//
|
|
41
|
+
// Errors come from the window's `error` and `unhandledrejection` events and
|
|
42
|
+
// from the hooks an application hands React's root. Each is reduced to a
|
|
43
|
+
// message, a digest, its kind, the same-origin script and line it came from
|
|
44
|
+
// and a few frames of its stack -- never a cookie's or storage's contents,
|
|
45
|
+
// nor the URL's query -- deduplicated, capped per page, filed under the page
|
|
46
|
+
// on screen, and sent in a batch a moment later or when the page is hidden.
|
|
47
|
+
//
|
|
48
|
+
// A report that fails is dropped: telemetry about telemetry would be sent
|
|
49
|
+
// through the thing that just failed.
|
|
50
|
+
|
|
51
|
+
import { csrfHeaders } from './csrf.js';
|
|
52
|
+
import { readPageMetadata } from './router.js';
|
|
53
|
+
|
|
54
|
+
export const VITALS_PATH = '/_vitals';
|
|
55
|
+
export const ERRORS_PATH = '/_errors';
|
|
56
|
+
export const REPORTS_PATH = '/_reports';
|
|
57
|
+
|
|
58
|
+
/** The server's cap on one report, in bytes. */
|
|
59
|
+
export const MAX_BODY_BYTES = 16384;
|
|
60
|
+
|
|
61
|
+
/** Errors reported for one page, at most; the rest are dropped. */
|
|
62
|
+
export const MAX_ERRORS_PER_PAGE = 10;
|
|
63
|
+
|
|
64
|
+
/** Errors in one report: the server takes no more. */
|
|
65
|
+
export const MAX_ERRORS_PER_REPORT = 10;
|
|
66
|
+
|
|
67
|
+
/** How long errors are gathered before they are sent, in milliseconds. */
|
|
68
|
+
export const ERROR_DELAY_MS = 1000;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The largest value of each vital the server believes: milliseconds, and a
|
|
72
|
+
* score for CLS. A vital past its ceiling is left out of the report, which
|
|
73
|
+
* the server would otherwise refuse whole.
|
|
74
|
+
*/
|
|
75
|
+
export const VITAL_CEILINGS = Object.freeze({
|
|
76
|
+
LCP: 120000,
|
|
77
|
+
INP: 120000,
|
|
78
|
+
CLS: 10,
|
|
79
|
+
FCP: 120000,
|
|
80
|
+
TTFB: 120000,
|
|
81
|
+
NAV: 120000,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** Policy violations sent for one document, at most; the rest are dropped. */
|
|
85
|
+
export const MAX_VIOLATIONS = 10;
|
|
86
|
+
|
|
87
|
+
/** Where an error was seen. */
|
|
88
|
+
const KINDS = new Set(['error', 'rejection', 'caught', 'uncaught', 'recoverable']);
|
|
89
|
+
|
|
90
|
+
/** What is kept of an error: a message, and a few short frames. */
|
|
91
|
+
const MAX_MESSAGE = 300;
|
|
92
|
+
const MAX_STACK_LINES = 10;
|
|
93
|
+
const MAX_STACK_LINE = 200;
|
|
94
|
+
|
|
95
|
+
/** A route name, as the server allows one. */
|
|
96
|
+
const ROUTE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
97
|
+
|
|
98
|
+
/** What a page's reports may be filed under: a route name, or a view. */
|
|
99
|
+
const ROUTE_LABEL = /^[A-Za-z0-9_][A-Za-z0-9_.\/-]{0,127}$/;
|
|
100
|
+
|
|
101
|
+
/** The label as given, when it is one; null otherwise. */
|
|
102
|
+
const labelOf = (/** @type {unknown} */ value) =>
|
|
103
|
+
typeof value === 'string' && ROUTE_LABEL.test(value) ? value : null;
|
|
104
|
+
|
|
105
|
+
/** A digest a server failure is shown under: the framework's, or React's. */
|
|
106
|
+
const DIGEST = /^[A-Za-z0-9_-]{1,64}$/;
|
|
107
|
+
|
|
108
|
+
/** A stack frame's script and line: `at f (https://host/a.js:1:2)`, `f@https://host/a.js:1:2`. */
|
|
109
|
+
const FRAME = /([a-z][a-z0-9+.-]*:\/\/[^\s()]+?|\/[^\s()]+?):(\d+):(\d+)\)?$/i;
|
|
110
|
+
|
|
111
|
+
/** A browser's own word for an error in a script from another origin: nothing to go on. */
|
|
112
|
+
const OPAQUE = 'Script error.';
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The route name a route token names, or null. Read without verifying: the
|
|
116
|
+
* server holds the name to its registry whatever this says.
|
|
117
|
+
*
|
|
118
|
+
* @param {string | null | undefined} token
|
|
119
|
+
* @returns {string | null}
|
|
120
|
+
*/
|
|
121
|
+
export function routeNameOf(token) {
|
|
122
|
+
if (typeof token !== 'string') return null;
|
|
123
|
+
const body = token.split('.')[1];
|
|
124
|
+
if (!body) return null;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const route = JSON.parse(atob(body.replace(/-/g, '+').replace(/_/g, '/')))?.r;
|
|
128
|
+
return typeof route === 'string' && route.length <= 128 && ROUTE_NAME.test(route) ? route : null;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @typedef {{
|
|
136
|
+
* router?: { routeTokens(): string[], currentView?(): string | null, on(name: string, fn: (event: CustomEvent) => void): () => void } | null,
|
|
137
|
+
* window?: Window,
|
|
138
|
+
* fetch?: typeof fetch,
|
|
139
|
+
* vitals?: boolean,
|
|
140
|
+
* errors?: boolean,
|
|
141
|
+
* violations?: boolean,
|
|
142
|
+
* }} TelemetryOptions
|
|
143
|
+
*
|
|
144
|
+
* `router` is the page's client router: the page on screen is read from it,
|
|
145
|
+
* and an error is counted against the page it happened on. Without one, the
|
|
146
|
+
* document's own page metadata says which page this is.
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
/** @param {TelemetryOptions} [options] */
|
|
150
|
+
export function createTelemetry(options = {}) {
|
|
151
|
+
const win = options.window ?? globalThis.window;
|
|
152
|
+
const doc = win?.document;
|
|
153
|
+
const router = options.router ?? null;
|
|
154
|
+
const doFetch = options.fetch ?? ((url, init) => win.fetch(url, init));
|
|
155
|
+
|
|
156
|
+
let started = false;
|
|
157
|
+
let stopped = false;
|
|
158
|
+
|
|
159
|
+
/** Set once the session has ended on this client: nothing more is sent for it. */
|
|
160
|
+
let revoked = false;
|
|
161
|
+
|
|
162
|
+
/** @type {Array<() => void>} */
|
|
163
|
+
const teardown = [];
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------- the page
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The page on screen: what its reports are filed under, how it came to be
|
|
169
|
+
* shown -- the document's load, or a client navigation -- how long that
|
|
170
|
+
* navigation took, and whether its vitals have gone.
|
|
171
|
+
*
|
|
172
|
+
* @type {{ route: string | null, navigation: 'document' | 'client', nav: number | null, sent: boolean, interactionsBefore: number }}
|
|
173
|
+
*/
|
|
174
|
+
let page = { route: null, navigation: 'document', nav: null, sent: false, interactionsBefore: 0 };
|
|
175
|
+
|
|
176
|
+
/** When the visit to the next page began, by the router's `start`. */
|
|
177
|
+
let visitStarted = /** @type {number | null} */ (null);
|
|
178
|
+
|
|
179
|
+
/** The route of the page on screen. */
|
|
180
|
+
function currentRoute() {
|
|
181
|
+
return page.route;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The route of a page the router has just mounted: its token's name, or its view. */
|
|
185
|
+
function mountedRoute() {
|
|
186
|
+
if (router === null) return null;
|
|
187
|
+
const [token] = router.routeTokens();
|
|
188
|
+
return routeNameOf(token) ?? labelOf(router.currentView?.() ?? null);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const now = () => win?.performance?.now?.() ?? 0;
|
|
192
|
+
|
|
193
|
+
// ------------------------------------------------------------------ sending
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* POSTs one report as every other request from script does -- with the
|
|
197
|
+
* session's CSRF token -- and outlives the page with `keepalive`, which
|
|
198
|
+
* sendBeacon cannot do with a header. A page with no token has no session
|
|
199
|
+
* to send it under, and sends it with no cookie at all, which the server
|
|
200
|
+
* takes without one (ADR 0129).
|
|
201
|
+
*
|
|
202
|
+
* @param {string} path
|
|
203
|
+
* @param {unknown} body
|
|
204
|
+
* @param {string} [type]
|
|
205
|
+
*/
|
|
206
|
+
function post(path, body, type = 'application/json') {
|
|
207
|
+
if (revoked) return;
|
|
208
|
+
const headers = path === REPORTS_PATH ? {} : csrfHeaders(doc);
|
|
209
|
+
const sessionless = Object.keys(headers).length === 0;
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const sent = doFetch(path, {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
keepalive: true,
|
|
215
|
+
credentials: sessionless ? 'omit' : 'same-origin',
|
|
216
|
+
headers: { 'Content-Type': type, ...headers },
|
|
217
|
+
body: JSON.stringify(body),
|
|
218
|
+
});
|
|
219
|
+
sent?.catch?.(() => {});
|
|
220
|
+
} catch {
|
|
221
|
+
// Dropped; see the top of this file.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ------------------------------------------------------------------- vitals
|
|
226
|
+
|
|
227
|
+
/** What has been measured: raw entry times, worked into vitals at send. */
|
|
228
|
+
const measured = {
|
|
229
|
+
activationStart: 0,
|
|
230
|
+
ttfb: /** @type {number | null} */ (null),
|
|
231
|
+
fcp: /** @type {number | null} */ (null),
|
|
232
|
+
lcp: /** @type {number | null} */ (null),
|
|
233
|
+
cls: /** @type {number | null} */ (null),
|
|
234
|
+
clsWindow: 0,
|
|
235
|
+
clsWindowStart: 0,
|
|
236
|
+
clsLast: 0,
|
|
237
|
+
interactions: /** @type {Map<number, number>} */ (new Map()),
|
|
238
|
+
interactionCount: 0,
|
|
239
|
+
lastInteraction: 0,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** When the page was first hidden: a paint after it was not one the reader saw load. */
|
|
243
|
+
let firstHidden = Infinity;
|
|
244
|
+
|
|
245
|
+
/** @type {Array<[PerformanceObserver, (entries: PerformanceEntryList) => void]>} */
|
|
246
|
+
const observers = [];
|
|
247
|
+
|
|
248
|
+
/** Whether layout shifts are measured here: a page's CLS starts at 0 where they are. */
|
|
249
|
+
let shiftsObserved = false;
|
|
250
|
+
|
|
251
|
+
/** The interactions the browser had counted when the page on screen was mounted. */
|
|
252
|
+
const interactionTotal = () => {
|
|
253
|
+
const count = /** @type {{ interactionCount?: number }} */ (win?.performance ?? {}).interactionCount;
|
|
254
|
+
return typeof count === 'number' && count > 0 ? count : 0;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {string} type
|
|
259
|
+
* @param {(entries: PerformanceEntryList) => void} handle
|
|
260
|
+
* @param {Record<string, unknown>} [more]
|
|
261
|
+
*/
|
|
262
|
+
function observe(type, handle, more = {}) {
|
|
263
|
+
const Observer = win.PerformanceObserver;
|
|
264
|
+
if (typeof Observer !== 'function' || !Observer.supportedEntryTypes?.includes(type)) return false;
|
|
265
|
+
try {
|
|
266
|
+
const observer = new Observer((list) => handle(list.getEntries()));
|
|
267
|
+
observer.observe({ type, buffered: true, ...more });
|
|
268
|
+
observers.push([observer, handle]);
|
|
269
|
+
return true;
|
|
270
|
+
} catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function observeVitals() {
|
|
276
|
+
firstHidden = doc?.visibilityState === 'hidden' ? 0 : Infinity;
|
|
277
|
+
|
|
278
|
+
observe('navigation', (entries) => {
|
|
279
|
+
const navigation = /** @type {PerformanceNavigationTiming | undefined} */ (entries[0]);
|
|
280
|
+
if (!navigation) return;
|
|
281
|
+
measured.activationStart =
|
|
282
|
+
/** @type {{ activationStart?: number }} */ (navigation).activationStart ?? 0;
|
|
283
|
+
if (navigation.responseStart > 0) measured.ttfb = navigation.responseStart;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
observe('paint', (entries) => {
|
|
287
|
+
for (const entry of entries) {
|
|
288
|
+
if (entry.name === 'first-contentful-paint' && entry.startTime < firstHidden)
|
|
289
|
+
measured.fcp = entry.startTime;
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// The last candidate is the largest; the browser stops naming them once
|
|
294
|
+
// the reader interacts.
|
|
295
|
+
observe('largest-contentful-paint', (entries) => {
|
|
296
|
+
const last = entries[entries.length - 1];
|
|
297
|
+
if (last && last.startTime < firstHidden) measured.lcp = last.startTime;
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// The largest session window of shifts: shifts less than a second apart,
|
|
301
|
+
// over five seconds at most, none just after the reader's own input.
|
|
302
|
+
shiftsObserved = observe('layout-shift', (entries) => {
|
|
303
|
+
for (const entry of /** @type {Array<PerformanceEntry & { value: number, hadRecentInput: boolean }>} */ (
|
|
304
|
+
entries
|
|
305
|
+
)) {
|
|
306
|
+
if (entry.hadRecentInput) continue;
|
|
307
|
+
const joins =
|
|
308
|
+
measured.clsWindow > 0 &&
|
|
309
|
+
entry.startTime - measured.clsLast < 1000 &&
|
|
310
|
+
entry.startTime - measured.clsWindowStart < 5000;
|
|
311
|
+
measured.clsWindow = joins ? measured.clsWindow + entry.value : entry.value;
|
|
312
|
+
if (!joins) measured.clsWindowStart = entry.startTime;
|
|
313
|
+
measured.clsLast = entry.startTime;
|
|
314
|
+
measured.cls = Math.max(measured.cls ?? 0, measured.clsWindow);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
if (shiftsObserved) measured.cls ??= 0;
|
|
318
|
+
|
|
319
|
+
// Each interaction's latency is its slowest event; INP is the slowest
|
|
320
|
+
// interaction, setting one aside for every fifty -- the 98th percentile.
|
|
321
|
+
/** @param {PerformanceEntryList} entries */
|
|
322
|
+
const interactions = (entries) => {
|
|
323
|
+
for (const entry of /** @type {Array<PerformanceEntry & { interactionId?: number }>} */ (entries)) {
|
|
324
|
+
const id = entry.interactionId;
|
|
325
|
+
if (!id) continue;
|
|
326
|
+
if (!measured.interactions.has(id) && id > measured.lastInteraction) measured.interactionCount++;
|
|
327
|
+
measured.lastInteraction = Math.max(measured.lastInteraction, id);
|
|
328
|
+
measured.interactions.set(id, Math.max(measured.interactions.get(id) ?? 0, entry.duration));
|
|
329
|
+
// Only the longest few can be the answer; the rest are let go.
|
|
330
|
+
if (measured.interactions.size > 50) {
|
|
331
|
+
const kept = [...measured.interactions].sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
332
|
+
measured.interactions = new Map(kept);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
observe('event', interactions, { durationThreshold: 40 });
|
|
337
|
+
observe('first-input', interactions);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The vitals of the page on screen as they stand: milliseconds, and a
|
|
342
|
+
* score for CLS. A vital past its ceiling, or not measured, is absent. A
|
|
343
|
+
* page a client navigation mounted has no LCP, FCP or TTFB of its own.
|
|
344
|
+
*
|
|
345
|
+
* @returns {Partial<Record<'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB' | 'NAV', number>>}
|
|
346
|
+
*/
|
|
347
|
+
function vitals() {
|
|
348
|
+
const since = (time) => (time === null ? null : Math.max(time - measured.activationStart, 0));
|
|
349
|
+
const latencies = [...measured.interactions.values()].sort((a, b) => b - a);
|
|
350
|
+
const counted = interactionTotal() - page.interactionsBefore;
|
|
351
|
+
const total = counted > 0 ? counted : measured.interactionCount;
|
|
352
|
+
const inp =
|
|
353
|
+
latencies.length === 0 ? null : latencies[Math.min(latencies.length - 1, Math.floor(total / 50))];
|
|
354
|
+
|
|
355
|
+
/** @type {Record<string, number | null>} */
|
|
356
|
+
const values =
|
|
357
|
+
page.navigation === 'document'
|
|
358
|
+
? {
|
|
359
|
+
LCP: since(measured.lcp),
|
|
360
|
+
INP: inp ?? null,
|
|
361
|
+
CLS: measured.cls,
|
|
362
|
+
FCP: since(measured.fcp),
|
|
363
|
+
TTFB: since(measured.ttfb),
|
|
364
|
+
}
|
|
365
|
+
: { INP: inp ?? null, CLS: measured.cls, NAV: page.nav };
|
|
366
|
+
|
|
367
|
+
/** @type {Record<string, number>} */
|
|
368
|
+
const kept = {};
|
|
369
|
+
for (const [name, value] of Object.entries(values)) {
|
|
370
|
+
const ceiling = VITAL_CEILINGS[/** @type {keyof typeof VITAL_CEILINGS} */ (name)];
|
|
371
|
+
if (value === null || !Number.isFinite(value) || value < 0 || value > ceiling) continue;
|
|
372
|
+
kept[name] = name === 'CLS' ? Math.round(value * 10000) / 10000 : Math.round(value * 10) / 10;
|
|
373
|
+
}
|
|
374
|
+
return kept;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The page on screen's vitals, once: whatever the observers still hold is
|
|
379
|
+
* read first, as this page's. They go on observing, for the next page.
|
|
380
|
+
*/
|
|
381
|
+
function sendVitals() {
|
|
382
|
+
if (page.sent || observers.length === 0) return;
|
|
383
|
+
page.sent = true;
|
|
384
|
+
|
|
385
|
+
for (const [observer, handle] of observers) {
|
|
386
|
+
try {
|
|
387
|
+
handle(observer.takeRecords());
|
|
388
|
+
} catch {
|
|
389
|
+
// An observer that cannot be read has nothing more to say.
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const metrics = vitals();
|
|
394
|
+
if (page.route !== null && Object.keys(metrics).length > 0) {
|
|
395
|
+
post(VITALS_PATH, { route: page.route, navigation: page.navigation, metrics });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* A client navigation mounted a page: the one it replaced has its vitals
|
|
401
|
+
* sent, and this one's are measured from now -- its layout shifts and its
|
|
402
|
+
* interactions, and how long the visit took.
|
|
403
|
+
*/
|
|
404
|
+
function mounted() {
|
|
405
|
+
sendVitals();
|
|
406
|
+
const at = now();
|
|
407
|
+
page = {
|
|
408
|
+
route: mountedRoute(),
|
|
409
|
+
navigation: 'client',
|
|
410
|
+
nav: visitStarted === null ? null : Math.max(at - visitStarted, 0),
|
|
411
|
+
sent: false,
|
|
412
|
+
interactionsBefore: interactionTotal(),
|
|
413
|
+
};
|
|
414
|
+
visitStarted = null;
|
|
415
|
+
measured.cls = shiftsObserved ? 0 : null;
|
|
416
|
+
measured.clsWindow = 0;
|
|
417
|
+
measured.clsWindowStart = 0;
|
|
418
|
+
measured.clsLast = 0;
|
|
419
|
+
measured.interactions = new Map();
|
|
420
|
+
measured.interactionCount = 0;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ---------------------------------------------------------- violations
|
|
424
|
+
|
|
425
|
+
/** The policy the document's meta element holds, read at start(): null where it has none. */
|
|
426
|
+
let metaPolicy = /** @type {string | null} */ (null);
|
|
427
|
+
|
|
428
|
+
/** Violations waiting to be sent, and what was sent already, by what it said. */
|
|
429
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
430
|
+
let violations = [];
|
|
431
|
+
const violated = new Set();
|
|
432
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
433
|
+
let violationTimer = null;
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* A URL as the server keeps it: no query, no fragment, no credentials; a
|
|
437
|
+
* keyword (`inline`, `eval`) as itself.
|
|
438
|
+
*
|
|
439
|
+
* @param {unknown} value
|
|
440
|
+
* @returns {string | undefined}
|
|
441
|
+
*/
|
|
442
|
+
function bareUrl(value) {
|
|
443
|
+
if (typeof value !== 'string' || value === '') return undefined;
|
|
444
|
+
if (/^[a-z][a-z-]{0,31}$/.test(value)) return value;
|
|
445
|
+
try {
|
|
446
|
+
const url = new URL(value, win.location.href);
|
|
447
|
+
return `${url.protocol}//${url.host}${url.pathname}`.slice(0, 512);
|
|
448
|
+
} catch {
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* A violation of the meta element's policy, which no report-uri can name:
|
|
455
|
+
* sent to /_reports as a Reporting API report would be. Not one of a
|
|
456
|
+
* header's policy -- the browser reports those itself -- and none of its
|
|
457
|
+
* sample, which can hold the script it blocked.
|
|
458
|
+
*
|
|
459
|
+
* @param {SecurityPolicyViolationEvent} event
|
|
460
|
+
*/
|
|
461
|
+
function violation(event) {
|
|
462
|
+
if (stopped || metaPolicy === null || String(event.originalPolicy).trim() !== metaPolicy) return;
|
|
463
|
+
const directive = typeof event.effectiveDirective === 'string' ? event.effectiveDirective : '';
|
|
464
|
+
const body = {
|
|
465
|
+
documentURL: bareUrl(event.documentURI),
|
|
466
|
+
blockedURL: bareUrl(event.blockedURI),
|
|
467
|
+
effectiveDirective: directive,
|
|
468
|
+
disposition: event.disposition === 'report' ? 'report' : 'enforce',
|
|
469
|
+
sourceFile: bareUrl(event.sourceFile),
|
|
470
|
+
lineNumber: Number.isInteger(event.lineNumber) ? event.lineNumber : undefined,
|
|
471
|
+
columnNumber: Number.isInteger(event.columnNumber) ? event.columnNumber : undefined,
|
|
472
|
+
};
|
|
473
|
+
const key = [directive, body.blockedURL, body.sourceFile, body.lineNumber].join('\u0000');
|
|
474
|
+
if (violated.has(key) || violated.size >= MAX_VIOLATIONS) return;
|
|
475
|
+
violated.add(key);
|
|
476
|
+
|
|
477
|
+
violations.push({ type: 'csp-violation', body });
|
|
478
|
+
violationTimer ??= win.setTimeout(flushViolations, ERROR_DELAY_MS);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function flushViolations() {
|
|
482
|
+
if (violationTimer !== null) win.clearTimeout(violationTimer);
|
|
483
|
+
violationTimer = null;
|
|
484
|
+
if (violations.length === 0) return;
|
|
485
|
+
post(REPORTS_PATH, violations.splice(0), 'application/reports+json');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ------------------------------------------------------------------- errors
|
|
489
|
+
|
|
490
|
+
/** What has been reported for the page on screen, by what it said; and how many. */
|
|
491
|
+
let seen = new Set();
|
|
492
|
+
let reportedHere = 0;
|
|
493
|
+
|
|
494
|
+
/** Errors waiting to be sent, each with the route it happened on. */
|
|
495
|
+
/** @type {Array<{ route: string, error: Record<string, unknown> }>} */
|
|
496
|
+
let pending = [];
|
|
497
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
498
|
+
let timer = null;
|
|
499
|
+
|
|
500
|
+
/** Error objects already taken, by whichever way they came first. */
|
|
501
|
+
const taken = new WeakSet();
|
|
502
|
+
|
|
503
|
+
/** The value a hook is handing on to the browser's own reporting, not to be taken twice. */
|
|
504
|
+
let handingOn;
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Reports an error for the page on screen. Nothing for a page with no
|
|
508
|
+
* route name, nothing past the page's cap, and nothing it has reported
|
|
509
|
+
* already.
|
|
510
|
+
*
|
|
511
|
+
* @param {unknown} error
|
|
512
|
+
* @param {string} [kind]
|
|
513
|
+
* @param {{ message?: unknown, filename?: unknown, lineno?: unknown }} [event]
|
|
514
|
+
*/
|
|
515
|
+
function reportError(error, kind = 'error', event = {}) {
|
|
516
|
+
if (stopped || revoked || !KINDS.has(kind)) return;
|
|
517
|
+
if (error !== null && (typeof error === 'object' || typeof error === 'function')) {
|
|
518
|
+
if (taken.has(error)) return;
|
|
519
|
+
taken.add(error);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const route = currentRoute();
|
|
523
|
+
if (route === null || reportedHere >= MAX_ERRORS_PER_PAGE) return;
|
|
524
|
+
|
|
525
|
+
const described = describe(error, kind, event);
|
|
526
|
+
if (described === null) return;
|
|
527
|
+
|
|
528
|
+
const key = [kind, described.message, described.file ?? '', described.line ?? ''].join('\u0000');
|
|
529
|
+
if (seen.has(key)) return;
|
|
530
|
+
seen.add(key);
|
|
531
|
+
reportedHere++;
|
|
532
|
+
|
|
533
|
+
pending.push({ route, error: described });
|
|
534
|
+
timer ??= win.setTimeout(flushErrors, ERROR_DELAY_MS);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* An error as it is reported: its message, the digest a server failure
|
|
539
|
+
* was shown under, the script and line it came from when that is this
|
|
540
|
+
* origin's, and a few frames.
|
|
541
|
+
*
|
|
542
|
+
* @param {unknown} error
|
|
543
|
+
* @param {string} kind
|
|
544
|
+
* @param {{ message?: unknown, filename?: unknown, lineno?: unknown }} event
|
|
545
|
+
* @returns {Record<string, unknown> | null}
|
|
546
|
+
*/
|
|
547
|
+
function describe(error, kind, event) {
|
|
548
|
+
const message = messageOf(error, event.message);
|
|
549
|
+
if (message === null) return null;
|
|
550
|
+
|
|
551
|
+
const stack = typeof (/** @type {{ stack?: unknown }} */ (error)?.stack) === 'string' ? error.stack : '';
|
|
552
|
+
const where = typeof event.filename === 'string' && event.filename !== '' ? event : frameOf(stack);
|
|
553
|
+
|
|
554
|
+
/** @type {Record<string, unknown>} */
|
|
555
|
+
const described = { kind, message: withoutQueries(scrub(message)).slice(0, MAX_MESSAGE) };
|
|
556
|
+
|
|
557
|
+
const digest = /** @type {{ digest?: unknown }} */ (error)?.digest;
|
|
558
|
+
if (typeof digest === 'string' && DIGEST.test(digest)) described.digest = digest;
|
|
559
|
+
|
|
560
|
+
const file = sameOriginPath(where?.filename);
|
|
561
|
+
if (file !== null) {
|
|
562
|
+
described.file = file;
|
|
563
|
+
const line = Number(where.lineno);
|
|
564
|
+
if (Number.isInteger(line) && line >= 1 && line <= 10_000_000) described.line = line;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const frames = framesOf(stack);
|
|
568
|
+
if (frames !== '') described.stack = frames;
|
|
569
|
+
|
|
570
|
+
return described;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* What an error says. Never a value's contents when it is not an error or
|
|
575
|
+
* a string: a rejected response, a record, whatever was thrown -- only
|
|
576
|
+
* what kind of thing it was.
|
|
577
|
+
*
|
|
578
|
+
* @param {unknown} error
|
|
579
|
+
* @param {unknown} fallback the event's own message
|
|
580
|
+
* @returns {string | null}
|
|
581
|
+
*/
|
|
582
|
+
function messageOf(error, fallback) {
|
|
583
|
+
if (error instanceof Error || (typeof error === 'object' && error !== null && 'message' in error)) {
|
|
584
|
+
const { name, message } = /** @type {{ name?: unknown, message?: unknown }} */ (error);
|
|
585
|
+
const text = typeof message === 'string' ? message : '';
|
|
586
|
+
return typeof name === 'string' && name !== '' && name !== 'Error' ? `${name}: ${text}` : text;
|
|
587
|
+
}
|
|
588
|
+
if (typeof error === 'string') return error;
|
|
589
|
+
if (typeof fallback === 'string' && fallback !== '') return fallback === OPAQUE ? null : fallback;
|
|
590
|
+
return `A value that is not an error: ${error === null ? 'null' : typeof error}`;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The script and line a stack's first frame names.
|
|
595
|
+
*
|
|
596
|
+
* @param {string} stack
|
|
597
|
+
* @returns {{ filename: string, lineno: number } | null}
|
|
598
|
+
*/
|
|
599
|
+
function frameOf(stack) {
|
|
600
|
+
for (const line of stack.split('\n')) {
|
|
601
|
+
const match = FRAME.exec(line.trim());
|
|
602
|
+
if (match) return { filename: match[1], lineno: Number(match[2]) };
|
|
603
|
+
}
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* A script's path when it is this origin's -- no query, no fragment --
|
|
609
|
+
* and null for any other: a third party's script, an extension's.
|
|
610
|
+
*
|
|
611
|
+
* @param {unknown} file
|
|
612
|
+
* @returns {string | null}
|
|
613
|
+
*/
|
|
614
|
+
function sameOriginPath(file) {
|
|
615
|
+
if (typeof file !== 'string' || file === '') return null;
|
|
616
|
+
try {
|
|
617
|
+
const url = new URL(file, win.location.href);
|
|
618
|
+
return url.origin === win.location.origin && url.pathname.length <= 512 ? url.pathname : null;
|
|
619
|
+
} catch {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* A few short frames: this origin left off its own URLs, every URL's
|
|
626
|
+
* query and fragment cut, and what a cookie holds taken out.
|
|
627
|
+
*
|
|
628
|
+
* @param {string} stack
|
|
629
|
+
*/
|
|
630
|
+
function framesOf(stack) {
|
|
631
|
+
const origin = win.location.origin;
|
|
632
|
+
const lines = [];
|
|
633
|
+
for (const raw of stack.split('\n')) {
|
|
634
|
+
const line = withoutQueries(scrub(raw.trim()).split(origin).join(''));
|
|
635
|
+
if (line === '') continue;
|
|
636
|
+
lines.push(line.slice(0, MAX_STACK_LINE));
|
|
637
|
+
if (lines.length === MAX_STACK_LINES) break;
|
|
638
|
+
}
|
|
639
|
+
return lines.join('\n');
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* $text with every query and fragment cut, where a token in an address
|
|
644
|
+
* would be: an absolute URL's, and a relative or path-only one's --
|
|
645
|
+
* `/reset?token=...`, `./x#access_token=...` -- alike. A `?` or `#` in
|
|
646
|
+
* prose goes with them, which costs a message little.
|
|
647
|
+
*
|
|
648
|
+
* @param {string} text
|
|
649
|
+
*/
|
|
650
|
+
function withoutQueries(text) {
|
|
651
|
+
return text.replace(/[?#][^\s():'"]*/g, '');
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* $text without anything a cookie script can read holds: a CSRF token a
|
|
656
|
+
* failed request's message quoted, say. Storage is never read at all.
|
|
657
|
+
*
|
|
658
|
+
* @param {string} text
|
|
659
|
+
*/
|
|
660
|
+
function scrub(text) {
|
|
661
|
+
const cookies = typeof doc?.cookie === 'string' ? doc.cookie : '';
|
|
662
|
+
let scrubbed = text;
|
|
663
|
+
for (const pair of cookies.split(';')) {
|
|
664
|
+
const value = pair.slice(pair.indexOf('=') + 1).trim();
|
|
665
|
+
if (pair.includes('=') && value.length >= 8) scrubbed = scrubbed.split(value).join('[redacted]');
|
|
666
|
+
}
|
|
667
|
+
return scrubbed;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Sends what is waiting: a report per route, each within the server's caps. */
|
|
671
|
+
function flushErrors() {
|
|
672
|
+
if (timer !== null) win.clearTimeout(timer);
|
|
673
|
+
timer = null;
|
|
674
|
+
if (pending.length === 0) return;
|
|
675
|
+
|
|
676
|
+
/** @type {Map<string, Array<Record<string, unknown>>>} */
|
|
677
|
+
const byRoute = new Map();
|
|
678
|
+
for (const { route, error } of pending.splice(0)) {
|
|
679
|
+
if (!byRoute.has(route)) byRoute.set(route, []);
|
|
680
|
+
byRoute.get(route)?.push(error);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
for (const [route, errors] of byRoute) {
|
|
684
|
+
for (const body of reports(route, errors)) post(ERRORS_PATH, body);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* $errors as reports of at most MAX_ERRORS_PER_REPORT, each under
|
|
690
|
+
* MAX_BODY_BYTES as sent.
|
|
691
|
+
*
|
|
692
|
+
* @param {string} route
|
|
693
|
+
* @param {Array<Record<string, unknown>>} errors
|
|
694
|
+
*/
|
|
695
|
+
function reports(route, errors) {
|
|
696
|
+
const bytes = (body) => new TextEncoder().encode(JSON.stringify(body)).length;
|
|
697
|
+
const bodies = [];
|
|
698
|
+
let current = [];
|
|
699
|
+
for (const error of errors) {
|
|
700
|
+
const next = [...current, error];
|
|
701
|
+
if (
|
|
702
|
+
current.length > 0 &&
|
|
703
|
+
(next.length > MAX_ERRORS_PER_REPORT || bytes({ route, errors: next }) > MAX_BODY_BYTES)
|
|
704
|
+
) {
|
|
705
|
+
bodies.push({ route, errors: current });
|
|
706
|
+
current = [error];
|
|
707
|
+
} else {
|
|
708
|
+
current = next;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (current.length > 0) bodies.push({ route, errors: current });
|
|
712
|
+
return bodies.filter((body) => bytes(body) <= MAX_BODY_BYTES);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* A hook for React's root: reports, then hands the error on as React's
|
|
717
|
+
* own default would -- to the console for one a boundary caught, to the
|
|
718
|
+
* browser's error reporting otherwise -- without it being taken twice.
|
|
719
|
+
*
|
|
720
|
+
* @param {string} kind
|
|
721
|
+
* @param {(error: unknown) => void} handOn
|
|
722
|
+
*/
|
|
723
|
+
function hook(kind, handOn) {
|
|
724
|
+
return (/** @type {unknown} */ error) => {
|
|
725
|
+
reportError(error, kind);
|
|
726
|
+
handingOn = error;
|
|
727
|
+
try {
|
|
728
|
+
handOn(error);
|
|
729
|
+
} finally {
|
|
730
|
+
handingOn = undefined;
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const toConsole = (error) => console.error(error);
|
|
736
|
+
const toBrowser = (error) =>
|
|
737
|
+
typeof win?.reportError === 'function' ? win.reportError(error) : console.error(error);
|
|
738
|
+
|
|
739
|
+
// ---------------------------------------------------------------- lifecycle
|
|
740
|
+
|
|
741
|
+
/** @param {Window} target @param {string} name @param {(event: any) => void} fn */
|
|
742
|
+
function listen(target, name, fn) {
|
|
743
|
+
target.addEventListener(name, fn);
|
|
744
|
+
teardown.push(() => target.removeEventListener(name, fn));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function hidden() {
|
|
748
|
+
if (doc?.visibilityState === 'hidden') firstHidden = Math.min(firstHidden, win.performance?.now?.() ?? 0);
|
|
749
|
+
sendVitals();
|
|
750
|
+
flushErrors();
|
|
751
|
+
flushViolations();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const telemetry = {
|
|
755
|
+
/** Starts measuring and listening. Once; and never again after stop(). */
|
|
756
|
+
start() {
|
|
757
|
+
if (started || stopped || win === undefined || doc === undefined) return telemetry;
|
|
758
|
+
started = true;
|
|
759
|
+
|
|
760
|
+
const [token] = router?.routeTokens() ?? [];
|
|
761
|
+
const metadata = readPageMetadata(doc);
|
|
762
|
+
page.route = labelOf(metadata?.route) ?? routeNameOf(metadata?.routeToken ?? token);
|
|
763
|
+
page.interactionsBefore = 0;
|
|
764
|
+
|
|
765
|
+
if (options.vitals !== false) observeVitals();
|
|
766
|
+
|
|
767
|
+
if (options.violations !== false) {
|
|
768
|
+
const meta = doc.querySelector?.('meta[http-equiv="Content-Security-Policy" i]');
|
|
769
|
+
const content = meta?.getAttribute?.('content');
|
|
770
|
+
metaPolicy = typeof content === 'string' && content.trim() !== '' ? content.trim() : null;
|
|
771
|
+
if (metaPolicy !== null) listen(doc, 'securitypolicyviolation', violation);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (options.errors !== false) {
|
|
775
|
+
listen(win, 'error', (event) => {
|
|
776
|
+
if (event.error !== undefined && event.error === handingOn) return;
|
|
777
|
+
reportError(event.error ?? null, 'error', event);
|
|
778
|
+
});
|
|
779
|
+
listen(win, 'unhandledrejection', (event) => reportError(event.reason, 'rejection'));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
listen(doc, 'visibilitychange', () => {
|
|
783
|
+
if (doc.visibilityState === 'hidden') hidden();
|
|
784
|
+
});
|
|
785
|
+
listen(win, 'pagehide', hidden);
|
|
786
|
+
|
|
787
|
+
if (router !== null) {
|
|
788
|
+
// When the visit to the next page began, for its NAV.
|
|
789
|
+
teardown.push(
|
|
790
|
+
router.on('start', (event) => {
|
|
791
|
+
if (!event.detail?.background) visitStarted = now();
|
|
792
|
+
}),
|
|
793
|
+
);
|
|
794
|
+
// A page the router mounts is a page of its own: its errors are
|
|
795
|
+
// counted and told apart afresh, and its vitals are its own. A poll
|
|
796
|
+
// or a partial reload is the same page.
|
|
797
|
+
teardown.push(
|
|
798
|
+
router.on('success', (event) => {
|
|
799
|
+
if (event.detail?.background || event.detail?.partial) return;
|
|
800
|
+
seen = new Set();
|
|
801
|
+
reportedHere = 0;
|
|
802
|
+
if (options.vitals !== false && !stopped) mounted();
|
|
803
|
+
else page = { ...page, route: mountedRoute() };
|
|
804
|
+
}),
|
|
805
|
+
);
|
|
806
|
+
// The session has ended here: nothing more is sent under it.
|
|
807
|
+
teardown.push(
|
|
808
|
+
router.on('revoke', () => {
|
|
809
|
+
revoked = true;
|
|
810
|
+
pending = [];
|
|
811
|
+
violations = [];
|
|
812
|
+
}),
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return telemetry;
|
|
817
|
+
},
|
|
818
|
+
|
|
819
|
+
/** Stops listening and measuring, sending nothing more. */
|
|
820
|
+
stop() {
|
|
821
|
+
stopped = true;
|
|
822
|
+
for (const undo of teardown.splice(0)) undo();
|
|
823
|
+
for (const [observer] of observers.splice(0)) observer.disconnect();
|
|
824
|
+
if (timer !== null) win.clearTimeout(timer);
|
|
825
|
+
timer = null;
|
|
826
|
+
pending = [];
|
|
827
|
+
if (violationTimer !== null) win.clearTimeout(violationTimer);
|
|
828
|
+
violationTimer = null;
|
|
829
|
+
violations = [];
|
|
830
|
+
},
|
|
831
|
+
|
|
832
|
+
reportError: (/** @type {unknown} */ error, /** @type {string} */ kind = 'error') =>
|
|
833
|
+
reportError(error, kind),
|
|
834
|
+
|
|
835
|
+
/** For hydrateRoot()'s options: an error a boundary caught. */
|
|
836
|
+
onCaughtError: hook('caught', toConsole),
|
|
837
|
+
|
|
838
|
+
/** For hydrateRoot()'s options: an error no boundary caught. */
|
|
839
|
+
onUncaughtError: hook('uncaught', toBrowser),
|
|
840
|
+
|
|
841
|
+
/** For hydrateRoot()'s options: an error React recovered from, such as a hydration mismatch. */
|
|
842
|
+
onRecoverableError: hook('recoverable', toBrowser),
|
|
843
|
+
|
|
844
|
+
/** Sends the errors waiting now, rather than in a moment. */
|
|
845
|
+
flush: flushErrors,
|
|
846
|
+
|
|
847
|
+
vitals,
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
return telemetry;
|
|
851
|
+
}
|