@flareapp/js 2.6.0 → 2.8.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/README.md +17 -0
- package/dist/browser.cjs +45 -8
- package/dist/browser.d.cts +4 -3
- package/dist/browser.d.mts +4 -3
- package/dist/browser.mjs +19 -3
- package/dist/componentProfiler-C6qY96Bt.cjs +2497 -0
- package/dist/componentProfiler-CoH2m13n.mjs +2342 -0
- package/dist/index.cjs +21 -2
- package/dist/index.d.cts +3 -2
- package/dist/index.d.mts +3 -2
- package/dist/index.mjs +3 -3
- package/dist/spanTypes-CjF1_Uqi.d.mts +161 -0
- package/dist/spanTypes-iA1RdfLw.d.cts +161 -0
- package/package.json +3 -2
- package/dist/BrowserFlushScheduler-CR7L8-z6.d.cts +0 -43
- package/dist/BrowserFlushScheduler-CZiDvxtf.d.mts +0 -43
- package/dist/catchWindowErrors-DaCqd2yC.mjs +0 -124
- package/dist/catchWindowErrors-_IlVo06V.cjs +0 -153
|
@@ -0,0 +1,2342 @@
|
|
|
1
|
+
import { BrowserSpanType, SpanStatusCode, buildTraceparent, defaultNowNano, redactUrlQuery, routeRejection, spanId, urlAttributes } from "@flareapp/core";
|
|
2
|
+
|
|
3
|
+
//#region src/browser/BrowserFlushScheduler.ts
|
|
4
|
+
var BrowserFlushScheduler = class {
|
|
5
|
+
register(flush) {
|
|
6
|
+
if (typeof document === "undefined" || !document) return;
|
|
7
|
+
document.addEventListener("visibilitychange", () => {
|
|
8
|
+
if (document.visibilityState === "hidden") flush({ keepalive: true });
|
|
9
|
+
});
|
|
10
|
+
if (typeof window !== "undefined" && window) window.addEventListener("pagehide", () => flush({ keepalive: true }));
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/browser/context/cookie.ts
|
|
16
|
+
/**
|
|
17
|
+
* Parses `document.cookie` into `http.request.cookies`, redacting the value of any cookie whose name
|
|
18
|
+
* matches `denylist`. Null-prototype accumulator so a cookie named `__proto__` is stored, not dropped.
|
|
19
|
+
*/
|
|
20
|
+
function cookie(denylist) {
|
|
21
|
+
if (!window.document.cookie) return {};
|
|
22
|
+
const cookies = Object.create(null);
|
|
23
|
+
window.document.cookie.split("; ").forEach((rawCookie) => {
|
|
24
|
+
const idx = rawCookie.indexOf("=");
|
|
25
|
+
if (idx === -1) {
|
|
26
|
+
cookies[rawCookie] = denylist.test(rawCookie) ? "[redacted]" : "";
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const name = rawCookie.slice(0, idx);
|
|
30
|
+
const value = rawCookie.slice(idx + 1);
|
|
31
|
+
cookies[name] = denylist.test(name) ? "[redacted]" : value;
|
|
32
|
+
});
|
|
33
|
+
return { "http.request.cookies": cookies };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region src/browser/context/request.ts
|
|
38
|
+
/**
|
|
39
|
+
* @param hrefOverride when set, the `url.*` attributes come from it instead of the live
|
|
40
|
+
* `window.location.href` (a framework navigation root whose router knows the destination
|
|
41
|
+
* before the URL commits). The override is pre-validated by the caller.
|
|
42
|
+
*/
|
|
43
|
+
function request(urlDenylist, hrefOverride) {
|
|
44
|
+
return {
|
|
45
|
+
...urlAttributes(hrefOverride ?? window.location.href, urlDenylist),
|
|
46
|
+
"user_agent.original": window.navigator.userAgent,
|
|
47
|
+
"http.request.referrer": redactUrlQuery(window.document.referrer, urlDenylist),
|
|
48
|
+
"document.ready_state": window.document.readyState
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/browser/context/collectBrowser.ts
|
|
54
|
+
function browserEntryPoint(config, urlOverride) {
|
|
55
|
+
if (typeof window === "undefined") return { "flare.entry_point.type": "web" };
|
|
56
|
+
const attrs = { "flare.entry_point.type": "web" };
|
|
57
|
+
const href = urlOverride ? urlOverride.href : window?.location?.href;
|
|
58
|
+
if (href) {
|
|
59
|
+
attrs["flare.entry_point.value"] = redactUrlQuery(href, config.urlDenylist);
|
|
60
|
+
const pathname = urlOverride ? urlOverride.pathname : window?.location?.pathname;
|
|
61
|
+
if (pathname) {
|
|
62
|
+
attrs["flare.entry_point.handler.identifier"] = pathname;
|
|
63
|
+
attrs["http.route"] = pathname;
|
|
64
|
+
attrs["flare.entry_point.handler.type"] = "browser";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return attrs;
|
|
68
|
+
}
|
|
69
|
+
const collectBrowser = (config) => {
|
|
70
|
+
const attrs = { ...browserEntryPoint(config) };
|
|
71
|
+
if (typeof window === "undefined") return attrs;
|
|
72
|
+
if (window?.location?.hostname) attrs["host.name"] = window.location.hostname;
|
|
73
|
+
Object.assign(attrs, request(config.urlDenylist));
|
|
74
|
+
Object.assign(attrs, cookie(config.urlDenylist));
|
|
75
|
+
return attrs;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/tracing/internalRequest.ts
|
|
80
|
+
/**
|
|
81
|
+
* Marks a request the SDK makes for its own bookkeeping (right now: fetching a source file so a
|
|
82
|
+
* stack frame can show a code snippet). The fetch patch passes those straight through: they are
|
|
83
|
+
* not the app's traffic, so tracing them puts a span in the customer's waterfall for a request
|
|
84
|
+
* their code never made, and propagating a `traceparent` on them is just as wrong.
|
|
85
|
+
*
|
|
86
|
+
* Flare's ingest calls are excluded by URL instead (`isFlareIngestUrl`), because their endpoints
|
|
87
|
+
* are known up front. A snippet fetch targets the customer's own asset, so only the caller knows.
|
|
88
|
+
*/
|
|
89
|
+
const INTERNAL_REQUEST_KEY = "__flare_internal_request__";
|
|
90
|
+
/** An init that marks the request as Flare's own. Unknown init keys are ignored by `fetch`. */
|
|
91
|
+
function internalRequestInit(init) {
|
|
92
|
+
return {
|
|
93
|
+
...init,
|
|
94
|
+
[INTERNAL_REQUEST_KEY]: true
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function isInternalRequest(init) {
|
|
98
|
+
return init?.[INTERNAL_REQUEST_KEY] === true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/browser/FetchFileReader.ts
|
|
103
|
+
/**
|
|
104
|
+
* Fetches source files so the stack-trace builder can render a snippet around the offending line.
|
|
105
|
+
* Only http(s) is fetched: other schemes (chrome-extension://, file://, blob:, data:) would cross a
|
|
106
|
+
* privilege boundary or hit a CORS/CSP wall for nothing. Returns null on any failure, never throws.
|
|
107
|
+
*/
|
|
108
|
+
var FetchFileReader = class {
|
|
109
|
+
read(url) {
|
|
110
|
+
if (!/^https?:\/\//i.test(url)) return Promise.resolve(null);
|
|
111
|
+
return fetch(url, internalRequestInit()).then((response) => {
|
|
112
|
+
if (response.status !== 200) return null;
|
|
113
|
+
return response.text();
|
|
114
|
+
}).catch(() => null);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region src/env/index.ts
|
|
120
|
+
const CLIENT_VERSION = typeof process !== "undefined" && true ? "2.8.0" : "?";
|
|
121
|
+
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/tracing/fill.ts
|
|
124
|
+
/**
|
|
125
|
+
* Replace `source[name]` with `replacer(original)`, tagging the wrapper with a
|
|
126
|
+
* non-enumerable `__flare_original__` so the patch is idempotent and reversible.
|
|
127
|
+
* Ported from Sentry's `fill` (packages/core/src/utils/object.ts), minus the
|
|
128
|
+
* prototype/own-property copying we do not need for `fetch`.
|
|
129
|
+
*/
|
|
130
|
+
function fill(source, name, replacer) {
|
|
131
|
+
const original = source[name];
|
|
132
|
+
if (typeof original !== "function") return;
|
|
133
|
+
if (original.__flare_original__) return;
|
|
134
|
+
const wrapped = replacer(original);
|
|
135
|
+
Object.defineProperty(wrapped, "__flare_original__", {
|
|
136
|
+
value: original,
|
|
137
|
+
enumerable: false,
|
|
138
|
+
configurable: true,
|
|
139
|
+
writable: true
|
|
140
|
+
});
|
|
141
|
+
source[name] = wrapped;
|
|
142
|
+
}
|
|
143
|
+
/** Restore a previously `fill`ed property to its original. Safe if never filled. */
|
|
144
|
+
function unfill(source, name) {
|
|
145
|
+
const current = source[name];
|
|
146
|
+
if (current && current.__flare_original__) source[name] = current.__flare_original__;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/tracing/createPatcher.ts
|
|
151
|
+
/**
|
|
152
|
+
* One `installed` flag for the whole patch set, not per method: XHR's `open` records what `send` reads,
|
|
153
|
+
* so a third party wrapping one of them must never leave the set half patched.
|
|
154
|
+
*
|
|
155
|
+
* Target is passed per call, not captured, because callers look it up fresh (`globalThis.fetch` may not
|
|
156
|
+
* exist yet under SSR).
|
|
157
|
+
*/
|
|
158
|
+
function createPatcher() {
|
|
159
|
+
let installed = false;
|
|
160
|
+
let names = [];
|
|
161
|
+
return {
|
|
162
|
+
get installed() {
|
|
163
|
+
return installed;
|
|
164
|
+
},
|
|
165
|
+
install(target, patches) {
|
|
166
|
+
if (installed) return;
|
|
167
|
+
function applyOne(name) {
|
|
168
|
+
const wrap = patches[name];
|
|
169
|
+
if (wrap) fill(target, name, wrap);
|
|
170
|
+
}
|
|
171
|
+
names = Object.keys(patches);
|
|
172
|
+
for (const name of names) applyOne(name);
|
|
173
|
+
installed = true;
|
|
174
|
+
},
|
|
175
|
+
uninstall(target) {
|
|
176
|
+
if (!installed) return;
|
|
177
|
+
if (!names.every((name) => {
|
|
178
|
+
const current = target[name];
|
|
179
|
+
return typeof current !== "function" || Boolean(current.__flare_original__);
|
|
180
|
+
})) return;
|
|
181
|
+
for (const name of names) unfill(target, name);
|
|
182
|
+
installed = false;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/tracing/propagation.ts
|
|
189
|
+
/** Follows OTel/Sentry `tracePropagationTargets`: same-origin by default, `[]` disables all. */
|
|
190
|
+
function shouldPropagate(url, absoluteUrl, currentOrigin, targets) {
|
|
191
|
+
if (targets) {
|
|
192
|
+
if (targets.length === 0) return false;
|
|
193
|
+
return targets.some((t) => typeof t === "string" ? url.includes(t) : t.test(url));
|
|
194
|
+
}
|
|
195
|
+
return absoluteUrl !== null && absoluteUrl.origin === currentOrigin;
|
|
196
|
+
}
|
|
197
|
+
/** Null on a throwing or malformed entry: the caller then passes the source through untouched, so a
|
|
198
|
+
* bad merge never breaks the host request. */
|
|
199
|
+
function headerPairsFrom(source) {
|
|
200
|
+
try {
|
|
201
|
+
const pairs = [];
|
|
202
|
+
for (const entry of source) {
|
|
203
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
204
|
+
const pair = Array.from(entry);
|
|
205
|
+
if (pair.length !== 2) return null;
|
|
206
|
+
pairs.push([String(pair[0]), String(pair[1])]);
|
|
207
|
+
}
|
|
208
|
+
return pairs;
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Fetch accepts any iterable of string pairs as HeadersInit (Map, URLSearchParams, cross-realm Headers). */
|
|
214
|
+
function isIterable(value) {
|
|
215
|
+
return value !== null && (typeof value === "object" || typeof value === "function") && typeof value[Symbol.iterator] === "function";
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* A new `RequestInit` carrying `traceparent`, without mutating the caller's `Request` or `init`.
|
|
219
|
+
* Caller-wins: a `traceparent` the caller already set is left alone, matching XHR's
|
|
220
|
+
* `hasAppTraceparent` skip. Returning an init rather than a rebuilt `Request` keeps the caller's
|
|
221
|
+
* single-shot body intact.
|
|
222
|
+
*/
|
|
223
|
+
function mergeTraceparentHeader(input, init, traceparent) {
|
|
224
|
+
const source = init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : void 0);
|
|
225
|
+
let headers;
|
|
226
|
+
if (source instanceof Headers) {
|
|
227
|
+
if (source.has("traceparent")) return init;
|
|
228
|
+
headers = new Headers(source);
|
|
229
|
+
headers.set("traceparent", traceparent);
|
|
230
|
+
} else if (Array.isArray(source)) {
|
|
231
|
+
if (source.some(([k]) => String(k).toLowerCase() === "traceparent")) return init;
|
|
232
|
+
headers = [...source, ["traceparent", traceparent]];
|
|
233
|
+
} else if (isIterable(source)) {
|
|
234
|
+
const pairs = headerPairsFrom(source);
|
|
235
|
+
if (pairs === null) headers = source;
|
|
236
|
+
else if (pairs.some(([k]) => k.toLowerCase() === "traceparent")) return init;
|
|
237
|
+
else headers = [...pairs, ["traceparent", traceparent]];
|
|
238
|
+
} else if (source) {
|
|
239
|
+
if (Object.keys(source).some((k) => k.toLowerCase() === "traceparent")) return init;
|
|
240
|
+
headers = {
|
|
241
|
+
...source,
|
|
242
|
+
traceparent
|
|
243
|
+
};
|
|
244
|
+
} else headers = { traceparent };
|
|
245
|
+
const result = { headers };
|
|
246
|
+
if (init) {
|
|
247
|
+
const descriptors = Object.getOwnPropertyDescriptors(init);
|
|
248
|
+
delete descriptors.headers;
|
|
249
|
+
Object.defineProperties(result, descriptors);
|
|
250
|
+
}
|
|
251
|
+
if (result.duplex === void 0 && typeof Request !== "undefined" && input instanceof Request && input.body != null) result.duplex = "half";
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/tracing/httpRequestSpan.ts
|
|
257
|
+
const INLINE_SCHEMES = new Set(["data:", "blob:"]);
|
|
258
|
+
/** The real browser context. Falls back to the origin where there is no document (SSR, tests). */
|
|
259
|
+
function browserUrlContext() {
|
|
260
|
+
const origin = globalThis.location?.origin ?? "";
|
|
261
|
+
return {
|
|
262
|
+
origin,
|
|
263
|
+
base: () => globalThis.document?.baseURI || origin
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/** Resolve `url` to an absolute URL against `base`, or null if it cannot be parsed. */
|
|
267
|
+
function safeAbsolute(url, base) {
|
|
268
|
+
try {
|
|
269
|
+
return new URL(url, base || void 0);
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
let ingestCacheKey = null;
|
|
275
|
+
let ingestCacheHrefs = [];
|
|
276
|
+
function resolvedIngestHrefs(config, base) {
|
|
277
|
+
const raw = [
|
|
278
|
+
config.ingestUrl,
|
|
279
|
+
config.logsIngestUrl,
|
|
280
|
+
config.tracesIngestUrl
|
|
281
|
+
];
|
|
282
|
+
const key = `${base} ${raw.join(" ")}`;
|
|
283
|
+
if (key !== ingestCacheKey) {
|
|
284
|
+
ingestCacheKey = key;
|
|
285
|
+
ingestCacheHrefs = raw.filter((u) => typeof u === "string" && u.length > 0).map((u) => safeAbsolute(u, base)).filter((u) => u !== null).map((u) => u.href);
|
|
286
|
+
}
|
|
287
|
+
return ingestCacheHrefs;
|
|
288
|
+
}
|
|
289
|
+
function matchesIngestHref(href, ingestHref) {
|
|
290
|
+
if (!href.startsWith(ingestHref)) return false;
|
|
291
|
+
const next = href.charAt(ingestHref.length);
|
|
292
|
+
return next === "" || next === "/" || next === "?" || next === "#";
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* True when `resolved` targets one of Flare's own ingest endpoints (never traced). The configured
|
|
296
|
+
* URLs are resolved against `base` first: a relative one (a customer proxying ingest through their
|
|
297
|
+
* own origin) would otherwise never match, so every flush POST would open a span that arms the next
|
|
298
|
+
* flush, forever.
|
|
299
|
+
*/
|
|
300
|
+
function isFlareIngestUrl(resolved, config, base) {
|
|
301
|
+
if (!resolved) return false;
|
|
302
|
+
return resolvedIngestHrefs(config, base).some((ingestHref) => matchesIngestHref(resolved.href, ingestHref));
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Shared request-span attributes for a fetch/XHR call. The `url.*` attributes are redacted the same
|
|
306
|
+
* way as error reports, so tokens and reset codes never leak.
|
|
307
|
+
*/
|
|
308
|
+
function requestSpanAttributes(method, resolved, url, config) {
|
|
309
|
+
return {
|
|
310
|
+
"http.request.method": method,
|
|
311
|
+
...urlAttributes(resolved ? resolved.href : url, config.urlDenylist),
|
|
312
|
+
...resolved ? { "server.address": resolved.hostname } : {},
|
|
313
|
+
...resolved && resolved.port ? { "server.port": Number(resolved.port) } : {}
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Completion mapping shared by fetch and XHR: record the status and mark an error on 5xx.
|
|
318
|
+
* `zeroIsError` additionally maps status 0 to error. XHR passes it only for http(s), where status
|
|
319
|
+
* 0 at DONE is always a network/CORS failure or abort; file:// and custom schemes return 0 on
|
|
320
|
+
* success, so it isn't set there. Fetch never passes it (an opaque no-cors response is 0, not error).
|
|
321
|
+
*/
|
|
322
|
+
function endHttpRequestSpan(span, status, opts) {
|
|
323
|
+
span.setAttribute("http.response.status_code", status);
|
|
324
|
+
if (status >= 500 || opts?.zeroIsError && status === 0) span.setStatus({ code: SpanStatusCode.Error });
|
|
325
|
+
span.end();
|
|
326
|
+
}
|
|
327
|
+
function finishHttpSpanError(span, error) {
|
|
328
|
+
span.setStatus({
|
|
329
|
+
code: SpanStatusCode.Error,
|
|
330
|
+
message: error instanceof Error ? error.message : String(error)
|
|
331
|
+
});
|
|
332
|
+
span.end();
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Propagation gate plus `traceparent` build shared by fetch and XHR. Returns null when
|
|
336
|
+
* `shouldPropagate` rejects the URL (caller then skips header injection).
|
|
337
|
+
*/
|
|
338
|
+
function traceparentFor(span, resolved, url, origin, config) {
|
|
339
|
+
if (!shouldPropagate(resolved ? resolved.href : url, resolved, origin, config.tracePropagationTargets)) return null;
|
|
340
|
+
return buildTraceparent(span.traceId, span.spanId, span.isRecording);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Open a request span for one outgoing fetch or XHR call. Null means the URL is one of Flare's own
|
|
344
|
+
* ingest endpoints, so the caller passes the request through untraced.
|
|
345
|
+
*
|
|
346
|
+
* `absoluteUrl` comes back with the span because both callers need it afterwards: for the traceparent
|
|
347
|
+
* gate, and for XHR's http(s)-only status-0 rule.
|
|
348
|
+
*/
|
|
349
|
+
function startHttpRequestSpan(tracer, request) {
|
|
350
|
+
const { method, url, urls, spanType } = request;
|
|
351
|
+
const config = tracer.config;
|
|
352
|
+
const base = urls.base();
|
|
353
|
+
const resolved = safeAbsolute(url, base);
|
|
354
|
+
if (isFlareIngestUrl(resolved, config, base)) return null;
|
|
355
|
+
if (resolved && INLINE_SCHEMES.has(resolved.protocol)) return null;
|
|
356
|
+
const pathname = resolved ? resolved.pathname : url;
|
|
357
|
+
return {
|
|
358
|
+
span: tracer.startSpan(`${method} ${pathname}`, {
|
|
359
|
+
spanType,
|
|
360
|
+
attributes: requestSpanAttributes(method, resolved, url, config)
|
|
361
|
+
}),
|
|
362
|
+
absoluteUrl: resolved
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
//#endregion
|
|
367
|
+
//#region src/tracing/instrumentationGuard.ts
|
|
368
|
+
/** For a callback the host invokes: a router guard, a store subscriber, ... */
|
|
369
|
+
function insulate(fn) {
|
|
370
|
+
return (...args) => {
|
|
371
|
+
try {
|
|
372
|
+
fn(...args);
|
|
373
|
+
} catch {}
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/** Invoke a teardown fn now (if present), swallowing any throw. For cleanup chains. */
|
|
377
|
+
function safeInvoke(fn) {
|
|
378
|
+
try {
|
|
379
|
+
fn?.();
|
|
380
|
+
} catch {}
|
|
381
|
+
}
|
|
382
|
+
const instrumented = /* @__PURE__ */ new WeakMap();
|
|
383
|
+
/**
|
|
384
|
+
* Instrument `target` at most once at a time, tearing down any prior instrumentation of the same object
|
|
385
|
+
* first. Vite HMR re-runs boot code against a router that survives the reload, so without this every
|
|
386
|
+
* cycle appends another listener set that is never removed. Keyed on the object, so a genuinely new
|
|
387
|
+
* router is unaffected.
|
|
388
|
+
*
|
|
389
|
+
* `install` hands each teardown to `track` as it produces it. A router's own `subscribe` / `on` / guard
|
|
390
|
+
* registration can throw, and `install` runs during the host's bootstrap, so a throw part-way through
|
|
391
|
+
* unwinds what already succeeded (newest first) and stops here rather than reaching the host.
|
|
392
|
+
*
|
|
393
|
+
* @returns the cleanup, or a no-op when the install failed and already unwound itself.
|
|
394
|
+
*/
|
|
395
|
+
function instrumentOnce(target, install) {
|
|
396
|
+
instrumented.get(target)?.();
|
|
397
|
+
const teardowns = [];
|
|
398
|
+
function unwind() {
|
|
399
|
+
for (let i = teardowns.length - 1; i >= 0; i--) safeInvoke(teardowns[i]);
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
install((teardown) => {
|
|
403
|
+
teardowns.push(teardown);
|
|
404
|
+
});
|
|
405
|
+
} catch {
|
|
406
|
+
unwind();
|
|
407
|
+
return () => {};
|
|
408
|
+
}
|
|
409
|
+
function cleanup() {
|
|
410
|
+
unwind();
|
|
411
|
+
if (instrumented.get(target) === cleanup) instrumented.delete(target);
|
|
412
|
+
}
|
|
413
|
+
instrumented.set(target, cleanup);
|
|
414
|
+
return cleanup;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
//#endregion
|
|
418
|
+
//#region src/tracing/supportsNativeFetch.ts
|
|
419
|
+
/** True if `fn` is the browser's native fetch (not a polyfill/wrapper). */
|
|
420
|
+
function isNativeFetch(fn) {
|
|
421
|
+
return typeof fn === "function" && /native code/.test(Function.prototype.toString.call(fn));
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Whether the current global `fetch` is native. A polyfilled fetch (e.g. whatwg-fetch) is
|
|
425
|
+
* XHR-backed; skip instrumenting it so the XHR patch is the single source for those requests.
|
|
426
|
+
* Ported from Sentry, including the hidden-iframe fallback used when another library has already
|
|
427
|
+
* wrapped `fetch` and the direct toString check is unreliable.
|
|
428
|
+
*/
|
|
429
|
+
function supportsNativeFetch() {
|
|
430
|
+
const globals = globalThis;
|
|
431
|
+
if (typeof globals.fetch !== "function") return false;
|
|
432
|
+
if (isNativeFetch(globals.fetch)) return true;
|
|
433
|
+
let result = false;
|
|
434
|
+
const document = globals.document;
|
|
435
|
+
if (document && typeof document.createElement === "function") {
|
|
436
|
+
let sandbox = null;
|
|
437
|
+
try {
|
|
438
|
+
sandbox = document.createElement("iframe");
|
|
439
|
+
sandbox.hidden = true;
|
|
440
|
+
document.head.appendChild(sandbox);
|
|
441
|
+
const sandboxWindow = sandbox.contentWindow;
|
|
442
|
+
if (sandboxWindow && typeof sandboxWindow.fetch === "function") result = isNativeFetch(sandboxWindow.fetch);
|
|
443
|
+
} catch {
|
|
444
|
+
result = false;
|
|
445
|
+
} finally {
|
|
446
|
+
try {
|
|
447
|
+
sandbox?.remove();
|
|
448
|
+
} catch {}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return result;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region src/tracing/instrumentFetch.ts
|
|
456
|
+
function resolveRequest(input, init) {
|
|
457
|
+
let url;
|
|
458
|
+
let method = init?.method;
|
|
459
|
+
if (typeof Request !== "undefined" && input instanceof Request) {
|
|
460
|
+
url = input.url;
|
|
461
|
+
method = method ?? input.method;
|
|
462
|
+
} else url = typeof input === "string" ? input : String(input);
|
|
463
|
+
return {
|
|
464
|
+
method: (method ?? "GET").toUpperCase(),
|
|
465
|
+
url
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Build a fetch replacement that opens a `browser_fetch` span per call, injects `traceparent` on
|
|
470
|
+
* propagation-eligible URLs, and ends the span on settle. Pure factory: `urls` is injected (node
|
|
471
|
+
* test env has no `location` or `document`), so this is unit-testable without a browser.
|
|
472
|
+
*/
|
|
473
|
+
function createFetchWrapper(tracer, original, urls) {
|
|
474
|
+
return function(input, init) {
|
|
475
|
+
const call = (i) => original.call(this, input, i);
|
|
476
|
+
let started = null;
|
|
477
|
+
let url = "";
|
|
478
|
+
let passthrough = false;
|
|
479
|
+
try {
|
|
480
|
+
passthrough = !tracer.config.enableTracing || isInternalRequest(init);
|
|
481
|
+
if (!passthrough) {
|
|
482
|
+
const resolved = resolveRequest(input, init);
|
|
483
|
+
url = resolved.url;
|
|
484
|
+
started = startHttpRequestSpan(tracer, {
|
|
485
|
+
method: resolved.method,
|
|
486
|
+
url,
|
|
487
|
+
urls,
|
|
488
|
+
spanType: BrowserSpanType.Fetch
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
} catch {
|
|
492
|
+
started = null;
|
|
493
|
+
}
|
|
494
|
+
if (passthrough || !started) return call(init);
|
|
495
|
+
const { span, absoluteUrl } = started;
|
|
496
|
+
let finalInit = init;
|
|
497
|
+
try {
|
|
498
|
+
const traceparent = traceparentFor(span, absoluteUrl, url, urls.origin, tracer.config);
|
|
499
|
+
if (traceparent) finalInit = mergeTraceparentHeader(input, init, traceparent);
|
|
500
|
+
} catch {
|
|
501
|
+
finalInit = init;
|
|
502
|
+
}
|
|
503
|
+
const endSpan = insulate((response) => endHttpRequestSpan(span, response.status));
|
|
504
|
+
const failSpan = insulate((error) => finishHttpSpanError(span, error));
|
|
505
|
+
const finishError = (error) => {
|
|
506
|
+
failSpan(error);
|
|
507
|
+
return Promise.reject(error);
|
|
508
|
+
};
|
|
509
|
+
let promise;
|
|
510
|
+
try {
|
|
511
|
+
promise = call(finalInit);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
return finishError(error);
|
|
514
|
+
}
|
|
515
|
+
return promise.then((response) => {
|
|
516
|
+
endSpan(response);
|
|
517
|
+
return response;
|
|
518
|
+
}, finishError);
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
const patcher$1 = createPatcher();
|
|
522
|
+
/**
|
|
523
|
+
* Patch the global `fetch` so outgoing requests are traced. No-op when there is no `fetch` or it
|
|
524
|
+
* is not native (a polyfilled/XHR-backed fetch is left for the XHR patch). Idempotent via `fill`.
|
|
525
|
+
* Reversible via `unpatchFetch`.
|
|
526
|
+
*/
|
|
527
|
+
function instrumentFetch(tracer) {
|
|
528
|
+
if (patcher$1.installed) return;
|
|
529
|
+
const globals = globalThis;
|
|
530
|
+
if (typeof globals.fetch !== "function") return;
|
|
531
|
+
if (!supportsNativeFetch()) return;
|
|
532
|
+
const urls = browserUrlContext();
|
|
533
|
+
patcher$1.install(globals, { fetch: (original) => createFetchWrapper(tracer, original, urls) });
|
|
534
|
+
}
|
|
535
|
+
/** Restore the original global `fetch`. Safe if never patched. */
|
|
536
|
+
function unpatchFetch() {
|
|
537
|
+
patcher$1.uninstall(globalThis);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
//#endregion
|
|
541
|
+
//#region src/tracing/instrumentXHR.ts
|
|
542
|
+
const XHR_DONE = 4;
|
|
543
|
+
const xhrState = /* @__PURE__ */ new WeakMap();
|
|
544
|
+
/**
|
|
545
|
+
* Drop the span and listener references once a request is done with them. The entry itself stays in
|
|
546
|
+
* the WeakMap for the re-send `ended` guard, so without this it would keep the Span and the listener
|
|
547
|
+
* closure alive for as long as the app holds on to the XHR.
|
|
548
|
+
*/
|
|
549
|
+
function releaseRequestRefs(state) {
|
|
550
|
+
state.span = void 0;
|
|
551
|
+
state.onDone = void 0;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Patch `open` to capture method/URL. Bails (records no state) when either is missing.
|
|
555
|
+
* Calling `open()` on an in-flight request ends that prior request's span (marked aborted)
|
|
556
|
+
* and detaches its `readystatechange` listener before the new request's method/URL are captured.
|
|
557
|
+
*/
|
|
558
|
+
function createXHROpen(original) {
|
|
559
|
+
return function(method, url, ...rest) {
|
|
560
|
+
const prior = xhrState.get(this);
|
|
561
|
+
if (prior && prior.span && !prior.ended) {
|
|
562
|
+
prior.ended = true;
|
|
563
|
+
if (prior.onDone) this.removeEventListener("readystatechange", prior.onDone);
|
|
564
|
+
try {
|
|
565
|
+
prior.span.setStatus({ code: SpanStatusCode.Error });
|
|
566
|
+
prior.span.end();
|
|
567
|
+
} catch {}
|
|
568
|
+
releaseRequestRefs(prior);
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
if (method && url != null) xhrState.set(this, {
|
|
572
|
+
method: String(method).toUpperCase(),
|
|
573
|
+
url: String(url),
|
|
574
|
+
hasAppTraceparent: false,
|
|
575
|
+
ended: false
|
|
576
|
+
});
|
|
577
|
+
else xhrState.delete(this);
|
|
578
|
+
} catch {
|
|
579
|
+
xhrState.delete(this);
|
|
580
|
+
}
|
|
581
|
+
return original.apply(this, [
|
|
582
|
+
method,
|
|
583
|
+
url,
|
|
584
|
+
...rest
|
|
585
|
+
]);
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Patch `setRequestHeader` to note when the app sets its own `traceparent`.
|
|
590
|
+
* There is no `getRequestHeader`, so this is the only way to avoid emitting a
|
|
591
|
+
* second `traceparent` (repeat calls merge into one malformed header).
|
|
592
|
+
*/
|
|
593
|
+
function createXHRSetRequestHeader(original) {
|
|
594
|
+
return function(name, value) {
|
|
595
|
+
original.call(this, name, value);
|
|
596
|
+
if (typeof name === "string" && name.toLowerCase() === "traceparent") {
|
|
597
|
+
const state = xhrState.get(this);
|
|
598
|
+
if (state) state.hasAppTraceparent = true;
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
function setTraceparentHeader(xhr, traceparent) {
|
|
603
|
+
if (!traceparent) return;
|
|
604
|
+
try {
|
|
605
|
+
xhr.setRequestHeader("traceparent", traceparent);
|
|
606
|
+
} catch {}
|
|
607
|
+
}
|
|
608
|
+
/** Patch `send` to open the span, inject `traceparent`, and end on `readyState === 4`. */
|
|
609
|
+
function createXHRSend(tracer, original, urls) {
|
|
610
|
+
return function(body) {
|
|
611
|
+
const send = () => original.call(this, body);
|
|
612
|
+
const config = tracer.config;
|
|
613
|
+
const state = xhrState.get(this);
|
|
614
|
+
if (!config.enableTracing || !state) return send();
|
|
615
|
+
if (state.ended) return send();
|
|
616
|
+
let started = null;
|
|
617
|
+
try {
|
|
618
|
+
started = startHttpRequestSpan(tracer, {
|
|
619
|
+
method: state.method,
|
|
620
|
+
url: state.url,
|
|
621
|
+
urls,
|
|
622
|
+
spanType: BrowserSpanType.Xhr
|
|
623
|
+
});
|
|
624
|
+
} catch {
|
|
625
|
+
started = null;
|
|
626
|
+
}
|
|
627
|
+
if (!started) return send();
|
|
628
|
+
const { span, absoluteUrl } = started;
|
|
629
|
+
state.span = span;
|
|
630
|
+
if (!state.hasAppTraceparent) {
|
|
631
|
+
let traceparent = null;
|
|
632
|
+
try {
|
|
633
|
+
traceparent = traceparentFor(span, absoluteUrl, state.url, urls.origin, config);
|
|
634
|
+
} catch {}
|
|
635
|
+
setTraceparentHeader(this, traceparent);
|
|
636
|
+
}
|
|
637
|
+
const onDone = () => {
|
|
638
|
+
if (this.readyState !== XHR_DONE) return;
|
|
639
|
+
this.removeEventListener("readystatechange", onDone);
|
|
640
|
+
if (state.ended) return;
|
|
641
|
+
state.ended = true;
|
|
642
|
+
let status = 0;
|
|
643
|
+
try {
|
|
644
|
+
status = this.status;
|
|
645
|
+
} catch {}
|
|
646
|
+
try {
|
|
647
|
+
const zeroIsError = absoluteUrl !== null && (absoluteUrl.protocol === "http:" || absoluteUrl.protocol === "https:");
|
|
648
|
+
endHttpRequestSpan(span, status, { zeroIsError });
|
|
649
|
+
} catch {}
|
|
650
|
+
releaseRequestRefs(state);
|
|
651
|
+
};
|
|
652
|
+
this.addEventListener("readystatechange", onDone);
|
|
653
|
+
state.onDone = onDone;
|
|
654
|
+
try {
|
|
655
|
+
return send();
|
|
656
|
+
} catch (error) {
|
|
657
|
+
this.removeEventListener("readystatechange", onDone);
|
|
658
|
+
state.ended = true;
|
|
659
|
+
try {
|
|
660
|
+
finishHttpSpanError(span, error);
|
|
661
|
+
} catch {}
|
|
662
|
+
releaseRequestRefs(state);
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
const patcher = createPatcher();
|
|
668
|
+
let patchedPrototype = null;
|
|
669
|
+
/**
|
|
670
|
+
* Patch `XMLHttpRequest.prototype` (`open`, `setRequestHeader`, `send`) so outgoing
|
|
671
|
+
* XHR requests are traced. No-op where `XMLHttpRequest` is absent (SSR). Idempotent
|
|
672
|
+
* via `fill`. Reversible via `unpatchXHR`.
|
|
673
|
+
*/
|
|
674
|
+
function instrumentXHR(tracer) {
|
|
675
|
+
if (patcher.installed) return;
|
|
676
|
+
const xhrConstructor = globalThis.XMLHttpRequest;
|
|
677
|
+
if (typeof xhrConstructor !== "function" || !xhrConstructor.prototype) return;
|
|
678
|
+
const urls = browserUrlContext();
|
|
679
|
+
patcher.install(xhrConstructor.prototype, {
|
|
680
|
+
open: (original) => createXHROpen(original),
|
|
681
|
+
setRequestHeader: (original) => createXHRSetRequestHeader(original),
|
|
682
|
+
send: (original) => createXHRSend(tracer, original, urls)
|
|
683
|
+
});
|
|
684
|
+
patchedPrototype = xhrConstructor.prototype;
|
|
685
|
+
}
|
|
686
|
+
/** Restore the original `XMLHttpRequest.prototype` methods. Safe if never patched. */
|
|
687
|
+
function unpatchXHR() {
|
|
688
|
+
if (!patchedPrototype) return;
|
|
689
|
+
patcher.uninstall(patchedPrototype);
|
|
690
|
+
if (!patcher.installed) patchedPrototype = null;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
//#endregion
|
|
694
|
+
//#region src/tracing/absoluteHref.ts
|
|
695
|
+
/**
|
|
696
|
+
* Resolve a router-reported href against the page we are on. Returns the `URL`, so a caller that
|
|
697
|
+
* wants the pathname as well as the href does not parse it a second time.
|
|
698
|
+
*
|
|
699
|
+
* Undefined outside a browser or for an unparseable href, so the caller can leave its attribute alone.
|
|
700
|
+
*/
|
|
701
|
+
function absoluteUrl(href) {
|
|
702
|
+
if (href == null || typeof window === "undefined") return;
|
|
703
|
+
try {
|
|
704
|
+
return new URL(href, window.location.href);
|
|
705
|
+
} catch {
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* The href form of `absoluteUrl`. Pass one built by the router's own `createHref`/`resolve` (see
|
|
711
|
+
* `resolveHref`), not a bare path: routers strip the app's base path, so `origin + path` yields an
|
|
712
|
+
* address the server does not have.
|
|
713
|
+
*/
|
|
714
|
+
function absoluteHref(href) {
|
|
715
|
+
return absoluteUrl(href)?.href;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
//#endregion
|
|
719
|
+
//#region src/browser/context/collectBrowserSpanContext.ts
|
|
720
|
+
/**
|
|
721
|
+
* Entry point plus request identity for a pageload/navigation root. Deliberately leaner than the report
|
|
722
|
+
* context: no cookies, no structured query params, no host.name (that is resource-level). Captured at
|
|
723
|
+
* span start, so a long-lived root reflects the page it represents rather than the page at close.
|
|
724
|
+
*
|
|
725
|
+
* @param hrefOverride destination href for a router that reports where it is going before the URL
|
|
726
|
+
* commits. Only the URL-derived keys come from it; the rest always reflect the live document. An
|
|
727
|
+
* unparseable override falls back to the live location instead of throwing into root creation.
|
|
728
|
+
*/
|
|
729
|
+
function collectBrowserSpanContext(config, hrefOverride) {
|
|
730
|
+
if (typeof window === "undefined") return {};
|
|
731
|
+
const url = absoluteUrl(hrefOverride);
|
|
732
|
+
return {
|
|
733
|
+
...browserEntryPoint(config, url),
|
|
734
|
+
...request(config.urlDenylist, url?.href)
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Updates a root's url after a redirect, or when a newer navigation replaces this one. The root opened
|
|
739
|
+
* with the first destination, so without this it reports a page the user never reached.
|
|
740
|
+
*
|
|
741
|
+
* Does not touch `flare.entry_point.handler.identifier` or `http.route`. Those hold the route template,
|
|
742
|
+
* and reading them back from the href would turn `/product/[id]` into `/product/p01`.
|
|
743
|
+
*
|
|
744
|
+
* Always sets `url.query`, even to an empty string. You can overwrite a span attribute but not remove
|
|
745
|
+
* it, so going from `/a?x=1` to `/b` would otherwise keep the old query.
|
|
746
|
+
*/
|
|
747
|
+
function browserSpanUrlAttributes(config, href) {
|
|
748
|
+
if (typeof window === "undefined") return {};
|
|
749
|
+
const resolved = absoluteUrl(href);
|
|
750
|
+
if (!resolved) return {};
|
|
751
|
+
const attributes = urlAttributes(resolved.href, config.urlDenylist);
|
|
752
|
+
return {
|
|
753
|
+
"url.query": "",
|
|
754
|
+
...attributes,
|
|
755
|
+
"flare.entry_point.value": attributes["url.full"]
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
//#endregion
|
|
760
|
+
//#region src/tracing/IdleRootController.ts
|
|
761
|
+
/** Browser defaults for the three idle-root timeouts, in ms. Overridable per Config. */
|
|
762
|
+
const DEFAULT_IDLE_TIMEOUTS = {
|
|
763
|
+
idleTimeout: 1e3,
|
|
764
|
+
finalTimeout: 3e4,
|
|
765
|
+
childSpanTimeout: 15e3
|
|
766
|
+
};
|
|
767
|
+
/**
|
|
768
|
+
* Owns one root span's idle lifecycle: open while child spans in its trace are active, closing after
|
|
769
|
+
* `idleTimeout` with no open children, or on the `finalTimeout` / `childSpanTimeout` backstops. Deps are
|
|
770
|
+
* injected so this is testable without real timers or a real tracer.
|
|
771
|
+
*/
|
|
772
|
+
var IdleRootController = class {
|
|
773
|
+
openChildren = 0;
|
|
774
|
+
lastChildEndTime = null;
|
|
775
|
+
settleTime = null;
|
|
776
|
+
idleTimer = null;
|
|
777
|
+
finalTimer = null;
|
|
778
|
+
childTimer = null;
|
|
779
|
+
ended = false;
|
|
780
|
+
held = false;
|
|
781
|
+
unsubscribe;
|
|
782
|
+
constructor(deps, timeouts) {
|
|
783
|
+
this.deps = deps;
|
|
784
|
+
this.timeouts = timeouts;
|
|
785
|
+
deps.setActiveRoot(deps.root);
|
|
786
|
+
this.unsubscribe = deps.addSpanListener((e) => this.onSpanEvent(e.phase, e.span));
|
|
787
|
+
const elapsedMs = Math.max(0, (deps.now() - deps.rootStartTime) / 1e6);
|
|
788
|
+
const remainingMs = Math.max(0, timeouts.finalTimeout - elapsedMs);
|
|
789
|
+
this.finalTimer = deps.setTimeout(() => this.finish(deps.now()), remainingMs);
|
|
790
|
+
this.held = !!deps.held;
|
|
791
|
+
this.armIdle();
|
|
792
|
+
}
|
|
793
|
+
get isEnded() {
|
|
794
|
+
return this.ended;
|
|
795
|
+
}
|
|
796
|
+
/** For a route change or pagehide. */
|
|
797
|
+
endNow() {
|
|
798
|
+
this.finish(this.openChildren > 0 || this.held ? this.deps.now() : this.trimmedEnd());
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Records the settle moment as a close floor and hands the root back to the normal idle lifecycle. It
|
|
802
|
+
* deliberately does not close here: a router settles before the framework mounts the new route
|
|
803
|
+
* component (vue-router runs `afterEach` in the route-update tick, Vue mounts on the next flush), so
|
|
804
|
+
* closing at settle cleared the active root ahead of every post-navigation mount: every component span
|
|
805
|
+
* read a null root, and a trailing fetch opened a root of its own.
|
|
806
|
+
*/
|
|
807
|
+
releaseHold() {
|
|
808
|
+
if (this.ended || !this.held) return;
|
|
809
|
+
this.held = false;
|
|
810
|
+
if (this.openChildren === 0) this.settleTime = this.deps.now();
|
|
811
|
+
this.armIdle();
|
|
812
|
+
}
|
|
813
|
+
onSpanEvent(phase, span) {
|
|
814
|
+
if (this.ended) return;
|
|
815
|
+
if (span === this.deps.root) return;
|
|
816
|
+
if (span.traceId !== this.deps.root.traceId) return;
|
|
817
|
+
if (phase === "start") {
|
|
818
|
+
this.onChildStarted();
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
this.onChildEnded(span);
|
|
822
|
+
}
|
|
823
|
+
onChildStarted() {
|
|
824
|
+
this.openChildren++;
|
|
825
|
+
this.clearIdle();
|
|
826
|
+
if (this.openChildren === 1) this.armChildTimeout();
|
|
827
|
+
}
|
|
828
|
+
onChildEnded(span) {
|
|
829
|
+
this.openChildren = Math.max(0, this.openChildren - 1);
|
|
830
|
+
this.lastChildEndTime = span.endTimeUnixNano || this.deps.now();
|
|
831
|
+
if (this.openChildren > 0) return;
|
|
832
|
+
this.clearChildTimeout();
|
|
833
|
+
this.armIdle();
|
|
834
|
+
}
|
|
835
|
+
armIdle() {
|
|
836
|
+
this.clearIdle();
|
|
837
|
+
if (this.held) return;
|
|
838
|
+
this.idleTimer = this.deps.setTimeout(() => {
|
|
839
|
+
if (this.openChildren > 0) return;
|
|
840
|
+
this.finish(this.trimmedEnd());
|
|
841
|
+
}, this.timeouts.idleTimeout);
|
|
842
|
+
}
|
|
843
|
+
/** The latest of the floor, a released hold's settle moment, and the last child's end, so a root
|
|
844
|
+
* covers its children without ever padding out to `now()`. */
|
|
845
|
+
trimmedEnd() {
|
|
846
|
+
return Math.max(this.deps.endFloor(), this.settleTime ?? 0, this.lastChildEndTime ?? 0);
|
|
847
|
+
}
|
|
848
|
+
clearIdle() {
|
|
849
|
+
if (this.idleTimer !== null) {
|
|
850
|
+
this.deps.clearTimeout(this.idleTimer);
|
|
851
|
+
this.idleTimer = null;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
armChildTimeout() {
|
|
855
|
+
this.childTimer = this.deps.setTimeout(() => this.finish(this.deps.now()), this.timeouts.childSpanTimeout);
|
|
856
|
+
}
|
|
857
|
+
clearChildTimeout() {
|
|
858
|
+
if (this.childTimer !== null) {
|
|
859
|
+
this.deps.clearTimeout(this.childTimer);
|
|
860
|
+
this.childTimer = null;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
finish(atTimeNano) {
|
|
864
|
+
if (this.ended) return;
|
|
865
|
+
this.ended = true;
|
|
866
|
+
atTimeNano = Math.max(atTimeNano, this.deps.rootStartTime);
|
|
867
|
+
this.clearIdle();
|
|
868
|
+
this.clearChildTimeout();
|
|
869
|
+
if (this.finalTimer !== null) {
|
|
870
|
+
this.deps.clearTimeout(this.finalTimer);
|
|
871
|
+
this.finalTimer = null;
|
|
872
|
+
}
|
|
873
|
+
this.unsubscribe();
|
|
874
|
+
this.deps.beforeEnd?.();
|
|
875
|
+
this.deps.root.end(atTimeNano);
|
|
876
|
+
this.deps.setActiveRoot(void 0);
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
//#endregion
|
|
881
|
+
//#region src/tracing/navigation.ts
|
|
882
|
+
/** The path the address bar is on, or '' outside a browser. */
|
|
883
|
+
function currentPath() {
|
|
884
|
+
return typeof location !== "undefined" ? location.pathname : "";
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Prefers the router's parameterized template over the raw path, so names aggregate per route rather
|
|
888
|
+
* than per url. `derive` runs inside a try: a router that throws on an unresolved match chain falls back
|
|
889
|
+
* to the url name instead of taking the host down.
|
|
890
|
+
*/
|
|
891
|
+
function routeName(derive, fallbackPath, url) {
|
|
892
|
+
try {
|
|
893
|
+
const name = derive();
|
|
894
|
+
if (name) return {
|
|
895
|
+
name,
|
|
896
|
+
source: "route",
|
|
897
|
+
url
|
|
898
|
+
};
|
|
899
|
+
} catch {}
|
|
900
|
+
return {
|
|
901
|
+
name: fallbackPath,
|
|
902
|
+
source: "url",
|
|
903
|
+
url
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* `build` is the router's own href builder (vue-router's `resolve`, React Router's `createHref`), which
|
|
908
|
+
* is what puts the app's base path and hash prefix back on. Without it an app served from `/app/` reports
|
|
909
|
+
* `/product/p01` for the real `/app/product/p01`. A router that throws still gets a url from `fallback`.
|
|
910
|
+
*/
|
|
911
|
+
function resolveHref(build, fallback) {
|
|
912
|
+
let href = fallback;
|
|
913
|
+
try {
|
|
914
|
+
href = build() ?? fallback;
|
|
915
|
+
} catch {}
|
|
916
|
+
return absoluteHref(href);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
//#endregion
|
|
920
|
+
//#region src/tracing/navigationTiming.ts
|
|
921
|
+
/** Split out so the timestamp maths is testable without a Navigation Timing entry. */
|
|
922
|
+
function computePageloadStartNano(timeOriginMs, startTimeMs) {
|
|
923
|
+
return Math.round((timeOriginMs + (startTimeMs ?? 0)) * 1e6);
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Choose the pageload root's start time: navigation start while that window is still open,
|
|
927
|
+
* otherwise `now`. Starting at `now` (when tracing began after the final cap, or the pageload was
|
|
928
|
+
* already traced) avoids a backdated root reporting a bogus multi-second duration.
|
|
929
|
+
*/
|
|
930
|
+
function resolvePageloadStartNano(backdatedNano, nowNano, finalTimeoutNano, alreadyTraced) {
|
|
931
|
+
if (alreadyTraced) return nowNano;
|
|
932
|
+
if (nowNano - backdatedNano > finalTimeoutNano) return nowNano;
|
|
933
|
+
return backdatedNano;
|
|
934
|
+
}
|
|
935
|
+
/** The Navigation Timing API, or null where it is missing or only partly implemented. */
|
|
936
|
+
function navigationTiming() {
|
|
937
|
+
const perf = globalThis.performance;
|
|
938
|
+
if (!perf || typeof perf.getEntriesByType !== "function" || typeof perf.timeOrigin !== "number") return null;
|
|
939
|
+
return perf;
|
|
940
|
+
}
|
|
941
|
+
function navigationEntry(perf) {
|
|
942
|
+
return perf.getEntriesByType("navigation")[0];
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* The pageload root's start time in unix nanoseconds, backdated to navigation start via the
|
|
946
|
+
* Navigation Timing entry. Falls back to the tracer's clock when the API is unavailable.
|
|
947
|
+
*/
|
|
948
|
+
function pageloadStartNano() {
|
|
949
|
+
const perf = navigationTiming();
|
|
950
|
+
if (!perf) return defaultNowNano();
|
|
951
|
+
return computePageloadStartNano(perf.timeOrigin, navigationEntry(perf)?.startTime);
|
|
952
|
+
}
|
|
953
|
+
function computePageloadEndNano(timeOriginMs, loadEventEndMs, domContentLoadedEventEndMs, nowNano) {
|
|
954
|
+
const endMs = loadEventEndMs || domContentLoadedEventEndMs || 0;
|
|
955
|
+
if (!endMs) return nowNano;
|
|
956
|
+
return Math.round((timeOriginMs + endMs) * 1e6);
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* The pageload root's end time in unix nanoseconds, taken from the Navigation
|
|
960
|
+
* Timing `loadEventEnd` (the browser's own "page finished loading" mark), falling
|
|
961
|
+
* back to `domContentLoadedEventEnd`, then the tracer's clock when neither has
|
|
962
|
+
* fired yet or the API is unavailable. Used as the pageload root's close floor so a
|
|
963
|
+
* childless pageload reports its real load duration rather than idle-timeout padding.
|
|
964
|
+
*/
|
|
965
|
+
function pageloadEndNano() {
|
|
966
|
+
const perf = navigationTiming();
|
|
967
|
+
if (!perf) return defaultNowNano();
|
|
968
|
+
const entry = navigationEntry(perf);
|
|
969
|
+
return computePageloadEndNano(perf.timeOrigin, entry?.loadEventEnd, entry?.domContentLoadedEventEnd, defaultNowNano());
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
//#endregion
|
|
973
|
+
//#region src/tracing/webvitals/lib/bfcache.ts
|
|
974
|
+
let bfcacheRestoreTime = -1;
|
|
975
|
+
const getBFCacheRestoreTime = () => bfcacheRestoreTime;
|
|
976
|
+
const onBFCacheRestore = (cb) => {
|
|
977
|
+
addEventListener("pageshow", (event) => {
|
|
978
|
+
if (event.persisted) {
|
|
979
|
+
bfcacheRestoreTime = event.timeStamp;
|
|
980
|
+
cb(event);
|
|
981
|
+
}
|
|
982
|
+
}, true);
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
//#endregion
|
|
986
|
+
//#region src/tracing/webvitals/lib/bindReporter.ts
|
|
987
|
+
const getRating = (value, thresholds) => {
|
|
988
|
+
if (value > thresholds[1]) return "poor";
|
|
989
|
+
if (value > thresholds[0]) return "needs-improvement";
|
|
990
|
+
return "good";
|
|
991
|
+
};
|
|
992
|
+
const bindReporter = (callback, metric, thresholds, reportAllChanges) => {
|
|
993
|
+
let prevValue;
|
|
994
|
+
let delta;
|
|
995
|
+
return (forceReport) => {
|
|
996
|
+
if (metric.value >= 0) {
|
|
997
|
+
if (forceReport || reportAllChanges) {
|
|
998
|
+
delta = metric.value - (prevValue ?? 0);
|
|
999
|
+
if (delta || prevValue === void 0) {
|
|
1000
|
+
prevValue = metric.value;
|
|
1001
|
+
metric.delta = delta;
|
|
1002
|
+
metric.rating = getRating(metric.value, thresholds);
|
|
1003
|
+
callback(metric);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
//#endregion
|
|
1011
|
+
//#region src/tracing/webvitals/lib/doubleRAF.ts
|
|
1012
|
+
const doubleRAF = (cb) => {
|
|
1013
|
+
requestAnimationFrame(() => requestAnimationFrame(cb));
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
//#endregion
|
|
1017
|
+
//#region src/tracing/webvitals/lib/getNavigationEntry.ts
|
|
1018
|
+
const getNavigationEntry = () => {
|
|
1019
|
+
const navigationEntry = performance.getEntriesByType("navigation")[0];
|
|
1020
|
+
if (navigationEntry && navigationEntry.responseStart > 0 && navigationEntry.responseStart < performance.now()) return navigationEntry;
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
//#endregion
|
|
1024
|
+
//#region src/tracing/webvitals/lib/getActivationStart.ts
|
|
1025
|
+
const getActivationStart = () => {
|
|
1026
|
+
return getNavigationEntry()?.activationStart ?? 0;
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
//#endregion
|
|
1030
|
+
//#region src/tracing/webvitals/lib/getVisibilityWatcher.ts
|
|
1031
|
+
let firstHiddenTime = -1;
|
|
1032
|
+
const onHiddenFunctions = /* @__PURE__ */ new Set();
|
|
1033
|
+
const initHiddenTime = () => {
|
|
1034
|
+
return document.visibilityState === "hidden" && !document.prerendering ? 0 : Infinity;
|
|
1035
|
+
};
|
|
1036
|
+
const onVisibilityUpdate = (event) => {
|
|
1037
|
+
if (document.visibilityState === "hidden") {
|
|
1038
|
+
if (event.type === "visibilitychange") for (const onHiddenFunction of onHiddenFunctions) onHiddenFunction();
|
|
1039
|
+
if (!isFinite(firstHiddenTime)) {
|
|
1040
|
+
firstHiddenTime = event.type === "visibilitychange" ? event.timeStamp : 0;
|
|
1041
|
+
removeEventListener("prerenderingchange", onVisibilityUpdate, true);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
const getVisibilityWatcher = (reset = false) => {
|
|
1046
|
+
if (reset) firstHiddenTime = Infinity;
|
|
1047
|
+
if (firstHiddenTime < 0) {
|
|
1048
|
+
const activationStart = getActivationStart();
|
|
1049
|
+
firstHiddenTime = (!document.prerendering ? globalThis.performance.getEntriesByType("visibility-state").find((e) => e.name === "hidden" && e.startTime >= activationStart)?.startTime : void 0) ?? initHiddenTime();
|
|
1050
|
+
addEventListener("visibilitychange", onVisibilityUpdate, true);
|
|
1051
|
+
addEventListener("prerenderingchange", onVisibilityUpdate, true);
|
|
1052
|
+
onBFCacheRestore(() => {
|
|
1053
|
+
setTimeout(() => {
|
|
1054
|
+
firstHiddenTime = initHiddenTime();
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
return {
|
|
1059
|
+
get firstHiddenTime() {
|
|
1060
|
+
return firstHiddenTime;
|
|
1061
|
+
},
|
|
1062
|
+
onHidden(cb) {
|
|
1063
|
+
onHiddenFunctions.add(cb);
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
//#endregion
|
|
1069
|
+
//#region src/tracing/webvitals/lib/generateUniqueID.ts
|
|
1070
|
+
/**
|
|
1071
|
+
* Performantly generate a unique, 30-char string by combining a version
|
|
1072
|
+
* number, the current timestamp with a 13-digit number integer.
|
|
1073
|
+
* @return {string}
|
|
1074
|
+
*/
|
|
1075
|
+
const generateUniqueID = () => {
|
|
1076
|
+
return `v6-${Date.now()}-${Math.floor(Math.random() * 8999999999999) + 0xe8d4a51000}`;
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
//#endregion
|
|
1080
|
+
//#region src/tracing/webvitals/lib/initMetric.ts
|
|
1081
|
+
const initMetric = (name, value = -1, navigationType, navigationId = 0, navigationInteractionId, navigationURL, navigationStartTime) => {
|
|
1082
|
+
const hardNavEntry = getNavigationEntry();
|
|
1083
|
+
const hardNavId = hardNavEntry?.navigationId || 0;
|
|
1084
|
+
let _navigationType = "navigate";
|
|
1085
|
+
if (navigationType) _navigationType = navigationType;
|
|
1086
|
+
else if (getBFCacheRestoreTime() >= 0) _navigationType = "back-forward-cache";
|
|
1087
|
+
else if (hardNavEntry) {
|
|
1088
|
+
if (document.prerendering || getActivationStart() > 0) _navigationType = "prerender";
|
|
1089
|
+
else if (document.wasDiscarded) _navigationType = "restore";
|
|
1090
|
+
else if (hardNavEntry.type) _navigationType = hardNavEntry.type.replace(/_/g, "-");
|
|
1091
|
+
}
|
|
1092
|
+
return {
|
|
1093
|
+
name,
|
|
1094
|
+
value,
|
|
1095
|
+
rating: "good",
|
|
1096
|
+
delta: 0,
|
|
1097
|
+
entries: [],
|
|
1098
|
+
id: generateUniqueID(),
|
|
1099
|
+
navigationType: _navigationType,
|
|
1100
|
+
navigationId: navigationId || hardNavId,
|
|
1101
|
+
navigationInteractionId,
|
|
1102
|
+
navigationURL: navigationURL || hardNavEntry?.name,
|
|
1103
|
+
navigationStartTime: navigationStartTime || 0
|
|
1104
|
+
};
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
//#endregion
|
|
1108
|
+
//#region src/tracing/webvitals/lib/initUnique.ts
|
|
1109
|
+
const instanceMap = /* @__PURE__ */ new WeakMap();
|
|
1110
|
+
/**
|
|
1111
|
+
* A function that accepts and identity object and a class object and returns
|
|
1112
|
+
* either a new instance of that class or an existing instance, if the
|
|
1113
|
+
* identity object was previously used.
|
|
1114
|
+
*/
|
|
1115
|
+
function initUnique(identityObj, ClassObj) {
|
|
1116
|
+
let classInstances = instanceMap.get(ClassObj);
|
|
1117
|
+
if (!classInstances) {
|
|
1118
|
+
classInstances = /* @__PURE__ */ new WeakMap();
|
|
1119
|
+
instanceMap.set(ClassObj, classInstances);
|
|
1120
|
+
}
|
|
1121
|
+
if (!classInstances.get(identityObj)) classInstances.set(identityObj, new ClassObj());
|
|
1122
|
+
return classInstances.get(identityObj);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
//#endregion
|
|
1126
|
+
//#region src/tracing/webvitals/lib/LayoutShiftManager.ts
|
|
1127
|
+
var LayoutShiftManager = class {
|
|
1128
|
+
_onAfterProcessingUnexpectedShift;
|
|
1129
|
+
_sessionValue = 0;
|
|
1130
|
+
_sessionEntries = [];
|
|
1131
|
+
_processEntry(entry) {
|
|
1132
|
+
if (entry.hadRecentInput) return;
|
|
1133
|
+
const firstSessionEntry = this._sessionEntries[0];
|
|
1134
|
+
const lastSessionEntry = this._sessionEntries.at(-1);
|
|
1135
|
+
if (this._sessionValue && firstSessionEntry && lastSessionEntry && entry.startTime - lastSessionEntry.startTime < 1e3 && entry.startTime - firstSessionEntry.startTime < 5e3) {
|
|
1136
|
+
this._sessionValue += entry.value;
|
|
1137
|
+
this._sessionEntries.push(entry);
|
|
1138
|
+
} else {
|
|
1139
|
+
this._sessionValue = entry.value;
|
|
1140
|
+
this._sessionEntries = [entry];
|
|
1141
|
+
}
|
|
1142
|
+
this._onAfterProcessingUnexpectedShift?.(entry);
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
//#endregion
|
|
1147
|
+
//#region src/tracing/webvitals/lib/observe.ts
|
|
1148
|
+
/**
|
|
1149
|
+
* Takes a performance entry type and a callback function, and creates a
|
|
1150
|
+
* `PerformanceObserver` instance that will observe the specified entry type
|
|
1151
|
+
* with buffering enabled and call the callback _for each entry_.
|
|
1152
|
+
*
|
|
1153
|
+
* This function also feature-detects entry support and wraps the logic in a
|
|
1154
|
+
* try/catch to avoid errors in unsupporting browsers.
|
|
1155
|
+
*/
|
|
1156
|
+
const observe = (types, callback, opts = {}) => {
|
|
1157
|
+
try {
|
|
1158
|
+
const supportedTypes = types.filter((t) => PerformanceObserver.supportedEntryTypes.includes(t));
|
|
1159
|
+
if (supportedTypes.length > 0) {
|
|
1160
|
+
const po = new PerformanceObserver((list) => {
|
|
1161
|
+
queueMicrotask(() => {
|
|
1162
|
+
const entries = list.getEntries();
|
|
1163
|
+
if (supportedTypes.length > 1) entries.sort((a, b) => {
|
|
1164
|
+
return a.startTime + a.duration - (b.startTime + b.duration);
|
|
1165
|
+
});
|
|
1166
|
+
callback(entries);
|
|
1167
|
+
});
|
|
1168
|
+
});
|
|
1169
|
+
for (const t of supportedTypes) po.observe({
|
|
1170
|
+
type: t,
|
|
1171
|
+
buffered: true,
|
|
1172
|
+
...opts
|
|
1173
|
+
});
|
|
1174
|
+
return po;
|
|
1175
|
+
}
|
|
1176
|
+
} catch {}
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
//#endregion
|
|
1180
|
+
//#region src/tracing/webvitals/lib/softNavs.ts
|
|
1181
|
+
const checkSoftNavsEnabled = (opts) => {
|
|
1182
|
+
return globalThis.PerformanceObserver?.supportedEntryTypes.includes("soft-navigation") && typeof globalThis.PerformanceSoftNavigation?.prototype?.getLargestInteractionContentfulPaint === "function" && opts && opts.reportSoftNavs;
|
|
1183
|
+
};
|
|
1184
|
+
const storeSoftNavEntry = (map, entry) => {
|
|
1185
|
+
map.set(entry.navigationId, entry);
|
|
1186
|
+
if (map.size > 2) {
|
|
1187
|
+
const firstKey = map.keys().next().value;
|
|
1188
|
+
if (firstKey !== void 0) map.delete(firstKey);
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
//#endregion
|
|
1193
|
+
//#region src/tracing/webvitals/lib/runOnce.ts
|
|
1194
|
+
const runOnce = (cb) => {
|
|
1195
|
+
let called = false;
|
|
1196
|
+
return () => {
|
|
1197
|
+
if (!called) {
|
|
1198
|
+
cb();
|
|
1199
|
+
called = true;
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
//#endregion
|
|
1205
|
+
//#region src/tracing/webvitals/lib/FCPEntryManager.ts
|
|
1206
|
+
var FCPEntryManager = class {
|
|
1207
|
+
_softNavigationEntryMap;
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
//#endregion
|
|
1211
|
+
//#region src/tracing/webvitals/lib/whenActivated.ts
|
|
1212
|
+
const whenActivated = (callback) => {
|
|
1213
|
+
if (document.prerendering) addEventListener("prerenderingchange", callback, true);
|
|
1214
|
+
else callback();
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
//#endregion
|
|
1218
|
+
//#region src/tracing/webvitals/onFCP.ts
|
|
1219
|
+
/** Thresholds for FCP. See https://web.dev/articles/fcp#what_is_a_good_fcp_score */
|
|
1220
|
+
const FCPThresholds = [1800, 3e3];
|
|
1221
|
+
/**
|
|
1222
|
+
* Calculates the [FCP](https://web.dev/articles/fcp) value for the current page and
|
|
1223
|
+
* calls the `callback` function once the value is ready, along with the
|
|
1224
|
+
* relevant `paint` performance entry used to determine the value. The reported
|
|
1225
|
+
* value is a `DOMHighResTimeStamp`.
|
|
1226
|
+
*/
|
|
1227
|
+
const onFCP = (onReport, opts = {}) => {
|
|
1228
|
+
const softNavsEnabled = checkSoftNavsEnabled(opts);
|
|
1229
|
+
whenActivated(() => {
|
|
1230
|
+
const fcpEntryManager = initUnique(opts, FCPEntryManager);
|
|
1231
|
+
const visibilityWatcher = getVisibilityWatcher();
|
|
1232
|
+
let metric = initMetric("FCP");
|
|
1233
|
+
let report;
|
|
1234
|
+
const handleEntries = (entries) => {
|
|
1235
|
+
for (const entry of entries) if (entry.name === "first-contentful-paint") {
|
|
1236
|
+
po.disconnect();
|
|
1237
|
+
if (entry.startTime < visibilityWatcher.firstHiddenTime) {
|
|
1238
|
+
metric.value = Math.max(entry.startTime - getActivationStart(), 0);
|
|
1239
|
+
metric.entries.push(entry);
|
|
1240
|
+
metric.navigationId = entry.navigationId || metric.navigationId;
|
|
1241
|
+
report(true);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
};
|
|
1245
|
+
const po = observe(["paint"], handleEntries);
|
|
1246
|
+
if (po) {
|
|
1247
|
+
report = bindReporter(onReport, metric, FCPThresholds, opts.reportAllChanges);
|
|
1248
|
+
onBFCacheRestore((event) => {
|
|
1249
|
+
metric = initMetric("FCP", -1, "back-forward-cache", metric.navigationId, metric.navigationInteractionId, metric.navigationURL, getBFCacheRestoreTime());
|
|
1250
|
+
report = bindReporter(onReport, metric, FCPThresholds, opts.reportAllChanges);
|
|
1251
|
+
doubleRAF(() => {
|
|
1252
|
+
metric.value = performance.now() - event.timeStamp;
|
|
1253
|
+
report(true);
|
|
1254
|
+
});
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
if (softNavsEnabled) {
|
|
1258
|
+
const handleSoftNavEntries = (entries) => {
|
|
1259
|
+
entries.forEach((entry) => {
|
|
1260
|
+
if (fcpEntryManager._softNavigationEntryMap && entry.navigationId) storeSoftNavEntry(fcpEntryManager._softNavigationEntryMap, entry);
|
|
1261
|
+
metric = initMetric("FCP", Math.max((entry.presentationTime || entry.paintTime || 0) - entry.startTime, 0), "soft-navigation", entry.navigationId, entry.interactionId, entry.name, entry.startTime);
|
|
1262
|
+
report = bindReporter(onReport, metric, FCPThresholds, opts.reportAllChanges);
|
|
1263
|
+
report(true);
|
|
1264
|
+
});
|
|
1265
|
+
};
|
|
1266
|
+
observe(["soft-navigation"], handleSoftNavEntries, opts);
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
//#endregion
|
|
1272
|
+
//#region src/tracing/webvitals/onCLS.ts
|
|
1273
|
+
/** Thresholds for CLS. See https://web.dev/articles/cls#what_is_a_good_cls_score */
|
|
1274
|
+
const CLSThresholds = [.1, .25];
|
|
1275
|
+
/**
|
|
1276
|
+
* Calculates the [CLS](https://web.dev/articles/cls) value for the current page and
|
|
1277
|
+
* calls the `callback` function once the value is ready to be reported, along
|
|
1278
|
+
* with all `layout-shift` performance entries that were used in the metric
|
|
1279
|
+
* value calculation. The reported value is a `double` (corresponding to a
|
|
1280
|
+
* [layout shift score](https://web.dev/articles/cls#layout_shift_score)).
|
|
1281
|
+
*
|
|
1282
|
+
* If the `reportAllChanges` configuration option is set to `true`, the
|
|
1283
|
+
* `callback` function will be called as soon as the value is initially
|
|
1284
|
+
* determined as well as any time the value changes throughout the page
|
|
1285
|
+
* lifespan.
|
|
1286
|
+
*
|
|
1287
|
+
* _**Important:** CLS should be continually monitored for changes throughout
|
|
1288
|
+
* the entire lifespan of a page—including if the user returns to the page after
|
|
1289
|
+
* it's been hidden/backgrounded. However, since browsers often [will not fire
|
|
1290
|
+
* additional callbacks once the user has backgrounded a
|
|
1291
|
+
* page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden),
|
|
1292
|
+
* `callback` is always called when the page's visibility state changes to
|
|
1293
|
+
* hidden. As a result, the `callback` function might be called multiple times
|
|
1294
|
+
* during the same page load._
|
|
1295
|
+
*/
|
|
1296
|
+
const onCLS = (onReport, opts = {}) => {
|
|
1297
|
+
const visibilityWatcher = getVisibilityWatcher();
|
|
1298
|
+
onFCP(runOnce(() => {
|
|
1299
|
+
let metric = initMetric("CLS", 0);
|
|
1300
|
+
let report;
|
|
1301
|
+
const layoutShiftManager = initUnique(opts, LayoutShiftManager);
|
|
1302
|
+
const initNewCLSMetric = (navigationType, navigationId, navigationInteractionId, navigationURL, navigationStartTime) => {
|
|
1303
|
+
metric = initMetric("CLS", 0, navigationType, navigationId, navigationInteractionId, navigationURL, navigationStartTime);
|
|
1304
|
+
layoutShiftManager._sessionValue = 0;
|
|
1305
|
+
report = bindReporter(onReport, metric, CLSThresholds, opts.reportAllChanges);
|
|
1306
|
+
};
|
|
1307
|
+
const updateAndReportMetric = (forceReport = false) => {
|
|
1308
|
+
if (layoutShiftManager._sessionValue > metric.value) {
|
|
1309
|
+
metric.value = layoutShiftManager._sessionValue;
|
|
1310
|
+
metric.entries = layoutShiftManager._sessionEntries;
|
|
1311
|
+
}
|
|
1312
|
+
report(forceReport);
|
|
1313
|
+
};
|
|
1314
|
+
const handleSoftNavEntry = (entry) => {
|
|
1315
|
+
updateAndReportMetric(true);
|
|
1316
|
+
initNewCLSMetric("soft-navigation", entry.navigationId, entry.interactionId, entry.name, entry.startTime);
|
|
1317
|
+
};
|
|
1318
|
+
const handleEntries = (entries) => {
|
|
1319
|
+
for (const entry of entries) {
|
|
1320
|
+
if (entry.entryType === "soft-navigation") {
|
|
1321
|
+
handleSoftNavEntry(entry);
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
layoutShiftManager._processEntry(entry);
|
|
1325
|
+
}
|
|
1326
|
+
updateAndReportMetric();
|
|
1327
|
+
};
|
|
1328
|
+
const types = ["layout-shift"];
|
|
1329
|
+
if (checkSoftNavsEnabled(opts)) types.push("soft-navigation");
|
|
1330
|
+
const po = observe(types, handleEntries);
|
|
1331
|
+
if (po) {
|
|
1332
|
+
report = bindReporter(onReport, metric, CLSThresholds, opts.reportAllChanges);
|
|
1333
|
+
visibilityWatcher.onHidden(() => {
|
|
1334
|
+
handleEntries(po.takeRecords());
|
|
1335
|
+
report(true);
|
|
1336
|
+
});
|
|
1337
|
+
onBFCacheRestore(() => {
|
|
1338
|
+
initNewCLSMetric("back-forward-cache", metric.navigationId, metric.navigationInteractionId, metric.navigationURL, getBFCacheRestoreTime());
|
|
1339
|
+
doubleRAF(report);
|
|
1340
|
+
});
|
|
1341
|
+
setTimeout(report);
|
|
1342
|
+
}
|
|
1343
|
+
}));
|
|
1344
|
+
};
|
|
1345
|
+
|
|
1346
|
+
//#endregion
|
|
1347
|
+
//#region src/tracing/webvitals/lib/polyfills/interactionCountPolyfill.ts
|
|
1348
|
+
let interactionCountEstimate = 0;
|
|
1349
|
+
let minKnownInteractionId = Infinity;
|
|
1350
|
+
let maxKnownInteractionId = 0;
|
|
1351
|
+
const updateEstimate = (entries) => {
|
|
1352
|
+
for (const entry of entries) if (entry.interactionId) {
|
|
1353
|
+
minKnownInteractionId = Math.min(minKnownInteractionId, entry.interactionId);
|
|
1354
|
+
maxKnownInteractionId = Math.max(maxKnownInteractionId, entry.interactionId);
|
|
1355
|
+
interactionCountEstimate = maxKnownInteractionId ? (maxKnownInteractionId - minKnownInteractionId) / 7 + 1 : 0;
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
let po;
|
|
1359
|
+
/**
|
|
1360
|
+
* Returns the `interactionCount` value using the native API (if available)
|
|
1361
|
+
* or the polyfill estimate in this module.
|
|
1362
|
+
*/
|
|
1363
|
+
const getInteractionCount = () => {
|
|
1364
|
+
return po ? interactionCountEstimate : performance.interactionCount ?? 0;
|
|
1365
|
+
};
|
|
1366
|
+
/**
|
|
1367
|
+
* Feature detects native support or initializes the polyfill if needed.
|
|
1368
|
+
*/
|
|
1369
|
+
const initInteractionCountPolyfill = () => {
|
|
1370
|
+
if ("interactionCount" in performance || po) return;
|
|
1371
|
+
po = observe(["event"], updateEstimate, { durationThreshold: 0 });
|
|
1372
|
+
};
|
|
1373
|
+
|
|
1374
|
+
//#endregion
|
|
1375
|
+
//#region src/tracing/webvitals/lib/InteractionManager.ts
|
|
1376
|
+
const MAX_INTERACTIONS_TO_CONSIDER = 10;
|
|
1377
|
+
let prevInteractionCount = 0;
|
|
1378
|
+
/**
|
|
1379
|
+
* Returns the interaction count since the last bfcache restore (or for the
|
|
1380
|
+
* full page lifecycle if there were no bfcache restores).
|
|
1381
|
+
*/
|
|
1382
|
+
const getInteractionCountForNavigation = () => {
|
|
1383
|
+
return getInteractionCount() - prevInteractionCount;
|
|
1384
|
+
};
|
|
1385
|
+
var InteractionManager = class {
|
|
1386
|
+
/**
|
|
1387
|
+
* A list of longest interactions on the page (by latency) sorted so the
|
|
1388
|
+
* longest one is first. The list is at most MAX_INTERACTIONS_TO_CONSIDER
|
|
1389
|
+
* long.
|
|
1390
|
+
*/
|
|
1391
|
+
_longestInteractionList = [];
|
|
1392
|
+
/**
|
|
1393
|
+
* A mapping of longest interactions by their interaction ID.
|
|
1394
|
+
* This is used for faster lookup.
|
|
1395
|
+
*/
|
|
1396
|
+
_longestInteractionMap = /* @__PURE__ */ new Map();
|
|
1397
|
+
_onBeforeProcessingEntry;
|
|
1398
|
+
_onAfterProcessingINPCandidate;
|
|
1399
|
+
_resetInteractions() {
|
|
1400
|
+
prevInteractionCount = getInteractionCount();
|
|
1401
|
+
this._longestInteractionList.length = 0;
|
|
1402
|
+
this._longestInteractionMap.clear();
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Returns the estimated p98 longest interaction based on the stored
|
|
1406
|
+
* interaction candidates and the interaction count for the current page.
|
|
1407
|
+
*/
|
|
1408
|
+
_estimateP98LongestInteraction(navigationType) {
|
|
1409
|
+
const interactionCountForNavigation = getInteractionCountForNavigation();
|
|
1410
|
+
const candidateInteractionIndex = Math.min(this._longestInteractionList.length - 1, Math.floor(interactionCountForNavigation / 50));
|
|
1411
|
+
if (interactionCountForNavigation && candidateInteractionIndex === -1 && (navigationType === "soft-navigation" || navigationType === "back-forward-cache")) return {
|
|
1412
|
+
_latency: 8,
|
|
1413
|
+
id: -1,
|
|
1414
|
+
entries: []
|
|
1415
|
+
};
|
|
1416
|
+
return this._longestInteractionList[candidateInteractionIndex];
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Takes a performance entry and adds it to the list of worst interactions
|
|
1420
|
+
* if its duration is long enough to make it among the worst. If the
|
|
1421
|
+
* entry is part of an existing interaction, it is merged and the latency
|
|
1422
|
+
* and entries list is updated as needed.
|
|
1423
|
+
*/
|
|
1424
|
+
_processEntry(entry) {
|
|
1425
|
+
this._onBeforeProcessingEntry?.(entry);
|
|
1426
|
+
if (!(entry.interactionId || entry.entryType === "first-input")) return;
|
|
1427
|
+
const minLongestInteraction = this._longestInteractionList.at(-1);
|
|
1428
|
+
let interaction = this._longestInteractionMap.get(entry.interactionId);
|
|
1429
|
+
if (interaction || this._longestInteractionList.length < MAX_INTERACTIONS_TO_CONSIDER || entry.duration > minLongestInteraction._latency) {
|
|
1430
|
+
if (interaction) {
|
|
1431
|
+
if (entry.duration > interaction._latency) {
|
|
1432
|
+
interaction.entries = [entry];
|
|
1433
|
+
interaction._latency = entry.duration;
|
|
1434
|
+
} else if (entry.duration === interaction._latency && entry.startTime === interaction.entries[0].startTime) interaction.entries.push(entry);
|
|
1435
|
+
} else {
|
|
1436
|
+
interaction = {
|
|
1437
|
+
id: entry.interactionId,
|
|
1438
|
+
entries: [entry],
|
|
1439
|
+
_latency: entry.duration
|
|
1440
|
+
};
|
|
1441
|
+
this._longestInteractionMap.set(interaction.id, interaction);
|
|
1442
|
+
this._longestInteractionList.push(interaction);
|
|
1443
|
+
}
|
|
1444
|
+
this._longestInteractionList.sort((a, b) => b._latency - a._latency);
|
|
1445
|
+
if (this._longestInteractionList.length > MAX_INTERACTIONS_TO_CONSIDER) {
|
|
1446
|
+
const removedInteractions = this._longestInteractionList.splice(MAX_INTERACTIONS_TO_CONSIDER);
|
|
1447
|
+
for (const interaction of removedInteractions) this._longestInteractionMap.delete(interaction.id);
|
|
1448
|
+
}
|
|
1449
|
+
this._onAfterProcessingINPCandidate?.(interaction);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
};
|
|
1453
|
+
|
|
1454
|
+
//#endregion
|
|
1455
|
+
//#region src/tracing/webvitals/lib/whenIdleOrHidden.ts
|
|
1456
|
+
/**
|
|
1457
|
+
* Runs the passed callback during the next idle period, or immediately
|
|
1458
|
+
* if the browser's visibility state is (or becomes) hidden.
|
|
1459
|
+
*/
|
|
1460
|
+
const whenIdleOrHidden = (cb) => {
|
|
1461
|
+
const timeout = "requestIdleCallback" in globalThis ? 1e3 : 0;
|
|
1462
|
+
const rIC = globalThis.requestIdleCallback || setTimeout;
|
|
1463
|
+
const cIC = globalThis.cancelIdleCallback || clearTimeout;
|
|
1464
|
+
if (document.visibilityState === "hidden") cb();
|
|
1465
|
+
else {
|
|
1466
|
+
const wrappedCb = runOnce(cb);
|
|
1467
|
+
let idleHandle = -1;
|
|
1468
|
+
const onHidden = () => {
|
|
1469
|
+
cIC(idleHandle);
|
|
1470
|
+
wrappedCb();
|
|
1471
|
+
};
|
|
1472
|
+
addEventListener("visibilitychange", onHidden, {
|
|
1473
|
+
once: true,
|
|
1474
|
+
capture: true
|
|
1475
|
+
});
|
|
1476
|
+
idleHandle = rIC(() => {
|
|
1477
|
+
removeEventListener("visibilitychange", onHidden, { capture: true });
|
|
1478
|
+
wrappedCb();
|
|
1479
|
+
}, { timeout });
|
|
1480
|
+
}
|
|
1481
|
+
};
|
|
1482
|
+
|
|
1483
|
+
//#endregion
|
|
1484
|
+
//#region src/tracing/webvitals/onINP.ts
|
|
1485
|
+
/** Thresholds for INP. See https://web.dev/articles/inp#what_is_a_good_inp_score */
|
|
1486
|
+
const INPThresholds = [200, 500];
|
|
1487
|
+
const DEFAULT_DURATION_THRESHOLD = 40;
|
|
1488
|
+
/**
|
|
1489
|
+
* Calculates the [INP](https://web.dev/articles/inp) value for the current
|
|
1490
|
+
* page and calls the `callback` function once the value is ready, along with
|
|
1491
|
+
* the `event` performance entries reported for that interaction. The reported
|
|
1492
|
+
* value is a `DOMHighResTimeStamp`.
|
|
1493
|
+
*
|
|
1494
|
+
* A custom `durationThreshold` configuration option can optionally be passed
|
|
1495
|
+
* to control what `event-timing` entries are considered for INP reporting. The
|
|
1496
|
+
* default threshold is `40`, which means INP scores of less than 40 will not
|
|
1497
|
+
* be reported. To avoid reporting no interactions in these cases, the library
|
|
1498
|
+
* will fall back to the input delay of the first interaction. Note that this
|
|
1499
|
+
* will not affect your 75th percentile INP value unless that value is also
|
|
1500
|
+
* less than 40 (well below the recommended
|
|
1501
|
+
* [good](https://web.dev/articles/inp#what_is_a_good_inp_score) threshold).
|
|
1502
|
+
*
|
|
1503
|
+
* If the `reportAllChanges` configuration option is set to `true`, the
|
|
1504
|
+
* `callback` function will be called as soon as the value is initially
|
|
1505
|
+
* determined as well as any time the value changes throughout the page
|
|
1506
|
+
* lifespan.
|
|
1507
|
+
*
|
|
1508
|
+
* _**Important:** INP should be continually monitored for changes throughout
|
|
1509
|
+
* the entire lifespan of a page—including if the user returns to the page after
|
|
1510
|
+
* it's been hidden/backgrounded. However, since browsers often [will not fire
|
|
1511
|
+
* additional callbacks once the user has backgrounded a
|
|
1512
|
+
* page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden),
|
|
1513
|
+
* `callback` is always called when the page's visibility state changes to
|
|
1514
|
+
* hidden. As a result, the `callback` function might be called multiple times
|
|
1515
|
+
* during the same page load._
|
|
1516
|
+
*/
|
|
1517
|
+
const onINP = (onReport, opts = {}) => {
|
|
1518
|
+
if (!(globalThis.PerformanceEventTiming && "interactionId" in PerformanceEventTiming.prototype)) return;
|
|
1519
|
+
const visibilityWatcher = getVisibilityWatcher();
|
|
1520
|
+
whenActivated(() => {
|
|
1521
|
+
initInteractionCountPolyfill();
|
|
1522
|
+
let metric = initMetric("INP");
|
|
1523
|
+
let report;
|
|
1524
|
+
const interactionManager = initUnique(opts, InteractionManager);
|
|
1525
|
+
const initNewINPMetric = (navigationType, navigationId, navigationInteractionId, navigationURL, navigationStartTime) => {
|
|
1526
|
+
interactionManager._resetInteractions();
|
|
1527
|
+
metric = initMetric("INP", -1, navigationType, navigationId, navigationInteractionId, navigationURL, navigationStartTime);
|
|
1528
|
+
report = bindReporter(onReport, metric, INPThresholds, opts.reportAllChanges);
|
|
1529
|
+
};
|
|
1530
|
+
const updateINPMetric = () => {
|
|
1531
|
+
const inp = interactionManager._estimateP98LongestInteraction(metric.navigationType);
|
|
1532
|
+
if (inp && inp._latency !== metric.value) {
|
|
1533
|
+
metric.value = inp._latency;
|
|
1534
|
+
metric.entries = inp.entries;
|
|
1535
|
+
report();
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
const handleSoftNavEntry = (entry) => {
|
|
1539
|
+
updateINPMetric();
|
|
1540
|
+
report(true);
|
|
1541
|
+
initNewINPMetric("soft-navigation", entry.navigationId, entry.interactionId, entry.name, entry.startTime);
|
|
1542
|
+
};
|
|
1543
|
+
const handleEntries = (entries, forceReport = false) => {
|
|
1544
|
+
whenIdleOrHidden(() => {
|
|
1545
|
+
for (const entry of entries) {
|
|
1546
|
+
if (entry.entryType === "soft-navigation") {
|
|
1547
|
+
handleSoftNavEntry(entry);
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
interactionManager._processEntry(entry);
|
|
1551
|
+
}
|
|
1552
|
+
updateINPMetric();
|
|
1553
|
+
if (forceReport) report(true);
|
|
1554
|
+
});
|
|
1555
|
+
};
|
|
1556
|
+
const types = ["event", "first-input"];
|
|
1557
|
+
if (checkSoftNavsEnabled(opts)) types.push("soft-navigation");
|
|
1558
|
+
const po = observe(types, handleEntries, {
|
|
1559
|
+
...opts,
|
|
1560
|
+
durationThreshold: opts.durationThreshold ?? DEFAULT_DURATION_THRESHOLD
|
|
1561
|
+
});
|
|
1562
|
+
report = bindReporter(onReport, metric, INPThresholds, opts.reportAllChanges);
|
|
1563
|
+
if (po) {
|
|
1564
|
+
visibilityWatcher.onHidden(() => {
|
|
1565
|
+
handleEntries(po.takeRecords(), true);
|
|
1566
|
+
});
|
|
1567
|
+
onBFCacheRestore(() => {
|
|
1568
|
+
initNewINPMetric("back-forward-cache", metric.navigationId, metric.navigationInteractionId, metric.navigationURL, getBFCacheRestoreTime());
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
});
|
|
1572
|
+
};
|
|
1573
|
+
|
|
1574
|
+
//#endregion
|
|
1575
|
+
//#region src/tracing/webvitals/lib/LCPEntryManager.ts
|
|
1576
|
+
var LCPEntryManager = class {
|
|
1577
|
+
_onBeforeProcessingEntry;
|
|
1578
|
+
_softNavigationEntryMap;
|
|
1579
|
+
_processEntry(entry) {
|
|
1580
|
+
this._onBeforeProcessingEntry?.(entry);
|
|
1581
|
+
}
|
|
1582
|
+
};
|
|
1583
|
+
|
|
1584
|
+
//#endregion
|
|
1585
|
+
//#region src/tracing/webvitals/onLCP.ts
|
|
1586
|
+
/** Thresholds for LCP. See https://web.dev/articles/lcp#what_is_a_good_lcp_score */
|
|
1587
|
+
const LCPThresholds = [2500, 4e3];
|
|
1588
|
+
/**
|
|
1589
|
+
* Calculates the [LCP](https://web.dev/articles/lcp) value for the current page and
|
|
1590
|
+
* calls the `callback` function once the value is ready (along with the
|
|
1591
|
+
* relevant `largest-contentful-paint` performance entry used to determine the
|
|
1592
|
+
* value). The reported value is a `DOMHighResTimeStamp`.
|
|
1593
|
+
*
|
|
1594
|
+
* If the `reportAllChanges` configuration option is set to `true`, the
|
|
1595
|
+
* `callback` function will be called any time a new `largest-contentful-paint`
|
|
1596
|
+
* performance entry is dispatched, or once the final value of the metric has
|
|
1597
|
+
* been determined.
|
|
1598
|
+
*/
|
|
1599
|
+
const onLCP = (onReport, opts = {}) => {
|
|
1600
|
+
let isFinalized = false;
|
|
1601
|
+
const softNavsEnabled = checkSoftNavsEnabled(opts);
|
|
1602
|
+
whenActivated(() => {
|
|
1603
|
+
let visibilityWatcher = getVisibilityWatcher();
|
|
1604
|
+
let metric = initMetric("LCP");
|
|
1605
|
+
let report;
|
|
1606
|
+
const lcpEntryManager = initUnique(opts, LCPEntryManager);
|
|
1607
|
+
const initNewLCPMetric = (navigation, navigationId, navigationInteractionId, navigationURL, navigationStartTime) => {
|
|
1608
|
+
metric = initMetric("LCP", -1, navigation, navigationId, navigationInteractionId, navigationURL, navigationStartTime);
|
|
1609
|
+
report = bindReporter(onReport, metric, LCPThresholds, opts.reportAllChanges);
|
|
1610
|
+
isFinalized = false;
|
|
1611
|
+
if (navigation === "soft-navigation") visibilityWatcher = getVisibilityWatcher(true);
|
|
1612
|
+
};
|
|
1613
|
+
const handleSoftNavEntry = (entry) => {
|
|
1614
|
+
if (lcpEntryManager._softNavigationEntryMap && entry.navigationId) storeSoftNavEntry(lcpEntryManager._softNavigationEntryMap, entry);
|
|
1615
|
+
if (!isFinalized) report(true);
|
|
1616
|
+
initNewLCPMetric("soft-navigation", entry.navigationId, entry.interactionId, entry.name, entry.startTime);
|
|
1617
|
+
const largestInteractionContentfulPaint = entry.getLargestInteractionContentfulPaint?.();
|
|
1618
|
+
if (largestInteractionContentfulPaint) handleEntries([largestInteractionContentfulPaint]);
|
|
1619
|
+
};
|
|
1620
|
+
const handleEntries = (entries) => {
|
|
1621
|
+
if (!opts.reportAllChanges && !softNavsEnabled) entries = entries.slice(-1);
|
|
1622
|
+
for (const entry of entries) {
|
|
1623
|
+
if (!entry) continue;
|
|
1624
|
+
if (entry.entryType === "soft-navigation") {
|
|
1625
|
+
handleSoftNavEntry(entry);
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
let value = 0;
|
|
1629
|
+
let metricEntries = [];
|
|
1630
|
+
let renderTime = entry.startTime;
|
|
1631
|
+
if (entry.entryType === "largest-contentful-paint") {
|
|
1632
|
+
value = Math.max(entry.startTime - getActivationStart(), 0);
|
|
1633
|
+
lcpEntryManager._processEntry(entry);
|
|
1634
|
+
metricEntries = [entry];
|
|
1635
|
+
} else if (entry.entryType === "interaction-contentful-paint") {
|
|
1636
|
+
const ICPEntry = entry;
|
|
1637
|
+
if (!metric.navigationId) continue;
|
|
1638
|
+
if ("interactionId" in ICPEntry && ICPEntry.interactionId != metric.navigationInteractionId) continue;
|
|
1639
|
+
renderTime = ICPEntry.largestContentfulPaint?.renderTime || 0;
|
|
1640
|
+
value = Math.max(renderTime - entry.startTime, 0);
|
|
1641
|
+
if (ICPEntry.largestContentfulPaint) {
|
|
1642
|
+
lcpEntryManager._processEntry(ICPEntry.largestContentfulPaint);
|
|
1643
|
+
metricEntries = [ICPEntry.largestContentfulPaint];
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
if (renderTime < visibilityWatcher.firstHiddenTime) {
|
|
1647
|
+
metric.value = value;
|
|
1648
|
+
metric.entries = metricEntries;
|
|
1649
|
+
report();
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
const types = ["largest-contentful-paint"];
|
|
1654
|
+
if (softNavsEnabled) types.push("interaction-contentful-paint", "soft-navigation");
|
|
1655
|
+
const po = observe(types, handleEntries);
|
|
1656
|
+
if (po) {
|
|
1657
|
+
report = bindReporter(onReport, metric, LCPThresholds, opts.reportAllChanges);
|
|
1658
|
+
const finalizeEventTypes = [
|
|
1659
|
+
"keydown",
|
|
1660
|
+
"click",
|
|
1661
|
+
"visibilitychange"
|
|
1662
|
+
];
|
|
1663
|
+
const finalizeLCP = (event) => {
|
|
1664
|
+
if (event.isTrusted && !isFinalized) {
|
|
1665
|
+
const metricIdToFinalize = metric.id;
|
|
1666
|
+
whenIdleOrHidden(() => {
|
|
1667
|
+
if (!isFinalized) {
|
|
1668
|
+
if (!softNavsEnabled) {
|
|
1669
|
+
po.disconnect();
|
|
1670
|
+
for (const type of finalizeEventTypes) removeEventListener(type, finalizeLCP, { capture: true });
|
|
1671
|
+
}
|
|
1672
|
+
if (metricIdToFinalize === metric.id) {
|
|
1673
|
+
isFinalized = true;
|
|
1674
|
+
report(true);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
for (const type of finalizeEventTypes) addEventListener(type, finalizeLCP, { capture: true });
|
|
1681
|
+
onBFCacheRestore((event) => {
|
|
1682
|
+
initNewLCPMetric("back-forward-cache", metric.navigationId, metric.navigationInteractionId, metric.navigationURL, getBFCacheRestoreTime());
|
|
1683
|
+
report = bindReporter(onReport, metric, LCPThresholds, opts.reportAllChanges);
|
|
1684
|
+
doubleRAF(() => {
|
|
1685
|
+
metric.value = performance.now() - event.timeStamp;
|
|
1686
|
+
isFinalized = true;
|
|
1687
|
+
report(true);
|
|
1688
|
+
});
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
};
|
|
1693
|
+
|
|
1694
|
+
//#endregion
|
|
1695
|
+
//#region src/tracing/webvitals/onTTFB.ts
|
|
1696
|
+
/** Thresholds for TTFB. See https://web.dev/articles/ttfb#what_is_a_good_ttfb_score */
|
|
1697
|
+
const TTFBThresholds = [800, 1800];
|
|
1698
|
+
/**
|
|
1699
|
+
* Runs in the next task after the page is done loading and/or prerendering.
|
|
1700
|
+
* @param callback
|
|
1701
|
+
*/
|
|
1702
|
+
const whenReady = (callback) => {
|
|
1703
|
+
if (document.prerendering) whenActivated(() => whenReady(callback));
|
|
1704
|
+
else if (document.readyState !== "complete") addEventListener("load", () => whenReady(callback), true);
|
|
1705
|
+
else setTimeout(callback);
|
|
1706
|
+
};
|
|
1707
|
+
/**
|
|
1708
|
+
* Calculates the [TTFB](https://web.dev/articles/ttfb) value for the
|
|
1709
|
+
* current page and calls the `callback` function once the page has loaded,
|
|
1710
|
+
* along with the relevant `navigation` performance entry used to determine the
|
|
1711
|
+
* value. The reported value is a `DOMHighResTimeStamp`.
|
|
1712
|
+
*
|
|
1713
|
+
* Note, this function waits until after the page is loaded to call `callback`
|
|
1714
|
+
* in order to ensure all properties of the `navigation` entry are populated.
|
|
1715
|
+
* This is useful if you want to report on other metrics exposed by the
|
|
1716
|
+
* [Navigation Timing API](https://w3c.github.io/navigation-timing/). For
|
|
1717
|
+
* example, the TTFB metric starts from the page's [time
|
|
1718
|
+
* origin](https://www.w3.org/TR/hr-time-2/#sec-time-origin), which means it
|
|
1719
|
+
* includes time spent on DNS lookup, connection negotiation, network latency,
|
|
1720
|
+
* and server processing time.
|
|
1721
|
+
*/
|
|
1722
|
+
const onTTFB = (onReport, opts = {}) => {
|
|
1723
|
+
const softNavsEnabled = checkSoftNavsEnabled(opts);
|
|
1724
|
+
let metric = initMetric("TTFB");
|
|
1725
|
+
let report = bindReporter(onReport, metric, TTFBThresholds, opts.reportAllChanges);
|
|
1726
|
+
whenReady(() => {
|
|
1727
|
+
const hardNavEntry = getNavigationEntry();
|
|
1728
|
+
if (hardNavEntry) {
|
|
1729
|
+
const responseStart = hardNavEntry.responseStart;
|
|
1730
|
+
metric.value = Math.max(responseStart - getActivationStart(), 0);
|
|
1731
|
+
metric.entries = [hardNavEntry];
|
|
1732
|
+
report(true);
|
|
1733
|
+
onBFCacheRestore(() => {
|
|
1734
|
+
metric = initMetric("TTFB", 0, "back-forward-cache", metric.navigationId, metric.navigationInteractionId, metric.navigationURL, getBFCacheRestoreTime());
|
|
1735
|
+
report = bindReporter(onReport, metric, TTFBThresholds, opts.reportAllChanges);
|
|
1736
|
+
report(true);
|
|
1737
|
+
});
|
|
1738
|
+
if (softNavsEnabled) {
|
|
1739
|
+
const reportSoftNavTTFBs = (entries) => {
|
|
1740
|
+
entries.forEach((entry) => {
|
|
1741
|
+
if (entry.navigationId) {
|
|
1742
|
+
metric = initMetric("TTFB", 0, "soft-navigation", entry.navigationId, entry.interactionId, entry.name, entry.startTime);
|
|
1743
|
+
metric.entries = [entry];
|
|
1744
|
+
report = bindReporter(onReport, metric, TTFBThresholds, opts.reportAllChanges);
|
|
1745
|
+
report(true);
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
};
|
|
1749
|
+
observe(["soft-navigation"], reportSoftNavTTFBs, opts);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
});
|
|
1753
|
+
};
|
|
1754
|
+
|
|
1755
|
+
//#endregion
|
|
1756
|
+
//#region src/tracing/webVitals.ts
|
|
1757
|
+
/**
|
|
1758
|
+
* Final the moment they first report, so they can ride the pageload span itself. The other three keep
|
|
1759
|
+
* changing until the page goes away, and stamping an early value on the root would leave the root and
|
|
1760
|
+
* the later span disagreeing about the same vital.
|
|
1761
|
+
*/
|
|
1762
|
+
const EARLY_VITALS = ["ttfb", "fcp"];
|
|
1763
|
+
/** The one place a vital becomes a wire key. Both the pageload stamp and the late span go through it. */
|
|
1764
|
+
function vitalAttributes(vitals) {
|
|
1765
|
+
const attributes = {};
|
|
1766
|
+
for (const [name, value] of Object.entries(vitals)) if (typeof value === "number") attributes[`browser.web_vital.${name}`] = value;
|
|
1767
|
+
return attributes;
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* Turns the leftover values into one zero-duration span. Pure on purpose: the shape is what the backend
|
|
1771
|
+
* groups on, and this way it is testable without a tracer or a clock.
|
|
1772
|
+
*
|
|
1773
|
+
* Both timestamps sit at the pageload root's start. `spans_2` buckets on `start_time_unix_nano`, so
|
|
1774
|
+
* stamping the report moment would drop a tab left open for forty minutes into a minute forty minutes
|
|
1775
|
+
* after the page actually loaded.
|
|
1776
|
+
*
|
|
1777
|
+
* Returns null when nothing is left to report, so the caller emits no span at all.
|
|
1778
|
+
*/
|
|
1779
|
+
function buildVitalsSpan(input) {
|
|
1780
|
+
const vitals = vitalAttributes(input.vitals);
|
|
1781
|
+
if (Object.keys(vitals).length === 0) return null;
|
|
1782
|
+
return {
|
|
1783
|
+
name: input.routeName,
|
|
1784
|
+
startTimeUnixNano: input.rootStartTimeUnixNano,
|
|
1785
|
+
endTimeUnixNano: input.rootStartTimeUnixNano,
|
|
1786
|
+
attributes: {
|
|
1787
|
+
...input.contextAttributes,
|
|
1788
|
+
"flare.entry_point.handler.identifier": input.routeName,
|
|
1789
|
+
"http.route": input.routeName,
|
|
1790
|
+
"flare.route.source": input.routeSource,
|
|
1791
|
+
...vitals
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
let collected = {};
|
|
1796
|
+
let subscribed = false;
|
|
1797
|
+
let recording = false;
|
|
1798
|
+
let taken = false;
|
|
1799
|
+
function defaultSubscribers() {
|
|
1800
|
+
return {
|
|
1801
|
+
onTTFB: (cb) => onTTFB(cb),
|
|
1802
|
+
onFCP: (cb) => onFCP(cb),
|
|
1803
|
+
onLCP: (cb) => onLCP(cb, { reportAllChanges: true }),
|
|
1804
|
+
onCLS: (cb) => onCLS(cb, { reportAllChanges: true }),
|
|
1805
|
+
onINP: (cb) => onINP(cb, { reportAllChanges: true })
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Subscribes at most once per document: upstream's on* functions return no unsubscribe handle, so a
|
|
1810
|
+
* second call would attach a second set of observers with no way to detach either.
|
|
1811
|
+
*/
|
|
1812
|
+
function startWebVitals(subscribers = defaultSubscribers()) {
|
|
1813
|
+
recording = true;
|
|
1814
|
+
if (subscribed) return;
|
|
1815
|
+
subscribed = true;
|
|
1816
|
+
subscribe(subscribers.onTTFB, "ttfb");
|
|
1817
|
+
subscribe(subscribers.onFCP, "fcp");
|
|
1818
|
+
subscribe(subscribers.onLCP, "lcp");
|
|
1819
|
+
subscribe(subscribers.onCLS, "cls");
|
|
1820
|
+
subscribe(subscribers.onINP, "inp");
|
|
1821
|
+
}
|
|
1822
|
+
function subscribe(on, name) {
|
|
1823
|
+
try {
|
|
1824
|
+
on((metric) => record(name, metric));
|
|
1825
|
+
} catch {}
|
|
1826
|
+
}
|
|
1827
|
+
function record(name, metric) {
|
|
1828
|
+
if (!recording || typeof metric?.value !== "number") return;
|
|
1829
|
+
collected[name] = metric.value;
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Stops recording and drops what was collected. The observers themselves cannot be detached, and both
|
|
1833
|
+
* the `subscribed` and `taken` latches deliberately survive: clearing `subscribed` would let a re-enable
|
|
1834
|
+
* attach a second set of observers that is just as undetachable as the first, and clearing `taken` would
|
|
1835
|
+
* let those surviving observers refill `collected` and ship a second `browser_web_vital` for the same
|
|
1836
|
+
* document once the page is re-enabled and later hidden.
|
|
1837
|
+
*/
|
|
1838
|
+
function stopWebVitals() {
|
|
1839
|
+
recording = false;
|
|
1840
|
+
collected = {};
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* The vitals that are already final when the pageload root closes, removed from `collected` so the late
|
|
1844
|
+
* span cannot report them a second time. Naturally idempotent: a second call finds the keys gone.
|
|
1845
|
+
*/
|
|
1846
|
+
function takeEarlyVitals() {
|
|
1847
|
+
if (!recording) return null;
|
|
1848
|
+
const taking = {};
|
|
1849
|
+
for (const name of EARLY_VITALS) {
|
|
1850
|
+
const value = collected[name];
|
|
1851
|
+
if (value !== void 0) {
|
|
1852
|
+
taking[name] = value;
|
|
1853
|
+
delete collected[name];
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
return Object.keys(taking).length === 0 ? null : taking;
|
|
1857
|
+
}
|
|
1858
|
+
/** Everything still outstanding, once. Null afterwards, and null when nothing is left. */
|
|
1859
|
+
function takeWebVitals() {
|
|
1860
|
+
if (taken || !recording || Object.keys(collected).length === 0) return null;
|
|
1861
|
+
taken = true;
|
|
1862
|
+
const taking = collected;
|
|
1863
|
+
collected = {};
|
|
1864
|
+
return taking;
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Undoes a take after the emit failed partway through, so the values are not lost and a later trigger
|
|
1868
|
+
* can retry. Safe to assign outright rather than merge: the whole emit runs synchronously between the
|
|
1869
|
+
* take and a catch block calling this, so nothing else can have written to `collected` in between.
|
|
1870
|
+
*/
|
|
1871
|
+
function restoreWebVitals(vitals) {
|
|
1872
|
+
collected = vitals;
|
|
1873
|
+
taken = false;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
//#endregion
|
|
1877
|
+
//#region src/tracing/browserTracing.ts
|
|
1878
|
+
let controller = null;
|
|
1879
|
+
let uninstall = null;
|
|
1880
|
+
let lastPath = "";
|
|
1881
|
+
let pageloadTraced = false;
|
|
1882
|
+
let navSource = null;
|
|
1883
|
+
let activeFlare = null;
|
|
1884
|
+
let currentRoot = null;
|
|
1885
|
+
let pendingRouteName = null;
|
|
1886
|
+
let pendingRouteNameOwner = null;
|
|
1887
|
+
let pageloadRoot = null;
|
|
1888
|
+
let pageloadRootStartNano = 0;
|
|
1889
|
+
let pageloadRoute = null;
|
|
1890
|
+
let pageloadContext = {};
|
|
1891
|
+
function resolveTimeouts(config) {
|
|
1892
|
+
return {
|
|
1893
|
+
idleTimeout: config.idleTimeout ?? DEFAULT_IDLE_TIMEOUTS.idleTimeout,
|
|
1894
|
+
finalTimeout: config.finalTimeout ?? DEFAULT_IDLE_TIMEOUTS.finalTimeout,
|
|
1895
|
+
childSpanTimeout: config.childSpanTimeout ?? DEFAULT_IDLE_TIMEOUTS.childSpanTimeout
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
/** No-ops once the controller has ended: it can close itself asynchronously via a timer, before this
|
|
1899
|
+
* module's `controller` reference is cleared. */
|
|
1900
|
+
function withLiveController(fn) {
|
|
1901
|
+
if (!controller || controller.isEnded) return;
|
|
1902
|
+
try {
|
|
1903
|
+
fn(controller);
|
|
1904
|
+
} catch (error) {
|
|
1905
|
+
if (activeFlare?.config.debug) console.error("Flare: browser tracing controller callback failed", error);
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
/** Run `fn` only while the current root is still open. Swallows a throw, like `withLiveController`. */
|
|
1909
|
+
function ifRootLive(fn) {
|
|
1910
|
+
withLiveController(() => fn());
|
|
1911
|
+
}
|
|
1912
|
+
function startRoot(flare, options) {
|
|
1913
|
+
const { spanType, startTimeUnixNano, name = location.pathname, urlOverride, hold, backdated } = options;
|
|
1914
|
+
let root;
|
|
1915
|
+
try {
|
|
1916
|
+
const context = collectBrowserSpanContext(flare.config, urlOverride);
|
|
1917
|
+
root = flare.startSpan(name, {
|
|
1918
|
+
spanType,
|
|
1919
|
+
startTimeUnixNano,
|
|
1920
|
+
forceRoot: true,
|
|
1921
|
+
attributes: {
|
|
1922
|
+
...context,
|
|
1923
|
+
"flare.route.source": "url"
|
|
1924
|
+
}
|
|
1925
|
+
});
|
|
1926
|
+
controller = new IdleRootController({
|
|
1927
|
+
root,
|
|
1928
|
+
addSpanListener: (fn) => flare.tracer.addSpanListener(fn),
|
|
1929
|
+
setActiveRoot: (span) => flare.tracer.setActiveRoot(span),
|
|
1930
|
+
now: defaultNowNano,
|
|
1931
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
1932
|
+
clearTimeout: (handle) => clearTimeout(handle),
|
|
1933
|
+
rootStartTime: startTimeUnixNano,
|
|
1934
|
+
endFloor: spanType === BrowserSpanType.Pageload && backdated ? pageloadEndNano : () => startTimeUnixNano,
|
|
1935
|
+
held: hold,
|
|
1936
|
+
beforeEnd: spanType === BrowserSpanType.Pageload ? () => stampEarlyVitals(root, flare) : void 0
|
|
1937
|
+
}, resolveTimeouts(flare.config));
|
|
1938
|
+
currentRoot = root;
|
|
1939
|
+
if (spanType === BrowserSpanType.Pageload) {
|
|
1940
|
+
pageloadRoot = root;
|
|
1941
|
+
pageloadRootStartNano = startTimeUnixNano;
|
|
1942
|
+
pageloadRoute = {
|
|
1943
|
+
name,
|
|
1944
|
+
source: "url"
|
|
1945
|
+
};
|
|
1946
|
+
pageloadContext = { ...context };
|
|
1947
|
+
}
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
controller = null;
|
|
1950
|
+
currentRoot = null;
|
|
1951
|
+
if (spanType === BrowserSpanType.Pageload) {
|
|
1952
|
+
pageloadRoot = null;
|
|
1953
|
+
pageloadRootStartNano = 0;
|
|
1954
|
+
pageloadRoute = null;
|
|
1955
|
+
pageloadContext = {};
|
|
1956
|
+
}
|
|
1957
|
+
try {
|
|
1958
|
+
root?.end();
|
|
1959
|
+
} catch {}
|
|
1960
|
+
try {
|
|
1961
|
+
flare.tracer.setActiveRoot(void 0);
|
|
1962
|
+
} catch {}
|
|
1963
|
+
if (flare.config.debug) console.error("Flare: failed to start browser tracing root", error);
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
function onUrlChanged(flare) {
|
|
1967
|
+
const path = location.pathname;
|
|
1968
|
+
if (path === lastPath) return;
|
|
1969
|
+
lastPath = path;
|
|
1970
|
+
if (navSource) return;
|
|
1971
|
+
withLiveController((live) => live.endNow());
|
|
1972
|
+
startRoot(flare, {
|
|
1973
|
+
spanType: BrowserSpanType.Navigation,
|
|
1974
|
+
startTimeUnixNano: defaultNowNano(),
|
|
1975
|
+
name: path
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* Writes the already-final vitals onto the pageload root itself, from `IdleRootController`'s beforeEnd
|
|
1980
|
+
* hook. Whatever has not reported yet stays in `collected` and rides the later `browser_web_vital`
|
|
1981
|
+
* span instead, so no vital is ever sent twice and none is lost.
|
|
1982
|
+
*
|
|
1983
|
+
* Swallows its own failures: this runs inside the root's close path, and a throw here would leave the
|
|
1984
|
+
* root open forever.
|
|
1985
|
+
*/
|
|
1986
|
+
function stampEarlyVitals(root, flare) {
|
|
1987
|
+
try {
|
|
1988
|
+
const early = takeEarlyVitals();
|
|
1989
|
+
if (!early) return;
|
|
1990
|
+
for (const [key, value] of Object.entries(vitalAttributes(early))) root.setAttribute(key, value);
|
|
1991
|
+
} catch (error) {
|
|
1992
|
+
if (flare.config.debug) console.error("Flare: failed to stamp web vitals on the pageload root", error);
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Emits whatever the pageload root could not carry as one zero-duration `browser_web_vital` span,
|
|
1997
|
+
* parented to that root. Page hide only, and once per document.
|
|
1998
|
+
*
|
|
1999
|
+
* Deliberately NOT on navigation: LCP, CLS and INP keep moving all document long, so emitting at the
|
|
2000
|
+
* first route change froze them a second after load, and a session whose first action was a nav click
|
|
2001
|
+
* reported no INP at all. One span per document is a backend constraint, so the emit waits for the last
|
|
2002
|
+
* moment we get instead. The cost is a page whose hide event never fires reports no vitals.
|
|
2003
|
+
*
|
|
2004
|
+
* `pageloadRoot` has usually ended by now; reading `traceId` and `spanId` off an ended span is fine,
|
|
2005
|
+
* and passing the `Span` rather than a `{ traceId, spanId }` pair is what makes sampling inherit:
|
|
2006
|
+
* `resolveTrace()` reads `parent.isRecording` instead of re-rolling the sampler.
|
|
2007
|
+
*/
|
|
2008
|
+
function emitWebVitals(flare) {
|
|
2009
|
+
const root = pageloadRoot;
|
|
2010
|
+
const route = pageloadRoute;
|
|
2011
|
+
if (!root || !route) return;
|
|
2012
|
+
const vitals = takeWebVitals();
|
|
2013
|
+
if (!vitals) return;
|
|
2014
|
+
try {
|
|
2015
|
+
const planned = buildVitalsSpan({
|
|
2016
|
+
vitals,
|
|
2017
|
+
rootStartTimeUnixNano: pageloadRootStartNano,
|
|
2018
|
+
routeName: route.name,
|
|
2019
|
+
routeSource: route.source,
|
|
2020
|
+
contextAttributes: pageloadContext
|
|
2021
|
+
});
|
|
2022
|
+
if (!planned) return;
|
|
2023
|
+
flare.startSpan(planned.name, {
|
|
2024
|
+
parent: root,
|
|
2025
|
+
forceRoot: true,
|
|
2026
|
+
spanType: BrowserSpanType.WebVital,
|
|
2027
|
+
startTimeUnixNano: planned.startTimeUnixNano,
|
|
2028
|
+
attributes: planned.attributes
|
|
2029
|
+
}).end(planned.endTimeUnixNano);
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
restoreWebVitals(vitals);
|
|
2032
|
+
if (flare.config.debug) console.error("Flare: failed to emit web vitals", error);
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
/** Opens a backdated pageload root, then a navigation root per History change. No-op outside a browser. Idempotent. */
|
|
2036
|
+
function startBrowserTracing(flare) {
|
|
2037
|
+
if (typeof window === "undefined" || typeof history === "undefined" || typeof location === "undefined") return;
|
|
2038
|
+
if (uninstall) return;
|
|
2039
|
+
activeFlare = flare;
|
|
2040
|
+
lastPath = location.pathname;
|
|
2041
|
+
const finalTimeoutNano = resolveTimeouts(flare.config).finalTimeout * 1e6;
|
|
2042
|
+
const navigationStart = pageloadStartNano();
|
|
2043
|
+
const pageloadStart = resolvePageloadStartNano(navigationStart, defaultNowNano(), finalTimeoutNano, pageloadTraced);
|
|
2044
|
+
pageloadTraced = true;
|
|
2045
|
+
startRoot(flare, {
|
|
2046
|
+
spanType: BrowserSpanType.Pageload,
|
|
2047
|
+
startTimeUnixNano: pageloadStart,
|
|
2048
|
+
backdated: pageloadStart === navigationStart
|
|
2049
|
+
});
|
|
2050
|
+
startWebVitals();
|
|
2051
|
+
if (pendingRouteName) {
|
|
2052
|
+
const route = pendingRouteName;
|
|
2053
|
+
const owner = pendingRouteNameOwner;
|
|
2054
|
+
pendingRouteName = null;
|
|
2055
|
+
pendingRouteNameOwner = null;
|
|
2056
|
+
if (owner === navSource) applyRouteName(route);
|
|
2057
|
+
}
|
|
2058
|
+
const handle = () => {
|
|
2059
|
+
if (!uninstall) return;
|
|
2060
|
+
try {
|
|
2061
|
+
onUrlChanged(flare);
|
|
2062
|
+
} catch (error) {
|
|
2063
|
+
if (flare.config.debug) console.error("Flare: browser tracing navigation handler failed", error);
|
|
2064
|
+
}
|
|
2065
|
+
};
|
|
2066
|
+
function wrapHistoryMethod(original) {
|
|
2067
|
+
return function(...args) {
|
|
2068
|
+
const result = original.apply(this, args);
|
|
2069
|
+
handle();
|
|
2070
|
+
return result;
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
fill(history, "pushState", wrapHistoryMethod);
|
|
2074
|
+
fill(history, "replaceState", wrapHistoryMethod);
|
|
2075
|
+
window.addEventListener("popstate", handle);
|
|
2076
|
+
function endRootAndFlush() {
|
|
2077
|
+
if (controller && !controller.isEnded) try {
|
|
2078
|
+
controller.endNow();
|
|
2079
|
+
} catch (error) {
|
|
2080
|
+
if (flare.config.debug) console.error("Flare: failed to end tracing root on page hide", error);
|
|
2081
|
+
}
|
|
2082
|
+
emitWebVitals(flare);
|
|
2083
|
+
try {
|
|
2084
|
+
flare.tracer.flush({ keepalive: true });
|
|
2085
|
+
} catch (error) {
|
|
2086
|
+
if (flare.config.debug) console.error("Flare: failed to flush spans on page hide", error);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
const onPageHide = () => endRootAndFlush();
|
|
2090
|
+
const onVisibilityChange = () => {
|
|
2091
|
+
if (document.visibilityState === "hidden") endRootAndFlush();
|
|
2092
|
+
};
|
|
2093
|
+
window.addEventListener("pagehide", onPageHide);
|
|
2094
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
2095
|
+
uninstall = () => {
|
|
2096
|
+
unfill(history, "pushState");
|
|
2097
|
+
unfill(history, "replaceState");
|
|
2098
|
+
window.removeEventListener("popstate", handle);
|
|
2099
|
+
window.removeEventListener("pagehide", onPageHide);
|
|
2100
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
/** Idempotent. */
|
|
2104
|
+
function stopBrowserTracing() {
|
|
2105
|
+
withLiveController((live) => live.endNow());
|
|
2106
|
+
controller = null;
|
|
2107
|
+
if (uninstall) {
|
|
2108
|
+
uninstall();
|
|
2109
|
+
uninstall = null;
|
|
2110
|
+
}
|
|
2111
|
+
activeFlare = null;
|
|
2112
|
+
currentRoot = null;
|
|
2113
|
+
pendingRouteName = null;
|
|
2114
|
+
pendingRouteNameOwner = null;
|
|
2115
|
+
lastPath = "";
|
|
2116
|
+
stopWebVitals();
|
|
2117
|
+
pageloadRoot = null;
|
|
2118
|
+
pageloadRootStartNano = 0;
|
|
2119
|
+
pageloadRoute = null;
|
|
2120
|
+
pageloadContext = {};
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Computes the url attributes a route rename carries, or null when there is nothing to add. Guarded
|
|
2124
|
+
* so the pin below (which runs outside ifRootLive's own try/catch) cannot throw into the host.
|
|
2125
|
+
*/
|
|
2126
|
+
function urlAttributesFor(route) {
|
|
2127
|
+
if (route.url === void 0 || !activeFlare) return null;
|
|
2128
|
+
try {
|
|
2129
|
+
return browserSpanUrlAttributes(activeFlare.config, route.url);
|
|
2130
|
+
} catch {
|
|
2131
|
+
return null;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* Rename the current root and update the attributes that go with the name, and pin the pageload's route
|
|
2136
|
+
* for the vitals emit. No-op once it closed; the pin is NOT gated the same way, see below.
|
|
2137
|
+
* With no root yet the name is held for the pageload root that opens next, rather than dropped.
|
|
2138
|
+
* `owner` stamps who is holding it, so a stale or superseded source cannot land its name later.
|
|
2139
|
+
*/
|
|
2140
|
+
function applyRouteName(route, owner) {
|
|
2141
|
+
const root = currentRoot;
|
|
2142
|
+
if (!root) {
|
|
2143
|
+
pendingRouteName = route;
|
|
2144
|
+
pendingRouteNameOwner = owner ?? null;
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
const urlAttrs = urlAttributesFor(route);
|
|
2148
|
+
ifRootLive(() => {
|
|
2149
|
+
root.name = route.name;
|
|
2150
|
+
root.setAttribute("flare.entry_point.handler.identifier", route.name);
|
|
2151
|
+
root.setAttribute("http.route", route.name);
|
|
2152
|
+
root.setAttribute("flare.route.source", route.source);
|
|
2153
|
+
if (!urlAttrs) return;
|
|
2154
|
+
for (const [key, value] of Object.entries(urlAttrs)) root.setAttribute(key, value);
|
|
2155
|
+
});
|
|
2156
|
+
if (root !== pageloadRoot) return;
|
|
2157
|
+
pageloadRoute = {
|
|
2158
|
+
name: route.name,
|
|
2159
|
+
source: route.source
|
|
2160
|
+
};
|
|
2161
|
+
if (urlAttrs) pageloadContext = {
|
|
2162
|
+
...pageloadContext,
|
|
2163
|
+
...urlAttrs
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* While registered, the built-in History detection opens no roots and the caller drives navigation
|
|
2168
|
+
* through the returned handle. Last-wins, and a stale handle no-ops, so an HMR-replaced bootstrap
|
|
2169
|
+
* cannot tear down a newer registration.
|
|
2170
|
+
*/
|
|
2171
|
+
function registerNavigationSource() {
|
|
2172
|
+
const token = {};
|
|
2173
|
+
if (navSource && activeFlare?.config.debug) console.debug("Flare: navigation source replaced");
|
|
2174
|
+
navSource = token;
|
|
2175
|
+
function active() {
|
|
2176
|
+
return navSource === token;
|
|
2177
|
+
}
|
|
2178
|
+
return {
|
|
2179
|
+
startNavigation(opts) {
|
|
2180
|
+
if (!active() || !activeFlare) return;
|
|
2181
|
+
const path = opts?.path ?? currentPath();
|
|
2182
|
+
lastPath = path;
|
|
2183
|
+
withLiveController((live) => live.endNow());
|
|
2184
|
+
startRoot(activeFlare, {
|
|
2185
|
+
spanType: BrowserSpanType.Navigation,
|
|
2186
|
+
startTimeUnixNano: defaultNowNano(),
|
|
2187
|
+
name: path,
|
|
2188
|
+
urlOverride: opts?.url,
|
|
2189
|
+
hold: opts?.hold
|
|
2190
|
+
});
|
|
2191
|
+
},
|
|
2192
|
+
setActiveRouteName(route) {
|
|
2193
|
+
if (!active()) return;
|
|
2194
|
+
applyRouteName(route, token);
|
|
2195
|
+
},
|
|
2196
|
+
settleNavigation(route) {
|
|
2197
|
+
if (!active()) return;
|
|
2198
|
+
applyRouteName(route, token);
|
|
2199
|
+
withLiveController((live) => live.releaseHold());
|
|
2200
|
+
},
|
|
2201
|
+
unregister() {
|
|
2202
|
+
if (!active()) return;
|
|
2203
|
+
withLiveController((live) => live.releaseHold());
|
|
2204
|
+
navSource = null;
|
|
2205
|
+
lastPath = currentPath();
|
|
2206
|
+
pendingRouteName = null;
|
|
2207
|
+
pendingRouteNameOwner = null;
|
|
2208
|
+
}
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
/** For sibling tracing modules (the component-profiler seam) that need the live tracer. */
|
|
2212
|
+
function activeTracingFlare() {
|
|
2213
|
+
return activeFlare;
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
//#endregion
|
|
2217
|
+
//#region src/createFlareResolver.ts
|
|
2218
|
+
/**
|
|
2219
|
+
* `process.env.NODE_ENV` is replaced inline by bundlers. The try/catch keeps a process-less
|
|
2220
|
+
* environment safe: treat "undetermined" as production (warn, never crash).
|
|
2221
|
+
*/
|
|
2222
|
+
function isDevMode() {
|
|
2223
|
+
try {
|
|
2224
|
+
return process.env.NODE_ENV !== "production";
|
|
2225
|
+
} catch {
|
|
2226
|
+
return false;
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
/**
|
|
2230
|
+
* Builds a per-package Flare resolver: `registerDefaultFlare` (wired once by the web entry) and
|
|
2231
|
+
* `resolveFlare` (called at wiring time). Each call holds its own default-provider state. The check
|
|
2232
|
+
* that warns about the Electron `__flare` bridge uses `packageName` in its message;
|
|
2233
|
+
* `injectInstruction` replaces the closing hint for packages whose advice differs (for example
|
|
2234
|
+
* svelte, which points at the preprocessor's importSource).
|
|
2235
|
+
*/
|
|
2236
|
+
function createFlareResolver(config) {
|
|
2237
|
+
const { packageName } = config;
|
|
2238
|
+
const injectInstruction = config.injectInstruction ?? `Import ${packageName}/inject and pass the @flareapp/electron/renderer instance instead.`;
|
|
2239
|
+
let defaultProvider = null;
|
|
2240
|
+
function registerDefaultFlare(provider) {
|
|
2241
|
+
if (typeof window !== "undefined" && window.__flare) {
|
|
2242
|
+
const message = `[flare] ${packageName} (web root) was imported in a renderer where the Electron bridge is present, pulling the keyed @flareapp/js singleton into the renderer. ` + injectInstruction;
|
|
2243
|
+
if (isDevMode()) throw new Error(message);
|
|
2244
|
+
console.warn(message);
|
|
2245
|
+
}
|
|
2246
|
+
defaultProvider = provider;
|
|
2247
|
+
}
|
|
2248
|
+
function resolveFlare(explicit) {
|
|
2249
|
+
if (explicit) return explicit;
|
|
2250
|
+
if (defaultProvider) return defaultProvider();
|
|
2251
|
+
throw new Error(`[flare] No Flare instance available. Pass \`flare\` (e.g. from @flareapp/electron/renderer), or import ${packageName} (the package root) to use the @flareapp/js default singleton.`);
|
|
2252
|
+
}
|
|
2253
|
+
return {
|
|
2254
|
+
registerDefaultFlare,
|
|
2255
|
+
resolveFlare
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
//#endregion
|
|
2260
|
+
//#region src/browser/catchWindowErrors.ts
|
|
2261
|
+
/**
|
|
2262
|
+
* Wire up global `error` and `unhandledrejection` listeners, routing reports through `window.flare`
|
|
2263
|
+
* (assigned by Flare.light()/configure()). Events are dropped, not queued, when the global is absent.
|
|
2264
|
+
*/
|
|
2265
|
+
function catchWindowErrors() {
|
|
2266
|
+
if (typeof window === "undefined") return;
|
|
2267
|
+
window.addEventListener("error", (event) => {
|
|
2268
|
+
const flare = window.flare;
|
|
2269
|
+
if (!flare) return;
|
|
2270
|
+
if (event.error instanceof Error) flare.reportSilently(event.error);
|
|
2271
|
+
});
|
|
2272
|
+
window.addEventListener("unhandledrejection", (event) => {
|
|
2273
|
+
const flare = window.flare;
|
|
2274
|
+
if (!flare) return;
|
|
2275
|
+
routeRejection(flare, event.reason);
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
//#endregion
|
|
2280
|
+
//#region src/tracing/componentProfiler.ts
|
|
2281
|
+
/** Unix nanos on the same clock the tracer uses for span timestamps. */
|
|
2282
|
+
const nowNano = defaultNowNano;
|
|
2283
|
+
/**
|
|
2284
|
+
* Reserved up front so descendants can point at a span before it is recorded. Null when the trace is at
|
|
2285
|
+
* its span cap: descendants record before this span does, so an id the cap will refuse orphans them.
|
|
2286
|
+
*/
|
|
2287
|
+
function reserveSpanId(traceId) {
|
|
2288
|
+
if (traceId !== void 0 && !activeTracingFlare()?.tracer.claimSpanSlot(traceId)) return null;
|
|
2289
|
+
return spanId();
|
|
2290
|
+
}
|
|
2291
|
+
/** The root a top-level component nests under. Null when tracing is off or no root is recording. */
|
|
2292
|
+
function activeComponentRoot() {
|
|
2293
|
+
try {
|
|
2294
|
+
const root = activeTracingFlare()?.tracer.getActiveSpan();
|
|
2295
|
+
if (!root || !root.isRecording) return null;
|
|
2296
|
+
return {
|
|
2297
|
+
traceId: root.traceId,
|
|
2298
|
+
parentSpanId: root.spanId
|
|
2299
|
+
};
|
|
2300
|
+
} catch {
|
|
2301
|
+
return null;
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* An ancestor's context is only usable while it still belongs to the live trace. A profiled component
|
|
2306
|
+
* that survives a navigation (a layout around a swapped page body) froze its context under the pageload
|
|
2307
|
+
* trace, and `recordComponentSpan` would drop anything pointing at that closed root.
|
|
2308
|
+
*/
|
|
2309
|
+
function resolveComponentParent(inherited, live) {
|
|
2310
|
+
if (inherited && live && inherited.traceId === live.traceId) return inherited;
|
|
2311
|
+
return live;
|
|
2312
|
+
}
|
|
2313
|
+
/**
|
|
2314
|
+
* Records only while the reserved root is still the live recording root, and drops the span otherwise.
|
|
2315
|
+
* Dropping avoids starting a fresh TraceState for a dead trace, which would re-run the sampler, and
|
|
2316
|
+
* avoids adding a child to a root that already shipped.
|
|
2317
|
+
*/
|
|
2318
|
+
function recordComponentSpan(record) {
|
|
2319
|
+
try {
|
|
2320
|
+
const flare = activeTracingFlare();
|
|
2321
|
+
if (!flare) return;
|
|
2322
|
+
const root = flare.tracer.getActiveSpan();
|
|
2323
|
+
if (!root || root.traceId !== record.parent.traceId || !root.isRecording) return;
|
|
2324
|
+
flare.startSpan(record.name, {
|
|
2325
|
+
spanId: record.spanId,
|
|
2326
|
+
parent: {
|
|
2327
|
+
traceId: record.parent.traceId,
|
|
2328
|
+
spanId: record.parent.parentSpanId
|
|
2329
|
+
},
|
|
2330
|
+
spanType: BrowserSpanType.Component,
|
|
2331
|
+
startTimeUnixNano: record.startTimeUnixNano,
|
|
2332
|
+
attributes: {
|
|
2333
|
+
...record.attributes,
|
|
2334
|
+
"flare.component.name": record.name
|
|
2335
|
+
},
|
|
2336
|
+
claimed: true
|
|
2337
|
+
}).end(record.endTimeUnixNano);
|
|
2338
|
+
} catch {}
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
//#endregion
|
|
2342
|
+
export { safeInvoke as C, BrowserFlushScheduler as D, collectBrowser as E, insulate as S, FetchFileReader as T, unpatchXHR as _, resolveComponentParent as a, BrowserSpanType as b, registerNavigationSource as c, currentPath as d, resolveHref as f, instrumentXHR as g, absoluteUrl as h, reserveSpanId as i, startBrowserTracing as l, absoluteHref as m, nowNano as n, catchWindowErrors as o, routeName as p, recordComponentSpan as r, createFlareResolver as s, activeComponentRoot as t, stopBrowserTracing as u, instrumentFetch as v, CLIENT_VERSION as w, instrumentOnce as x, unpatchFetch as y };
|