@flareapp/core 2.7.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.
package/dist/index.mjs CHANGED
@@ -1,196 +1,84 @@
1
+ import { D as CLIENT_VERSION, E as assert, O as KEY, S as convertToError, T as assertKey, _ as TRUNCATED, a as routeRejection, b as extractCode, c as redactUrlQuery, d as now, f as timelineEvents, g as MAX_TRAVERSAL_DEPTH, h as safeClone, i as describeRejectionReason, k as SOURCEMAP_VERSION, l as resolveDenylist, m as flatJsonStringify, n as urlAttributes, o as DEFAULT_URL_DENYLIST, p as glowsToEvents, r as toCustomContext, s as redactObjectValues, u as safeDecode, v as createTraversalBudget, x as createIdentityTagger, y as spendNode } from "./urlAttributes-qNkR9fIF.mjs";
1
2
  import ErrorStackParser from "error-stack-parser";
2
3
 
3
- //#region src/env/index.ts
4
- const CLIENT_VERSION = typeof process !== "undefined" && true ? "2.7.0" : "?";
5
- const KEY = typeof FLARE_JS_KEY === "undefined" ? "" : FLARE_JS_KEY;
6
- const SOURCEMAP_VERSION = typeof FLARE_SOURCEMAP_VERSION === "undefined" ? "" : FLARE_SOURCEMAP_VERSION;
7
-
8
- //#endregion
9
- //#region src/util/assert.ts
10
- function assert(value, message, debug) {
11
- if (debug && !value) console.error(`Flare JavaScript client v${CLIENT_VERSION}: ${message}`);
12
- return !!value;
13
- }
14
-
15
- //#endregion
16
- //#region src/util/assertKey.ts
17
- function assertKey(key, debug) {
18
- return assert(key, "The client was not yet initialised with an API key. Run client.light('<flare-project-key>') when you initialise your app. If you are running in dev mode and didn't run the light command on purpose, you can ignore this error.", debug);
19
- }
20
-
21
- //#endregion
22
- //#region src/util/convertToError.ts
23
- function convertToError(error) {
24
- if (error instanceof Error) return error;
25
- if (typeof error === "string") return new Error(error);
26
- if (typeof error === "object" && error !== null) {
27
- const obj = error;
28
- const message = typeof obj.message === "string" ? obj.message : String(error);
29
- const converted = new Error(message);
30
- if (typeof obj.stack === "string") converted.stack = obj.stack;
31
- if (typeof obj.name === "string") converted.name = obj.name;
32
- return converted;
33
- }
34
- return new Error(String(error));
35
- }
36
-
37
- //#endregion
38
- //#region src/util/extractCode.ts
39
- const MAX_CODE_LENGTH = 64;
40
- function extractCode(error) {
41
- const code = error.code;
42
- if (typeof code !== "string" || code.length === 0) return;
43
- return code.slice(0, MAX_CODE_LENGTH);
44
- }
45
-
46
- //#endregion
47
- //#region src/util/flatJsonStringify.ts
48
- function flatJsonStringify(json) {
49
- return JSON.stringify(decycle(json));
50
- }
51
- function isPlainObject(value) {
52
- if (typeof value !== "object" || value === null) return false;
53
- const proto = Object.getPrototypeOf(value);
54
- return proto === Object.prototype || proto === null;
55
- }
56
- function decycle(root) {
57
- const inPath = /* @__PURE__ */ new WeakSet();
58
- function clone(node) {
59
- if (Array.isArray(node)) {
60
- if (inPath.has(node)) return "[Circular]";
61
- inPath.add(node);
62
- const result = node.map(clone);
63
- inPath.delete(node);
64
- return result;
65
- }
66
- if (isPlainObject(node)) {
67
- if (inPath.has(node)) return "[Circular]";
68
- inPath.add(node);
69
- const result = {};
70
- for (const [k, v] of Object.entries(node)) result[k] = clone(v);
71
- inPath.delete(node);
72
- return result;
73
- }
74
- return node;
75
- }
76
- return clone(root);
77
- }
78
-
79
- //#endregion
80
- //#region src/util/glowsToEvents.ts
81
- function glowsToEvents(glows) {
82
- return glows.map((glow) => ({
83
- type: "php_glow",
84
- startTimeUnixNano: Math.round(glow.microtime * 1e9),
85
- endTimeUnixNano: null,
86
- attributes: {
87
- "glow.name": String(glow.name),
88
- "glow.level": glow.messageLevel,
89
- "glow.context": glow.metaData ?? {}
90
- }
91
- }));
92
- }
4
+ //#region src/framework.ts
5
+ /**
6
+ * Framework names the Flare backend recognises. Wire format, so the values never change: they ship as
7
+ * `flare.framework.name` and (lowercased) as `context.custom.framework`.
8
+ *
9
+ * `Js` and `Node` are the base SDKs' fallback claim, overwritten when a framework package tags its
10
+ * own name. `NodeElectron` is an Electron main process; its renderers report their own.
11
+ */
12
+ const FrameworkName = {
13
+ Js: "js",
14
+ Node: "node",
15
+ NodeElectron: "node-electron",
16
+ React: "react",
17
+ Vue: "vue",
18
+ Svelte: "svelte",
19
+ SvelteKit: "sveltekit",
20
+ ReactNative: "react-native"
21
+ };
93
22
 
94
23
  //#endregion
95
- //#region src/util/now.ts
96
- function now() {
97
- return Math.round(Date.now() / 1e3);
98
- }
24
+ //#region src/spanTypes.ts
25
+ /**
26
+ * Span types the Flare backend recognises. Wire format, so the values never change: they ship as the
27
+ * `flare.span_type` attribute and the backend groups performance data by them.
28
+ *
29
+ * These are the browser client's set. They live in core because core's `SpanOptions.spanType` needs
30
+ * to name them and core cannot import from `@flareapp/js`.
31
+ */
32
+ const BrowserSpanType = {
33
+ Pageload: "browser_pageload",
34
+ Navigation: "browser_navigation",
35
+ Fetch: "browser_fetch",
36
+ Xhr: "browser_xhr",
37
+ Component: "browser_component",
38
+ WebVital: "browser_web_vital"
39
+ };
40
+ const BrowserSpanEventType = {
41
+ Click: "browser_click",
42
+ Input: "browser_input",
43
+ RouteChange: "browser_route_change"
44
+ };
99
45
 
100
46
  //#endregion
101
- //#region src/util/redactUrl.ts
102
- const DEFAULT_URL_DENYLIST = /password|passwd|pwd|token|secret|authorization|\bauth\b|bearer|oauth|credentials?|cookie|api[-_]?key|private[-_]?key|session|csrf|xsrf|\bpin\b|\bssn\b|card[-_]?number|\bcvv\b/i;
103
- function resolveDenylist(custom, replaceDefault = false, defaultDenylist = DEFAULT_URL_DENYLIST) {
104
- if (!custom) return defaultDenylist;
105
- if (replaceDefault) {
106
- const safeFlags = custom.flags.replace(/[gy]/g, "");
107
- return new RegExp(custom.source, safeFlags);
108
- }
109
- const flags = unionFlags(defaultDenylist.flags, custom.flags);
110
- return new RegExp(`(?:${defaultDenylist.source})|(?:${custom.source})`, flags);
111
- }
112
- function unionFlags(a, b) {
113
- const merged = /* @__PURE__ */ new Set();
114
- for (const flag of a + b) {
115
- if (flag === "g" || flag === "y") continue;
116
- merged.add(flag);
117
- }
118
- return [...merged].join("");
119
- }
120
- function redactUrlQuery(fullPath, denylist = DEFAULT_URL_DENYLIST) {
121
- const queryStart = fullPath.indexOf("?");
122
- if (queryStart === -1) return fullPath;
123
- const hashStart = fullPath.indexOf("#", queryStart);
124
- const queryEnd = hashStart === -1 ? fullPath.length : hashStart;
125
- const prefix = fullPath.slice(0, queryStart + 1);
126
- const queryString = fullPath.slice(queryStart + 1, queryEnd);
127
- const suffix = fullPath.slice(queryEnd);
128
- return `${prefix}${queryString.split("&").map((pair) => {
129
- if (pair === "") return pair;
130
- const eq = pair.indexOf("=");
131
- const rawKey = eq === -1 ? pair : pair.slice(0, eq);
132
- const decodedKey = safeDecode(rawKey);
133
- if (!denylist.test(decodedKey)) return pair;
134
- return eq === -1 ? rawKey : `${rawKey}=[redacted]`;
135
- }).join("&")}${suffix}`;
136
- }
137
- function safeDecode(value) {
138
- try {
139
- return decodeURIComponent(value);
140
- } catch {
141
- return value;
142
- }
143
- }
47
+ //#region src/types.ts
48
+ /** OTel status codes. Wire format: these numbers ship in the span envelope, so the values never change. */
49
+ const SpanStatusCode = {
50
+ Unset: 0,
51
+ Ok: 1,
52
+ Error: 2
53
+ };
144
54
 
145
55
  //#endregion
146
- //#region src/util/rejection.ts
147
- /** Best-effort human-readable description of an arbitrary rejection reason. */
148
- function describeRejectionReason(reason) {
149
- if (typeof reason === "string") return reason;
150
- if (reason && typeof reason === "object") {
151
- const message = reason.message;
152
- if (typeof message === "string" && message) return message;
153
- try {
154
- return JSON.stringify(reason);
155
- } catch {
156
- return "Unhandled promise rejection (non-serializable reason)";
157
- }
158
- }
159
- return String(reason);
160
- }
161
- function hasStack$1(reason) {
162
- return !!reason && typeof reason === "object" && typeof reason.stack === "string";
163
- }
164
- /**
165
- * Route a rejection reason to the reporter: an Error (or any stack-bearing
166
- * object) goes to `reportSilently` so the STACK survives; only a stackless
167
- * reason falls back to `reportUnhandledRejection` (string message, empty-stack
168
- * `UnhandledRejection` class). Any rejection from `reportUnhandledRejection`'s
169
- * returned promise is swallowed so a transport failure cannot itself surface as
170
- * an unhandled rejection. `reportSilently` is assumed not to throw synchronously
171
- * (core's does its work asynchronously); it is intentionally not wrapped, so a
172
- * synchronous throw there would propagate.
173
- */
174
- function routeRejection(reporter, reason) {
175
- if (reason instanceof Error) {
176
- reporter.reportSilently(reason);
177
- return;
178
- }
179
- if (hasStack$1(reason)) {
180
- const error = new Error(describeRejectionReason(reason));
181
- error.stack = reason.stack;
182
- reporter.reportSilently(error);
183
- return;
184
- }
185
- Promise.resolve(reporter.reportUnhandledRejection(describeRejectionReason(reason))).catch(() => {});
56
+ //#region src/util/utf8Bytes.ts
57
+ const textEncoder = new TextEncoder();
58
+ /** UTF-8 byte length of `value`. */
59
+ function utf8Bytes(value) {
60
+ return textEncoder.encode(value).length;
186
61
  }
187
62
 
188
63
  //#endregion
189
64
  //#region src/api/Api.ts
65
+ const MAX_PENDING_KEEPALIVE_BYTES = 6e4;
66
+ const MAX_PENDING_KEEPALIVE_REQUESTS = 15;
190
67
  var Api = class {
68
+ pendingKeepaliveBytes = 0;
69
+ pendingKeepaliveRequests = 0;
70
+ /**
71
+ * How many keepalive bytes are still available. Logs and traces share one browser allowance and both
72
+ * flush on page hide, so whichever goes second has to pack against what is left rather than assume the
73
+ * whole budget, or it exceeds the gate in send() and silently degrades to a cancellable fetch.
74
+ */
75
+ keepaliveBudgetRemaining() {
76
+ if (this.pendingKeepaliveRequests >= MAX_PENDING_KEEPALIVE_REQUESTS) return 0;
77
+ return Math.max(0, MAX_PENDING_KEEPALIVE_BYTES - this.pendingKeepaliveBytes);
78
+ }
191
79
  report(report, url, key, reportBrowserExtensionErrors, debug = false) {
192
- return fetch(url, {
193
- method: "POST",
80
+ return this.send({
81
+ url,
194
82
  headers: {
195
83
  "Accept": "application/json",
196
84
  "Content-Type": "application/json",
@@ -198,34 +86,274 @@ var Api = class {
198
86
  "X-Report-Browser-Extension-Errors": JSON.stringify(reportBrowserExtensionErrors),
199
87
  "X-Flare-Client-Version": "2"
200
88
  },
201
- body: flatJsonStringify(report)
202
- }).then((response) => {
203
- if (debug && response.status !== 201) console.error(`Received response with status ${response.status} from Flare`);
204
- }, (error) => {
205
- if (debug) console.error(error);
89
+ body: flatJsonStringify(report),
90
+ label: "Flare",
91
+ debug,
92
+ keepalive: false
206
93
  });
207
94
  }
208
95
  logs(envelope, url, key, debug = false, keepalive = false) {
96
+ return this.send({
97
+ url,
98
+ headers: this.ingestHeaders(key),
99
+ body: flatJsonStringify(envelope),
100
+ label: "Flare logs",
101
+ debug,
102
+ keepalive
103
+ });
104
+ }
105
+ traces(envelope, url, key, debug = false, keepalive = false) {
106
+ let body;
107
+ try {
108
+ body = JSON.stringify(envelope);
109
+ } catch {
110
+ body = flatJsonStringify(envelope);
111
+ }
112
+ return this.send({
113
+ url,
114
+ headers: this.ingestHeaders(key),
115
+ body,
116
+ label: "Flare traces",
117
+ debug,
118
+ keepalive
119
+ });
120
+ }
121
+ ingestHeaders(key) {
122
+ return {
123
+ "Accept": "application/json",
124
+ "Content-Type": "application/json",
125
+ "x-api-token": key ?? ""
126
+ };
127
+ }
128
+ send(request) {
129
+ const { url, headers, body, label, debug, keepalive: keepaliveRequested } = request;
130
+ const bytes = keepaliveRequested ? utf8Bytes(body) : 0;
131
+ const keepalive = keepaliveRequested && this.pendingKeepaliveRequests < MAX_PENDING_KEEPALIVE_REQUESTS && this.pendingKeepaliveBytes + bytes <= MAX_PENDING_KEEPALIVE_BYTES;
132
+ if (keepalive) {
133
+ this.pendingKeepaliveBytes += bytes;
134
+ this.pendingKeepaliveRequests += 1;
135
+ }
209
136
  return fetch(url, {
210
137
  method: "POST",
211
138
  keepalive,
212
- headers: {
213
- "Accept": "application/json",
214
- "Content-Type": "application/json",
215
- "x-api-token": key ?? ""
216
- },
217
- body: flatJsonStringify(envelope)
139
+ headers,
140
+ body
218
141
  }).then((response) => {
219
- if (debug && response.status !== 201) console.error(`Received response with status ${response.status} from Flare logs`);
142
+ if (debug && response.status !== 201) console.error(`Received response with status ${response.status} from ${label}`);
220
143
  }, (error) => {
221
144
  if (debug) console.error(error);
145
+ }).finally(() => {
146
+ if (keepalive) {
147
+ this.pendingKeepaliveBytes -= bytes;
148
+ this.pendingKeepaliveRequests -= 1;
149
+ }
150
+ });
151
+ }
152
+ };
153
+
154
+ //#endregion
155
+ //#region src/breadcrumbs/recordBreadcrumb.ts
156
+ const MAX_BREADCRUMB_URL_LENGTH = 256;
157
+ function breadcrumbUrl(href, denylist) {
158
+ const redacted = redactUrlQuery(href, denylist);
159
+ return redacted.length > MAX_BREADCRUMB_URL_LENGTH ? redacted.slice(0, MAX_BREADCRUMB_URL_LENGTH) : redacted;
160
+ }
161
+ function recordBreadcrumb(scopeProvider, config, type, attributes, startTimeUnixNano) {
162
+ if (!config.enableBreadcrumbs) return;
163
+ scopeProvider.active().addBreadcrumb({
164
+ type,
165
+ startTimeUnixNano,
166
+ endTimeUnixNano: null,
167
+ attributes
168
+ }, config.maxBreadcrumbs);
169
+ }
170
+
171
+ //#endregion
172
+ //#region src/telemetry/TelemetryBuffer.ts
173
+ /**
174
+ * The batching machine behind both telemetry signals: hold records, ship them when a size, weight or time
175
+ * trigger fires, and shed the oldest when nothing can drain. One instance owns one signal; what that signal
176
+ * is comes entirely from the policy.
177
+ */
178
+ var TelemetryBuffer = class {
179
+ entries = [];
180
+ bufferedBytes = 0;
181
+ timer;
182
+ timerActive = false;
183
+ constructor(deps, policy) {
184
+ this.deps = deps;
185
+ this.policy = policy;
186
+ const flush = (opts) => this.flush(opts);
187
+ this.deps.scheduler.register(flush);
188
+ }
189
+ length() {
190
+ return this.entries.length;
191
+ }
192
+ add(record) {
193
+ const config = this.deps.getConfig();
194
+ const limits = this.policy.limits(config);
195
+ const bytes = this.policy.estimateBytes(record);
196
+ if (bytes > limits.maxBytes) {
197
+ if (config.debug) console.error(this.policy.oversizedMessage);
198
+ return;
199
+ }
200
+ this.entries.push({
201
+ record,
202
+ bytes
222
203
  });
204
+ this.bufferedBytes += bytes;
205
+ this.policy.onRecordBuffered?.(record);
206
+ this.evaluateTriggers(config, limits);
207
+ this.trim(limits);
208
+ }
209
+ flush(opts) {
210
+ const config = this.deps.getConfig();
211
+ if (!this.policy.enabled(config)) return;
212
+ if (this.entries.length === 0) return;
213
+ if (!assertKey(config.key, config.debug)) {
214
+ this.clearTimer();
215
+ return;
216
+ }
217
+ this.clearTimer();
218
+ const resource = this.policy.resourceForFlush();
219
+ let selected;
220
+ let sendKeepalive = !!opts?.keepalive;
221
+ if (opts?.keepalive) {
222
+ selected = this.packForKeepalive(config, resource);
223
+ if (selected.length === 0) {
224
+ selected = this.entries;
225
+ this.entries = [];
226
+ this.bufferedBytes = 0;
227
+ sendKeepalive = false;
228
+ } else {
229
+ this.entries = this.entries.filter((entry) => !selected.includes(entry));
230
+ this.bufferedBytes = this.entries.reduce((sum, entry) => sum + entry.bytes, 0);
231
+ if (this.entries.length > 0) this.armTimer(this.policy.limits(config));
232
+ }
233
+ } else {
234
+ selected = this.entries;
235
+ this.entries = [];
236
+ this.bufferedBytes = 0;
237
+ }
238
+ if (selected.length === 0) return;
239
+ try {
240
+ this.policy.send(this.policy.buildEnvelope(selected.map((entry) => entry.record), resource), config, sendKeepalive);
241
+ } catch (error) {
242
+ if (config.debug) console.error(this.policy.sendFailureMessage, error);
243
+ }
244
+ }
245
+ clear() {
246
+ this.entries = [];
247
+ this.bufferedBytes = 0;
248
+ this.clearTimer();
249
+ }
250
+ evaluateTriggers(config, limits) {
251
+ if (this.entries.length >= limits.maxSize) {
252
+ this.flush();
253
+ return;
254
+ }
255
+ if (this.bufferedBytes >= limits.maxBytes) {
256
+ this.flush();
257
+ return;
258
+ }
259
+ this.armTimer(limits);
260
+ }
261
+ armTimer(limits) {
262
+ if (this.timerActive) return;
263
+ this.timerActive = true;
264
+ this.timer = setTimeout(() => {
265
+ this.timerActive = false;
266
+ this.timer = void 0;
267
+ this.flush();
268
+ }, limits.flushIntervalMs);
269
+ this.timer.unref?.();
270
+ }
271
+ trim(limits) {
272
+ if (this.entries.length > limits.maxSize) {
273
+ const excess = this.entries.length - limits.maxSize;
274
+ for (let i = 0; i < excess; i++) this.bufferedBytes -= this.entries[i].bytes;
275
+ this.entries = this.entries.slice(excess);
276
+ }
277
+ while (this.entries.length > 1 && this.bufferedBytes > limits.maxBytes) {
278
+ const dropped = this.entries.shift();
279
+ if (dropped) this.bufferedBytes -= dropped.bytes;
280
+ }
281
+ }
282
+ /**
283
+ * Newest-wins. An over-budget record is skipped, not a stop signal, so a smaller older record behind a fat
284
+ * one still ships. Runs on visibilitychange:hidden, which fires on plain backgrounding too, so the tail this
285
+ * leaves behind is retained and re-armed rather than dropped (see flush).
286
+ */
287
+ packForKeepalive(config, resource) {
288
+ const fixedBytes = this.policy.emptyEnvelopeBytes(resource);
289
+ const budget = Math.min(config.keepaliveMaxBytes, this.policy.keepaliveBudget?.(config) ?? config.keepaliveMaxBytes);
290
+ const selected = [];
291
+ let selectedBytes = 0;
292
+ let droppedCount = 0;
293
+ for (let i = this.entries.length - 1; i >= 0; i--) {
294
+ const entry = this.entries[i];
295
+ const candidateBytes = this.policy.recordBytes(entry.record);
296
+ if (fixedBytes + selectedBytes + candidateBytes + selected.length <= budget) {
297
+ selected.unshift(entry);
298
+ selectedBytes += candidateBytes;
299
+ } else if (config.debug) droppedCount++;
300
+ }
301
+ if (config.debug && droppedCount > 0) console.error(this.policy.keepaliveDropMessage(droppedCount));
302
+ return selected;
303
+ }
304
+ clearTimer() {
305
+ if (this.timer) {
306
+ clearTimeout(this.timer);
307
+ this.timer = void 0;
308
+ }
309
+ this.timerActive = false;
223
310
  }
224
311
  };
225
312
 
313
+ //#endregion
314
+ //#region src/telemetry/resourceIdentity.ts
315
+ /**
316
+ * Builds the attributes that go on every logs or traces envelope: the caller's own attributes in `base`, with
317
+ * our SDK, service and framework identity on top. Our keys win, so a user value cannot overwrite something
318
+ * like `telemetry.sdk.name`.
319
+ */
320
+ function buildResourceIdentity(base, config, sdk, framework) {
321
+ const identity = {
322
+ "telemetry.sdk.language": "javascript",
323
+ "telemetry.sdk.name": sdk.name,
324
+ "telemetry.sdk.version": sdk.version,
325
+ "flare.language.name": "javascript"
326
+ };
327
+ if (config.serviceName) identity["service.name"] = config.serviceName;
328
+ if (config.version) identity["service.version"] = config.version;
329
+ if (config.stage) identity["service.stage"] = config.stage;
330
+ if (framework?.name) identity["flare.framework.name"] = framework.name;
331
+ if (framework?.version) identity["flare.framework.version"] = framework.version;
332
+ return {
333
+ ...base,
334
+ ...identity
335
+ };
336
+ }
337
+
226
338
  //#endregion
227
339
  //#region src/logging/otel.ts
228
- function valueToOpenTelemetry(value, inPath = /* @__PURE__ */ new WeakSet()) {
340
+ /**
341
+ * Converts one attribute value into the OpenTelemetry `AnyValue` shape. Strings, numbers and booleans become
342
+ * leaves, arrays and objects are walked recursively. Anything OpenTelemetry cannot carry (null, undefined,
343
+ * NaN, functions) returns null and the caller drops that key.
344
+ *
345
+ * A value that contains itself becomes the string `[Circular]`. `inPath` only holds the parents of the value
346
+ * being converted right now, so the same object used twice side by side is converted twice instead of being
347
+ * wrongly called circular.
348
+ *
349
+ * The walk also stops at a maximum depth and a maximum number of nodes, see traversalBudget.ts. Pass `budget`
350
+ * to let several calls share one allowance, otherwise every call gets its own.
351
+ */
352
+ function valueToOpenTelemetry(value, inPath = /* @__PURE__ */ new WeakSet(), budget = createTraversalBudget()) {
353
+ return convert(value, inPath, 0, budget);
354
+ }
355
+ function convert(value, inPath, depth, budget) {
356
+ if (!spendNode(budget)) return { stringValue: TRUNCATED };
229
357
  if (typeof value === "string") return { stringValue: value };
230
358
  if (typeof value === "boolean") return { boolValue: value };
231
359
  if (typeof value === "number") {
@@ -233,12 +361,13 @@ function valueToOpenTelemetry(value, inPath = /* @__PURE__ */ new WeakSet()) {
233
361
  return Number.isInteger(value) ? { intValue: value } : { doubleValue: value };
234
362
  }
235
363
  if (value === null || value === void 0) return null;
364
+ if (depth >= MAX_TRAVERSAL_DEPTH) return { stringValue: TRUNCATED };
236
365
  if (Array.isArray(value)) {
237
366
  if (inPath.has(value)) return { stringValue: "[Circular]" };
238
367
  inPath.add(value);
239
368
  const values = [];
240
369
  for (const item of value) {
241
- const mapped = valueToOpenTelemetry(item, inPath);
370
+ const mapped = convert(item, inPath, depth + 1, budget);
242
371
  if (mapped !== null) values.push(mapped);
243
372
  }
244
373
  inPath.delete(value);
@@ -249,7 +378,7 @@ function valueToOpenTelemetry(value, inPath = /* @__PURE__ */ new WeakSet()) {
249
378
  inPath.add(value);
250
379
  const values = [];
251
380
  for (const [key, item] of Object.entries(value)) {
252
- const mapped = valueToOpenTelemetry(item, inPath);
381
+ const mapped = convert(item, inPath, depth + 1, budget);
253
382
  if (mapped !== null) values.push({
254
383
  key,
255
384
  value: mapped
@@ -261,9 +390,10 @@ function valueToOpenTelemetry(value, inPath = /* @__PURE__ */ new WeakSet()) {
261
390
  return null;
262
391
  }
263
392
  function attributesToOpenTelemetry(attributes) {
393
+ const budget = createTraversalBudget();
264
394
  const out = [];
265
395
  for (const [key, value] of Object.entries(attributes)) {
266
- const mapped = valueToOpenTelemetry(value);
396
+ const mapped = valueToOpenTelemetry(value, /* @__PURE__ */ new WeakSet(), budget);
267
397
  if (mapped !== null) out.push({
268
398
  key,
269
399
  value: mapped
@@ -274,6 +404,18 @@ function attributesToOpenTelemetry(attributes) {
274
404
 
275
405
  //#endregion
276
406
  //#region src/logging/envelope.ts
407
+ function toOtelLogRecord(record) {
408
+ return {
409
+ timeUnixNano: record.timeUnixNano,
410
+ observedTimeUnixNano: record.timeUnixNano,
411
+ severityNumber: record.severityNumber,
412
+ severityText: record.severityText,
413
+ body: { stringValue: record.message },
414
+ attributes: record.recordAttributes,
415
+ flags: 0,
416
+ droppedAttributesCount: 0
417
+ };
418
+ }
277
419
  function buildLogsEnvelope(records, resourceAttributes, scopeName, scopeVersion) {
278
420
  return { resourceLogs: [{
279
421
  resource: {
@@ -287,19 +429,24 @@ function buildLogsEnvelope(records, resourceAttributes, scopeName, scopeVersion)
287
429
  attributes: [],
288
430
  droppedAttributesCount: 0
289
431
  },
290
- logRecords: records.map((record) => ({
291
- timeUnixNano: record.timeUnixNano,
292
- observedTimeUnixNano: record.timeUnixNano,
293
- severityNumber: record.severityNumber,
294
- severityText: record.severityText,
295
- body: { stringValue: record.message },
296
- attributes: record.recordAttributes,
297
- flags: 0,
298
- droppedAttributesCount: 0
299
- }))
432
+ logRecords: records.map(toOtelLogRecord)
300
433
  }]
301
434
  }] };
302
435
  }
436
+ /**
437
+ * How many UTF-8 bytes one record adds to an envelope. We measure the real toOtelLogRecord output instead of
438
+ * reusing the cached BufferedLog estimate, because keepaliveMaxBytes is a hard browser limit and an estimate
439
+ * is not good enough.
440
+ *
441
+ * Uses flatJsonStringify to match Api.logs, which sends the envelope through the same encoder.
442
+ */
443
+ function otelLogRecordBytes(record) {
444
+ return utf8Bytes(flatJsonStringify(toOtelLogRecord(record)));
445
+ }
446
+ /** UTF-8 bytes of an empty envelope: the fixed overhead every batch has, before any records are added. */
447
+ function emptyLogsEnvelopeBytes(resourceAttributes, scopeName, scopeVersion) {
448
+ return utf8Bytes(flatJsonStringify(buildLogsEnvelope([], resourceAttributes, scopeName, scopeVersion)));
449
+ }
303
450
 
304
451
  //#endregion
305
452
  //#region src/logging/severity.ts
@@ -326,14 +473,42 @@ function isAtOrAboveMinimum(level, minimum) {
326
473
  //#endregion
327
474
  //#region src/logging/Logger.ts
328
475
  var Logger = class {
329
- buffer = [];
476
+ inner;
330
477
  resourceAttributes = {};
331
- timer;
332
- timerActive = false;
333
478
  constructor(deps) {
334
479
  this.deps = deps;
335
- const flush = (opts) => this.flush(opts);
336
- this.deps.scheduler.register(flush);
480
+ this.inner = new TelemetryBuffer({
481
+ getConfig: deps.getConfig,
482
+ scheduler: deps.scheduler
483
+ }, {
484
+ limits: (config) => ({
485
+ maxSize: config.maxLogBufferSize,
486
+ maxBytes: config.logFlushMaxBytes,
487
+ flushIntervalMs: config.logFlushIntervalMs
488
+ }),
489
+ enabled: (config) => config.enableLogs,
490
+ keepaliveBudget: () => deps.api.keepaliveBudgetRemaining(),
491
+ estimateBytes: (record) => this.estimateBytes(record),
492
+ emptyEnvelopeBytes: (resource) => {
493
+ const sdk = deps.getSdkInfo();
494
+ return emptyLogsEnvelopeBytes(resource, sdk.name, sdk.version);
495
+ },
496
+ recordBytes: (record) => otelLogRecordBytes(record),
497
+ oversizedMessage: "Flare: dropping oversized log record",
498
+ keepaliveDropMessage: (count) => `Flare: dropped ${count} log record(s) from keepalive envelope (over budget)`,
499
+ sendFailureMessage: "Flare: failed to send buffered log records",
500
+ resourceForFlush: () => this.resourceForFlush(),
501
+ buildEnvelope: (records, resource) => {
502
+ const sdk = deps.getSdkInfo();
503
+ return buildLogsEnvelope(records, resource, sdk.name, sdk.version);
504
+ },
505
+ send: (envelope, config, keepalive) => {
506
+ deps.track(deps.api.logs(envelope, config.logsIngestUrl, config.key, config.debug, keepalive));
507
+ },
508
+ onRecordBuffered: (record) => {
509
+ this.resourceAttributes = record.resourceAttributes;
510
+ }
511
+ });
337
512
  }
338
513
  debug(message, context = {}, attributes = {}) {
339
514
  this.record("debug", message, context, attributes);
@@ -360,7 +535,13 @@ var Logger = class {
360
535
  this.record("emergency", message, context, attributes);
361
536
  }
362
537
  bufferLength() {
363
- return this.buffer.length;
538
+ return this.inner.length();
539
+ }
540
+ flush(opts) {
541
+ this.inner.flush(opts);
542
+ }
543
+ clear() {
544
+ this.inner.clear();
364
545
  }
365
546
  record(level, message, context, attributes) {
366
547
  const config = this.deps.getConfig();
@@ -371,115 +552,21 @@ var Logger = class {
371
552
  ...attributes
372
553
  };
373
554
  const { record, resource } = this.deps.buildLogAttributes(userAttributes);
374
- const buffered = {
555
+ this.inner.add({
375
556
  timeUnixNano: String(Date.now()) + "000000",
376
557
  severityNumber: severityNumber(level),
377
558
  severityText: severityText(level),
378
559
  message,
379
560
  recordAttributes: attributesToOpenTelemetry(record),
380
561
  resourceAttributes: resource
381
- };
382
- if (this.estimateBytes(buffered) > config.logFlushMaxBytes) {
383
- if (config.debug) console.error("Flare: dropping oversized log record");
384
- return;
385
- }
386
- this.buffer.push(buffered);
387
- this.resourceAttributes = resource;
388
- this.evaluateTriggers(config);
389
- this.trim(config);
390
- }
391
- evaluateTriggers(config) {
392
- if (this.buffer.length >= config.maxLogBufferSize) {
393
- this.flush();
394
- return;
395
- }
396
- if (this.bufferBytes() >= config.logFlushMaxBytes) {
397
- this.flush();
398
- return;
399
- }
400
- this.armTimer(config);
401
- }
402
- armTimer(config) {
403
- if (this.timerActive) return;
404
- this.timerActive = true;
405
- this.timer = setTimeout(() => this.flush(), config.logFlushIntervalMs);
406
- this.timer.unref?.();
407
- }
408
- trim(config) {
409
- if (this.buffer.length > config.maxLogBufferSize) this.buffer = this.buffer.slice(this.buffer.length - config.maxLogBufferSize);
410
- while (this.buffer.length > 1 && this.bufferBytes() > config.logFlushMaxBytes) this.buffer.shift();
411
- }
412
- flush(opts) {
413
- const config = this.deps.getConfig();
414
- if (!config.enableLogs) return;
415
- if (this.buffer.length === 0) return;
416
- if (!assertKey(config.key, config.debug)) {
417
- this.clearTimer();
418
- return;
419
- }
420
- this.clearTimer();
421
- let records;
422
- if (opts?.keepalive) {
423
- records = this.packForKeepalive(config);
424
- this.buffer = this.buffer.filter((log) => !records.includes(log));
425
- if (this.buffer.length > 0) this.armTimer(config);
426
- } else {
427
- records = this.buffer;
428
- this.buffer = [];
429
- }
430
- if (records.length === 0) return;
431
- this.deps.track(this.deps.api.logs(this.buildEnvelope(records), config.logsIngestUrl, config.key, config.debug, !!opts?.keepalive));
432
- }
433
- clear() {
434
- this.buffer = [];
435
- this.clearTimer();
436
- }
437
- packForKeepalive(config) {
438
- let selected = [];
439
- for (let i = this.buffer.length - 1; i >= 0; i--) {
440
- const trial = [this.buffer[i], ...selected];
441
- if (new TextEncoder().encode(flatJsonStringify(this.buildEnvelope(trial))).length <= config.keepaliveMaxBytes) selected = trial;
442
- else if (config.debug) console.error("Flare: dropping log record from keepalive envelope (over budget)");
443
- }
444
- return selected;
445
- }
446
- buildEnvelope(records) {
447
- const sdk = this.deps.getSdkInfo();
448
- return buildLogsEnvelope(records, this.resourceForFlush(), sdk.name, sdk.version);
562
+ });
449
563
  }
450
564
  resourceForFlush() {
451
- const config = this.deps.getConfig();
452
- const sdk = this.deps.getSdkInfo();
453
- const framework = this.deps.getFramework();
454
- const identity = {
455
- "telemetry.sdk.language": "javascript",
456
- "telemetry.sdk.name": sdk.name,
457
- "telemetry.sdk.version": sdk.version,
458
- "flare.language.name": "javascript"
459
- };
460
- if (config.serviceName) identity["service.name"] = config.serviceName;
461
- if (config.version) identity["service.version"] = config.version;
462
- if (config.stage) identity["service.stage"] = config.stage;
463
- if (framework?.name) identity["flare.framework.name"] = framework.name;
464
- if (framework?.version) identity["flare.framework.version"] = framework.version;
465
- return {
466
- ...this.resourceAttributes,
467
- ...identity
468
- };
469
- }
470
- clearTimer() {
471
- if (this.timer) {
472
- clearTimeout(this.timer);
473
- this.timer = void 0;
474
- }
475
- this.timerActive = false;
565
+ return buildResourceIdentity(this.resourceAttributes, this.deps.getConfig(), this.deps.getSdkInfo(), this.deps.getFramework());
476
566
  }
477
567
  estimateBytes(log) {
478
568
  return flatJsonStringify(log).length;
479
569
  }
480
- bufferBytes() {
481
- return this.buffer.reduce((sum, log) => sum + this.estimateBytes(log), 0);
482
- }
483
570
  };
484
571
 
485
572
  //#endregion
@@ -513,31 +600,17 @@ function partitionAttributes(attributes) {
513
600
 
514
601
  //#endregion
515
602
  //#region src/Scope.ts
516
- /**
517
- * Maps each `User` identity field to the flat report attribute key it projects to.
518
- * `Flare.setUser`'s set pass writes through these so the literal key strings live in
519
- * exactly one place; `USER_IDENTITY_KEYS` (the clear pass) derives from them, so adding
520
- * a field here can never silently leave the clear pass out of date.
521
- */
603
+ /** `USER_IDENTITY_KEYS` derives from this, so adding a field here can never leave the clear pass stale. */
522
604
  const USER_FIELD_KEYS = {
523
605
  id: "user.id",
524
606
  email: "user.email",
525
607
  fullName: "user.full_name",
526
608
  ipAddress: "client.address"
527
609
  };
528
- /**
529
- * The report attribute keys that `Flare.setUser` owns: the four projected identity
530
- * fields plus the `user.attributes` bag for extras. Single source of truth so the
531
- * clear pass and the set pass in `setUser` cannot drift, and so consumers that must
532
- * stamp identity outside core's report pipeline (Electron's forwarded-renderer path)
533
- * pick up the exact same set instead of re-hardcoding it.
534
- */
610
+ /** Every key `Flare.setUser` owns. Consumers stamping identity outside core's report pipeline (Electron's
611
+ * forwarded-renderer path) reuse this exact set. */
535
612
  const USER_IDENTITY_KEYS = [...Object.values(USER_FIELD_KEYS), "user.attributes"];
536
- /**
537
- * Pick the user-identity attributes currently set on a scope. Used where identity must
538
- * be copied onto a report that does not flow through `Flare.report()` (which would
539
- * otherwise spread `pendingAttributes` automatically).
540
- */
613
+ /** For reports that do not flow through `Flare.report()`, which would spread `pendingAttributes` itself. */
541
614
  function userIdentityAttributes(scope) {
542
615
  const attrs = {};
543
616
  for (const key of USER_IDENTITY_KEYS) {
@@ -547,39 +620,16 @@ function userIdentityAttributes(scope) {
547
620
  return attrs;
548
621
  }
549
622
  /**
550
- * Holds the per-call mutable state that used to live on the `Flare` instance:
551
- * breadcrumbs (`glows`), custom attributes (`pendingAttributes`), and the
552
- * current entry-point handler.
553
- *
554
- * Why this exists as its own class: in the browser there is one `Flare` per
555
- * page and one user at a time, so a single shared bag of state is fine. In
556
- * Node, a single `Flare` instance serves many concurrent requests, and each
557
- * request wants its own breadcrumbs and its own custom context that do NOT
558
- * leak into other requests. Splitting this state out of `Flare` lets the
559
- * consumer choose: one global `Scope` (browser) or one `Scope` per request
560
- * via AsyncLocalStorage (Node).
561
- *
562
- * `Flare` reads and writes this through `scopeProvider.active()` instead of
563
- * holding the state directly, so the per-request behavior comes from the
564
- * provider, not from the class itself.
565
- *
566
- * `NodeScope` (in `@flareapp/node`) extends this with a `request` bucket
567
- * (HTTP method, path, headers). User identity is written to `pendingAttributes`
568
- * by `Flare.setUser`, so it needs no dedicated field. Browser does not need `request`.
623
+ * Per-call mutable state, split out of `Flare` so the consumer can choose one global `Scope` (browser, one
624
+ * user at a time) or one per request via AsyncLocalStorage (Node, where concurrent requests must not leak
625
+ * into each other). `@flareapp/node`'s `NodeScope` extends this with a `request` bucket.
569
626
  */
570
627
  var Scope = class {
571
628
  glows = [];
629
+ breadcrumbs = [];
572
630
  pendingAttributes = {};
573
631
  entryPoint = null;
574
- /**
575
- * Append a breadcrumb. Caps the list at `maxGlowsPerReport` by dropping the
576
- * OLDEST entries when the limit is exceeded; this keeps reports below a
577
- * payload-size threshold while preserving the most recent events leading
578
- * up to an error.
579
- *
580
- * `slice(length - max)` returns the trailing `max` items, which is the
581
- * shortest way to drop from the front and keep insertion order.
582
- */
632
+ /** Caps at `maxGlowsPerReport` by dropping the oldest, so the payload stays bounded. */
583
633
  addGlow(glow, maxGlowsPerReport) {
584
634
  this.glows.push(glow);
585
635
  if (this.glows.length > maxGlowsPerReport) this.glows = this.glows.slice(this.glows.length - maxGlowsPerReport);
@@ -587,29 +637,24 @@ var Scope = class {
587
637
  clearGlows() {
588
638
  this.glows = [];
589
639
  }
590
- /**
591
- * Set a single attribute on this scope. Called from `Flare.addContext` and
592
- * `Flare.addContextGroup`. Last write wins.
593
- */
640
+ /** Drops the oldest when full. */
641
+ addBreadcrumb(breadcrumb, maxBreadcrumbs) {
642
+ if (maxBreadcrumbs <= 0) return;
643
+ this.breadcrumbs.push(breadcrumb);
644
+ if (this.breadcrumbs.length > maxBreadcrumbs) this.breadcrumbs = this.breadcrumbs.slice(this.breadcrumbs.length - maxBreadcrumbs);
645
+ }
646
+ clearBreadcrumbs() {
647
+ this.breadcrumbs = [];
648
+ }
594
649
  setAttribute(key, value) {
595
650
  this.pendingAttributes[key] = value;
596
651
  }
597
- /**
598
- * Shallow-merge a bag of attributes into this scope. Used by Node's
599
- * AsyncLocalStorage provider when patching the live request context via
600
- * `flare.mergeContext({ ... })`. Last write wins per key; nested objects
601
- * are NOT deep-merged.
602
- */
652
+ /** Shallow: last write wins per key, nested objects are not deep-merged. */
603
653
  mergeAttributes(partial) {
604
654
  Object.assign(this.pendingAttributes, partial);
605
655
  }
606
656
  };
607
- /**
608
- * The simplest provider: one `Scope` for the lifetime of the provider, shared
609
- * by every caller. This is the right default for environments with a single
610
- * logical context (browser tab, CLI script, etc.) and is the default that
611
- * `Flare`'s constructor falls back to when no provider is supplied.
612
- */
657
+ /** One `Scope` for the provider's lifetime. The right default for a browser tab or a CLI script. */
613
658
  var GlobalScopeProvider = class {
614
659
  scope = new Scope();
615
660
  active() {
@@ -636,11 +681,14 @@ function getCodeSnippet(fileReader, url, lineNumber, columnNumber) {
636
681
  });
637
682
  }
638
683
  function readFile(fileReader, url) {
639
- if (cachedFiles[url] !== void 0) return Promise.resolve(cachedFiles[url]);
640
- return fileReader.read(url).then((text) => {
641
- if (text !== null) cachedFiles[url] = text;
684
+ const cached = cachedFiles[url];
685
+ if (cached !== void 0) return cached;
686
+ const pending = fileReader.read(url).then((text) => {
687
+ if (text === null) delete cachedFiles[url];
642
688
  return text;
643
689
  });
690
+ cachedFiles[url] = pending;
691
+ return pending;
644
692
  }
645
693
  function readLinesFromFile(fileText, lineNumber, columnNumber, maxSnippetLineLength = 1e3, maxSnippetLines = 40) {
646
694
  const codeSnippet = {};
@@ -732,25 +780,11 @@ function isApplicationFrame(fileName) {
732
780
  //#endregion
733
781
  //#region src/stacktrace/NullFileReader.ts
734
782
  /**
735
- * No-op `FileReader` that returns `null` for every URL it is asked to read.
736
- *
737
- * Used as the default for `Flare`'s `fileReader` constructor parameter so the
738
- * class is usable without picking a side: instantiated bare (`new Flare()`),
739
- * reports still build, but stack frames omit source-code snippets — which is
740
- * the correct, safe behavior in an environment we know nothing about.
741
- *
742
- * The two real implementations live in the consumer packages and take their
743
- * place once the right environment is established:
744
- *
745
- * - `@flareapp/js` injects `FetchFileReader`, which `fetch()`s source maps
746
- * and original files over HTTP for browser stack frames.
747
- * - `@flareapp/node` injects `DiskFileReader`, which reads files from disk
748
- * via `node:fs/promises` for server stack frames.
749
- *
750
- * The interface (`read(url) -> Promise<string | null>`) lets the stack-trace
751
- * builder treat all three the same way: ask for a URL, render the snippet
752
- * when text comes back, gracefully skip it when `null` does. No environment
753
- * checks anywhere in core.
783
+ * No-op `FileReader` returning `null` for every URL. Default for `Flare`'s `fileReader` param so `new Flare()` builds
784
+ * reports without picking an environment; stack frames just omit source snippets. Consumer packages inject the real
785
+ * ones: `@flareapp/js` a fetch-based reader, `@flareapp/node` a disk reader. The `read(url) -> Promise<string | null>`
786
+ * interface lets the stack-trace builder treat all three the same (render on text, skip on null), so core needs no
787
+ * environment checks.
754
788
  */
755
789
  var NullFileReader = class {
756
790
  read(_url) {
@@ -758,18 +792,632 @@ var NullFileReader = class {
758
792
  }
759
793
  };
760
794
 
795
+ //#endregion
796
+ //#region src/tracing/context.ts
797
+ var InMemoryActiveSpanHolder = class {
798
+ active;
799
+ root;
800
+ getActive() {
801
+ return this.active ?? this.root;
802
+ }
803
+ withActive(span, fn) {
804
+ const previous = this.active;
805
+ this.active = span;
806
+ try {
807
+ return fn();
808
+ } finally {
809
+ this.active = previous;
810
+ }
811
+ }
812
+ setActiveRoot(span) {
813
+ this.root = span;
814
+ }
815
+ };
816
+
817
+ //#endregion
818
+ //#region src/tracing/ids.ts
819
+ function randomHex(bytes) {
820
+ const randomBytes = new Uint8Array(bytes);
821
+ const cryptoApi = globalThis.crypto;
822
+ if (cryptoApi && typeof cryptoApi.getRandomValues === "function") cryptoApi.getRandomValues(randomBytes);
823
+ else for (let i = 0; i < bytes; i++) randomBytes[i] = Math.floor(Math.random() * 256);
824
+ if (randomBytes.every((b) => b === 0)) randomBytes[bytes - 1] = 1;
825
+ let out = "";
826
+ for (let i = 0; i < bytes; i++) out += randomBytes[i].toString(16).padStart(2, "0");
827
+ return out;
828
+ }
829
+ function traceId() {
830
+ return randomHex(16);
831
+ }
832
+ function spanId() {
833
+ return randomHex(8);
834
+ }
835
+
836
+ //#endregion
837
+ //#region src/tracing/sampler.ts
838
+ function resolveSampling(samplingContext, config, rng = Math.random) {
839
+ if (samplingContext.parentSampled !== void 0) return samplingContext.parentSampled;
840
+ let rate;
841
+ if (config.tracesSampler) {
842
+ let result;
843
+ try {
844
+ result = config.tracesSampler(samplingContext);
845
+ } catch (error) {
846
+ if (config.debug) console.error("Flare: tracesSampler threw, treating span as not sampled", error);
847
+ return false;
848
+ }
849
+ if (typeof result === "boolean") return result;
850
+ rate = result;
851
+ } else rate = config.tracesSampleRate;
852
+ rate = Math.max(0, Math.min(1, rate));
853
+ if (rate <= 0) return false;
854
+ if (rate >= 1) return true;
855
+ return rng() < rate;
856
+ }
857
+
858
+ //#endregion
859
+ //#region src/tracing/Span.ts
860
+ var SpanImpl = class {
861
+ traceId;
862
+ spanId;
863
+ parentSpanId;
864
+ name;
865
+ isRecording;
866
+ epoch;
867
+ stateGeneration;
868
+ startTimeUnixNano;
869
+ scopeAttributes;
870
+ endTimeUnixNano = 0;
871
+ status = { code: SpanStatusCode.Unset };
872
+ attributes = {};
873
+ droppedAttributesCount = 0;
874
+ events = [];
875
+ droppedEventsCount = 0;
876
+ ended = false;
877
+ constructor(init, deps) {
878
+ this.deps = deps;
879
+ this.traceId = init.traceId;
880
+ this.spanId = init.spanId;
881
+ this.parentSpanId = init.parentSpanId;
882
+ this.name = init.name;
883
+ this.isRecording = init.recording;
884
+ this.epoch = init.epoch;
885
+ this.stateGeneration = init.stateGeneration;
886
+ this.startTimeUnixNano = init.startTimeUnixNano;
887
+ this.scopeAttributes = init.scopeAttributes;
888
+ }
889
+ setAttribute(key, value) {
890
+ if (this.ended) return this;
891
+ if (!(key in this.attributes) && Object.keys(this.attributes).length >= this.deps.maxAttributesPerSpan) {
892
+ this.droppedAttributesCount++;
893
+ return this;
894
+ }
895
+ this.attributes[key] = value;
896
+ return this;
897
+ }
898
+ setStatus(status) {
899
+ if (!this.ended) this.status = status;
900
+ return this;
901
+ }
902
+ addEvent(name, attributes = {}) {
903
+ if (this.ended) return this;
904
+ if (this.events.length >= this.deps.maxEventsPerSpan) {
905
+ this.droppedEventsCount++;
906
+ return this;
907
+ }
908
+ const capped = {};
909
+ let dropped = 0;
910
+ for (const [key, value] of Object.entries(attributes)) {
911
+ if (Object.keys(capped).length >= this.deps.maxAttributesPerSpanEvent) {
912
+ dropped++;
913
+ continue;
914
+ }
915
+ capped[key] = value;
916
+ }
917
+ this.events.push({
918
+ name,
919
+ timeUnixNano: this.deps.now(),
920
+ attributes: capped,
921
+ droppedAttributesCount: dropped
922
+ });
923
+ return this;
924
+ }
925
+ end(endTimeUnixNano) {
926
+ if (this.ended) return;
927
+ this.ended = true;
928
+ this.endTimeUnixNano = endTimeUnixNano ?? this.deps.now();
929
+ this.deps.onEnd(this);
930
+ }
931
+ };
932
+
933
+ //#endregion
934
+ //#region src/tracing/envelope.ts
935
+ function toOtelSpan(span) {
936
+ const status = span.status.message !== void 0 ? {
937
+ code: span.status.code,
938
+ message: span.status.message
939
+ } : { code: span.status.code };
940
+ return {
941
+ traceId: span.traceId,
942
+ spanId: span.spanId,
943
+ parentSpanId: span.parentSpanId,
944
+ name: span.name,
945
+ startTimeUnixNano: span.startTimeUnixNano,
946
+ endTimeUnixNano: span.endTimeUnixNano,
947
+ status,
948
+ attributes: span.recordAttributes,
949
+ events: span.events,
950
+ droppedAttributesCount: span.droppedAttributesCount,
951
+ droppedEventsCount: span.droppedEventsCount,
952
+ links: [],
953
+ droppedLinksCount: 0
954
+ };
955
+ }
956
+ function buildTracesEnvelope(spans, resourceAttributes, scopeName, scopeVersion) {
957
+ return { resourceSpans: [{
958
+ resource: {
959
+ attributes: attributesToOpenTelemetry(resourceAttributes),
960
+ droppedAttributesCount: 0
961
+ },
962
+ scopeSpans: [{
963
+ scope: {
964
+ name: scopeName,
965
+ version: scopeVersion,
966
+ attributes: [],
967
+ droppedAttributesCount: 0
968
+ },
969
+ spans: spans.map(toOtelSpan)
970
+ }]
971
+ }] };
972
+ }
973
+ /**
974
+ * How many UTF-8 bytes one span adds to an envelope. We measure the real toOtelSpan output instead of reusing
975
+ * the cached BufferedSpan estimate, because keepaliveMaxBytes is a hard browser limit and an estimate is not
976
+ * good enough.
977
+ *
978
+ * We use flatJsonStringify instead of JSON.stringify because a span keeps values the caller still owns, like
979
+ * status.message, and those can turn unserializable after the span ended. This runs from a visibilitychange
980
+ * listener with no try/catch around it, so a throw here loses the flush. flatJsonStringify handles the usual
981
+ * suspects (circular references, BigInt, a getter that throws on a plain object) but is not bulletproof: a
982
+ * class instance with a throwing getter goes through untouched and can still throw.
983
+ */
984
+ function otelSpanBytes(span) {
985
+ return utf8Bytes(flatJsonStringify(toOtelSpan(span)));
986
+ }
987
+ /** UTF-8 bytes of an empty envelope: the fixed overhead every batch has, before any spans are added. */
988
+ function emptyTracesEnvelopeBytes(resourceAttributes, scopeName, scopeVersion) {
989
+ return utf8Bytes(JSON.stringify(buildTracesEnvelope([], resourceAttributes, scopeName, scopeVersion)));
990
+ }
991
+
992
+ //#endregion
993
+ //#region src/tracing/SpanBuffer.ts
994
+ /** The span half of the shared telemetry buffer: names the config keys, the envelope and the ingest call. */
995
+ var SpanBuffer = class {
996
+ inner;
997
+ constructor(deps) {
998
+ this.deps = deps;
999
+ this.inner = new TelemetryBuffer({
1000
+ getConfig: deps.getConfig,
1001
+ scheduler: deps.scheduler
1002
+ }, {
1003
+ limits: (config) => ({
1004
+ maxSize: config.maxSpanBufferSize,
1005
+ maxBytes: config.spanFlushMaxBytes,
1006
+ flushIntervalMs: config.spanFlushIntervalMs
1007
+ }),
1008
+ enabled: (config) => config.enableTracing,
1009
+ keepaliveBudget: () => deps.api.keepaliveBudgetRemaining(),
1010
+ estimateBytes: (span) => this.estimateBytes(span),
1011
+ emptyEnvelopeBytes: (resource) => {
1012
+ const sdk = deps.getSdkInfo();
1013
+ return emptyTracesEnvelopeBytes(resource, sdk.name, sdk.version);
1014
+ },
1015
+ recordBytes: (span) => otelSpanBytes(span),
1016
+ oversizedMessage: "Flare: dropping oversized span",
1017
+ keepaliveDropMessage: (count) => `Flare: dropped ${count} span(s) from keepalive envelope (over budget)`,
1018
+ sendFailureMessage: "Flare: failed to send buffered spans",
1019
+ resourceForFlush: () => this.resourceForFlush(),
1020
+ buildEnvelope: (spans, resource) => {
1021
+ const sdk = deps.getSdkInfo();
1022
+ return buildTracesEnvelope(spans, resource, sdk.name, sdk.version);
1023
+ },
1024
+ send: (envelope, config, keepalive) => {
1025
+ deps.track(deps.api.traces(envelope, config.tracesIngestUrl, config.key, config.debug, keepalive));
1026
+ }
1027
+ });
1028
+ }
1029
+ length() {
1030
+ return this.inner.length();
1031
+ }
1032
+ add(span) {
1033
+ this.inner.add(span);
1034
+ }
1035
+ flush(opts) {
1036
+ this.inner.flush(opts);
1037
+ }
1038
+ clear() {
1039
+ this.inner.clear();
1040
+ }
1041
+ resourceForFlush() {
1042
+ return buildResourceIdentity(this.deps.getResourceAttributes(), this.deps.getConfig(), this.deps.getSdkInfo(), this.deps.getFramework());
1043
+ }
1044
+ estimateBytes(span) {
1045
+ return JSON.stringify(span).length;
1046
+ }
1047
+ };
1048
+
1049
+ //#endregion
1050
+ //#region src/tracing/traceparent.ts
1051
+ const HEX32 = /^[0-9a-f]{32}$/;
1052
+ const HEX16 = /^[0-9a-f]{16}$/;
1053
+ const HEX8 = /^[0-9a-f]{2}$/;
1054
+ const ZERO32 = "0".repeat(32);
1055
+ const ZERO16 = "0".repeat(16);
1056
+ function buildTraceparent(traceId, spanId, sampled) {
1057
+ return `00-${traceId}-${spanId}-${sampled ? "01" : "00"}`;
1058
+ }
1059
+ function parseTraceparent(header) {
1060
+ const parts = header.trim().split("-");
1061
+ if (parts.length !== 4) return null;
1062
+ const [version, traceId, spanId, flags] = parts;
1063
+ if (version !== "00") return null;
1064
+ if (!HEX32.test(traceId) || traceId === ZERO32) return null;
1065
+ if (!HEX16.test(spanId) || spanId === ZERO16) return null;
1066
+ if (!HEX8.test(flags)) return null;
1067
+ return {
1068
+ traceId,
1069
+ parentSpanId: spanId,
1070
+ sampled: (parseInt(flags, 16) & 1) === 1
1071
+ };
1072
+ }
1073
+
1074
+ //#endregion
1075
+ //#region src/tracing/Tracer.ts
1076
+ function isPromiseLike(value) {
1077
+ return (typeof value === "object" || typeof value === "function") && value !== null && typeof value.then === "function";
1078
+ }
1079
+ /** `SpanOptions.parent` is a structurally overlapping union; `isRecording` is what tells a real Span apart. */
1080
+ function isSpan(parent) {
1081
+ return "isRecording" in parent;
1082
+ }
1083
+ /** A SpanImpl carries the epoch it was created under; a hand-stitched `{traceId, spanId}` parent does not. */
1084
+ function hasEpoch(parent) {
1085
+ return "epoch" in parent && typeof parent.epoch === "number";
1086
+ }
1087
+ function defaultNowNano() {
1088
+ const performanceApi = globalThis.performance;
1089
+ const ms = performanceApi && typeof performanceApi.now === "function" && typeof performanceApi.timeOrigin === "number" ? performanceApi.timeOrigin + performanceApi.now() : Date.now();
1090
+ return Math.round(ms * 1e6);
1091
+ }
1092
+ /**
1093
+ * Both trace maps cap their size the same way: insertion order is LRU, so the first key is the one to drop.
1094
+ * Only evicts when `key` is not already in the map. A set() that overwrites an existing key does not grow the
1095
+ * map, so it must not evict an unrelated entry to make room for it.
1096
+ */
1097
+ function evictLruIfNew(map, key, cap) {
1098
+ if (map.has(key) || map.size < cap) return;
1099
+ const lru = map.keys().next().value;
1100
+ if (lru !== void 0) map.delete(lru);
1101
+ }
1102
+ const MAX_CLOSED_TRACES = 100;
1103
+ /** Bounded backstop for the live TraceState map: an app that never ends spans must not grow it forever. */
1104
+ const DEFAULT_MAX_LIVE_TRACES = 1e3;
1105
+ var Tracer = class {
1106
+ buffer;
1107
+ holder;
1108
+ traceStates = /* @__PURE__ */ new Map();
1109
+ closedTraces = /* @__PURE__ */ new Map();
1110
+ stateGeneration = 0;
1111
+ now;
1112
+ rng;
1113
+ maxLiveTraces;
1114
+ epoch = 0;
1115
+ pendingContinuation = null;
1116
+ spanListeners = /* @__PURE__ */ new Set();
1117
+ constructor(deps) {
1118
+ this.deps = deps;
1119
+ this.buffer = new SpanBuffer({
1120
+ api: deps.api,
1121
+ getConfig: deps.getConfig,
1122
+ getSdkInfo: deps.getSdkInfo,
1123
+ getFramework: deps.getFramework,
1124
+ getResourceAttributes: deps.getResourceAttributes,
1125
+ track: deps.track,
1126
+ scheduler: deps.scheduler
1127
+ });
1128
+ this.holder = deps.activeSpanHolder ?? new InMemoryActiveSpanHolder();
1129
+ this.now = deps.now ?? defaultNowNano;
1130
+ this.rng = deps.rng ?? Math.random;
1131
+ this.maxLiveTraces = deps.maxLiveTraces ?? DEFAULT_MAX_LIVE_TRACES;
1132
+ }
1133
+ getActiveSpan() {
1134
+ return this.holder.getActive();
1135
+ }
1136
+ setActiveRoot(span) {
1137
+ this.holder.setActiveRoot?.(span);
1138
+ }
1139
+ /**
1140
+ * Take one span against `traceId`'s cap up front, for a caller that publishes a span id before the
1141
+ * span exists (the component profilers do; their descendants record first). False means the trace is
1142
+ * full and the caller should stay transparent instead of handing out an id the cap will refuse.
1143
+ * Consumed by the matching `startSpan({ claimed: true })`.
1144
+ */
1145
+ claimSpanSlot(traceId) {
1146
+ const config = this.deps.getConfig();
1147
+ if (!config.enableTracing) return false;
1148
+ const state = this.traceStates.get(traceId);
1149
+ if (!state || !state.recording || state.startedSpanCount >= config.maxSpansPerTrace) return false;
1150
+ state.startedSpanCount++;
1151
+ return true;
1152
+ }
1153
+ addSpanListener(fn) {
1154
+ this.spanListeners.add(fn);
1155
+ return () => {
1156
+ this.spanListeners.delete(fn);
1157
+ };
1158
+ }
1159
+ emitSpanEvent(phase, span) {
1160
+ for (const fn of this.spanListeners) try {
1161
+ fn({
1162
+ phase,
1163
+ span
1164
+ });
1165
+ } catch {}
1166
+ }
1167
+ flush(opts) {
1168
+ this.buffer.flush(opts);
1169
+ }
1170
+ clear() {
1171
+ this.buffer.clear();
1172
+ this.traceStates.clear();
1173
+ this.closedTraces.clear();
1174
+ this.setActiveRoot(void 0);
1175
+ this.pendingContinuation = null;
1176
+ this.epoch++;
1177
+ }
1178
+ continueFromTraceparent(header) {
1179
+ this.pendingContinuation = parseTraceparent(header);
1180
+ }
1181
+ /**
1182
+ * Runs `fn` with the span active, so spans started inside auto-parent to it, then ends it.
1183
+ * Records an error status first if `fn` throws or its returned promise rejects.
1184
+ */
1185
+ withSpan(name, fn, opts = {}) {
1186
+ const span = this.startSpan(name, opts);
1187
+ const finishError = (error) => {
1188
+ span.setStatus({
1189
+ code: SpanStatusCode.Error,
1190
+ message: error instanceof Error ? error.message : String(error)
1191
+ });
1192
+ span.end();
1193
+ };
1194
+ return this.holder.withActive(span, () => {
1195
+ try {
1196
+ const result = fn(span);
1197
+ if (isPromiseLike(result)) return result.then((value) => {
1198
+ span.end();
1199
+ return value;
1200
+ }, (error) => {
1201
+ finishError(error);
1202
+ throw error;
1203
+ });
1204
+ span.end();
1205
+ return result;
1206
+ } catch (error) {
1207
+ finishError(error);
1208
+ throw error;
1209
+ }
1210
+ });
1211
+ }
1212
+ /** Starts a span the caller must end. Unlike `withSpan`, it does not become the active span,
1213
+ * so spans started after it do not auto-parent to it. */
1214
+ startSpan(name, opts = {}) {
1215
+ const config = this.deps.getConfig();
1216
+ const spanId$1 = opts.spanId ?? spanId();
1217
+ const continuation = this.pendingContinuation;
1218
+ this.pendingContinuation = null;
1219
+ if (!config.enableTracing) return this.startInertSpan(name, spanId$1, opts, config);
1220
+ const { traceId, parentSpanId, state } = this.resolveTrace(spanId$1, name, opts, config, continuation);
1221
+ let recording = state.recording;
1222
+ if (opts.claimed) {} else if (state.startedSpanCount >= config.maxSpansPerTrace) {
1223
+ recording = false;
1224
+ if (config.debug && !state.loggedCap) {
1225
+ state.loggedCap = true;
1226
+ console.error("Flare: maxSpansPerTrace reached, dropping span");
1227
+ }
1228
+ } else state.startedSpanCount++;
1229
+ state.openSpanCount++;
1230
+ const isLocalRoot = state.localRootSpanId === spanId$1;
1231
+ const span = this.makeSpan({
1232
+ traceId,
1233
+ spanId: spanId$1,
1234
+ parentSpanId,
1235
+ name,
1236
+ recording,
1237
+ isLocalRoot,
1238
+ stateGeneration: state.generation
1239
+ }, opts, config);
1240
+ this.emitSpanEvent("start", span);
1241
+ return span;
1242
+ }
1243
+ /** A real Span handle that records nothing, so callers never have to branch on whether tracing is on. */
1244
+ startInertSpan(name, spanId, opts, config) {
1245
+ const span = this.makeSpan({
1246
+ traceId: traceId(),
1247
+ spanId,
1248
+ parentSpanId: null,
1249
+ name,
1250
+ recording: false,
1251
+ isLocalRoot: true,
1252
+ stateGeneration: 0
1253
+ }, opts, config);
1254
+ this.emitSpanEvent("start", span);
1255
+ return span;
1256
+ }
1257
+ resolveTrace(spanId, name, opts, config, continuation) {
1258
+ let parent = opts.forceRoot ? opts.parent : opts.parent ?? this.holder.getActive();
1259
+ if (parent && hasEpoch(parent) && parent.epoch !== this.epoch) parent = void 0;
1260
+ if (parent && "spanId" in parent && "traceId" in parent) {
1261
+ const traceId = parent.traceId;
1262
+ const fallbackRecording = () => isSpan(parent) ? parent.isRecording : resolveSampling({
1263
+ name,
1264
+ attributes: opts.attributes ?? {},
1265
+ spanType: opts.spanType
1266
+ }, config, this.rng);
1267
+ const state = this.getOrSeedState(traceId, spanId, fallbackRecording);
1268
+ return {
1269
+ traceId,
1270
+ parentSpanId: parent.spanId,
1271
+ state
1272
+ };
1273
+ }
1274
+ if (continuation) {
1275
+ const recording = resolveSampling({
1276
+ name,
1277
+ parentSampled: continuation.sampled,
1278
+ attributes: opts.attributes ?? {},
1279
+ spanType: opts.spanType
1280
+ }, config, this.rng);
1281
+ const state = this.createState(continuation.traceId, spanId, recording);
1282
+ return {
1283
+ traceId: continuation.traceId,
1284
+ parentSpanId: continuation.parentSpanId,
1285
+ state
1286
+ };
1287
+ }
1288
+ const traceId$1 = traceId();
1289
+ const recording = resolveSampling({
1290
+ name,
1291
+ attributes: opts.attributes ?? {},
1292
+ spanType: opts.spanType
1293
+ }, config, this.rng);
1294
+ return {
1295
+ traceId: traceId$1,
1296
+ parentSpanId: null,
1297
+ state: this.createState(traceId$1, spanId, recording)
1298
+ };
1299
+ }
1300
+ getOrSeedState(traceId, localRootSpanId, fallbackRecording) {
1301
+ const existing = this.traceStates.get(traceId);
1302
+ if (existing) {
1303
+ this.traceStates.delete(traceId);
1304
+ this.traceStates.set(traceId, existing);
1305
+ return existing;
1306
+ }
1307
+ const closed = this.closedTraces.get(traceId);
1308
+ if (closed) {
1309
+ this.closedTraces.delete(traceId);
1310
+ const state = this.createState(traceId, closed.localRootSpanId, closed.recording);
1311
+ state.startedSpanCount = closed.startedSpanCount;
1312
+ state.rootEnded = true;
1313
+ return state;
1314
+ }
1315
+ return this.createState(traceId, localRootSpanId, fallbackRecording());
1316
+ }
1317
+ createState(traceId, localRootSpanId, recording) {
1318
+ evictLruIfNew(this.traceStates, traceId, this.maxLiveTraces);
1319
+ const state = {
1320
+ traceId,
1321
+ recording,
1322
+ localRootSpanId,
1323
+ rootEnded: false,
1324
+ startedSpanCount: 0,
1325
+ openSpanCount: 0,
1326
+ generation: ++this.stateGeneration,
1327
+ loggedCap: false
1328
+ };
1329
+ this.traceStates.set(traceId, state);
1330
+ return state;
1331
+ }
1332
+ makeSpan(init, opts, config) {
1333
+ const scopeAttributes = init.recording && init.isLocalRoot ? this.deps.getScopeAttributes() : {};
1334
+ const span = new SpanImpl({
1335
+ ...init,
1336
+ startTimeUnixNano: opts.startTimeUnixNano ?? this.now(),
1337
+ epoch: this.epoch,
1338
+ scopeAttributes
1339
+ }, {
1340
+ maxAttributesPerSpan: config.maxAttributesPerSpan,
1341
+ maxEventsPerSpan: config.maxEventsPerSpan,
1342
+ maxAttributesPerSpanEvent: config.maxAttributesPerSpanEvent,
1343
+ now: this.now,
1344
+ onEnd: (s) => this.onSpanEnd(s)
1345
+ });
1346
+ if (opts.spanType) span.setAttribute("flare.span_type", opts.spanType);
1347
+ if (opts.attributes) for (const [key, value] of Object.entries(opts.attributes)) span.setAttribute(key, value);
1348
+ return span;
1349
+ }
1350
+ /** Bounded, LRU by insertion order, like traceStates. Holds primitives only, never a span. */
1351
+ rememberClosed(state) {
1352
+ evictLruIfNew(this.closedTraces, state.traceId, MAX_CLOSED_TRACES);
1353
+ this.closedTraces.set(state.traceId, {
1354
+ localRootSpanId: state.localRootSpanId,
1355
+ recording: state.recording,
1356
+ startedSpanCount: state.startedSpanCount
1357
+ });
1358
+ }
1359
+ onSpanEnd(span) {
1360
+ this.emitSpanEvent("end", span);
1361
+ if (span.epoch !== this.epoch) return;
1362
+ const state = this.traceStates.get(span.traceId);
1363
+ if (state && state.generation === span.stateGeneration) {
1364
+ state.openSpanCount--;
1365
+ if (span.spanId === state.localRootSpanId) state.rootEnded = true;
1366
+ if (state.rootEnded && state.openSpanCount <= 0) {
1367
+ this.traceStates.delete(span.traceId);
1368
+ this.rememberClosed(state);
1369
+ }
1370
+ }
1371
+ if (!span.isRecording) return;
1372
+ if (!this.deps.getConfig().enableTracing) return;
1373
+ try {
1374
+ const record = {
1375
+ ...span.scopeAttributes,
1376
+ ...span.attributes
1377
+ };
1378
+ const buffered = {
1379
+ traceId: span.traceId,
1380
+ spanId: span.spanId,
1381
+ parentSpanId: span.parentSpanId,
1382
+ name: span.name,
1383
+ startTimeUnixNano: span.startTimeUnixNano,
1384
+ endTimeUnixNano: span.endTimeUnixNano,
1385
+ status: span.status,
1386
+ recordAttributes: attributesToOpenTelemetry(record),
1387
+ droppedAttributesCount: span.droppedAttributesCount,
1388
+ droppedEventsCount: span.droppedEventsCount,
1389
+ events: span.events.map((event) => ({
1390
+ name: event.name,
1391
+ timeUnixNano: event.timeUnixNano,
1392
+ attributes: attributesToOpenTelemetry(event.attributes),
1393
+ droppedAttributesCount: event.droppedAttributesCount
1394
+ }))
1395
+ };
1396
+ this.buffer.add(buffered);
1397
+ } catch (error) {
1398
+ if (this.deps.getConfig().debug) console.error("Flare: failed to buffer span", error);
1399
+ }
1400
+ }
1401
+ };
1402
+
761
1403
  //#endregion
762
1404
  //#region src/Flare.ts
1405
+ /** Scope attributes a span never inherits. Derived from `USER_IDENTITY_KEYS` so a future user field is
1406
+ * excluded automatically, without anyone needing to remember to list it here. See `getScopeAttributes`. */
1407
+ const SPAN_SCOPE_EXCLUDED_KEYS = USER_IDENTITY_KEYS.filter((key) => key !== USER_FIELD_KEYS.id);
763
1408
  const DEFAULT_SDK_NAME = "@flareapp/core";
764
1409
  var Flare = class {
765
1410
  inflight = /* @__PURE__ */ new Set();
766
1411
  _logger;
1412
+ _tracer;
767
1413
  _config = {
768
1414
  key: null,
769
1415
  version: "",
770
1416
  sourcemapVersionId: SOURCEMAP_VERSION,
771
1417
  stage: "",
772
1418
  maxGlowsPerReport: 30,
1419
+ enableBreadcrumbs: false,
1420
+ maxBreadcrumbs: 100,
773
1421
  ingestUrl: "https://ingress.flareapp.io/v1/errors",
774
1422
  reportBrowserExtensionErrors: false,
775
1423
  debug: false,
@@ -783,7 +1431,17 @@ var Flare = class {
783
1431
  maxLogBufferSize: 100,
784
1432
  logFlushIntervalMs: 5e3,
785
1433
  logFlushMaxBytes: 8e5,
786
- keepaliveMaxBytes: 6e4
1434
+ keepaliveMaxBytes: 6e4,
1435
+ enableTracing: false,
1436
+ tracesIngestUrl: "https://ingress.flareapp.io/v1/traces",
1437
+ tracesSampleRate: 1,
1438
+ maxSpanBufferSize: 100,
1439
+ spanFlushIntervalMs: 5e3,
1440
+ spanFlushMaxBytes: 8e5,
1441
+ maxSpansPerTrace: 1024,
1442
+ maxAttributesPerSpan: 128,
1443
+ maxEventsPerSpan: 128,
1444
+ maxAttributesPerSpanEvent: 128
787
1445
  };
788
1446
  sdkInfo = {
789
1447
  name: DEFAULT_SDK_NAME,
@@ -791,18 +1449,19 @@ var Flare = class {
791
1449
  };
792
1450
  framework = null;
793
1451
  /**
794
- * @param api sends the report over HTTP.
795
- * @param contextCollector returns per-report attributes (browser DOM info, Node
796
- * process info, etc). Default is a no-op.
797
- * @param fileReader reads source files for stack-trace snippets. Default
798
- * returns null (no snippets); `@flareapp/js` injects a
799
- * fetch-based reader, `@flareapp/node` injects a disk reader.
800
- * @param scopeProvider returns the current `Scope` (per-call mutable state:
801
- * glows, pendingAttributes, entryPoint). Browser uses a
802
- * single global scope; Node uses an AsyncLocalStorage-
803
- * backed provider so each request gets its own.
1452
+ * @param api fetch transport for reports, logs and traces. Stateless: ingest url and
1453
+ * key are passed per call, so tests swap in a fake.
1454
+ * @param contextCollector per-report attributes (browser DOM, Node process). No-op by default.
1455
+ * @param fileReader source files for stack-trace snippets. Defaults to no snippets;
1456
+ * `@flareapp/js` injects a fetch reader, `@flareapp/node` a disk reader.
1457
+ * @param scopeProvider the current `Scope`. Browser uses one global scope; Node an
1458
+ * AsyncLocalStorage-backed provider so each request gets its own.
1459
+ * @param scheduler drains the log and span buffers when the host's lifecycle ends (browser
1460
+ * unload, process exit). No-op by default, leaving only size/timer flushes.
1461
+ * @param activeSpanHolder tracks the active span so new spans auto-parent to it. In-memory by
1462
+ * default; a platform can back it with AsyncLocalStorage instead.
804
1463
  */
805
- constructor(api = new Api(), contextCollector = () => ({}), fileReader = new NullFileReader(), scopeProvider = new GlobalScopeProvider(), scheduler = new NoopFlushScheduler()) {
1464
+ constructor(api = new Api(), contextCollector = () => ({}), fileReader = new NullFileReader(), scopeProvider = new GlobalScopeProvider(), scheduler = new NoopFlushScheduler(), activeSpanHolder = new InMemoryActiveSpanHolder()) {
806
1465
  this.api = api;
807
1466
  this.contextCollector = contextCollector;
808
1467
  this.fileReader = fileReader;
@@ -816,57 +1475,25 @@ var Flare = class {
816
1475
  track: (p) => this.track(p),
817
1476
  scheduler
818
1477
  });
1478
+ this._tracer = new Tracer({
1479
+ api: this.api,
1480
+ getConfig: () => this._config,
1481
+ getSdkInfo: () => this.sdkInfo,
1482
+ getFramework: () => this.framework,
1483
+ getScopeAttributes: () => this.getScopeAttributes(),
1484
+ getResourceAttributes: () => this.spanResourceAttributes(),
1485
+ track: (p) => this.track(p),
1486
+ scheduler,
1487
+ activeSpanHolder
1488
+ });
819
1489
  }
820
1490
  /**
821
- * Register an in-flight report so `flush()` can wait for it. Called by
822
- * every public report entry point (`report`, `reportSilently`,
823
- * `reportMessage`, `reportUnhandledRejection`, `test`); each wraps its
824
- * full async pipeline (beforeEvaluate -> stack trace + source snippets ->
825
- * beforeSubmit -> `api.report()`) so the entire roundtrip is what's
826
- * tracked, not just the HTTP send at the end.
827
- *
828
- * Two problems this method solves at once.
829
- *
830
- * Problem 1: hold a reference to the work without leaking rejections.
831
- *
832
- * `p` is the real report pipeline; it can reject (network failure,
833
- * `beforeSubmit` throws, etc). If we stored `p` directly in `inflight`
834
- * and no caller attached a `.catch` (the global error listeners use
835
- * `reportSilently` which DOES catch, but the path is still subtle), an
836
- * eventual rejection would surface as an unhandled-rejection warning
837
- * on Node and a console error in the browser. Bad citizen.
838
- *
839
- * So we build a SHADOW promise that mirrors `p`'s timing but cannot
840
- * reject:
841
- *
842
- * p.then(
843
- * () => undefined, // on fulfilment, value is undefined
844
- * () => undefined, // on rejection, ALSO resolve with undefined
845
- * )
846
- *
847
- * Providing the second argument means we have "handled" any rejection
848
- * from `p`. The shadow always resolves with `undefined`, and `p`'s
849
- * rejection is consumed at the boundary. From the runtime's point of
850
- * view, the shadow is well-behaved.
851
- *
852
- * Problem 2: self-cleaning entry.
853
- *
854
- * `tracked.finally(() => this.inflight.delete(tracked))`. `finally`
855
- * fires whether the shadow resolves or rejects, but the shadow can no
856
- * longer reject (problem 1 normalized it), so this is effectively
857
- * "when the underlying report has settled, remove me from the Set."
858
- * No GC magic, no external cleanup, no race window.
1491
+ * Register an in-flight report so `flush()` can wait for it. Every entry point wraps its whole async
1492
+ * pipeline, from beforeEvaluate through api.report, so flush() waits on all of it.
859
1493
  *
860
- * Note that `.finally` itself returns a new promise that we drop on
861
- * the floor. If the cleanup callback ever throws, that would surface
862
- * as an unhandled rejection on the dropped promise; `delete` does not
863
- * throw so we are safe today, but anything more elaborate added here
864
- * should be wrapped in try/catch.
865
- *
866
- * The return value is the ORIGINAL `p`. The caller awaits real success
867
- * or failure; the tracking is completely invisible to them. This is why
868
- * `await flare.report(err)` inside a fatal handler observes network
869
- * errors the same as before tracking was added.
1494
+ * What goes in the Set is a shadow promise that mirrors `p`'s timing but cannot reject, so a failed
1495
+ * report never surfaces as an unhandled rejection warning. `p` itself is returned untouched, so the
1496
+ * caller still observes real success or failure.
870
1497
  */
871
1498
  track(p) {
872
1499
  const tracked = p.then(() => void 0, () => void 0);
@@ -875,86 +1502,16 @@ var Flare = class {
875
1502
  return p;
876
1503
  }
877
1504
  /**
878
- * Wait until every in-flight report settles, or until `timeoutMs`
879
- * elapses, whichever comes first. Always resolves; never rejects.
880
- *
881
- * The main consumer is `@flareapp/node`'s fatal handler:
882
- *
883
- * process.on('uncaughtException', async (err) => {
884
- * process.exitCode = 1;
885
- * try { await flare.report(err); } catch {}
886
- * await flare.flush(shutdownTimeoutMs);
887
- * process.exit(1);
888
- * });
889
- *
890
- * The fatal `report` is awaited explicitly; `flush` then drains any
891
- * OTHER reports that were already in flight (a request handler that
892
- * fired `flare.report(...)` concurrently with the crash). The timeout
893
- * caps the wait so a hung HTTP request cannot indefinitely block
894
- * shutdown.
895
- *
896
- * Walking the implementation:
897
- *
898
- * const pending = [...this.inflight];
899
- *
900
- * Spread takes a SNAPSHOT of the Set at this instant. Reports that
901
- * start AFTER this line are not included in `pending`, so they are
902
- * not awaited by THIS flush call. This is intentional: it bounds
903
- * the wait. Without the snapshot, a handler that kept emitting
904
- * reports during shutdown could keep flush alive forever and block
905
- * the process from exiting.
906
- *
907
- * if (pending.length === 0) return Promise.resolve();
908
- *
909
- * Fast path. No timer scheduled, no promise constructor needed.
910
- * Resolves on the microtask queue. Cheap.
911
- *
912
- * return new Promise<void>((resolve) => {
913
- * const timer = setTimeout(resolve, timeoutMs);
914
- * Promise.allSettled(pending).then(() => {
915
- * clearTimeout(timer);
916
- * resolve();
917
- * });
918
- * });
919
- *
920
- * The race between two outcomes, both calling the same `resolve`:
921
- *
922
- * 1. `setTimeout(resolve, timeoutMs)` schedules a "give up" call.
923
- * After `timeoutMs` it fires, calling `resolve()` from the
924
- * timer-queue side. The outer promise resolves immediately,
925
- * even if reports are still pending. Those reports are abandoned
926
- * (they continue running but the process is about to die).
927
- *
928
- * 2. `Promise.allSettled(pending)` returns a promise that resolves
929
- * when every promise in `pending` has either fulfilled or
930
- * rejected. It NEVER rejects on its own. We use `allSettled`
931
- * rather than `Promise.all` because `all` short-circuits on the
932
- * first rejection -- we want to wait for everyone regardless of
933
- * whether their HTTP calls succeed or fail. (Our shadows cannot
934
- * reject anyway because `track` normalized them, but using
935
- * `allSettled` documents the intent and survives future changes
936
- * to shadow construction.) When it resolves, we call
937
- * `clearTimeout(timer)` to cancel the pending timer (so it does
938
- * not fire later and call `resolve` a second time -- a no-op,
939
- * but wasted work) and then `resolve()` ourselves.
940
- *
941
- * Resolve can only meaningfully fire once. Subsequent calls to the
942
- * same `resolve` are silently ignored by the Promise spec, so the
943
- * race is safe even if for some reason both branches fired together.
1505
+ * Wait until every in-flight report settles or `timeoutMs` elapses. Always resolves, never rejects.
1506
+ * Written for `@flareapp/node`'s fatal handler, which awaits the fatal report itself then flushes to
1507
+ * drain any other concurrent reports before `process.exit`.
944
1508
  *
945
- * Things flush() deliberately does NOT do:
946
- *
947
- * - It does not reject. Even if every report failed, allSettled
948
- * resolves. Callers do not need a `.catch`.
949
- * - It does not retry. One pipeline attempt per report, then move on.
950
- * - It does not stop new reports from starting. The Flare instance
951
- * is still usable after flush resolves. flush is "wait for what is
952
- * in flight," not "freeze the SDK."
953
- * - It does not drain reports started after the snapshot. Call flush
954
- * again if you need to wait for those too.
1509
+ * Snapshotting the Set bounds the wait: reports started after this line are not awaited, so a handler
1510
+ * that keeps emitting during shutdown cannot block the process forever. Call flush again for those.
955
1511
  */
956
1512
  flush(timeoutMs = 2e3) {
957
1513
  this._logger.flush();
1514
+ this._tracer.flush();
958
1515
  const pending = [...this.inflight];
959
1516
  if (pending.length === 0) return Promise.resolve();
960
1517
  return new Promise((resolve) => {
@@ -974,22 +1531,44 @@ var Flare = class {
974
1531
  get logger() {
975
1532
  return this._logger;
976
1533
  }
1534
+ get tracer() {
1535
+ return this._tracer;
1536
+ }
1537
+ /** Starts a span the caller must end. Unlike `withSpan`, it does not become the active span,
1538
+ * so spans started after it do not auto-parent to it. */
1539
+ startSpan(name, opts) {
1540
+ return this._tracer.startSpan(name, opts);
1541
+ }
1542
+ /**
1543
+ * Runs `fn` with the span active, so spans started inside auto-parent to it, then ends it.
1544
+ * Records an error status first if `fn` throws or its returned promise rejects.
1545
+ */
1546
+ withSpan(name, fn, opts) {
1547
+ return this._tracer.withSpan(name, fn, opts);
1548
+ }
977
1549
  light(key = KEY, debug) {
978
1550
  this._config.key = key;
979
1551
  if (debug !== void 0) this._config.debug = debug;
980
1552
  this._logger.flush();
1553
+ this._tracer.flush();
981
1554
  return this;
982
1555
  }
983
1556
  configure(config) {
984
1557
  const wasLogsEnabled = this._config.enableLogs;
1558
+ const wasTracingEnabled = this._config.enableTracing;
1559
+ const wasBreadcrumbsEnabled = this._config.enableBreadcrumbs;
985
1560
  this._config = {
986
1561
  ...this._config,
987
1562
  ...config
988
1563
  };
989
1564
  if (config.sampleRate !== void 0) this._config.sampleRate = Math.max(0, Math.min(1, config.sampleRate));
990
- this._config.urlDenylist = resolveDenylist(config.urlDenylist, config.replaceDefaultUrlDenylist ?? this._config.replaceDefaultUrlDenylist);
1565
+ if (config.tracesSampleRate !== void 0) this._config.tracesSampleRate = Math.max(0, Math.min(1, config.tracesSampleRate));
1566
+ if (config.urlDenylist !== void 0 || config.replaceDefaultUrlDenylist !== void 0) this._config.urlDenylist = resolveDenylist(config.urlDenylist, config.replaceDefaultUrlDenylist ?? this._config.replaceDefaultUrlDenylist);
991
1567
  if (wasLogsEnabled && this._config.enableLogs === false) this._logger.clear();
992
1568
  if (config.key !== void 0) this._logger.flush();
1569
+ if (wasBreadcrumbsEnabled && this._config.enableBreadcrumbs === false) this.scopeProvider.active().clearBreadcrumbs();
1570
+ if (wasTracingEnabled && this._config.enableTracing === false) this._tracer.clear();
1571
+ if (config.key !== void 0) this._tracer.flush();
993
1572
  return this;
994
1573
  }
995
1574
  test() {
@@ -1011,6 +1590,9 @@ var Flare = class {
1011
1590
  }, this._config.maxGlowsPerReport);
1012
1591
  return this;
1013
1592
  }
1593
+ addBreadcrumb(type, attributes, startTimeUnixNano) {
1594
+ recordBreadcrumb(this.scopeProvider, this._config, type, attributes, startTimeUnixNano);
1595
+ }
1014
1596
  clearGlows() {
1015
1597
  this.scopeProvider.active().clearGlows();
1016
1598
  return this;
@@ -1029,11 +1611,8 @@ var Flare = class {
1029
1611
  return this;
1030
1612
  }
1031
1613
  /**
1032
- * Attach an identified user to the active scope. Fields are projected to the
1033
- * keys the Flare backend reads: `user.id`, `user.email`, `user.full_name`,
1034
- * and `client.address`. Any extra keys are bundled into `user.attributes`.
1035
- * Pass `null` to clear the user. Scope-aware: in Node this targets the
1036
- * per-request scope via the scope provider.
1614
+ * Maps the known fields onto the keys the Flare backend reads (see `USER_FIELD_KEYS`) and bundles
1615
+ * anything else into `user.attributes`. Pass `null` to clear. In Node this targets the per-request scope.
1037
1616
  */
1038
1617
  setUser(user) {
1039
1618
  const scope = this.scopeProvider.active();
@@ -1066,7 +1645,7 @@ var Flare = class {
1066
1645
  async reportInternal(error, attributes = {}) {
1067
1646
  if (this._config.sampleRate < 1 && Math.random() >= this._config.sampleRate) return;
1068
1647
  const seenAtUnixNano = Date.now() * 1e6;
1069
- const coerced = error instanceof Error ? error : new Error(typeof error === "string" ? error : String(error));
1648
+ const coerced = error instanceof Error ? error : new Error(String(error));
1070
1649
  const errorToReport = await this._config.beforeEvaluate(coerced);
1071
1650
  if (!errorToReport) return;
1072
1651
  const report = await this.createReportFromError(errorToReport, attributes, seenAtUnixNano);
@@ -1148,7 +1727,10 @@ var Flare = class {
1148
1727
  const baseAttributes = includeBase ? this.buildBaseAttributes() : {};
1149
1728
  const entryPoint = activeScope.entryPoint;
1150
1729
  const entryPointOverrides = {};
1151
- if (entryPoint?.identifier !== void 0) entryPointOverrides["flare.entry_point.handler.identifier"] = entryPoint.identifier;
1730
+ if (entryPoint?.identifier !== void 0) {
1731
+ entryPointOverrides["flare.entry_point.handler.identifier"] = entryPoint.identifier;
1732
+ entryPointOverrides["http.route"] = entryPoint.identifier;
1733
+ }
1152
1734
  if (entryPoint?.type !== void 0) entryPointOverrides["flare.entry_point.handler.type"] = entryPoint.type;
1153
1735
  if (entryPoint?.name !== void 0) entryPointOverrides["flare.entry_point.handler.name"] = entryPoint.name;
1154
1736
  const attributes = {
@@ -1177,6 +1759,23 @@ var Flare = class {
1177
1759
  record: this.assembleAttributes(collectorRecord, userAttributes, false)
1178
1760
  };
1179
1761
  }
1762
+ /**
1763
+ * Local roots only, snapshotted by the Tracer at span START so a long-lived root does not drift into
1764
+ * the next page's scope. Children get none, and no span ever runs the DOM collector.
1765
+ *
1766
+ * Everything assembled is inherited except user identity (excluding the opaque `user.id`): a root span
1767
+ * goes out for every page view, so email, full name, IP and `user.attributes` would turn normal
1768
+ * browsing into PII traffic. The rest — `context.custom`, `addContextGroup` bags — stays in, because
1769
+ * the trace viewer renders any span attribute whose key does not start with `flare.`.
1770
+ */
1771
+ getScopeAttributes() {
1772
+ const scoped = { ...this.assembleAttributes({}, {}, false) };
1773
+ for (const key of SPAN_SCOPE_EXCLUDED_KEYS) delete scoped[key];
1774
+ return scoped;
1775
+ }
1776
+ spanResourceAttributes() {
1777
+ return partitionAttributes(this.contextCollector(this._config)).resource;
1778
+ }
1180
1779
  buildReport(input) {
1181
1780
  const activeScope = this.scopeProvider.active();
1182
1781
  const attributes = this.assembleAttributes(this.contextCollector(this._config), input.extraAttributes, true);
@@ -1185,7 +1784,7 @@ var Flare = class {
1185
1784
  message: input.message,
1186
1785
  seenAtUnixNano: input.seenAtUnixNano,
1187
1786
  stacktrace: input.stacktrace,
1188
- events: glowsToEvents(activeScope.glows),
1787
+ events: timelineEvents(activeScope.glows, activeScope.breadcrumbs),
1189
1788
  attributes
1190
1789
  };
1191
1790
  if (input.isLog) report.isLog = true;
@@ -1203,4 +1802,4 @@ var Flare = class {
1203
1802
  };
1204
1803
 
1205
1804
  //#endregion
1206
- export { Api, DEFAULT_URL_DENYLIST, Flare, GlobalScopeProvider, Logger, NoopFlushScheduler, NullFileReader, Scope, USER_IDENTITY_KEYS, assert, assertKey, convertToError, createStackTrace, describeRejectionReason, extractCode, flatJsonStringify, getCodeSnippet, glowsToEvents, now, readLinesFromFile, redactUrlQuery, resolveDenylist, routeRejection, userIdentityAttributes };
1805
+ export { Api, BrowserSpanEventType, BrowserSpanType, DEFAULT_MAX_LIVE_TRACES, DEFAULT_URL_DENYLIST, Flare, FrameworkName, GlobalScopeProvider, InMemoryActiveSpanHolder, Logger, MAX_BREADCRUMB_URL_LENGTH, NoopFlushScheduler, NullFileReader, Scope, SpanStatusCode, Tracer, USER_IDENTITY_KEYS, assert, assertKey, breadcrumbUrl, buildTraceparent, buildTracesEnvelope, convertToError, createIdentityTagger, createStackTrace, defaultNowNano, describeRejectionReason, extractCode, flatJsonStringify, getCodeSnippet, glowsToEvents, now, parseTraceparent, readLinesFromFile, recordBreadcrumb, redactObjectValues, redactUrlQuery, resolveDenylist, routeRejection, safeClone, safeDecode, spanId, toCustomContext, urlAttributes, userIdentityAttributes };