@flareapp/js 2.10.0 → 2.12.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.
@@ -73,10 +73,11 @@ var FormChangeRecorder = class {
73
73
  //#endregion
74
74
  //#region src/tracing/utils/absoluteHref.ts
75
75
  /**
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.
76
+ * Resolves a router-reported href against the current page. Returns the `URL`, so a caller that also
77
+ * wants the pathname does not have to parse it again.
78
78
  *
79
- * Undefined outside a browser or for an unparseable href, so the caller can leave its attribute alone.
79
+ * Returns undefined outside a browser or for an unparseable href, so the caller can leave its
80
+ * attribute alone.
80
81
  */
81
82
  function absoluteUrl(href) {
82
83
  if (href == null || typeof window === "undefined") return;
@@ -87,9 +88,9 @@ function absoluteUrl(href) {
87
88
  }
88
89
  }
89
90
  /**
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.
91
+ * The href form of `absoluteUrl`. Pass a value built by the router's own `createHref`/`resolve`, not
92
+ * a bare path routers strip the app's base path, so `origin + path` gives an address the server
93
+ * does not have.
93
94
  */
94
95
  function absoluteHref(href) {
95
96
  return absoluteUrl(href)?.href;
@@ -100,9 +101,14 @@ function absoluteHref(href) {
100
101
  function currentPath() {
101
102
  return typeof location !== "undefined" ? location.pathname : "";
102
103
  }
104
+ /** The whole address, query string included. */
103
105
  function currentHref() {
104
106
  return typeof location !== "undefined" ? location.href : "";
105
107
  }
108
+ /**
109
+ * Prefers the router's route template (`/product/:id`) over the raw path, so all urls of one route
110
+ * group together. If `derive` throws, the fallback path is used instead of breaking the app.
111
+ */
106
112
  function routeName(derive, fallbackPath, url) {
107
113
  try {
108
114
  const name = derive();
@@ -119,9 +125,9 @@ function routeName(derive, fallbackPath, url) {
119
125
  };
120
126
  }
121
127
  /**
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`.
128
+ * `build` is the router's own href builder (vue-router `resolve`, React Router `createHref`). It restores
129
+ * the app's base path and hash prefix, so an app served from `/app/` reports `/app/product/p01` instead
130
+ * of `/product/p01`. Falls back to `fallbackHref` if `build` throws.
125
131
  */
126
132
  function resolveHref(build, fallbackHref) {
127
133
  let href = fallbackHref;
@@ -133,12 +139,6 @@ function resolveHref(build, fallbackHref) {
133
139
 
134
140
  //#endregion
135
141
  //#region src/tracing/utils/fill.ts
136
- /**
137
- * Replace `source[name]` with `replacer(original)`, tagging the wrapper with a
138
- * non-enumerable `__flare_original__` so the patch is idempotent and reversible.
139
- * Ported from Sentry's `fill` (packages/core/src/utils/object.ts), minus the
140
- * prototype/own-property copying we do not need for `fetch`.
141
- */
142
142
  function fill(source, name, replacer) {
143
143
  const original = source[name];
144
144
  if (typeof original !== "function") return;
@@ -152,7 +152,6 @@ function fill(source, name, replacer) {
152
152
  });
153
153
  source[name] = wrapped;
154
154
  }
155
- /** Restore a previously `fill`ed property to its original. Safe if never filled. */
156
155
  function unfill(source, name) {
157
156
  const current = source[name];
158
157
  if (current && current.__flare_original__) source[name] = current.__flare_original__;
@@ -262,7 +261,6 @@ function registerNavigationSource() {
262
261
  }
263
262
  };
264
263
  }
265
- /** A name from an earlier call is only valid while that source is still registered. */
266
264
  function isActiveNavigationSource(token) {
267
265
  return token !== null && source === token;
268
266
  }
@@ -331,12 +329,6 @@ How to fix: use one Flare instance, and check your bundle for two copies of @fla
331
329
  function hasRequestSubscribers() {
332
330
  return subscribers.size > 0 || mutator !== null;
333
331
  }
334
- /**
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.
339
- */
340
332
  function publishRequestStart(start) {
341
333
  const handlers = [];
342
334
  for (const subscriber of subscribers) try {
@@ -367,17 +359,7 @@ function publishRequestStart(start) {
367
359
 
368
360
  //#endregion
369
361
  //#region src/tracing/requests/internalRequest.ts
370
- /**
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.
375
- *
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.
378
- */
379
362
  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
363
  function internalRequestInit(init) {
382
364
  return {
383
365
  ...init,
@@ -390,16 +372,9 @@ function isInternalRequest(init) {
390
372
 
391
373
  //#endregion
392
374
  //#region src/tracing/requests/supportsNativeFetch.ts
393
- /** True if `fn` is the browser's native fetch (not a polyfill/wrapper). */
394
375
  function isNativeFetch(fn) {
395
376
  return typeof fn === "function" && /native code/.test(Function.prototype.toString.call(fn));
396
377
  }
397
- /**
398
- * Whether the current global `fetch` is native. A polyfilled fetch (e.g. whatwg-fetch) is
399
- * XHR-backed; skip instrumenting it so the XHR patch is the single source for those requests.
400
- * Ported from Sentry, including the hidden-iframe fallback used when another library has already
401
- * wrapped `fetch` and the direct toString check is unreliable.
402
- */
403
378
  function supportsNativeFetch() {
404
379
  const globals = globalThis;
405
380
  if (typeof globals.fetch !== "function") return false;
@@ -427,10 +402,6 @@ function supportsNativeFetch() {
427
402
 
428
403
  //#endregion
429
404
  //#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
405
  function createPatcher() {
435
406
  let installed = false;
436
407
  let names = [];
@@ -638,12 +609,6 @@ function unpatchXHR() {
638
609
  //#endregion
639
610
  //#region src/instrumentation/requests/requestPatches.ts
640
611
  let subscriptions = 0;
641
- /**
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.
644
- *
645
- * @param subscribe registers one subscriber and returns its own teardown
646
- */
647
612
  function withRequestPatches(subscribe) {
648
613
  if (subscriptions === 0) {
649
614
  instrumentFetch();
@@ -666,7 +631,6 @@ function withRequestPatches(subscribe) {
666
631
 
667
632
  //#endregion
668
633
  //#region src/tracing/requests/propagation.ts
669
- /** Follows OTel/Sentry `tracePropagationTargets`: same-origin by default, `[]` disables all. */
670
634
  function shouldPropagate(url, absoluteUrl, currentOrigin, targets) {
671
635
  if (targets) {
672
636
  if (targets.length === 0) return false;
@@ -674,8 +638,6 @@ function shouldPropagate(url, absoluteUrl, currentOrigin, targets) {
674
638
  }
675
639
  return absoluteUrl !== null && absoluteUrl.origin === currentOrigin;
676
640
  }
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
641
  function headerPairsFrom(source) {
680
642
  try {
681
643
  const pairs = [];
@@ -690,16 +652,9 @@ function headerPairsFrom(source) {
690
652
  return null;
691
653
  }
692
654
  }
693
- /** Fetch accepts any iterable of string pairs as HeadersInit (Map, URLSearchParams, cross-realm Headers). */
694
655
  function isIterable(value) {
695
656
  return value !== null && (typeof value === "object" || typeof value === "function") && typeof value[Symbol.iterator] === "function";
696
657
  }
697
- /**
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.
702
- */
703
658
  function mergeTraceparentHeader(input, init, traceparent) {
704
659
  const source = init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : void 0);
705
660
  let headers;
@@ -739,7 +694,6 @@ const REQUEST_SPAN_TYPES = {
739
694
  xhr: _flareapp_core.BrowserSpanType.Xhr
740
695
  };
741
696
  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
697
  function browserUrlContext() {
744
698
  const origin = globalThis.location?.origin ?? "";
745
699
  return {
@@ -747,7 +701,6 @@ function browserUrlContext() {
747
701
  base: () => globalThis.document?.baseURI || origin
748
702
  };
749
703
  }
750
- /** Resolve `url` to an absolute URL against `base`, or null if it cannot be parsed. */
751
704
  function safeAbsolute(url, base) {
752
705
  try {
753
706
  return new URL(url, base || void 0);
@@ -775,20 +728,10 @@ function matchesIngestHref(href, ingestHref) {
775
728
  const next = href.charAt(ingestHref.length);
776
729
  return next === "" || next === "/" || next === "?" || next === "#";
777
730
  }
778
- /**
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.
783
- */
784
731
  function isFlareIngestUrl(resolved, config, base) {
785
732
  if (!resolved) return false;
786
733
  return resolvedIngestHrefs(config, base).some((ingestHref) => matchesIngestHref(resolved.href, ingestHref));
787
734
  }
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
735
  function requestSpanAttributes(method, resolved, url, config) {
793
736
  return {
794
737
  "http.request.method": method,
@@ -797,12 +740,6 @@ function requestSpanAttributes(method, resolved, url, config) {
797
740
  ...resolved && resolved.port ? { "server.port": Number(resolved.port) } : {}
798
741
  };
799
742
  }
800
- /**
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
743
  function endHttpRequestSpan(span, status, opts) {
807
744
  span.setAttribute("http.response.status_code", status);
808
745
  if (status >= 500 || opts?.zeroIsError && status === 0) span.setStatus({ code: _flareapp_core.SpanStatusCode.Error });
@@ -815,21 +752,10 @@ function finishHttpSpanError(span, error) {
815
752
  });
816
753
  span.end();
817
754
  }
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
755
  function traceparentFor(span, resolved, url, origin, config) {
823
756
  if (!shouldPropagate(resolved ? resolved.href : url, resolved, origin, config.tracePropagationTargets)) return null;
824
757
  return (0, _flareapp_core.buildTraceparent)(span.traceId, span.spanId, span.isRecording);
825
758
  }
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.
829
- *
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.
832
- */
833
759
  function startHttpRequestSpan(tracer, request) {
834
760
  const { method, url, urls, spanType } = request;
835
761
  const config = tracer.config;
@@ -849,12 +775,6 @@ function startHttpRequestSpan(tracer, request) {
849
775
 
850
776
  //#endregion
851
777
  //#region src/tracing/requests/traceRequests.ts
852
- /**
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.
857
- */
858
778
  function zeroIsError(absoluteUrl) {
859
779
  return absoluteUrl !== null && (absoluteUrl.protocol === "http:" || absoluteUrl.protocol === "https:");
860
780
  }
@@ -865,7 +785,6 @@ function propagate(span, absoluteUrl, start, urls, tracer) {
865
785
  if (start.input === void 0) return {};
866
786
  return { init: mergeTraceparentHeader(start.input, start.init, traceparent) };
867
787
  }
868
- /** Tracing takes the mutation slot, not a plain subscription, because it adds a `traceparent` header. */
869
788
  function traceRequests(tracer, urls) {
870
789
  return claimRequestMutation((start) => {
871
790
  if (!tracer.config.enableTracing) return;
@@ -941,7 +860,6 @@ var RequestRecorder = class {
941
860
 
942
861
  //#endregion
943
862
  //#region src/breadcrumbs/index.ts
944
- /** Starts every recorder, returns one teardown. A recorder that fails to install is skipped. */
945
863
  function startBreadcrumbs(host) {
946
864
  if (typeof document === "undefined") return () => {};
947
865
  const recorders = [
@@ -975,10 +893,6 @@ var BrowserFlushScheduler = class {
975
893
 
976
894
  //#endregion
977
895
  //#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
896
  function cookie(denylist) {
983
897
  if (!window.document.cookie) return {};
984
898
  const cookies = Object.create(null);
@@ -995,13 +909,86 @@ function cookie(denylist) {
995
909
  return { "http.request.cookies": cookies };
996
910
  }
997
911
 
912
+ //#endregion
913
+ //#region src/browser/context/deviceReaders.ts
914
+ const EFFECTIVE_TYPES = [
915
+ "slow-2g",
916
+ "2g",
917
+ "3g",
918
+ "4g"
919
+ ];
920
+ function readScreen() {
921
+ if (typeof screen === "undefined" || typeof screen.width !== "number" || typeof screen.height !== "number") return null;
922
+ const scale = typeof devicePixelRatio === "number" ? devicePixelRatio : void 0;
923
+ return scale != null ? {
924
+ width: screen.width,
925
+ height: screen.height,
926
+ scale
927
+ } : {
928
+ width: screen.width,
929
+ height: screen.height
930
+ };
931
+ }
932
+ function readNetwork(nav) {
933
+ const network = {};
934
+ if (typeof nav.onLine === "boolean") network.online = nav.onLine;
935
+ const connection = nav.connection;
936
+ if (connection) {
937
+ const { effectiveType, downlink, rtt } = connection;
938
+ if (effectiveType && EFFECTIVE_TYPES.includes(effectiveType)) network.effectiveType = effectiveType;
939
+ if (typeof downlink === "number") network.downlinkMbps = downlink;
940
+ if (typeof rtt === "number") network.rttMs = rtt;
941
+ }
942
+ return Object.keys(network).length > 0 ? network : null;
943
+ }
944
+ function readTimezone() {
945
+ try {
946
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || void 0;
947
+ } catch {
948
+ return;
949
+ }
950
+ }
951
+
952
+ //#endregion
953
+ //#region src/browser/context/deviceInfo.ts
954
+ var BrowserDeviceInfoProvider = class {
955
+ staticInfo = null;
956
+ collect() {
957
+ if (typeof window === "undefined" || typeof navigator === "undefined") return {};
958
+ const nav = navigator;
959
+ const info = {};
960
+ const staticInfo = this.readStatic(nav);
961
+ const screen = readScreen();
962
+ const device = {
963
+ ...staticInfo.device,
964
+ ...screen ? { screen } : {}
965
+ };
966
+ if (Object.keys(device).length > 0) info.device = device;
967
+ if (staticInfo.locale) info.locale = staticInfo.locale;
968
+ const network = readNetwork(nav);
969
+ if (network) info.network = network;
970
+ return info;
971
+ }
972
+ readStatic(nav) {
973
+ if (this.staticInfo) return this.staticInfo;
974
+ const device = {};
975
+ if (typeof nav.deviceMemory === "number") device.memoryGb = nav.deviceMemory;
976
+ if (typeof nav.hardwareConcurrency === "number") device.cpuCores = nav.hardwareConcurrency;
977
+ const locale = {};
978
+ if (nav.language) locale.language = nav.language;
979
+ const timezone = readTimezone();
980
+ if (timezone) locale.timezone = timezone;
981
+ this.staticInfo = {
982
+ device: Object.keys(device).length > 0 ? device : void 0,
983
+ locale: Object.keys(locale).length > 0 ? locale : void 0
984
+ };
985
+ return this.staticInfo;
986
+ }
987
+ };
988
+ const browserDeviceInfoProvider = new BrowserDeviceInfoProvider();
989
+
998
990
  //#endregion
999
991
  //#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
992
  function request(urlDenylist, hrefOverride) {
1006
993
  return {
1007
994
  ...(0, _flareapp_core.urlAttributes)(hrefOverride ?? window.location.href, urlDenylist),
@@ -1030,6 +1017,7 @@ function browserEntryPoint(config, urlOverride) {
1030
1017
  }
1031
1018
  const collectBrowser = (config) => {
1032
1019
  const attrs = { ...browserEntryPoint(config) };
1020
+ Object.assign(attrs, (0, _flareapp_core.deviceInfoToAttributes)(browserDeviceInfoProvider.collect()));
1033
1021
  if (typeof window === "undefined") return attrs;
1034
1022
  if (window?.location?.hostname) attrs["host.name"] = window.location.hostname;
1035
1023
  Object.assign(attrs, request(config.urlDenylist));
@@ -1056,7 +1044,7 @@ var FetchFileReader = class {
1056
1044
 
1057
1045
  //#endregion
1058
1046
  //#region src/env/index.ts
1059
- const CLIENT_VERSION = typeof process !== "undefined" && true ? "2.10.0" : "?";
1047
+ const CLIENT_VERSION = typeof process !== "undefined" && true ? "2.12.0" : "?";
1060
1048
 
1061
1049
  //#endregion
1062
1050
  //#region src/tracing/utils/instrumentationGuard.ts
@@ -1076,16 +1064,16 @@ function safeInvoke(fn) {
1076
1064
  }
1077
1065
  const instrumented = /* @__PURE__ */ new WeakMap();
1078
1066
  /**
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
1067
+ * Instruments `target` at most once at a time, tearing down any prior instrumentation of the same
1068
+ * object first. Vite HMR re-runs boot code against a router that survives the reload, so without this
1069
+ * every cycle would add another listener set that is never removed. Keyed on the object, so a new
1082
1070
  * router is unaffected.
1083
1071
  *
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.
1072
+ * `install` hands each teardown to `track` as it produces it. A router's `subscribe`/`on`/guard call
1073
+ * can throw during the host's bootstrap, so a throw part-way through unwinds what already succeeded
1074
+ * (newest first) instead of reaching the host.
1087
1075
  *
1088
- * @returns the cleanup, or a no-op when the install failed and already unwound itself.
1076
+ * @returns The cleanup function, or a no-op if install failed and already unwound itself.
1089
1077
  */
1090
1078
  function instrumentOnce(target, install) {
1091
1079
  instrumented.get(target)?.();
@@ -1111,15 +1099,6 @@ function instrumentOnce(target, install) {
1111
1099
 
1112
1100
  //#endregion
1113
1101
  //#region src/browser/context/collectBrowserSpanContext.ts
1114
- /**
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.
1122
- */
1123
1102
  function collectBrowserSpanContext(config, hrefOverride) {
1124
1103
  if (typeof window === "undefined") return {};
1125
1104
  const url = absoluteUrl(hrefOverride);
@@ -1128,16 +1107,6 @@ function collectBrowserSpanContext(config, hrefOverride) {
1128
1107
  ...request(config.urlDenylist, url?.href)
1129
1108
  };
1130
1109
  }
1131
- /**
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.
1140
- */
1141
1110
  function browserSpanUrlAttributes(config, href) {
1142
1111
  if (typeof window === "undefined") return {};
1143
1112
  const resolved = absoluteUrl(href);
@@ -1935,28 +1904,12 @@ const onTTFB = (onReport, opts = {}) => {
1935
1904
 
1936
1905
  //#endregion
1937
1906
  //#region src/tracing/vitals/webVitals.ts
1938
- /**
1939
- * Final the moment they first report, so they can ride the pageload span itself. The other three keep
1940
- * changing until the page goes away, and stamping an early value on the root would leave the root and
1941
- * the later span disagreeing about the same vital.
1942
- */
1943
1907
  const EARLY_VITALS = ["ttfb", "fcp"];
1944
- /** The one place a vital becomes a wire key. Both the pageload stamp and the late span go through it. */
1945
1908
  function vitalAttributes(vitals) {
1946
1909
  const attributes = {};
1947
1910
  for (const [name, value] of Object.entries(vitals)) if (typeof value === "number") attributes[`browser.web_vital.${name}`] = value;
1948
1911
  return attributes;
1949
1912
  }
1950
- /**
1951
- * Turns the leftover values into one zero-duration span. Pure on purpose: the shape is what the backend
1952
- * groups on, and this way it is testable without a tracer or a clock.
1953
- *
1954
- * Both timestamps sit at the pageload root's start. `spans_2` buckets on `start_time_unix_nano`, so
1955
- * stamping the report moment would drop a tab left open for forty minutes into a minute forty minutes
1956
- * after the page actually loaded.
1957
- *
1958
- * Returns null when nothing is left to report, so the caller emits no span at all.
1959
- */
1960
1913
  function buildVitalsSpan(input) {
1961
1914
  const vitals = vitalAttributes(input.vitals);
1962
1915
  if (Object.keys(vitals).length === 0) return null;
@@ -1986,10 +1939,6 @@ function defaultSubscribers() {
1986
1939
  onINP: (cb) => onINP(cb, { reportAllChanges: true })
1987
1940
  };
1988
1941
  }
1989
- /**
1990
- * Subscribes at most once per document: upstream's on* functions return no unsubscribe handle, so a
1991
- * second call would attach a second set of observers with no way to detach either.
1992
- */
1993
1942
  function startWebVitals(subscribers = defaultSubscribers()) {
1994
1943
  recording = true;
1995
1944
  if (subscribed) return;
@@ -2009,21 +1958,10 @@ function record(name, metric) {
2009
1958
  if (!recording || typeof metric?.value !== "number") return;
2010
1959
  collected[name] = metric.value;
2011
1960
  }
2012
- /**
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
1961
  function stopWebVitals() {
2020
1962
  recording = false;
2021
1963
  collected = {};
2022
1964
  }
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
1965
  function takeEarlyVitals() {
2028
1966
  if (!recording) return null;
2029
1967
  const taking = {};
@@ -2036,7 +1974,6 @@ function takeEarlyVitals() {
2036
1974
  }
2037
1975
  return Object.keys(taking).length === 0 ? null : taking;
2038
1976
  }
2039
- /** Everything still outstanding, once. Null afterwards, and null when nothing is left. */
2040
1977
  function takeWebVitals() {
2041
1978
  if (taken || !recording || Object.keys(collected).length === 0) return null;
2042
1979
  taken = true;
@@ -2044,29 +1981,76 @@ function takeWebVitals() {
2044
1981
  collected = {};
2045
1982
  return taking;
2046
1983
  }
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
1984
  function restoreWebVitals(vitals) {
2053
1985
  collected = vitals;
2054
1986
  taken = false;
2055
1987
  }
2056
1988
 
1989
+ //#endregion
1990
+ //#region src/tracing/roots/componentSelfTime.ts
1991
+ /**
1992
+ * Only a parent that never records leaves an entry behind: an async child that registers after its
1993
+ * parent already shipped. The normal path frees every entry the moment the parent records, so this
1994
+ * cap is a backstop, not a working limit.
1995
+ */
1996
+ const MAX_TRACKED_PARENTS = 256;
1997
+ const childIntervals = /* @__PURE__ */ new Map();
1998
+ /** Files a recorded child under its parent, so the parent can subtract it. */
1999
+ function trackChildInterval(parentSpanId, startTimeUnixNano, endTimeUnixNano) {
2000
+ const existing = childIntervals.get(parentSpanId);
2001
+ if (existing) {
2002
+ existing.push([startTimeUnixNano, endTimeUnixNano]);
2003
+ return;
2004
+ }
2005
+ (0, _flareapp_core.evictLruIfNew)(childIntervals, parentSpanId, MAX_TRACKED_PARENTS);
2006
+ childIntervals.set(parentSpanId, [[startTimeUnixNano, endTimeUnixNano]]);
2007
+ }
2008
+ /**
2009
+ * Duration minus the time the component's own children already account for, and consumes those
2010
+ * children. Grandchildren need no handling: they sit inside a child's interval.
2011
+ *
2012
+ * Children that record after their parent are not subtracted, so the value is self time at commit.
2013
+ * That covers async components and `<Suspense>`, and a vue-router layout too: the initial route
2014
+ * resolves after the layout mounted, so the page component's work falls outside the layout's window
2015
+ * and the layout keeps its full duration.
2016
+ */
2017
+ function takeSelfTime(spanId, startTimeUnixNano, endTimeUnixNano) {
2018
+ const children = childIntervals.get(spanId);
2019
+ childIntervals.delete(spanId);
2020
+ const duration = endTimeUnixNano - startTimeUnixNano;
2021
+ if (!children) return Math.max(0, duration);
2022
+ return Math.max(0, duration - coveredTime(children, startTimeUnixNano, endTimeUnixNano));
2023
+ }
2024
+ function resetComponentSelfTime() {
2025
+ childIntervals.clear();
2026
+ }
2027
+ /**
2028
+ * The union of the intervals, clamped to the parent window. A sum would double-count: React starts
2029
+ * every component during render and ends them all during commit, so siblings overlap, and summing
2030
+ * their durations exceeds the parent's own.
2031
+ */
2032
+ function coveredTime(intervals, start, end) {
2033
+ intervals.sort((a, b) => a[0] - b[0]);
2034
+ let covered = 0;
2035
+ let cursor = start;
2036
+ for (const [childStart, childEnd] of intervals) {
2037
+ const from = Math.max(childStart, cursor);
2038
+ const to = Math.min(childEnd, end);
2039
+ if (to > from) {
2040
+ covered += to - from;
2041
+ cursor = to;
2042
+ }
2043
+ }
2044
+ return covered;
2045
+ }
2046
+
2057
2047
  //#endregion
2058
2048
  //#region src/tracing/roots/IdleRootController.ts
2059
- /** Browser defaults for the three idle-root timeouts, in ms. Overridable per Config. */
2060
2049
  const DEFAULT_IDLE_TIMEOUTS = {
2061
2050
  idleTimeout: 1e3,
2062
2051
  finalTimeout: 3e4,
2063
2052
  childSpanTimeout: 15e3
2064
2053
  };
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
2054
  var IdleRootController = class {
2071
2055
  openChildren = 0;
2072
2056
  lastChildEndTime = null;
@@ -2091,17 +2075,9 @@ var IdleRootController = class {
2091
2075
  get isEnded() {
2092
2076
  return this.ended;
2093
2077
  }
2094
- /** For a route change or pagehide. */
2095
2078
  endNow() {
2096
2079
  this.finish(this.openChildren > 0 || this.held ? this.deps.now() : this.trimmedEnd());
2097
2080
  }
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
2081
  releaseHold() {
2106
2082
  if (this.ended || !this.held) return;
2107
2083
  this.held = false;
@@ -2138,8 +2114,6 @@ var IdleRootController = class {
2138
2114
  this.finish(this.trimmedEnd());
2139
2115
  }, this.timeouts.idleTimeout);
2140
2116
  }
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
2117
  trimmedEnd() {
2144
2118
  return Math.max(this.deps.endFloor(), this.settleTime ?? 0, this.lastChildEndTime ?? 0);
2145
2119
  }
@@ -2177,21 +2151,14 @@ var IdleRootController = class {
2177
2151
 
2178
2152
  //#endregion
2179
2153
  //#region src/tracing/roots/navigationTiming.ts
2180
- /** Split out so the timestamp maths is testable without a Navigation Timing entry. */
2181
2154
  function computePageloadStartNano(timeOriginMs, startTimeMs) {
2182
2155
  return Math.round((timeOriginMs + (startTimeMs ?? 0)) * 1e6);
2183
2156
  }
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.
2188
- */
2189
2157
  function resolvePageloadStartNano(backdatedNano, nowNano, finalTimeoutNano, alreadyTraced) {
2190
2158
  if (alreadyTraced) return nowNano;
2191
2159
  if (nowNano - backdatedNano > finalTimeoutNano) return nowNano;
2192
2160
  return backdatedNano;
2193
2161
  }
2194
- /** The Navigation Timing API, or null where it is missing or only partly implemented. */
2195
2162
  function navigationTiming() {
2196
2163
  const perf = globalThis.performance;
2197
2164
  if (!perf || typeof perf.getEntriesByType !== "function" || typeof perf.timeOrigin !== "number") return null;
@@ -2200,10 +2167,6 @@ function navigationTiming() {
2200
2167
  function navigationEntry(perf) {
2201
2168
  return perf.getEntriesByType("navigation")[0];
2202
2169
  }
2203
- /**
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.
2206
- */
2207
2170
  function pageloadStartNano() {
2208
2171
  const perf = navigationTiming();
2209
2172
  if (!perf) return (0, _flareapp_core.defaultNowNano)();
@@ -2214,13 +2177,6 @@ function computePageloadEndNano(timeOriginMs, loadEventEndMs, domContentLoadedEv
2214
2177
  if (!endMs) return nowNano;
2215
2178
  return Math.round((timeOriginMs + endMs) * 1e6);
2216
2179
  }
2217
- /**
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.
2223
- */
2224
2180
  function pageloadEndNano() {
2225
2181
  const perf = navigationTiming();
2226
2182
  if (!perf) return (0, _flareapp_core.defaultNowNano)();
@@ -2249,8 +2205,6 @@ function resolveTimeouts(config) {
2249
2205
  childSpanTimeout: config.childSpanTimeout ?? DEFAULT_IDLE_TIMEOUTS.childSpanTimeout
2250
2206
  };
2251
2207
  }
2252
- /** No-ops once the controller has ended: it can close itself asynchronously via a timer, before this
2253
- * module's `controller` reference is cleared. */
2254
2208
  function withLiveController(fn) {
2255
2209
  if (!controller || controller.isEnded) return;
2256
2210
  try {
@@ -2259,13 +2213,13 @@ function withLiveController(fn) {
2259
2213
  if (activeFlare?.config.debug) console.error("Flare: browser tracing controller callback failed", error);
2260
2214
  }
2261
2215
  }
2262
- /** Run `fn` only while the current root is still open. Swallows a throw, like `withLiveController`. */
2263
2216
  function ifRootLive(fn) {
2264
2217
  withLiveController(() => fn());
2265
2218
  }
2266
2219
  function startRoot(flare, options) {
2267
2220
  const { spanType, startTimeUnixNano, name = location.pathname, urlOverride, hold, backdated } = options;
2268
2221
  let root;
2222
+ resetComponentSelfTime();
2269
2223
  try {
2270
2224
  const context = collectBrowserSpanContext(flare.config, urlOverride);
2271
2225
  root = flare.startSpan(name, {
@@ -2327,14 +2281,6 @@ function openNavigationRoot(flare, opts) {
2327
2281
  hold: opts.hold
2328
2282
  });
2329
2283
  }
2330
- /**
2331
- * Writes the already-final vitals onto the pageload root itself, from `IdleRootController`'s beforeEnd
2332
- * hook. Whatever has not reported yet stays in `collected` and rides the later `browser_web_vital`
2333
- * span instead, so no vital is ever sent twice and none is lost.
2334
- *
2335
- * Swallows its own failures: this runs inside the root's close path, and a throw here would leave the
2336
- * root open forever.
2337
- */
2338
2284
  function stampEarlyVitals(root, flare) {
2339
2285
  try {
2340
2286
  const early = takeEarlyVitals();
@@ -2344,19 +2290,6 @@ function stampEarlyVitals(root, flare) {
2344
2290
  if (flare.config.debug) console.error("Flare: failed to stamp web vitals on the pageload root", error);
2345
2291
  }
2346
2292
  }
2347
- /**
2348
- * Emits whatever the pageload root could not carry as one zero-duration `browser_web_vital` span,
2349
- * parented to that root. Page hide only, and once per document.
2350
- *
2351
- * Deliberately NOT on navigation: LCP, CLS and INP keep moving all document long, so emitting at the
2352
- * first route change froze them a second after load, and a session whose first action was a nav click
2353
- * reported no INP at all. One span per document is a backend constraint, so the emit waits for the last
2354
- * moment we get instead. The cost is a page whose hide event never fires reports no vitals.
2355
- *
2356
- * `pageloadRoot` has usually ended by now; reading `traceId` and `spanId` off an ended span is fine,
2357
- * and passing the `Span` rather than a `{ traceId, spanId }` pair is what makes sampling inherit:
2358
- * `resolveTrace()` reads `parent.isRecording` instead of re-rolling the sampler.
2359
- */
2360
2293
  function emitWebVitals(flare) {
2361
2294
  const root = pageloadRoot;
2362
2295
  const route = pageloadRoute;
@@ -2384,7 +2317,6 @@ function emitWebVitals(flare) {
2384
2317
  if (flare.config.debug) console.error("Flare: failed to emit web vitals", error);
2385
2318
  }
2386
2319
  }
2387
- /** Opens a backdated pageload root, then a navigation root per History change. No-op outside a browser. Idempotent. */
2388
2320
  function startBrowserTracing(flare) {
2389
2321
  if (typeof window === "undefined" || typeof history === "undefined" || typeof location === "undefined") return;
2390
2322
  if (uninstall) return;
@@ -2444,7 +2376,6 @@ function startBrowserTracing(flare) {
2444
2376
  document.removeEventListener("visibilitychange", onVisibilityChange);
2445
2377
  };
2446
2378
  }
2447
- /** Idempotent. */
2448
2379
  function stopBrowserTracing() {
2449
2380
  withLiveController((live) => live.endNow());
2450
2381
  controller = null;
@@ -2459,15 +2390,12 @@ function stopBrowserTracing() {
2459
2390
  pendingRouteName = null;
2460
2391
  pendingRouteNameOwner = null;
2461
2392
  stopWebVitals();
2393
+ resetComponentSelfTime();
2462
2394
  pageloadRoot = null;
2463
2395
  pageloadRootStartNano = 0;
2464
2396
  pageloadRoute = null;
2465
2397
  pageloadContext = {};
2466
2398
  }
2467
- /**
2468
- * Computes the url attributes a route rename carries, or null when there is nothing to add. Guarded
2469
- * so the pin below (which runs outside ifRootLive's own try/catch) cannot throw into the host.
2470
- */
2471
2399
  function urlAttributesFor(route) {
2472
2400
  if (route.url === void 0 || !activeFlare) return null;
2473
2401
  try {
@@ -2476,12 +2404,6 @@ function urlAttributesFor(route) {
2476
2404
  return null;
2477
2405
  }
2478
2406
  }
2479
- /**
2480
- * Rename the current root and update the attributes that go with the name, and pin the pageload's route
2481
- * for the vitals emit. No-op once it closed; the pin is NOT gated the same way, see below.
2482
- * With no root yet the name is held for the pageload root that opens next, rather than dropped.
2483
- * `owner` stamps who is holding it, so a stale or superseded source cannot land its name later.
2484
- */
2485
2407
  function applyRouteName(route, owner) {
2486
2408
  const root = currentRoot;
2487
2409
  if (!root) {
@@ -2517,8 +2439,8 @@ function activeTracingFlare() {
2517
2439
  /** Unix nanos on the same clock the tracer uses for span timestamps. */
2518
2440
  const nowNano = _flareapp_core.defaultNowNano;
2519
2441
  /**
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.
2442
+ * Reserved up front so descendants can point at a span before it is recorded. Null when the trace
2443
+ * is at its span cap, since descendants record before this span does and would be orphaned.
2522
2444
  */
2523
2445
  function reserveSpanId(traceId) {
2524
2446
  if (traceId !== void 0 && !activeTracingFlare()?.tracer.claimSpanSlot(traceId)) return null;
@@ -2538,18 +2460,18 @@ function activeComponentRoot() {
2538
2460
  }
2539
2461
  }
2540
2462
  /**
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.
2463
+ * An ancestor's context is only usable while it still belongs to the live trace. A component that
2464
+ * survives a navigation (a layout around a swapped page body) froze its context under the old
2465
+ * pageload trace, and `recordComponentSpan` would drop anything pointing at that closed root.
2544
2466
  */
2545
2467
  function resolveComponentParent(inherited, live) {
2546
2468
  if (inherited && live && inherited.traceId === live.traceId) return inherited;
2547
2469
  return live;
2548
2470
  }
2549
2471
  /**
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.
2472
+ * Records only while the reserved root is still the live recording root, and drops the span
2473
+ * otherwise. Dropping avoids re-running the sampler for a dead trace and avoids adding a child to
2474
+ * a root that already shipped.
2553
2475
  */
2554
2476
  function recordComponentSpan(record) {
2555
2477
  try {
@@ -2557,6 +2479,7 @@ function recordComponentSpan(record) {
2557
2479
  if (!flare) return;
2558
2480
  const root = flare.tracer.getActiveSpan();
2559
2481
  if (!root || root.traceId !== record.parent.traceId || !root.isRecording) return;
2482
+ const selfTime = takeSelfTime(record.spanId, record.startTimeUnixNano, record.endTimeUnixNano);
2560
2483
  flare.startSpan(record.name, {
2561
2484
  spanId: record.spanId,
2562
2485
  parent: {
@@ -2567,19 +2490,17 @@ function recordComponentSpan(record) {
2567
2490
  startTimeUnixNano: record.startTimeUnixNano,
2568
2491
  attributes: {
2569
2492
  ...record.attributes,
2570
- "flare.component.name": record.name
2493
+ "flare.component.name": record.name,
2494
+ "flare.component.self_time_ns": selfTime
2571
2495
  },
2572
2496
  claimed: true
2573
2497
  }).end(record.endTimeUnixNano);
2498
+ trackChildInterval(record.parent.parentSpanId, record.startTimeUnixNano, record.endTimeUnixNano);
2574
2499
  } catch {}
2575
2500
  }
2576
2501
 
2577
2502
  //#endregion
2578
2503
  //#region src/createFlareResolver.ts
2579
- /**
2580
- * `process.env.NODE_ENV` is replaced inline by bundlers. The try/catch keeps a process-less
2581
- * environment safe: treat "undetermined" as production (warn, never crash).
2582
- */
2583
2504
  function isDevMode() {
2584
2505
  try {
2585
2506
  return process.env.NODE_ENV !== "production";
@@ -2588,11 +2509,10 @@ function isDevMode() {
2588
2509
  }
2589
2510
  }
2590
2511
  /**
2591
- * Builds a per-package Flare resolver: `registerDefaultFlare` (wired once by the web entry) and
2592
- * `resolveFlare` (called at wiring time). Each call holds its own default-provider state. The check
2593
- * that warns about the Electron `__flare` bridge uses `packageName` in its message;
2594
- * `injectInstruction` replaces the closing hint for packages whose advice differs (for example
2595
- * svelte, which points at the preprocessor's importSource).
2512
+ * Builds a per-package Flare resolver: `registerDefaultFlare` (set once by the web entry) and
2513
+ * `resolveFlare` (called at wiring time). Each call keeps its own default-provider state.
2514
+ * `packageName` names the package in the Electron-bridge warning; `injectInstruction` overrides the
2515
+ * closing hint for packages with different advice (for example Svelte's preprocessor importSource).
2596
2516
  */
2597
2517
  function createFlareResolver(config) {
2598
2518
  const { packageName } = config;