@flareapp/js 2.8.0 → 2.9.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.
@@ -1,126 +1,138 @@
1
- import { BrowserSpanType, SpanStatusCode, buildTraceparent, defaultNowNano, redactUrlQuery, routeRejection, spanId, urlAttributes } from "@flareapp/core";
1
+ import { BrowserSpanEventType, BrowserSpanType, BrowserSpanType as BrowserSpanType$1, SpanStatusCode, breadcrumbUrl, buildTraceparent, defaultNowNano, redactUrlQuery, routeRejection, spanId, urlAttributes } from "@flareapp/core";
2
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
- };
3
+ //#region src/breadcrumbs/utils/documentEvent.ts
4
+ function onDocumentEvent(name, handle) {
5
+ document.addEventListener(name, handle, true);
6
+ return () => document.removeEventListener(name, handle, true);
7
+ }
13
8
 
14
9
  //#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 };
10
+ //#region src/breadcrumbs/utils/elementSelector.ts
11
+ const INTERACTIVE_HTML_ELEMENTS = "button, a, input, select, textarea, label, [role], [tabindex], [onclick]";
12
+ const MAX_ANCESTOR_DEPTH = 5;
13
+ function interactiveTarget(target) {
14
+ let element = target;
15
+ for (let depth = 0; element && depth < MAX_ANCESTOR_DEPTH; depth++) {
16
+ if (element.matches?.(INTERACTIVE_HTML_ELEMENTS)) return element;
17
+ element = element.parentElement;
18
+ }
19
+ return target;
20
+ }
21
+ function elementSelector(element) {
22
+ let selector = element.tagName.toLowerCase();
23
+ if (element.id) selector += `#${element.id}`;
24
+ for (const className of element.classList) selector += `.${className}`;
25
+ return selector;
26
+ }
27
+ function elementTestId(element) {
28
+ return element.getAttribute?.("data-testid") ?? void 0;
29
+ }
30
+ function elementAttributes(element) {
31
+ const attributes = { "browser.element.selector": elementSelector(element) };
32
+ const testId = elementTestId(element);
33
+ if (testId) attributes["browser.element.test_id"] = testId;
34
+ return attributes;
34
35
  }
35
36
 
36
37
  //#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
- }
38
+ //#region src/breadcrumbs/ClickRecorder.ts
39
+ var ClickRecorder = class {
40
+ type = BrowserSpanEventType.Click;
41
+ constructor(host) {
42
+ this.host = host;
43
+ this.onClick = this.onClick.bind(this);
44
+ }
45
+ install() {
46
+ return onDocumentEvent("click", this.onClick);
47
+ }
48
+ onClick(event) {
49
+ const target = event.target;
50
+ if (!(target instanceof Element)) return;
51
+ this.host.record(this.type, elementAttributes(interactiveTarget(target)), defaultNowNano());
52
+ }
53
+ };
51
54
 
52
55
  //#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
- }
56
+ //#region src/breadcrumbs/FormChangeRecorder.ts
57
+ var FormChangeRecorder = class {
58
+ type = BrowserSpanEventType.Input;
59
+ constructor(host) {
60
+ this.host = host;
61
+ this.onChange = this.onChange.bind(this);
62
+ }
63
+ install() {
64
+ return onDocumentEvent("change", this.onChange);
65
+ }
66
+ onChange(event) {
67
+ const target = event.target;
68
+ if (!(target instanceof Element)) return;
69
+ this.host.record(this.type, elementAttributes(target), defaultNowNano());
66
70
  }
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
71
  };
77
72
 
78
73
  //#endregion
79
- //#region src/tracing/internalRequest.ts
74
+ //#region src/tracing/utils/absoluteHref.ts
80
75
  /**
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.
76
+ * Resolve a router-reported href against the page we are on. Returns the `URL`, so a caller that
77
+ * wants the pathname as well as the href does not parse it a second time.
85
78
  *
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.
79
+ * Undefined outside a browser or for an unparseable href, so the caller can leave its attribute alone.
88
80
  */
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
- };
81
+ function absoluteUrl(href) {
82
+ if (href == null || typeof window === "undefined") return;
83
+ try {
84
+ return new URL(href, window.location.href);
85
+ } catch {
86
+ return;
87
+ }
96
88
  }
97
- function isInternalRequest(init) {
98
- return init?.[INTERNAL_REQUEST_KEY] === true;
89
+ /**
90
+ * The href form of `absoluteUrl`. Pass one built by the router's own `createHref`/`resolve` (see
91
+ * `resolveHref`), not a bare path: routers strip the app's base path, so `origin + path` yields an
92
+ * address the server does not have.
93
+ */
94
+ function absoluteHref(href) {
95
+ return absoluteUrl(href)?.href;
99
96
  }
100
97
 
101
98
  //#endregion
102
- //#region src/browser/FetchFileReader.ts
99
+ //#region src/instrumentation/navigation/utils.ts
100
+ function currentPath() {
101
+ return typeof location !== "undefined" ? location.pathname : "";
102
+ }
103
+ function currentHref() {
104
+ return typeof location !== "undefined" ? location.href : "";
105
+ }
106
+ function routeName(derive, fallbackPath, url) {
107
+ try {
108
+ const name = derive();
109
+ if (name) return {
110
+ name,
111
+ source: "route",
112
+ url
113
+ };
114
+ } catch {}
115
+ return {
116
+ name: fallbackPath,
117
+ source: "url",
118
+ url
119
+ };
120
+ }
103
121
  /**
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.
122
+ * `build` is the router's own href builder (vue-router `resolve`, React Router `createHref`). It puts
123
+ * the app's base path and hash prefix back. Without it, an app served from `/app/` reports
124
+ * `/product/p01` instead of `/app/product/p01`. If `build` throws, we use `fallback`.
107
125
  */
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" : "?";
126
+ function resolveHref(build, fallbackHref) {
127
+ let href = fallbackHref;
128
+ try {
129
+ href = build() ?? fallbackHref;
130
+ } catch {}
131
+ return absoluteHref(href);
132
+ }
121
133
 
122
134
  //#endregion
123
- //#region src/tracing/fill.ts
135
+ //#region src/tracing/utils/fill.ts
124
136
  /**
125
137
  * Replace `source[name]` with `replacer(original)`, tagging the wrapper with a
126
138
  * non-enumerable `__flare_original__` so the patch is idempotent and reversible.
@@ -147,275 +159,237 @@ function unfill(source, name) {
147
159
  }
148
160
 
149
161
  //#endregion
150
- //#region src/tracing/createPatcher.ts
162
+ //#region src/instrumentation/navigation/navigationBus.ts
163
+ const subscribers$1 = /* @__PURE__ */ new Set();
164
+ let source = null;
165
+ let currentRoute = null;
166
+ let lastPath = "";
167
+ let uninstallHistory = null;
168
+ function broadcast(callback) {
169
+ for (const subscriber of subscribers$1) try {
170
+ callback(subscriber);
171
+ } catch {}
172
+ }
173
+ function onHistoryChange() {
174
+ if (!uninstallHistory) return;
175
+ const path = currentPath();
176
+ if (path === lastPath) return;
177
+ lastPath = path;
178
+ if (source) return;
179
+ broadcast((subscriber) => subscriber.onUrlChanged?.(path));
180
+ }
181
+ function installHistory() {
182
+ if (uninstallHistory) return;
183
+ if (typeof window === "undefined" || typeof history === "undefined" || typeof location === "undefined") return;
184
+ lastPath = currentPath();
185
+ function wrapHistoryMethod(original) {
186
+ return function(...args) {
187
+ const result = original.apply(this, args);
188
+ onHistoryChange();
189
+ return result;
190
+ };
191
+ }
192
+ fill(history, "pushState", wrapHistoryMethod);
193
+ fill(history, "replaceState", wrapHistoryMethod);
194
+ window.addEventListener("popstate", onHistoryChange);
195
+ uninstallHistory = () => {
196
+ unfill(history, "pushState");
197
+ unfill(history, "replaceState");
198
+ window.removeEventListener("popstate", onHistoryChange);
199
+ };
200
+ }
201
+ function subscribeToNavigation(subscriber) {
202
+ if (subscribers$1.size === 0) installHistory();
203
+ subscribers$1.add(subscriber);
204
+ if (currentRoute) try {
205
+ subscriber.onRouteName?.(currentRoute.route, currentRoute.owner);
206
+ } catch {}
207
+ let removed = false;
208
+ return () => {
209
+ if (removed) return;
210
+ removed = true;
211
+ subscribers$1.delete(subscriber);
212
+ if (subscribers$1.size === 0) {
213
+ uninstallHistory?.();
214
+ uninstallHistory = null;
215
+ lastPath = "";
216
+ }
217
+ };
218
+ }
151
219
  /**
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).
220
+ * Hands navigation to a framework router: while registered, the built-in History detection stays
221
+ * quiet and the router drives every step through the returned handle. The newest registration wins
222
+ * and a stale handle no-ops, because HMR can replace a router that still holds one.
157
223
  */
158
- function createPatcher() {
159
- let installed = false;
160
- let names = [];
224
+ function registerNavigationSource() {
225
+ const token = {};
226
+ if (source) console.debug("Flare: navigation source replaced");
227
+ source = token;
228
+ const active = () => source === token;
161
229
  return {
162
- get installed() {
163
- return installed;
230
+ startNavigation(opts) {
231
+ if (!active()) return;
232
+ const path = opts?.path ?? currentPath();
233
+ lastPath = path;
234
+ broadcast((subscriber) => subscriber.onNavigationStart?.({
235
+ path,
236
+ url: opts?.url,
237
+ hold: opts?.hold
238
+ }));
164
239
  },
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;
240
+ setActiveRouteName(route) {
241
+ if (!active()) return;
242
+ currentRoute = {
243
+ route,
244
+ owner: token
245
+ };
246
+ broadcast((subscriber) => subscriber.onRouteName?.(route, token));
174
247
  },
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;
248
+ settleNavigation(route) {
249
+ if (!active()) return;
250
+ currentRoute = {
251
+ route,
252
+ owner: token
253
+ };
254
+ broadcast((subscriber) => subscriber.onNavigationSettle?.(route, token));
255
+ },
256
+ unregister() {
257
+ if (!active()) return;
258
+ broadcast((subscriber) => subscriber.onSourceUnregistered?.());
259
+ source = null;
260
+ currentRoute = null;
261
+ lastPath = currentPath();
183
262
  }
184
263
  };
185
264
  }
265
+ /** A name from an earlier call is only valid while that source is still registered. */
266
+ function isActiveNavigationSource(token) {
267
+ return token !== null && source === token;
268
+ }
186
269
 
187
270
  //#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));
271
+ //#region src/breadcrumbs/NavigationRecorder.ts
272
+ var NavigationRecorder = class {
273
+ type = BrowserSpanEventType.RouteChange;
274
+ previousHref = "";
275
+ constructor(host) {
276
+ this.host = host;
277
+ this.onUrlChanged = this.onUrlChanged.bind(this);
278
+ this.onNavigationSettle = this.onNavigationSettle.bind(this);
194
279
  }
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])]);
280
+ install() {
281
+ this.record(currentHref());
282
+ return subscribeToNavigation({
283
+ onUrlChanged: this.onUrlChanged,
284
+ onNavigationSettle: this.onNavigationSettle
285
+ });
286
+ }
287
+ onUrlChanged() {
288
+ this.record(currentHref());
289
+ }
290
+ onNavigationSettle(route) {
291
+ this.record(route.url ?? currentHref(), route);
292
+ }
293
+ record(href, route) {
294
+ const attributes = { "browser.route.to": this.clean(href) };
295
+ if (this.previousHref) attributes["browser.route.from"] = this.clean(this.previousHref);
296
+ if (route?.source === "route") {
297
+ attributes["flare.entry_point.handler.identifier"] = route.name;
298
+ attributes["flare.route.source"] = route.source;
207
299
  }
208
- return pairs;
209
- } catch {
210
- return null;
300
+ this.previousHref = href;
301
+ this.host.record(this.type, attributes, defaultNowNano());
302
+ }
303
+ clean(href) {
304
+ return breadcrumbUrl(href, this.host.config().urlDenylist);
211
305
  }
306
+ };
307
+
308
+ //#endregion
309
+ //#region src/instrumentation/requests/requestBus.ts
310
+ const subscribers = /* @__PURE__ */ new Set();
311
+ let mutator = null;
312
+ function subscribeToRequests(subscriber) {
313
+ subscribers.add(subscriber);
314
+ return () => {
315
+ subscribers.delete(subscriber);
316
+ };
212
317
  }
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";
318
+ function claimRequestMutation(owner) {
319
+ if (mutator !== null) console.warn(`%c FLARE %c
320
+
321
+ What: two things tried to add headers to outgoing requests. Only one can.
322
+
323
+ Why it matters: the first one stopped. Requests can now go out without a traceparent header, so Flare cannot link a browser request to its server trace.
324
+
325
+ How to fix: use one Flare instance, and check your bundle for two copies of @flareapp/js.`, "background:#e11d48;color:#fff;font-weight:bold;font-size:14px;padding:2px 6px", "color:#e11d48;font-size:13px");
326
+ mutator = owner;
327
+ return () => {
328
+ if (mutator === owner) mutator = null;
329
+ };
330
+ }
331
+ function hasRequestSubscribers() {
332
+ return subscribers.size > 0 || mutator !== null;
216
333
  }
217
334
  /**
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.
335
+ * Tells every subscriber a request is about to go out. Returns the (possibly mutated) `init` and
336
+ * `headers` plus one `settle` callback that fans the result out to every subscriber. Returns null
337
+ * when nothing acted on the request; the wrapper must then call the real fetch or send untouched.
338
+ * A subscriber that throws is skipped, so instrumentation never breaks the app's request.
222
339
  */
223
- function mergeTraceparentHeader(input, init, traceparent) {
224
- const source = init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : void 0);
340
+ function publishRequestStart(start) {
341
+ const handlers = [];
342
+ for (const subscriber of subscribers) try {
343
+ const handler = subscriber(start);
344
+ if (handler) handlers.push(handler);
345
+ } catch {}
346
+ let init = start.init;
225
347
  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) {
348
+ if (mutator) try {
349
+ const handler = mutator(start);
350
+ if (handler) {
351
+ handlers.push(handler);
352
+ if (handler.init !== void 0) init = handler.init;
353
+ headers = handler.headers;
354
+ }
355
+ } catch {}
356
+ if (handlers.length === 0 && init === start.init && headers === void 0) return null;
309
357
  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) } : {}
358
+ init,
359
+ headers,
360
+ settle(result) {
361
+ for (const handler of handlers) try {
362
+ handler.onSettle?.(result);
363
+ } catch {}
364
+ }
314
365
  };
315
366
  }
367
+
368
+ //#endregion
369
+ //#region src/tracing/requests/internalRequest.ts
316
370
  /**
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.
371
+ * Marks a request the SDK makes for its own bookkeeping (right now: fetching a source file so a
372
+ * stack frame can show a code snippet). The fetch patch passes those straight through: they are
373
+ * not the app's traffic, so tracing them puts a span in the customer's waterfall for a request
374
+ * their code never made, and propagating a `traceparent` on them is just as wrong.
345
375
  *
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.
376
+ * Flare's ingest calls are excluded by URL instead (`isFlareIngestUrl`), because their endpoints
377
+ * are known up front. A snippet fetch targets the customer's own asset, so only the caller knows.
348
378
  */
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;
379
+ const INTERNAL_REQUEST_KEY = "__flare_internal_request__";
380
+ /** An init that marks the request as Flare's own. Unknown init keys are ignored by `fetch`. */
381
+ function internalRequestInit(init) {
357
382
  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 {}
383
+ ...init,
384
+ [INTERNAL_REQUEST_KEY]: true
374
385
  };
375
386
  }
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;
387
+ function isInternalRequest(init) {
388
+ return init?.[INTERNAL_REQUEST_KEY] === true;
415
389
  }
416
390
 
417
391
  //#endregion
418
- //#region src/tracing/supportsNativeFetch.ts
392
+ //#region src/tracing/requests/supportsNativeFetch.ts
419
393
  /** True if `fn` is the browser's native fetch (not a polyfill/wrapper). */
420
394
  function isNativeFetch(fn) {
421
395
  return typeof fn === "function" && /native code/.test(Function.prototype.toString.call(fn));
@@ -452,7 +426,42 @@ function supportsNativeFetch() {
452
426
  }
453
427
 
454
428
  //#endregion
455
- //#region src/tracing/instrumentFetch.ts
429
+ //#region src/tracing/utils/createPatcher.ts
430
+ /**
431
+ * One `installed` flag for the whole set, not one per method: `open` remembers the URL that `send`
432
+ * reads, so a half patched set is broken.
433
+ */
434
+ function createPatcher() {
435
+ let installed = false;
436
+ let names = [];
437
+ return {
438
+ get installed() {
439
+ return installed;
440
+ },
441
+ install(target, patches) {
442
+ if (installed) return;
443
+ function applyOne(name) {
444
+ const wrap = patches[name];
445
+ if (wrap) fill(target, name, wrap);
446
+ }
447
+ names = Object.keys(patches);
448
+ for (const name of names) applyOne(name);
449
+ installed = true;
450
+ },
451
+ uninstall(target) {
452
+ if (!installed) return;
453
+ if (!names.every((name) => {
454
+ const current = target[name];
455
+ return typeof current !== "function" || Boolean(current.__flare_original__);
456
+ })) return;
457
+ for (const name of names) unfill(target, name);
458
+ installed = false;
459
+ }
460
+ };
461
+ }
462
+
463
+ //#endregion
464
+ //#region src/instrumentation/requests/instrumentFetch.ts
456
465
  function resolveRequest(input, init) {
457
466
  let url;
458
467
  let method = init?.method;
@@ -465,113 +474,79 @@ function resolveRequest(input, init) {
465
474
  url
466
475
  };
467
476
  }
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) {
477
+ function createFetchWrapper(original) {
474
478
  return function(input, init) {
475
479
  const call = (i) => original.call(this, input, i);
476
- let started = null;
477
- let url = "";
478
- let passthrough = false;
480
+ let watched = null;
479
481
  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
482
+ if (hasRequestSubscribers() && !isInternalRequest(init)) {
483
+ const request = resolveRequest(input, init);
484
+ watched = publishRequestStart({
485
+ kind: "fetch",
486
+ method: request.method,
487
+ url: request.url,
488
+ input,
489
+ init
489
490
  });
490
491
  }
491
492
  } 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;
493
+ watched = null;
502
494
  }
503
- const endSpan = insulate((response) => endHttpRequestSpan(span, response.status));
504
- const failSpan = insulate((error) => finishHttpSpanError(span, error));
495
+ if (!watched) return call(init);
496
+ const settle = watched.settle;
505
497
  const finishError = (error) => {
506
- failSpan(error);
498
+ settle({ error });
507
499
  return Promise.reject(error);
508
500
  };
509
501
  let promise;
510
502
  try {
511
- promise = call(finalInit);
503
+ promise = call(watched.init);
512
504
  } catch (error) {
513
505
  return finishError(error);
514
506
  }
515
507
  return promise.then((response) => {
516
- endSpan(response);
508
+ settle({ status: response.status });
517
509
  return response;
518
510
  }, finishError);
519
511
  };
520
512
  }
521
513
  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) {
514
+ function instrumentFetch() {
528
515
  if (patcher$1.installed) return;
529
516
  const globals = globalThis;
530
517
  if (typeof globals.fetch !== "function") return;
531
518
  if (!supportsNativeFetch()) return;
532
- const urls = browserUrlContext();
533
- patcher$1.install(globals, { fetch: (original) => createFetchWrapper(tracer, original, urls) });
519
+ patcher$1.install(globals, { fetch: (original) => createFetchWrapper(original) });
534
520
  }
535
- /** Restore the original global `fetch`. Safe if never patched. */
536
521
  function unpatchFetch() {
537
522
  patcher$1.uninstall(globalThis);
538
523
  }
539
524
 
540
525
  //#endregion
541
- //#region src/tracing/instrumentXHR.ts
526
+ //#region src/instrumentation/requests/instrumentXHR.ts
542
527
  const XHR_DONE = 4;
543
528
  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
529
  function releaseRequestRefs(state) {
550
- state.span = void 0;
530
+ state.watched = void 0;
551
531
  state.onDone = void 0;
552
532
  }
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
- */
533
+ function settleOnce(state, result) {
534
+ state.ended = true;
535
+ state.watched?.settle(result);
536
+ releaseRequestRefs(state);
537
+ }
558
538
  function createXHROpen(original) {
559
539
  return function(method, url, ...rest) {
560
540
  const prior = xhrState.get(this);
561
- if (prior && prior.span && !prior.ended) {
562
- prior.ended = true;
541
+ if (prior && prior.watched && !prior.ended) {
563
542
  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);
543
+ settleOnce(prior, { aborted: true });
569
544
  }
570
545
  try {
571
546
  if (method && url != null) xhrState.set(this, {
572
547
  method: String(method).toUpperCase(),
573
548
  url: String(url),
574
- hasAppTraceparent: false,
549
+ appHeaders: /* @__PURE__ */ new Set(),
575
550
  ended: false
576
551
  });
577
552
  else xhrState.delete(this);
@@ -585,69 +560,50 @@ function createXHROpen(original) {
585
560
  ]);
586
561
  };
587
562
  }
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
563
  function createXHRSetRequestHeader(original) {
594
564
  return function(name, value) {
595
565
  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
- }
566
+ if (typeof name === "string") xhrState.get(this)?.appHeaders.add(name.toLowerCase());
600
567
  };
601
568
  }
602
- function setTraceparentHeader(xhr, traceparent) {
603
- if (!traceparent) return;
604
- try {
605
- xhr.setRequestHeader("traceparent", traceparent);
606
- } catch {}
569
+ function applyHeaders(xhr, state) {
570
+ const headers = state.watched?.headers;
571
+ if (!headers) return;
572
+ for (const [name, value] of Object.entries(headers)) {
573
+ if (state.appHeaders.has(name.toLowerCase())) continue;
574
+ try {
575
+ xhr.setRequestHeader(name, value);
576
+ } catch {}
577
+ }
607
578
  }
608
- /** Patch `send` to open the span, inject `traceparent`, and end on `readyState === 4`. */
609
- function createXHRSend(tracer, original, urls) {
579
+ function createXHRSend(original) {
610
580
  return function(body) {
611
581
  const send = () => original.call(this, body);
612
- const config = tracer.config;
613
582
  const state = xhrState.get(this);
614
- if (!config.enableTracing || !state) return send();
583
+ if (!state || !hasRequestSubscribers()) return send();
615
584
  if (state.ended) return send();
616
- let started = null;
585
+ let watched = null;
617
586
  try {
618
- started = startHttpRequestSpan(tracer, {
587
+ watched = publishRequestStart({
588
+ kind: "xhr",
619
589
  method: state.method,
620
- url: state.url,
621
- urls,
622
- spanType: BrowserSpanType.Xhr
590
+ url: state.url
623
591
  });
624
592
  } 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);
593
+ watched = null;
636
594
  }
595
+ if (!watched) return send();
596
+ state.watched = watched;
597
+ applyHeaders(this, state);
637
598
  const onDone = () => {
638
599
  if (this.readyState !== XHR_DONE) return;
639
600
  this.removeEventListener("readystatechange", onDone);
640
601
  if (state.ended) return;
641
- state.ended = true;
642
602
  let status = 0;
643
603
  try {
644
604
  status = this.status;
645
605
  } catch {}
646
- try {
647
- const zeroIsError = absoluteUrl !== null && (absoluteUrl.protocol === "http:" || absoluteUrl.protocol === "https:");
648
- endHttpRequestSpan(span, status, { zeroIsError });
649
- } catch {}
650
- releaseRequestRefs(state);
606
+ settleOnce(state, { status });
651
607
  };
652
608
  this.addEventListener("readystatechange", onDone);
653
609
  state.onDone = onDone;
@@ -655,35 +611,24 @@ function createXHRSend(tracer, original, urls) {
655
611
  return send();
656
612
  } catch (error) {
657
613
  this.removeEventListener("readystatechange", onDone);
658
- state.ended = true;
659
- try {
660
- finishHttpSpanError(span, error);
661
- } catch {}
662
- releaseRequestRefs(state);
614
+ settleOnce(state, { error });
663
615
  throw error;
664
616
  }
665
617
  };
666
618
  }
667
619
  const patcher = createPatcher();
668
620
  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) {
621
+ function instrumentXHR() {
675
622
  if (patcher.installed) return;
676
623
  const xhrConstructor = globalThis.XMLHttpRequest;
677
624
  if (typeof xhrConstructor !== "function" || !xhrConstructor.prototype) return;
678
- const urls = browserUrlContext();
679
625
  patcher.install(xhrConstructor.prototype, {
680
626
  open: (original) => createXHROpen(original),
681
627
  setRequestHeader: (original) => createXHRSetRequestHeader(original),
682
- send: (original) => createXHRSend(tracer, original, urls)
628
+ send: (original) => createXHRSend(original)
683
629
  });
684
630
  patchedPrototype = xhrConstructor.prototype;
685
631
  }
686
- /** Restore the original `XMLHttpRequest.prototype` methods. Safe if never patched. */
687
632
  function unpatchXHR() {
688
633
  if (!patchedPrototype) return;
689
634
  patcher.uninstall(patchedPrototype);
@@ -691,286 +636,522 @@ function unpatchXHR() {
691
636
  }
692
637
 
693
638
  //#endregion
694
- //#region src/tracing/absoluteHref.ts
639
+ //#region src/instrumentation/requests/requestPatches.ts
640
+ let subscriptions = 0;
695
641
  /**
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.
642
+ * Keeps fetch and XHR patched while at least one subscriber lives. Counted, so turning tracing off
643
+ * cannot remove a patch that breadcrumbs still need.
698
644
  *
699
- * Undefined outside a browser or for an unparseable href, so the caller can leave its attribute alone.
645
+ * @param subscribe registers one subscriber and returns its own teardown
700
646
  */
701
- function absoluteUrl(href) {
702
- if (href == null || typeof window === "undefined") return;
647
+ function withRequestPatches(subscribe) {
648
+ if (subscriptions === 0) {
649
+ instrumentFetch();
650
+ instrumentXHR();
651
+ }
652
+ subscriptions++;
653
+ const unsubscribe = subscribe();
654
+ let removed = false;
655
+ return () => {
656
+ if (removed) return;
657
+ removed = true;
658
+ unsubscribe();
659
+ subscriptions--;
660
+ if (subscriptions === 0) {
661
+ unpatchFetch();
662
+ unpatchXHR();
663
+ }
664
+ };
665
+ }
666
+
667
+ //#endregion
668
+ //#region src/tracing/requests/propagation.ts
669
+ /** Follows OTel/Sentry `tracePropagationTargets`: same-origin by default, `[]` disables all. */
670
+ function shouldPropagate(url, absoluteUrl, currentOrigin, targets) {
671
+ if (targets) {
672
+ if (targets.length === 0) return false;
673
+ return targets.some((t) => typeof t === "string" ? url.includes(t) : t.test(url));
674
+ }
675
+ return absoluteUrl !== null && absoluteUrl.origin === currentOrigin;
676
+ }
677
+ /** Null on a throwing or malformed entry: the caller then passes the source through untouched, so a
678
+ * bad merge never breaks the host request. */
679
+ function headerPairsFrom(source) {
703
680
  try {
704
- return new URL(href, window.location.href);
681
+ const pairs = [];
682
+ for (const entry of source) {
683
+ if (entry === null || typeof entry !== "object") return null;
684
+ const pair = Array.from(entry);
685
+ if (pair.length !== 2) return null;
686
+ pairs.push([String(pair[0]), String(pair[1])]);
687
+ }
688
+ return pairs;
705
689
  } catch {
706
- return;
690
+ return null;
707
691
  }
708
692
  }
693
+ /** Fetch accepts any iterable of string pairs as HeadersInit (Map, URLSearchParams, cross-realm Headers). */
694
+ function isIterable(value) {
695
+ return value !== null && (typeof value === "object" || typeof value === "function") && typeof value[Symbol.iterator] === "function";
696
+ }
709
697
  /**
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.
698
+ * A new `RequestInit` carrying `traceparent`, without mutating the caller's `Request` or `init`.
699
+ * Caller-wins: a `traceparent` the caller already set is left alone, matching XHR's
700
+ * `hasAppTraceparent` skip. Returning an init rather than a rebuilt `Request` keeps the caller's
701
+ * single-shot body intact.
713
702
  */
714
- function absoluteHref(href) {
715
- return absoluteUrl(href)?.href;
703
+ function mergeTraceparentHeader(input, init, traceparent) {
704
+ const source = init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : void 0);
705
+ let headers;
706
+ if (source instanceof Headers) {
707
+ if (source.has("traceparent")) return init;
708
+ headers = new Headers(source);
709
+ headers.set("traceparent", traceparent);
710
+ } else if (Array.isArray(source)) {
711
+ if (source.some(([k]) => String(k).toLowerCase() === "traceparent")) return init;
712
+ headers = [...source, ["traceparent", traceparent]];
713
+ } else if (isIterable(source)) {
714
+ const pairs = headerPairsFrom(source);
715
+ if (pairs === null) headers = source;
716
+ else if (pairs.some(([k]) => k.toLowerCase() === "traceparent")) return init;
717
+ else headers = [...pairs, ["traceparent", traceparent]];
718
+ } else if (source) {
719
+ if (Object.keys(source).some((k) => k.toLowerCase() === "traceparent")) return init;
720
+ headers = {
721
+ ...source,
722
+ traceparent
723
+ };
724
+ } else headers = { traceparent };
725
+ const result = { headers };
726
+ if (init) {
727
+ const descriptors = Object.getOwnPropertyDescriptors(init);
728
+ delete descriptors.headers;
729
+ Object.defineProperties(result, descriptors);
730
+ }
731
+ if (result.duplex === void 0 && typeof Request !== "undefined" && input instanceof Request && input.body != null) result.duplex = "half";
732
+ return result;
716
733
  }
717
734
 
718
735
  //#endregion
719
- //#region src/browser/context/collectBrowserSpanContext.ts
736
+ //#region src/tracing/requests/httpRequestSpan.ts
737
+ const REQUEST_SPAN_TYPES = {
738
+ fetch: BrowserSpanType.Fetch,
739
+ xhr: BrowserSpanType.Xhr
740
+ };
741
+ const INLINE_SCHEMES = new Set(["data:", "blob:"]);
742
+ /** The real browser context. Falls back to the origin where there is no document (SSR, tests). */
743
+ function browserUrlContext() {
744
+ const origin = globalThis.location?.origin ?? "";
745
+ return {
746
+ origin,
747
+ base: () => globalThis.document?.baseURI || origin
748
+ };
749
+ }
750
+ /** Resolve `url` to an absolute URL against `base`, or null if it cannot be parsed. */
751
+ function safeAbsolute(url, base) {
752
+ try {
753
+ return new URL(url, base || void 0);
754
+ } catch {
755
+ return null;
756
+ }
757
+ }
758
+ let ingestCacheKey = null;
759
+ let ingestCacheHrefs = [];
760
+ function resolvedIngestHrefs(config, base) {
761
+ const raw = [
762
+ config.ingestUrl,
763
+ config.logsIngestUrl,
764
+ config.tracesIngestUrl
765
+ ];
766
+ const key = `${base} ${raw.join(" ")}`;
767
+ if (key !== ingestCacheKey) {
768
+ ingestCacheKey = key;
769
+ ingestCacheHrefs = raw.filter((u) => typeof u === "string" && u.length > 0).map((u) => safeAbsolute(u, base)).filter((u) => u !== null).map((u) => u.href);
770
+ }
771
+ return ingestCacheHrefs;
772
+ }
773
+ function matchesIngestHref(href, ingestHref) {
774
+ if (!href.startsWith(ingestHref)) return false;
775
+ const next = href.charAt(ingestHref.length);
776
+ return next === "" || next === "/" || next === "?" || next === "#";
777
+ }
720
778
  /**
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.
779
+ * True when `resolved` targets one of Flare's own ingest endpoints (never traced). The configured
780
+ * URLs are resolved against `base` first: a relative one (a customer proxying ingest through their
781
+ * own origin) would otherwise never match, so every flush POST would open a span that arms the next
782
+ * flush, forever.
728
783
  */
729
- function collectBrowserSpanContext(config, hrefOverride) {
730
- if (typeof window === "undefined") return {};
731
- const url = absoluteUrl(hrefOverride);
784
+ function isFlareIngestUrl(resolved, config, base) {
785
+ if (!resolved) return false;
786
+ return resolvedIngestHrefs(config, base).some((ingestHref) => matchesIngestHref(resolved.href, ingestHref));
787
+ }
788
+ /**
789
+ * Shared request-span attributes for a fetch/XHR call. The `url.*` attributes are redacted the same
790
+ * way as error reports, so tokens and reset codes never leak.
791
+ */
792
+ function requestSpanAttributes(method, resolved, url, config) {
732
793
  return {
733
- ...browserEntryPoint(config, url),
734
- ...request(config.urlDenylist, url?.href)
794
+ "http.request.method": method,
795
+ ...urlAttributes(resolved ? resolved.href : url, config.urlDenylist),
796
+ ...resolved ? { "server.address": resolved.hostname } : {},
797
+ ...resolved && resolved.port ? { "server.port": Number(resolved.port) } : {}
735
798
  };
736
799
  }
737
800
  /**
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`.
801
+ * Completion mapping shared by fetch and XHR: record the status and mark an error on 5xx.
802
+ * `zeroIsError` additionally maps status 0 to error. XHR passes it only for http(s), where status
803
+ * 0 at DONE is always a network/CORS failure or abort; file:// and custom schemes return 0 on
804
+ * success, so it isn't set there. Fetch never passes it (an opaque no-cors response is 0, not error).
805
+ */
806
+ function endHttpRequestSpan(span, status, opts) {
807
+ span.setAttribute("http.response.status_code", status);
808
+ if (status >= 500 || opts?.zeroIsError && status === 0) span.setStatus({ code: SpanStatusCode.Error });
809
+ span.end();
810
+ }
811
+ function finishHttpSpanError(span, error) {
812
+ span.setStatus({
813
+ code: SpanStatusCode.Error,
814
+ message: error instanceof Error ? error.message : String(error)
815
+ });
816
+ span.end();
817
+ }
818
+ /**
819
+ * Propagation gate plus `traceparent` build shared by fetch and XHR. Returns null when
820
+ * `shouldPropagate` rejects the URL (caller then skips header injection).
821
+ */
822
+ function traceparentFor(span, resolved, url, origin, config) {
823
+ if (!shouldPropagate(resolved ? resolved.href : url, resolved, origin, config.tracePropagationTargets)) return null;
824
+ return buildTraceparent(span.traceId, span.spanId, span.isRecording);
825
+ }
826
+ /**
827
+ * Open a request span for one outgoing fetch or XHR call. Null means the URL is one of Flare's own
828
+ * ingest endpoints, so the caller passes the request through untraced.
743
829
  *
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.
830
+ * `absoluteUrl` comes back with the span because both callers need it afterwards: for the traceparent
831
+ * gate, and for XHR's http(s)-only status-0 rule.
746
832
  */
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);
833
+ function startHttpRequestSpan(tracer, request) {
834
+ const { method, url, urls, spanType } = request;
835
+ const config = tracer.config;
836
+ const base = urls.base();
837
+ const resolved = safeAbsolute(url, base);
838
+ if (isFlareIngestUrl(resolved, config, base)) return null;
839
+ if (resolved && INLINE_SCHEMES.has(resolved.protocol)) return null;
840
+ const pathname = resolved ? resolved.pathname : url;
752
841
  return {
753
- "url.query": "",
754
- ...attributes,
755
- "flare.entry_point.value": attributes["url.full"]
842
+ span: tracer.startSpan(`${method} ${pathname}`, {
843
+ spanType,
844
+ attributes: requestSpanAttributes(method, resolved, url, config)
845
+ }),
846
+ absoluteUrl: resolved
756
847
  };
757
848
  }
758
849
 
759
850
  //#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
- };
851
+ //#region src/tracing/requests/traceRequests.ts
767
852
  /**
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.
853
+ * For http and https, status 0 at DONE means the request got no response.
854
+ *
855
+ * Other schemes return 0 when they succeed. file:// does, and so do custom ones like Electron's
856
+ * registerFileProtocol. A URL we could not parse is not an error either.
771
857
  */
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;
858
+ function zeroIsError(absoluteUrl) {
859
+ return absoluteUrl !== null && (absoluteUrl.protocol === "http:" || absoluteUrl.protocol === "https:");
860
+ }
861
+ function propagate(span, absoluteUrl, start, urls, tracer) {
862
+ const traceparent = traceparentFor(span, absoluteUrl, start.url, urls.origin, tracer.config);
863
+ if (!traceparent) return {};
864
+ if (start.kind === "xhr") return { headers: { traceparent } };
865
+ if (start.input === void 0) return {};
866
+ return { init: mergeTraceparentHeader(start.input, start.init, traceparent) };
867
+ }
868
+ /** Tracing takes the mutation slot, not a plain subscription, because it adds a `traceparent` header. */
869
+ function traceRequests(tracer, urls) {
870
+ return claimRequestMutation((start) => {
871
+ if (!tracer.config.enableTracing) return;
872
+ const started = startHttpRequestSpan(tracer, {
873
+ method: start.method,
874
+ url: start.url,
875
+ urls,
876
+ spanType: REQUEST_SPAN_TYPES[start.kind]
877
+ });
878
+ if (!started) return;
879
+ const { span, absoluteUrl } = started;
880
+ let mutated = {};
881
+ try {
882
+ mutated = propagate(span, absoluteUrl, start, urls, tracer);
883
+ } catch {
884
+ mutated = {};
820
885
  }
821
- this.onChildEnded(span);
822
- }
823
- onChildStarted() {
824
- this.openChildren++;
825
- this.clearIdle();
826
- if (this.openChildren === 1) this.armChildTimeout();
886
+ return {
887
+ ...mutated,
888
+ onSettle({ status, error, aborted }) {
889
+ if (aborted) {
890
+ span.setStatus({ code: SpanStatusCode.Error });
891
+ span.end();
892
+ return;
893
+ }
894
+ if (error !== void 0) {
895
+ finishHttpSpanError(span, error);
896
+ return;
897
+ }
898
+ if (start.kind === "xhr") {
899
+ endHttpRequestSpan(span, status ?? 0, { zeroIsError: zeroIsError(absoluteUrl) });
900
+ return;
901
+ }
902
+ endHttpRequestSpan(span, status ?? 0);
903
+ }
904
+ };
905
+ });
906
+ }
907
+
908
+ //#endregion
909
+ //#region src/breadcrumbs/RequestRecorder.ts
910
+ var RequestRecorder = class {
911
+ type = "browser_request";
912
+ constructor(host) {
913
+ this.host = host;
914
+ this.subscribe = this.subscribe.bind(this);
915
+ this.onStart = this.onStart.bind(this);
916
+ this.onSettle = this.onSettle.bind(this);
827
917
  }
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();
918
+ install() {
919
+ return withRequestPatches(this.subscribe);
834
920
  }
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);
921
+ subscribe() {
922
+ return subscribeToRequests(this.onStart);
842
923
  }
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);
924
+ onStart(start) {
925
+ const base = browserUrlContext().base();
926
+ const absolute = safeAbsolute(start.url, base);
927
+ if (isFlareIngestUrl(absolute, this.host.config(), base)) return;
928
+ return { onSettle: this.onSettle.bind(this, start, absolute) };
847
929
  }
848
- clearIdle() {
849
- if (this.idleTimer !== null) {
850
- this.deps.clearTimeout(this.idleTimer);
851
- this.idleTimer = null;
852
- }
930
+ onSettle(start, absolute, settle) {
931
+ const url = absolute ? absolute.href : start.url;
932
+ const attributes = {
933
+ "http.request.method": start.method,
934
+ "url.full": breadcrumbUrl(url, this.host.config().urlDenylist)
935
+ };
936
+ if (absolute?.hostname) attributes["server.address"] = absolute.hostname;
937
+ if (settle.status !== void 0) attributes["http.response.status_code"] = settle.status;
938
+ this.host.record(REQUEST_SPAN_TYPES[start.kind], attributes, defaultNowNano());
853
939
  }
854
- armChildTimeout() {
855
- this.childTimer = this.deps.setTimeout(() => this.finish(this.deps.now()), this.timeouts.childSpanTimeout);
940
+ };
941
+
942
+ //#endregion
943
+ //#region src/breadcrumbs/index.ts
944
+ /** Starts every recorder, returns one teardown. A recorder that fails to install is skipped. */
945
+ function startBreadcrumbs(host) {
946
+ if (typeof document === "undefined") return () => {};
947
+ const recorders = [
948
+ new ClickRecorder(host),
949
+ new FormChangeRecorder(host),
950
+ new RequestRecorder(host),
951
+ new NavigationRecorder(host)
952
+ ];
953
+ const teardowns = [];
954
+ for (const recorder of recorders) try {
955
+ teardowns.push(recorder.install());
956
+ } catch {}
957
+ return () => {
958
+ for (const teardown of teardowns) try {
959
+ teardown();
960
+ } catch {}
961
+ };
962
+ }
963
+
964
+ //#endregion
965
+ //#region src/browser/BrowserFlushScheduler.ts
966
+ var BrowserFlushScheduler = class {
967
+ register(flush) {
968
+ if (typeof document === "undefined" || !document) return;
969
+ document.addEventListener("visibilitychange", () => {
970
+ if (document.visibilityState === "hidden") flush({ keepalive: true });
971
+ });
972
+ if (typeof window !== "undefined" && window) window.addEventListener("pagehide", () => flush({ keepalive: true }));
856
973
  }
857
- clearChildTimeout() {
858
- if (this.childTimer !== null) {
859
- this.deps.clearTimeout(this.childTimer);
860
- this.childTimer = null;
974
+ };
975
+
976
+ //#endregion
977
+ //#region src/browser/context/cookie.ts
978
+ /**
979
+ * Parses `document.cookie` into `http.request.cookies`, redacting the value of any cookie whose name
980
+ * matches `denylist`. Null-prototype accumulator so a cookie named `__proto__` is stored, not dropped.
981
+ */
982
+ function cookie(denylist) {
983
+ if (!window.document.cookie) return {};
984
+ const cookies = Object.create(null);
985
+ window.document.cookie.split("; ").forEach((rawCookie) => {
986
+ const idx = rawCookie.indexOf("=");
987
+ if (idx === -1) {
988
+ cookies[rawCookie] = denylist.test(rawCookie) ? "[redacted]" : "";
989
+ return;
861
990
  }
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;
991
+ const name = rawCookie.slice(0, idx);
992
+ const value = rawCookie.slice(idx + 1);
993
+ cookies[name] = denylist.test(name) ? "[redacted]" : value;
994
+ });
995
+ return { "http.request.cookies": cookies };
996
+ }
997
+
998
+ //#endregion
999
+ //#region src/browser/context/request.ts
1000
+ /**
1001
+ * @param hrefOverride when set, the `url.*` attributes come from it instead of the live
1002
+ * `window.location.href` (a framework navigation root whose router knows the destination
1003
+ * before the URL commits). The override is pre-validated by the caller.
1004
+ */
1005
+ function request(urlDenylist, hrefOverride) {
1006
+ return {
1007
+ ...urlAttributes(hrefOverride ?? window.location.href, urlDenylist),
1008
+ "user_agent.original": window.navigator.userAgent,
1009
+ "http.request.referrer": redactUrlQuery(window.document.referrer, urlDenylist),
1010
+ "document.ready_state": window.document.readyState
1011
+ };
1012
+ }
1013
+
1014
+ //#endregion
1015
+ //#region src/browser/context/collectBrowser.ts
1016
+ function browserEntryPoint(config, urlOverride) {
1017
+ if (typeof window === "undefined") return { "flare.entry_point.type": "web" };
1018
+ const attrs = { "flare.entry_point.type": "web" };
1019
+ const href = urlOverride ? urlOverride.href : window?.location?.href;
1020
+ if (href) {
1021
+ attrs["flare.entry_point.value"] = redactUrlQuery(href, config.urlDenylist);
1022
+ const pathname = urlOverride ? urlOverride.pathname : window?.location?.pathname;
1023
+ if (pathname) {
1024
+ attrs["flare.entry_point.handler.identifier"] = pathname;
1025
+ attrs["http.route"] = pathname;
1026
+ attrs["flare.entry_point.handler.type"] = "browser";
872
1027
  }
873
- this.unsubscribe();
874
- this.deps.beforeEnd?.();
875
- this.deps.root.end(atTimeNano);
876
- this.deps.setActiveRoot(void 0);
877
1028
  }
1029
+ return attrs;
1030
+ }
1031
+ const collectBrowser = (config) => {
1032
+ const attrs = { ...browserEntryPoint(config) };
1033
+ if (typeof window === "undefined") return attrs;
1034
+ if (window?.location?.hostname) attrs["host.name"] = window.location.hostname;
1035
+ Object.assign(attrs, request(config.urlDenylist));
1036
+ Object.assign(attrs, cookie(config.urlDenylist));
1037
+ return attrs;
878
1038
  };
879
1039
 
880
1040
  //#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
- }
1041
+ //#region src/browser/FetchFileReader.ts
886
1042
  /**
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.
1043
+ * Fetches source files so the stack-trace builder can render a snippet around the offending line.
1044
+ * Only http(s) is fetched: other schemes (chrome-extension://, file://, blob:, data:) would cross a
1045
+ * privilege boundary or hit a CORS/CSP wall for nothing. Returns null on any failure, never throws.
890
1046
  */
891
- function routeName(derive, fallbackPath, url) {
1047
+ var FetchFileReader = class {
1048
+ read(url) {
1049
+ if (!/^https?:\/\//i.test(url)) return Promise.resolve(null);
1050
+ return fetch(url, internalRequestInit()).then((response) => {
1051
+ if (response.status !== 200) return null;
1052
+ return response.text();
1053
+ }).catch(() => null);
1054
+ }
1055
+ };
1056
+
1057
+ //#endregion
1058
+ //#region src/env/index.ts
1059
+ const CLIENT_VERSION = typeof process !== "undefined" && true ? "2.9.0" : "?";
1060
+
1061
+ //#endregion
1062
+ //#region src/tracing/utils/instrumentationGuard.ts
1063
+ /** For a callback the host invokes: a router guard, a store subscriber, ... */
1064
+ function insulate(fn) {
1065
+ return (...args) => {
1066
+ try {
1067
+ fn(...args);
1068
+ } catch {}
1069
+ };
1070
+ }
1071
+ /** Invoke a teardown fn now (if present), swallowing any throw. For cleanup chains. */
1072
+ function safeInvoke(fn) {
892
1073
  try {
893
- const name = derive();
894
- if (name) return {
895
- name,
896
- source: "route",
897
- url
898
- };
1074
+ fn?.();
899
1075
  } catch {}
900
- return {
901
- name: fallbackPath,
902
- source: "url",
903
- url
904
- };
905
1076
  }
1077
+ const instrumented = /* @__PURE__ */ new WeakMap();
906
1078
  /**
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`.
1079
+ * Instrument `target` at most once at a time, tearing down any prior instrumentation of the same object
1080
+ * first. Vite HMR re-runs boot code against a router that survives the reload, so without this every
1081
+ * cycle appends another listener set that is never removed. Keyed on the object, so a genuinely new
1082
+ * router is unaffected.
1083
+ *
1084
+ * `install` hands each teardown to `track` as it produces it. A router's own `subscribe` / `on` / guard
1085
+ * registration can throw, and `install` runs during the host's bootstrap, so a throw part-way through
1086
+ * unwinds what already succeeded (newest first) and stops here rather than reaching the host.
1087
+ *
1088
+ * @returns the cleanup, or a no-op when the install failed and already unwound itself.
910
1089
  */
911
- function resolveHref(build, fallback) {
912
- let href = fallback;
1090
+ function instrumentOnce(target, install) {
1091
+ instrumented.get(target)?.();
1092
+ const teardowns = [];
1093
+ function unwind() {
1094
+ for (let i = teardowns.length - 1; i >= 0; i--) safeInvoke(teardowns[i]);
1095
+ }
913
1096
  try {
914
- href = build() ?? fallback;
915
- } catch {}
916
- return absoluteHref(href);
1097
+ install((teardown) => {
1098
+ teardowns.push(teardown);
1099
+ });
1100
+ } catch {
1101
+ unwind();
1102
+ return () => {};
1103
+ }
1104
+ function cleanup() {
1105
+ unwind();
1106
+ if (instrumented.get(target) === cleanup) instrumented.delete(target);
1107
+ }
1108
+ instrumented.set(target, cleanup);
1109
+ return cleanup;
917
1110
  }
918
1111
 
919
1112
  //#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
- }
1113
+ //#region src/browser/context/collectBrowserSpanContext.ts
944
1114
  /**
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.
1115
+ * Entry point plus request identity for a pageload/navigation root. Deliberately leaner than the report
1116
+ * context: no cookies, no structured query params, no host.name (that is resource-level). Captured at
1117
+ * span start, so a long-lived root reflects the page it represents rather than the page at close.
1118
+ *
1119
+ * @param hrefOverride destination href for a router that reports where it is going before the URL
1120
+ * commits. Only the URL-derived keys come from it; the rest always reflect the live document. An
1121
+ * unparseable override falls back to the live location instead of throwing into root creation.
947
1122
  */
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);
1123
+ function collectBrowserSpanContext(config, hrefOverride) {
1124
+ if (typeof window === "undefined") return {};
1125
+ const url = absoluteUrl(hrefOverride);
1126
+ return {
1127
+ ...browserEntryPoint(config, url),
1128
+ ...request(config.urlDenylist, url?.href)
1129
+ };
957
1130
  }
958
1131
  /**
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.
1132
+ * Updates a root's url after a redirect, or when a newer navigation replaces this one. The root opened
1133
+ * with the first destination, so without this it reports a page the user never reached.
1134
+ *
1135
+ * Does not touch `flare.entry_point.handler.identifier` or `http.route`. Those hold the route template,
1136
+ * and reading them back from the href would turn `/product/[id]` into `/product/p01`.
1137
+ *
1138
+ * Always sets `url.query`, even to an empty string. You can overwrite a span attribute but not remove
1139
+ * it, so going from `/a?x=1` to `/b` would otherwise keep the old query.
964
1140
  */
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());
1141
+ function browserSpanUrlAttributes(config, href) {
1142
+ if (typeof window === "undefined") return {};
1143
+ const resolved = absoluteUrl(href);
1144
+ if (!resolved) return {};
1145
+ const attributes = urlAttributes(resolved.href, config.urlDenylist);
1146
+ return {
1147
+ "url.query": "",
1148
+ ...attributes,
1149
+ "flare.entry_point.value": attributes["url.full"]
1150
+ };
970
1151
  }
971
1152
 
972
1153
  //#endregion
973
- //#region src/tracing/webvitals/lib/bfcache.ts
1154
+ //#region src/tracing/vitals/webvitals/lib/bfcache.ts
974
1155
  let bfcacheRestoreTime = -1;
975
1156
  const getBFCacheRestoreTime = () => bfcacheRestoreTime;
976
1157
  const onBFCacheRestore = (cb) => {
@@ -983,7 +1164,7 @@ const onBFCacheRestore = (cb) => {
983
1164
  };
984
1165
 
985
1166
  //#endregion
986
- //#region src/tracing/webvitals/lib/bindReporter.ts
1167
+ //#region src/tracing/vitals/webvitals/lib/bindReporter.ts
987
1168
  const getRating = (value, thresholds) => {
988
1169
  if (value > thresholds[1]) return "poor";
989
1170
  if (value > thresholds[0]) return "needs-improvement";
@@ -1008,26 +1189,26 @@ const bindReporter = (callback, metric, thresholds, reportAllChanges) => {
1008
1189
  };
1009
1190
 
1010
1191
  //#endregion
1011
- //#region src/tracing/webvitals/lib/doubleRAF.ts
1192
+ //#region src/tracing/vitals/webvitals/lib/doubleRAF.ts
1012
1193
  const doubleRAF = (cb) => {
1013
1194
  requestAnimationFrame(() => requestAnimationFrame(cb));
1014
1195
  };
1015
1196
 
1016
1197
  //#endregion
1017
- //#region src/tracing/webvitals/lib/getNavigationEntry.ts
1198
+ //#region src/tracing/vitals/webvitals/lib/getNavigationEntry.ts
1018
1199
  const getNavigationEntry = () => {
1019
1200
  const navigationEntry = performance.getEntriesByType("navigation")[0];
1020
1201
  if (navigationEntry && navigationEntry.responseStart > 0 && navigationEntry.responseStart < performance.now()) return navigationEntry;
1021
1202
  };
1022
1203
 
1023
1204
  //#endregion
1024
- //#region src/tracing/webvitals/lib/getActivationStart.ts
1205
+ //#region src/tracing/vitals/webvitals/lib/getActivationStart.ts
1025
1206
  const getActivationStart = () => {
1026
1207
  return getNavigationEntry()?.activationStart ?? 0;
1027
1208
  };
1028
1209
 
1029
1210
  //#endregion
1030
- //#region src/tracing/webvitals/lib/getVisibilityWatcher.ts
1211
+ //#region src/tracing/vitals/webvitals/lib/getVisibilityWatcher.ts
1031
1212
  let firstHiddenTime = -1;
1032
1213
  const onHiddenFunctions = /* @__PURE__ */ new Set();
1033
1214
  const initHiddenTime = () => {
@@ -1066,7 +1247,7 @@ const getVisibilityWatcher = (reset = false) => {
1066
1247
  };
1067
1248
 
1068
1249
  //#endregion
1069
- //#region src/tracing/webvitals/lib/generateUniqueID.ts
1250
+ //#region src/tracing/vitals/webvitals/lib/generateUniqueID.ts
1070
1251
  /**
1071
1252
  * Performantly generate a unique, 30-char string by combining a version
1072
1253
  * number, the current timestamp with a 13-digit number integer.
@@ -1077,7 +1258,7 @@ const generateUniqueID = () => {
1077
1258
  };
1078
1259
 
1079
1260
  //#endregion
1080
- //#region src/tracing/webvitals/lib/initMetric.ts
1261
+ //#region src/tracing/vitals/webvitals/lib/initMetric.ts
1081
1262
  const initMetric = (name, value = -1, navigationType, navigationId = 0, navigationInteractionId, navigationURL, navigationStartTime) => {
1082
1263
  const hardNavEntry = getNavigationEntry();
1083
1264
  const hardNavId = hardNavEntry?.navigationId || 0;
@@ -1105,7 +1286,7 @@ const initMetric = (name, value = -1, navigationType, navigationId = 0, navigati
1105
1286
  };
1106
1287
 
1107
1288
  //#endregion
1108
- //#region src/tracing/webvitals/lib/initUnique.ts
1289
+ //#region src/tracing/vitals/webvitals/lib/initUnique.ts
1109
1290
  const instanceMap = /* @__PURE__ */ new WeakMap();
1110
1291
  /**
1111
1292
  * A function that accepts and identity object and a class object and returns
@@ -1123,7 +1304,7 @@ function initUnique(identityObj, ClassObj) {
1123
1304
  }
1124
1305
 
1125
1306
  //#endregion
1126
- //#region src/tracing/webvitals/lib/LayoutShiftManager.ts
1307
+ //#region src/tracing/vitals/webvitals/lib/LayoutShiftManager.ts
1127
1308
  var LayoutShiftManager = class {
1128
1309
  _onAfterProcessingUnexpectedShift;
1129
1310
  _sessionValue = 0;
@@ -1144,7 +1325,7 @@ var LayoutShiftManager = class {
1144
1325
  };
1145
1326
 
1146
1327
  //#endregion
1147
- //#region src/tracing/webvitals/lib/observe.ts
1328
+ //#region src/tracing/vitals/webvitals/lib/observe.ts
1148
1329
  /**
1149
1330
  * Takes a performance entry type and a callback function, and creates a
1150
1331
  * `PerformanceObserver` instance that will observe the specified entry type
@@ -1177,7 +1358,7 @@ const observe = (types, callback, opts = {}) => {
1177
1358
  };
1178
1359
 
1179
1360
  //#endregion
1180
- //#region src/tracing/webvitals/lib/softNavs.ts
1361
+ //#region src/tracing/vitals/webvitals/lib/softNavs.ts
1181
1362
  const checkSoftNavsEnabled = (opts) => {
1182
1363
  return globalThis.PerformanceObserver?.supportedEntryTypes.includes("soft-navigation") && typeof globalThis.PerformanceSoftNavigation?.prototype?.getLargestInteractionContentfulPaint === "function" && opts && opts.reportSoftNavs;
1183
1364
  };
@@ -1190,7 +1371,7 @@ const storeSoftNavEntry = (map, entry) => {
1190
1371
  };
1191
1372
 
1192
1373
  //#endregion
1193
- //#region src/tracing/webvitals/lib/runOnce.ts
1374
+ //#region src/tracing/vitals/webvitals/lib/runOnce.ts
1194
1375
  const runOnce = (cb) => {
1195
1376
  let called = false;
1196
1377
  return () => {
@@ -1202,20 +1383,20 @@ const runOnce = (cb) => {
1202
1383
  };
1203
1384
 
1204
1385
  //#endregion
1205
- //#region src/tracing/webvitals/lib/FCPEntryManager.ts
1386
+ //#region src/tracing/vitals/webvitals/lib/FCPEntryManager.ts
1206
1387
  var FCPEntryManager = class {
1207
1388
  _softNavigationEntryMap;
1208
1389
  };
1209
1390
 
1210
1391
  //#endregion
1211
- //#region src/tracing/webvitals/lib/whenActivated.ts
1392
+ //#region src/tracing/vitals/webvitals/lib/whenActivated.ts
1212
1393
  const whenActivated = (callback) => {
1213
1394
  if (document.prerendering) addEventListener("prerenderingchange", callback, true);
1214
1395
  else callback();
1215
1396
  };
1216
1397
 
1217
1398
  //#endregion
1218
- //#region src/tracing/webvitals/onFCP.ts
1399
+ //#region src/tracing/vitals/webvitals/onFCP.ts
1219
1400
  /** Thresholds for FCP. See https://web.dev/articles/fcp#what_is_a_good_fcp_score */
1220
1401
  const FCPThresholds = [1800, 3e3];
1221
1402
  /**
@@ -1269,7 +1450,7 @@ const onFCP = (onReport, opts = {}) => {
1269
1450
  };
1270
1451
 
1271
1452
  //#endregion
1272
- //#region src/tracing/webvitals/onCLS.ts
1453
+ //#region src/tracing/vitals/webvitals/onCLS.ts
1273
1454
  /** Thresholds for CLS. See https://web.dev/articles/cls#what_is_a_good_cls_score */
1274
1455
  const CLSThresholds = [.1, .25];
1275
1456
  /**
@@ -1344,7 +1525,7 @@ const onCLS = (onReport, opts = {}) => {
1344
1525
  };
1345
1526
 
1346
1527
  //#endregion
1347
- //#region src/tracing/webvitals/lib/polyfills/interactionCountPolyfill.ts
1528
+ //#region src/tracing/vitals/webvitals/lib/polyfills/interactionCountPolyfill.ts
1348
1529
  let interactionCountEstimate = 0;
1349
1530
  let minKnownInteractionId = Infinity;
1350
1531
  let maxKnownInteractionId = 0;
@@ -1372,7 +1553,7 @@ const initInteractionCountPolyfill = () => {
1372
1553
  };
1373
1554
 
1374
1555
  //#endregion
1375
- //#region src/tracing/webvitals/lib/InteractionManager.ts
1556
+ //#region src/tracing/vitals/webvitals/lib/InteractionManager.ts
1376
1557
  const MAX_INTERACTIONS_TO_CONSIDER = 10;
1377
1558
  let prevInteractionCount = 0;
1378
1559
  /**
@@ -1452,7 +1633,7 @@ var InteractionManager = class {
1452
1633
  };
1453
1634
 
1454
1635
  //#endregion
1455
- //#region src/tracing/webvitals/lib/whenIdleOrHidden.ts
1636
+ //#region src/tracing/vitals/webvitals/lib/whenIdleOrHidden.ts
1456
1637
  /**
1457
1638
  * Runs the passed callback during the next idle period, or immediately
1458
1639
  * if the browser's visibility state is (or becomes) hidden.
@@ -1481,7 +1662,7 @@ const whenIdleOrHidden = (cb) => {
1481
1662
  };
1482
1663
 
1483
1664
  //#endregion
1484
- //#region src/tracing/webvitals/onINP.ts
1665
+ //#region src/tracing/vitals/webvitals/onINP.ts
1485
1666
  /** Thresholds for INP. See https://web.dev/articles/inp#what_is_a_good_inp_score */
1486
1667
  const INPThresholds = [200, 500];
1487
1668
  const DEFAULT_DURATION_THRESHOLD = 40;
@@ -1572,7 +1753,7 @@ const onINP = (onReport, opts = {}) => {
1572
1753
  };
1573
1754
 
1574
1755
  //#endregion
1575
- //#region src/tracing/webvitals/lib/LCPEntryManager.ts
1756
+ //#region src/tracing/vitals/webvitals/lib/LCPEntryManager.ts
1576
1757
  var LCPEntryManager = class {
1577
1758
  _onBeforeProcessingEntry;
1578
1759
  _softNavigationEntryMap;
@@ -1582,7 +1763,7 @@ var LCPEntryManager = class {
1582
1763
  };
1583
1764
 
1584
1765
  //#endregion
1585
- //#region src/tracing/webvitals/onLCP.ts
1766
+ //#region src/tracing/vitals/webvitals/onLCP.ts
1586
1767
  /** Thresholds for LCP. See https://web.dev/articles/lcp#what_is_a_good_lcp_score */
1587
1768
  const LCPThresholds = [2500, 4e3];
1588
1769
  /**
@@ -1692,7 +1873,7 @@ const onLCP = (onReport, opts = {}) => {
1692
1873
  };
1693
1874
 
1694
1875
  //#endregion
1695
- //#region src/tracing/webvitals/onTTFB.ts
1876
+ //#region src/tracing/vitals/webvitals/onTTFB.ts
1696
1877
  /** Thresholds for TTFB. See https://web.dev/articles/ttfb#what_is_a_good_ttfb_score */
1697
1878
  const TTFBThresholds = [800, 1800];
1698
1879
  /**
@@ -1753,7 +1934,7 @@ const onTTFB = (onReport, opts = {}) => {
1753
1934
  };
1754
1935
 
1755
1936
  //#endregion
1756
- //#region src/tracing/webVitals.ts
1937
+ //#region src/tracing/vitals/webVitals.ts
1757
1938
  /**
1758
1939
  * Final the moment they first report, so they can ride the pageload span itself. The other three keep
1759
1940
  * changing until the page goes away, and stamping an early value on the root would leave the root and
@@ -1829,57 +2010,230 @@ function record(name, metric) {
1829
2010
  collected[name] = metric.value;
1830
2011
  }
1831
2012
  /**
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.
2013
+ * Stops recording and drops what was collected. The observers themselves cannot be detached, and both
2014
+ * the `subscribed` and `taken` latches deliberately survive: clearing `subscribed` would let a re-enable
2015
+ * attach a second set of observers that is just as undetachable as the first, and clearing `taken` would
2016
+ * let those surviving observers refill `collected` and ship a second `browser_web_vital` for the same
2017
+ * document once the page is re-enabled and later hidden.
2018
+ */
2019
+ function stopWebVitals() {
2020
+ recording = false;
2021
+ collected = {};
2022
+ }
2023
+ /**
2024
+ * The vitals that are already final when the pageload root closes, removed from `collected` so the late
2025
+ * span cannot report them a second time. Naturally idempotent: a second call finds the keys gone.
2026
+ */
2027
+ function takeEarlyVitals() {
2028
+ if (!recording) return null;
2029
+ const taking = {};
2030
+ for (const name of EARLY_VITALS) {
2031
+ const value = collected[name];
2032
+ if (value !== void 0) {
2033
+ taking[name] = value;
2034
+ delete collected[name];
2035
+ }
2036
+ }
2037
+ return Object.keys(taking).length === 0 ? null : taking;
2038
+ }
2039
+ /** Everything still outstanding, once. Null afterwards, and null when nothing is left. */
2040
+ function takeWebVitals() {
2041
+ if (taken || !recording || Object.keys(collected).length === 0) return null;
2042
+ taken = true;
2043
+ const taking = collected;
2044
+ collected = {};
2045
+ return taking;
2046
+ }
2047
+ /**
2048
+ * Undoes a take after the emit failed partway through, so the values are not lost and a later trigger
2049
+ * can retry. Safe to assign outright rather than merge: the whole emit runs synchronously between the
2050
+ * take and a catch block calling this, so nothing else can have written to `collected` in between.
2051
+ */
2052
+ function restoreWebVitals(vitals) {
2053
+ collected = vitals;
2054
+ taken = false;
2055
+ }
2056
+
2057
+ //#endregion
2058
+ //#region src/tracing/roots/IdleRootController.ts
2059
+ /** Browser defaults for the three idle-root timeouts, in ms. Overridable per Config. */
2060
+ const DEFAULT_IDLE_TIMEOUTS = {
2061
+ idleTimeout: 1e3,
2062
+ finalTimeout: 3e4,
2063
+ childSpanTimeout: 15e3
2064
+ };
2065
+ /**
2066
+ * Owns one root span's idle lifecycle: open while child spans in its trace are active, closing after
2067
+ * `idleTimeout` with no open children, or on the `finalTimeout` / `childSpanTimeout` backstops. Deps are
2068
+ * injected so this is testable without real timers or a real tracer.
2069
+ */
2070
+ var IdleRootController = class {
2071
+ openChildren = 0;
2072
+ lastChildEndTime = null;
2073
+ settleTime = null;
2074
+ idleTimer = null;
2075
+ finalTimer = null;
2076
+ childTimer = null;
2077
+ ended = false;
2078
+ held = false;
2079
+ unsubscribe;
2080
+ constructor(deps, timeouts) {
2081
+ this.deps = deps;
2082
+ this.timeouts = timeouts;
2083
+ deps.setActiveRoot(deps.root);
2084
+ this.unsubscribe = deps.addSpanListener((e) => this.onSpanEvent(e.phase, e.span));
2085
+ const elapsedMs = Math.max(0, (deps.now() - deps.rootStartTime) / 1e6);
2086
+ const remainingMs = Math.max(0, timeouts.finalTimeout - elapsedMs);
2087
+ this.finalTimer = deps.setTimeout(() => this.finish(deps.now()), remainingMs);
2088
+ this.held = !!deps.held;
2089
+ this.armIdle();
2090
+ }
2091
+ get isEnded() {
2092
+ return this.ended;
2093
+ }
2094
+ /** For a route change or pagehide. */
2095
+ endNow() {
2096
+ this.finish(this.openChildren > 0 || this.held ? this.deps.now() : this.trimmedEnd());
2097
+ }
2098
+ /**
2099
+ * Records the settle moment as a close floor and hands the root back to the normal idle lifecycle. It
2100
+ * deliberately does not close here: a router settles before the framework mounts the new route
2101
+ * component (vue-router runs `afterEach` in the route-update tick, Vue mounts on the next flush), so
2102
+ * closing at settle cleared the active root ahead of every post-navigation mount: every component span
2103
+ * read a null root, and a trailing fetch opened a root of its own.
2104
+ */
2105
+ releaseHold() {
2106
+ if (this.ended || !this.held) return;
2107
+ this.held = false;
2108
+ if (this.openChildren === 0) this.settleTime = this.deps.now();
2109
+ this.armIdle();
2110
+ }
2111
+ onSpanEvent(phase, span) {
2112
+ if (this.ended) return;
2113
+ if (span === this.deps.root) return;
2114
+ if (span.traceId !== this.deps.root.traceId) return;
2115
+ if (phase === "start") {
2116
+ this.onChildStarted();
2117
+ return;
2118
+ }
2119
+ this.onChildEnded(span);
2120
+ }
2121
+ onChildStarted() {
2122
+ this.openChildren++;
2123
+ this.clearIdle();
2124
+ if (this.openChildren === 1) this.armChildTimeout();
2125
+ }
2126
+ onChildEnded(span) {
2127
+ this.openChildren = Math.max(0, this.openChildren - 1);
2128
+ this.lastChildEndTime = span.endTimeUnixNano || this.deps.now();
2129
+ if (this.openChildren > 0) return;
2130
+ this.clearChildTimeout();
2131
+ this.armIdle();
2132
+ }
2133
+ armIdle() {
2134
+ this.clearIdle();
2135
+ if (this.held) return;
2136
+ this.idleTimer = this.deps.setTimeout(() => {
2137
+ if (this.openChildren > 0) return;
2138
+ this.finish(this.trimmedEnd());
2139
+ }, this.timeouts.idleTimeout);
2140
+ }
2141
+ /** The latest of the floor, a released hold's settle moment, and the last child's end, so a root
2142
+ * covers its children without ever padding out to `now()`. */
2143
+ trimmedEnd() {
2144
+ return Math.max(this.deps.endFloor(), this.settleTime ?? 0, this.lastChildEndTime ?? 0);
2145
+ }
2146
+ clearIdle() {
2147
+ if (this.idleTimer !== null) {
2148
+ this.deps.clearTimeout(this.idleTimer);
2149
+ this.idleTimer = null;
2150
+ }
2151
+ }
2152
+ armChildTimeout() {
2153
+ this.childTimer = this.deps.setTimeout(() => this.finish(this.deps.now()), this.timeouts.childSpanTimeout);
2154
+ }
2155
+ clearChildTimeout() {
2156
+ if (this.childTimer !== null) {
2157
+ this.deps.clearTimeout(this.childTimer);
2158
+ this.childTimer = null;
2159
+ }
2160
+ }
2161
+ finish(atTimeNano) {
2162
+ if (this.ended) return;
2163
+ this.ended = true;
2164
+ atTimeNano = Math.max(atTimeNano, this.deps.rootStartTime);
2165
+ this.clearIdle();
2166
+ this.clearChildTimeout();
2167
+ if (this.finalTimer !== null) {
2168
+ this.deps.clearTimeout(this.finalTimer);
2169
+ this.finalTimer = null;
2170
+ }
2171
+ this.unsubscribe();
2172
+ this.deps.beforeEnd?.();
2173
+ this.deps.root.end(atTimeNano);
2174
+ this.deps.setActiveRoot(void 0);
2175
+ }
2176
+ };
2177
+
2178
+ //#endregion
2179
+ //#region src/tracing/roots/navigationTiming.ts
2180
+ /** Split out so the timestamp maths is testable without a Navigation Timing entry. */
2181
+ function computePageloadStartNano(timeOriginMs, startTimeMs) {
2182
+ return Math.round((timeOriginMs + (startTimeMs ?? 0)) * 1e6);
2183
+ }
2184
+ /**
2185
+ * Choose the pageload root's start time: navigation start while that window is still open,
2186
+ * otherwise `now`. Starting at `now` (when tracing began after the final cap, or the pageload was
2187
+ * already traced) avoids a backdated root reporting a bogus multi-second duration.
1837
2188
  */
1838
- function stopWebVitals() {
1839
- recording = false;
1840
- collected = {};
2189
+ function resolvePageloadStartNano(backdatedNano, nowNano, finalTimeoutNano, alreadyTraced) {
2190
+ if (alreadyTraced) return nowNano;
2191
+ if (nowNano - backdatedNano > finalTimeoutNano) return nowNano;
2192
+ return backdatedNano;
2193
+ }
2194
+ /** The Navigation Timing API, or null where it is missing or only partly implemented. */
2195
+ function navigationTiming() {
2196
+ const perf = globalThis.performance;
2197
+ if (!perf || typeof perf.getEntriesByType !== "function" || typeof perf.timeOrigin !== "number") return null;
2198
+ return perf;
2199
+ }
2200
+ function navigationEntry(perf) {
2201
+ return perf.getEntriesByType("navigation")[0];
1841
2202
  }
1842
2203
  /**
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.
2204
+ * The pageload root's start time in unix nanoseconds, backdated to navigation start via the
2205
+ * Navigation Timing entry. Falls back to the tracer's clock when the API is unavailable.
1845
2206
  */
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;
2207
+ function pageloadStartNano() {
2208
+ const perf = navigationTiming();
2209
+ if (!perf) return defaultNowNano();
2210
+ return computePageloadStartNano(perf.timeOrigin, navigationEntry(perf)?.startTime);
1857
2211
  }
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;
2212
+ function computePageloadEndNano(timeOriginMs, loadEventEndMs, domContentLoadedEventEndMs, nowNano) {
2213
+ const endMs = loadEventEndMs || domContentLoadedEventEndMs || 0;
2214
+ if (!endMs) return nowNano;
2215
+ return Math.round((timeOriginMs + endMs) * 1e6);
1865
2216
  }
1866
2217
  /**
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.
2218
+ * The pageload root's end time in unix nanoseconds, taken from the Navigation
2219
+ * Timing `loadEventEnd` (the browser's own "page finished loading" mark), falling
2220
+ * back to `domContentLoadedEventEnd`, then the tracer's clock when neither has
2221
+ * fired yet or the API is unavailable. Used as the pageload root's close floor so a
2222
+ * childless pageload reports its real load duration rather than idle-timeout padding.
1870
2223
  */
1871
- function restoreWebVitals(vitals) {
1872
- collected = vitals;
1873
- taken = false;
2224
+ function pageloadEndNano() {
2225
+ const perf = navigationTiming();
2226
+ if (!perf) return defaultNowNano();
2227
+ const entry = navigationEntry(perf);
2228
+ return computePageloadEndNano(perf.timeOrigin, entry?.loadEventEnd, entry?.domContentLoadedEventEnd, defaultNowNano());
1874
2229
  }
1875
2230
 
1876
2231
  //#endregion
1877
- //#region src/tracing/browserTracing.ts
2232
+ //#region src/tracing/roots/browserTracing.ts
1878
2233
  let controller = null;
1879
2234
  let uninstall = null;
1880
- let lastPath = "";
2235
+ let removeNavigationSubscription = null;
1881
2236
  let pageloadTraced = false;
1882
- let navSource = null;
1883
2237
  let activeFlare = null;
1884
2238
  let currentRoot = null;
1885
2239
  let pendingRouteName = null;
@@ -1931,12 +2285,12 @@ function startRoot(flare, options) {
1931
2285
  setTimeout: (fn, ms) => setTimeout(fn, ms),
1932
2286
  clearTimeout: (handle) => clearTimeout(handle),
1933
2287
  rootStartTime: startTimeUnixNano,
1934
- endFloor: spanType === BrowserSpanType.Pageload && backdated ? pageloadEndNano : () => startTimeUnixNano,
2288
+ endFloor: spanType === BrowserSpanType$1.Pageload && backdated ? pageloadEndNano : () => startTimeUnixNano,
1935
2289
  held: hold,
1936
- beforeEnd: spanType === BrowserSpanType.Pageload ? () => stampEarlyVitals(root, flare) : void 0
2290
+ beforeEnd: spanType === BrowserSpanType$1.Pageload ? () => stampEarlyVitals(root, flare) : void 0
1937
2291
  }, resolveTimeouts(flare.config));
1938
2292
  currentRoot = root;
1939
- if (spanType === BrowserSpanType.Pageload) {
2293
+ if (spanType === BrowserSpanType$1.Pageload) {
1940
2294
  pageloadRoot = root;
1941
2295
  pageloadRootStartNano = startTimeUnixNano;
1942
2296
  pageloadRoute = {
@@ -1948,7 +2302,7 @@ function startRoot(flare, options) {
1948
2302
  } catch (error) {
1949
2303
  controller = null;
1950
2304
  currentRoot = null;
1951
- if (spanType === BrowserSpanType.Pageload) {
2305
+ if (spanType === BrowserSpanType$1.Pageload) {
1952
2306
  pageloadRoot = null;
1953
2307
  pageloadRootStartNano = 0;
1954
2308
  pageloadRoute = null;
@@ -1963,16 +2317,14 @@ function startRoot(flare, options) {
1963
2317
  if (flare.config.debug) console.error("Flare: failed to start browser tracing root", error);
1964
2318
  }
1965
2319
  }
1966
- function onUrlChanged(flare) {
1967
- const path = location.pathname;
1968
- if (path === lastPath) return;
1969
- lastPath = path;
1970
- if (navSource) return;
2320
+ function openNavigationRoot(flare, opts) {
1971
2321
  withLiveController((live) => live.endNow());
1972
2322
  startRoot(flare, {
1973
- spanType: BrowserSpanType.Navigation,
2323
+ spanType: BrowserSpanType$1.Navigation,
1974
2324
  startTimeUnixNano: defaultNowNano(),
1975
- name: path
2325
+ name: opts.path,
2326
+ urlOverride: opts.url,
2327
+ hold: opts.hold
1976
2328
  });
1977
2329
  }
1978
2330
  /**
@@ -2023,7 +2375,7 @@ function emitWebVitals(flare) {
2023
2375
  flare.startSpan(planned.name, {
2024
2376
  parent: root,
2025
2377
  forceRoot: true,
2026
- spanType: BrowserSpanType.WebVital,
2378
+ spanType: BrowserSpanType$1.WebVital,
2027
2379
  startTimeUnixNano: planned.startTimeUnixNano,
2028
2380
  attributes: planned.attributes
2029
2381
  }).end(planned.endTimeUnixNano);
@@ -2037,13 +2389,26 @@ function startBrowserTracing(flare) {
2037
2389
  if (typeof window === "undefined" || typeof history === "undefined" || typeof location === "undefined") return;
2038
2390
  if (uninstall) return;
2039
2391
  activeFlare = flare;
2040
- lastPath = location.pathname;
2392
+ removeNavigationSubscription = subscribeToNavigation({
2393
+ onUrlChanged: (path) => openNavigationRoot(flare, { path }),
2394
+ onNavigationStart: (opts) => openNavigationRoot(flare, opts),
2395
+ onRouteName: (route, owner) => applyRouteName(route, owner),
2396
+ onNavigationSettle: (route, owner) => {
2397
+ applyRouteName(route, owner);
2398
+ withLiveController((live) => live.releaseHold());
2399
+ },
2400
+ onSourceUnregistered: () => {
2401
+ withLiveController((live) => live.releaseHold());
2402
+ pendingRouteName = null;
2403
+ pendingRouteNameOwner = null;
2404
+ }
2405
+ });
2041
2406
  const finalTimeoutNano = resolveTimeouts(flare.config).finalTimeout * 1e6;
2042
2407
  const navigationStart = pageloadStartNano();
2043
2408
  const pageloadStart = resolvePageloadStartNano(navigationStart, defaultNowNano(), finalTimeoutNano, pageloadTraced);
2044
2409
  pageloadTraced = true;
2045
2410
  startRoot(flare, {
2046
- spanType: BrowserSpanType.Pageload,
2411
+ spanType: BrowserSpanType$1.Pageload,
2047
2412
  startTimeUnixNano: pageloadStart,
2048
2413
  backdated: pageloadStart === navigationStart
2049
2414
  });
@@ -2053,26 +2418,8 @@ function startBrowserTracing(flare) {
2053
2418
  const owner = pendingRouteNameOwner;
2054
2419
  pendingRouteName = null;
2055
2420
  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
- };
2421
+ if (isActiveNavigationSource(owner)) applyRouteName(route);
2072
2422
  }
2073
- fill(history, "pushState", wrapHistoryMethod);
2074
- fill(history, "replaceState", wrapHistoryMethod);
2075
- window.addEventListener("popstate", handle);
2076
2423
  function endRootAndFlush() {
2077
2424
  if (controller && !controller.isEnded) try {
2078
2425
  controller.endNow();
@@ -2093,9 +2440,6 @@ function startBrowserTracing(flare) {
2093
2440
  window.addEventListener("pagehide", onPageHide);
2094
2441
  document.addEventListener("visibilitychange", onVisibilityChange);
2095
2442
  uninstall = () => {
2096
- unfill(history, "pushState");
2097
- unfill(history, "replaceState");
2098
- window.removeEventListener("popstate", handle);
2099
2443
  window.removeEventListener("pagehide", onPageHide);
2100
2444
  document.removeEventListener("visibilitychange", onVisibilityChange);
2101
2445
  };
@@ -2108,11 +2452,12 @@ function stopBrowserTracing() {
2108
2452
  uninstall();
2109
2453
  uninstall = null;
2110
2454
  }
2455
+ removeNavigationSubscription?.();
2456
+ removeNavigationSubscription = null;
2111
2457
  activeFlare = null;
2112
2458
  currentRoot = null;
2113
2459
  pendingRouteName = null;
2114
2460
  pendingRouteNameOwner = null;
2115
- lastPath = "";
2116
2461
  stopWebVitals();
2117
2462
  pageloadRoot = null;
2118
2463
  pageloadRootStartNano = 0;
@@ -2163,54 +2508,70 @@ function applyRouteName(route, owner) {
2163
2508
  ...urlAttrs
2164
2509
  };
2165
2510
  }
2511
+ function activeTracingFlare() {
2512
+ return activeFlare;
2513
+ }
2514
+
2515
+ //#endregion
2516
+ //#region src/tracing/roots/componentProfiler.ts
2517
+ /** Unix nanos on the same clock the tracer uses for span timestamps. */
2518
+ const nowNano = defaultNowNano;
2166
2519
  /**
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.
2520
+ * Reserved up front so descendants can point at a span before it is recorded. Null when the trace is at
2521
+ * its span cap: descendants record before this span does, so an id the cap will refuse orphans them.
2170
2522
  */
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;
2523
+ function reserveSpanId(traceId) {
2524
+ if (traceId !== void 0 && !activeTracingFlare()?.tracer.claimSpanSlot(traceId)) return null;
2525
+ return spanId();
2526
+ }
2527
+ /** The root a top-level component nests under. Null when tracing is off or no root is recording. */
2528
+ function activeComponentRoot() {
2529
+ try {
2530
+ const root = activeTracingFlare()?.tracer.getActiveSpan();
2531
+ if (!root || !root.isRecording) return null;
2532
+ return {
2533
+ traceId: root.traceId,
2534
+ parentSpanId: root.spanId
2535
+ };
2536
+ } catch {
2537
+ return null;
2177
2538
  }
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
2539
  }
2211
- /** For sibling tracing modules (the component-profiler seam) that need the live tracer. */
2212
- function activeTracingFlare() {
2213
- return activeFlare;
2540
+ /**
2541
+ * An ancestor's context is only usable while it still belongs to the live trace. A profiled component
2542
+ * that survives a navigation (a layout around a swapped page body) froze its context under the pageload
2543
+ * trace, and `recordComponentSpan` would drop anything pointing at that closed root.
2544
+ */
2545
+ function resolveComponentParent(inherited, live) {
2546
+ if (inherited && live && inherited.traceId === live.traceId) return inherited;
2547
+ return live;
2548
+ }
2549
+ /**
2550
+ * Records only while the reserved root is still the live recording root, and drops the span otherwise.
2551
+ * Dropping avoids starting a fresh TraceState for a dead trace, which would re-run the sampler, and
2552
+ * avoids adding a child to a root that already shipped.
2553
+ */
2554
+ function recordComponentSpan(record) {
2555
+ try {
2556
+ const flare = activeTracingFlare();
2557
+ if (!flare) return;
2558
+ const root = flare.tracer.getActiveSpan();
2559
+ if (!root || root.traceId !== record.parent.traceId || !root.isRecording) return;
2560
+ flare.startSpan(record.name, {
2561
+ spanId: record.spanId,
2562
+ parent: {
2563
+ traceId: record.parent.traceId,
2564
+ spanId: record.parent.parentSpanId
2565
+ },
2566
+ spanType: BrowserSpanType$1.Component,
2567
+ startTimeUnixNano: record.startTimeUnixNano,
2568
+ attributes: {
2569
+ ...record.attributes,
2570
+ "flare.component.name": record.name
2571
+ },
2572
+ claimed: true
2573
+ }).end(record.endTimeUnixNano);
2574
+ } catch {}
2214
2575
  }
2215
2576
 
2216
2577
  //#endregion
@@ -2277,66 +2638,4 @@ function catchWindowErrors() {
2277
2638
  }
2278
2639
 
2279
2640
  //#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 };
2641
+ export { currentHref as C, absoluteHref as D, routeName as E, absoluteUrl as O, registerNavigationSource as S, resolveHref as T, BrowserFlushScheduler as _, recordComponentSpan as a, browserUrlContext as b, startBrowserTracing as c, instrumentOnce as d, insulate as f, collectBrowser as g, FetchFileReader as h, nowNano as i, stopBrowserTracing as l, CLIENT_VERSION as m, createFlareResolver as n, reserveSpanId as o, safeInvoke as p, activeComponentRoot as r, resolveComponentParent as s, catchWindowErrors as t, BrowserSpanType$1 as u, startBreadcrumbs as v, currentPath as w, withRequestPatches as x, traceRequests as y };