@flareapp/core 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,196 +1,79 @@
1
+ import { D as KEY, E as CLIENT_VERSION, O as SOURCEMAP_VERSION, T as assert, _ as createTraversalBudget, a as routeRejection, b as createIdentityTagger, c as redactUrlQuery, d as now, f as glowsToEvents, g as TRUNCATED, h as MAX_TRAVERSAL_DEPTH, i as describeRejectionReason, l as resolveDenylist, m as safeClone, n as urlAttributes, o as DEFAULT_URL_DENYLIST, p as flatJsonStringify, r as toCustomContext, s as redactObjectValues, u as safeDecode, v as spendNode, w as assertKey, x as convertToError, y as extractCode } from "./urlAttributes-D3gCx23B.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
+ };
99
40
 
100
41
  //#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
- }
42
+ //#region src/types.ts
43
+ /** OTel status codes. Wire format: these numbers ship in the span envelope, so the values never change. */
44
+ const SpanStatusCode = {
45
+ Unset: 0,
46
+ Ok: 1,
47
+ Error: 2
48
+ };
144
49
 
145
50
  //#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(() => {});
51
+ //#region src/util/utf8Bytes.ts
52
+ const textEncoder = new TextEncoder();
53
+ /** UTF-8 byte length of `value`. */
54
+ function utf8Bytes(value) {
55
+ return textEncoder.encode(value).length;
186
56
  }
187
57
 
188
58
  //#endregion
189
59
  //#region src/api/Api.ts
60
+ const MAX_PENDING_KEEPALIVE_BYTES = 6e4;
61
+ const MAX_PENDING_KEEPALIVE_REQUESTS = 15;
190
62
  var Api = class {
63
+ pendingKeepaliveBytes = 0;
64
+ pendingKeepaliveRequests = 0;
65
+ /**
66
+ * How many keepalive bytes are still available. Logs and traces share one browser allowance and both
67
+ * flush on page hide, so whichever goes second has to pack against what is left rather than assume the
68
+ * whole budget, or it exceeds the gate in send() and silently degrades to a cancellable fetch.
69
+ */
70
+ keepaliveBudgetRemaining() {
71
+ if (this.pendingKeepaliveRequests >= MAX_PENDING_KEEPALIVE_REQUESTS) return 0;
72
+ return Math.max(0, MAX_PENDING_KEEPALIVE_BYTES - this.pendingKeepaliveBytes);
73
+ }
191
74
  report(report, url, key, reportBrowserExtensionErrors, debug = false) {
192
- return fetch(url, {
193
- method: "POST",
75
+ return this.send({
76
+ url,
194
77
  headers: {
195
78
  "Accept": "application/json",
196
79
  "Content-Type": "application/json",
@@ -198,34 +81,257 @@ var Api = class {
198
81
  "X-Report-Browser-Extension-Errors": JSON.stringify(reportBrowserExtensionErrors),
199
82
  "X-Flare-Client-Version": "2"
200
83
  },
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);
84
+ body: flatJsonStringify(report),
85
+ label: "Flare",
86
+ debug,
87
+ keepalive: false
206
88
  });
207
89
  }
208
90
  logs(envelope, url, key, debug = false, keepalive = false) {
91
+ return this.send({
92
+ url,
93
+ headers: this.ingestHeaders(key),
94
+ body: flatJsonStringify(envelope),
95
+ label: "Flare logs",
96
+ debug,
97
+ keepalive
98
+ });
99
+ }
100
+ traces(envelope, url, key, debug = false, keepalive = false) {
101
+ let body;
102
+ try {
103
+ body = JSON.stringify(envelope);
104
+ } catch {
105
+ body = flatJsonStringify(envelope);
106
+ }
107
+ return this.send({
108
+ url,
109
+ headers: this.ingestHeaders(key),
110
+ body,
111
+ label: "Flare traces",
112
+ debug,
113
+ keepalive
114
+ });
115
+ }
116
+ ingestHeaders(key) {
117
+ return {
118
+ "Accept": "application/json",
119
+ "Content-Type": "application/json",
120
+ "x-api-token": key ?? ""
121
+ };
122
+ }
123
+ send(request) {
124
+ const { url, headers, body, label, debug, keepalive: keepaliveRequested } = request;
125
+ const bytes = keepaliveRequested ? utf8Bytes(body) : 0;
126
+ const keepalive = keepaliveRequested && this.pendingKeepaliveRequests < MAX_PENDING_KEEPALIVE_REQUESTS && this.pendingKeepaliveBytes + bytes <= MAX_PENDING_KEEPALIVE_BYTES;
127
+ if (keepalive) {
128
+ this.pendingKeepaliveBytes += bytes;
129
+ this.pendingKeepaliveRequests += 1;
130
+ }
209
131
  return fetch(url, {
210
132
  method: "POST",
211
133
  keepalive,
212
- headers: {
213
- "Accept": "application/json",
214
- "Content-Type": "application/json",
215
- "x-api-token": key ?? ""
216
- },
217
- body: flatJsonStringify(envelope)
134
+ headers,
135
+ body
218
136
  }).then((response) => {
219
- if (debug && response.status !== 201) console.error(`Received response with status ${response.status} from Flare logs`);
137
+ if (debug && response.status !== 201) console.error(`Received response with status ${response.status} from ${label}`);
220
138
  }, (error) => {
221
139
  if (debug) console.error(error);
140
+ }).finally(() => {
141
+ if (keepalive) {
142
+ this.pendingKeepaliveBytes -= bytes;
143
+ this.pendingKeepaliveRequests -= 1;
144
+ }
222
145
  });
223
146
  }
224
147
  };
225
148
 
149
+ //#endregion
150
+ //#region src/telemetry/TelemetryBuffer.ts
151
+ /**
152
+ * The batching machine behind both telemetry signals: hold records, ship them when a size, weight or time
153
+ * trigger fires, and shed the oldest when nothing can drain. One instance owns one signal; what that signal
154
+ * is comes entirely from the policy.
155
+ */
156
+ var TelemetryBuffer = class {
157
+ entries = [];
158
+ bufferedBytes = 0;
159
+ timer;
160
+ timerActive = false;
161
+ constructor(deps, policy) {
162
+ this.deps = deps;
163
+ this.policy = policy;
164
+ const flush = (opts) => this.flush(opts);
165
+ this.deps.scheduler.register(flush);
166
+ }
167
+ length() {
168
+ return this.entries.length;
169
+ }
170
+ add(record) {
171
+ const config = this.deps.getConfig();
172
+ const limits = this.policy.limits(config);
173
+ const bytes = this.policy.estimateBytes(record);
174
+ if (bytes > limits.maxBytes) {
175
+ if (config.debug) console.error(this.policy.oversizedMessage);
176
+ return;
177
+ }
178
+ this.entries.push({
179
+ record,
180
+ bytes
181
+ });
182
+ this.bufferedBytes += bytes;
183
+ this.policy.onRecordBuffered?.(record);
184
+ this.evaluateTriggers(config, limits);
185
+ this.trim(limits);
186
+ }
187
+ flush(opts) {
188
+ const config = this.deps.getConfig();
189
+ if (!this.policy.enabled(config)) return;
190
+ if (this.entries.length === 0) return;
191
+ if (!assertKey(config.key, config.debug)) {
192
+ this.clearTimer();
193
+ return;
194
+ }
195
+ this.clearTimer();
196
+ const resource = this.policy.resourceForFlush();
197
+ let selected;
198
+ let sendKeepalive = !!opts?.keepalive;
199
+ if (opts?.keepalive) {
200
+ selected = this.packForKeepalive(config, resource);
201
+ if (selected.length === 0) {
202
+ selected = this.entries;
203
+ this.entries = [];
204
+ this.bufferedBytes = 0;
205
+ sendKeepalive = false;
206
+ } else {
207
+ this.entries = this.entries.filter((entry) => !selected.includes(entry));
208
+ this.bufferedBytes = this.entries.reduce((sum, entry) => sum + entry.bytes, 0);
209
+ if (this.entries.length > 0) this.armTimer(this.policy.limits(config));
210
+ }
211
+ } else {
212
+ selected = this.entries;
213
+ this.entries = [];
214
+ this.bufferedBytes = 0;
215
+ }
216
+ if (selected.length === 0) return;
217
+ try {
218
+ this.policy.send(this.policy.buildEnvelope(selected.map((entry) => entry.record), resource), config, sendKeepalive);
219
+ } catch (error) {
220
+ if (config.debug) console.error(this.policy.sendFailureMessage, error);
221
+ }
222
+ }
223
+ clear() {
224
+ this.entries = [];
225
+ this.bufferedBytes = 0;
226
+ this.clearTimer();
227
+ }
228
+ evaluateTriggers(config, limits) {
229
+ if (this.entries.length >= limits.maxSize) {
230
+ this.flush();
231
+ return;
232
+ }
233
+ if (this.bufferedBytes >= limits.maxBytes) {
234
+ this.flush();
235
+ return;
236
+ }
237
+ this.armTimer(limits);
238
+ }
239
+ armTimer(limits) {
240
+ if (this.timerActive) return;
241
+ this.timerActive = true;
242
+ this.timer = setTimeout(() => {
243
+ this.timerActive = false;
244
+ this.timer = void 0;
245
+ this.flush();
246
+ }, limits.flushIntervalMs);
247
+ this.timer.unref?.();
248
+ }
249
+ trim(limits) {
250
+ if (this.entries.length > limits.maxSize) {
251
+ const excess = this.entries.length - limits.maxSize;
252
+ for (let i = 0; i < excess; i++) this.bufferedBytes -= this.entries[i].bytes;
253
+ this.entries = this.entries.slice(excess);
254
+ }
255
+ while (this.entries.length > 1 && this.bufferedBytes > limits.maxBytes) {
256
+ const dropped = this.entries.shift();
257
+ if (dropped) this.bufferedBytes -= dropped.bytes;
258
+ }
259
+ }
260
+ /**
261
+ * Newest-wins. An over-budget record is skipped, not a stop signal, so a smaller older record behind a fat
262
+ * one still ships. Runs on visibilitychange:hidden, which fires on plain backgrounding too, so the tail this
263
+ * leaves behind is retained and re-armed rather than dropped (see flush).
264
+ */
265
+ packForKeepalive(config, resource) {
266
+ const fixedBytes = this.policy.emptyEnvelopeBytes(resource);
267
+ const budget = Math.min(config.keepaliveMaxBytes, this.policy.keepaliveBudget?.(config) ?? config.keepaliveMaxBytes);
268
+ const selected = [];
269
+ let selectedBytes = 0;
270
+ let droppedCount = 0;
271
+ for (let i = this.entries.length - 1; i >= 0; i--) {
272
+ const entry = this.entries[i];
273
+ const candidateBytes = this.policy.recordBytes(entry.record);
274
+ if (fixedBytes + selectedBytes + candidateBytes + selected.length <= budget) {
275
+ selected.unshift(entry);
276
+ selectedBytes += candidateBytes;
277
+ } else if (config.debug) droppedCount++;
278
+ }
279
+ if (config.debug && droppedCount > 0) console.error(this.policy.keepaliveDropMessage(droppedCount));
280
+ return selected;
281
+ }
282
+ clearTimer() {
283
+ if (this.timer) {
284
+ clearTimeout(this.timer);
285
+ this.timer = void 0;
286
+ }
287
+ this.timerActive = false;
288
+ }
289
+ };
290
+
291
+ //#endregion
292
+ //#region src/telemetry/resourceIdentity.ts
293
+ /**
294
+ * Builds the attributes that go on every logs or traces envelope: the caller's own attributes in `base`, with
295
+ * our SDK, service and framework identity on top. Our keys win, so a user value cannot overwrite something
296
+ * like `telemetry.sdk.name`.
297
+ */
298
+ function buildResourceIdentity(base, config, sdk, framework) {
299
+ const identity = {
300
+ "telemetry.sdk.language": "javascript",
301
+ "telemetry.sdk.name": sdk.name,
302
+ "telemetry.sdk.version": sdk.version,
303
+ "flare.language.name": "javascript"
304
+ };
305
+ if (config.serviceName) identity["service.name"] = config.serviceName;
306
+ if (config.version) identity["service.version"] = config.version;
307
+ if (config.stage) identity["service.stage"] = config.stage;
308
+ if (framework?.name) identity["flare.framework.name"] = framework.name;
309
+ if (framework?.version) identity["flare.framework.version"] = framework.version;
310
+ return {
311
+ ...base,
312
+ ...identity
313
+ };
314
+ }
315
+
226
316
  //#endregion
227
317
  //#region src/logging/otel.ts
228
- function valueToOpenTelemetry(value, inPath = /* @__PURE__ */ new WeakSet()) {
318
+ /**
319
+ * Converts one attribute value into the OpenTelemetry `AnyValue` shape. Strings, numbers and booleans become
320
+ * leaves, arrays and objects are walked recursively. Anything OpenTelemetry cannot carry (null, undefined,
321
+ * NaN, functions) returns null and the caller drops that key.
322
+ *
323
+ * A value that contains itself becomes the string `[Circular]`. `inPath` only holds the parents of the value
324
+ * being converted right now, so the same object used twice side by side is converted twice instead of being
325
+ * wrongly called circular.
326
+ *
327
+ * The walk also stops at a maximum depth and a maximum number of nodes, see traversalBudget.ts. Pass `budget`
328
+ * to let several calls share one allowance, otherwise every call gets its own.
329
+ */
330
+ function valueToOpenTelemetry(value, inPath = /* @__PURE__ */ new WeakSet(), budget = createTraversalBudget()) {
331
+ return convert(value, inPath, 0, budget);
332
+ }
333
+ function convert(value, inPath, depth, budget) {
334
+ if (!spendNode(budget)) return { stringValue: TRUNCATED };
229
335
  if (typeof value === "string") return { stringValue: value };
230
336
  if (typeof value === "boolean") return { boolValue: value };
231
337
  if (typeof value === "number") {
@@ -233,12 +339,13 @@ function valueToOpenTelemetry(value, inPath = /* @__PURE__ */ new WeakSet()) {
233
339
  return Number.isInteger(value) ? { intValue: value } : { doubleValue: value };
234
340
  }
235
341
  if (value === null || value === void 0) return null;
342
+ if (depth >= MAX_TRAVERSAL_DEPTH) return { stringValue: TRUNCATED };
236
343
  if (Array.isArray(value)) {
237
344
  if (inPath.has(value)) return { stringValue: "[Circular]" };
238
345
  inPath.add(value);
239
346
  const values = [];
240
347
  for (const item of value) {
241
- const mapped = valueToOpenTelemetry(item, inPath);
348
+ const mapped = convert(item, inPath, depth + 1, budget);
242
349
  if (mapped !== null) values.push(mapped);
243
350
  }
244
351
  inPath.delete(value);
@@ -249,7 +356,7 @@ function valueToOpenTelemetry(value, inPath = /* @__PURE__ */ new WeakSet()) {
249
356
  inPath.add(value);
250
357
  const values = [];
251
358
  for (const [key, item] of Object.entries(value)) {
252
- const mapped = valueToOpenTelemetry(item, inPath);
359
+ const mapped = convert(item, inPath, depth + 1, budget);
253
360
  if (mapped !== null) values.push({
254
361
  key,
255
362
  value: mapped
@@ -261,9 +368,10 @@ function valueToOpenTelemetry(value, inPath = /* @__PURE__ */ new WeakSet()) {
261
368
  return null;
262
369
  }
263
370
  function attributesToOpenTelemetry(attributes) {
371
+ const budget = createTraversalBudget();
264
372
  const out = [];
265
373
  for (const [key, value] of Object.entries(attributes)) {
266
- const mapped = valueToOpenTelemetry(value);
374
+ const mapped = valueToOpenTelemetry(value, /* @__PURE__ */ new WeakSet(), budget);
267
375
  if (mapped !== null) out.push({
268
376
  key,
269
377
  value: mapped
@@ -274,6 +382,18 @@ function attributesToOpenTelemetry(attributes) {
274
382
 
275
383
  //#endregion
276
384
  //#region src/logging/envelope.ts
385
+ function toOtelLogRecord(record) {
386
+ return {
387
+ timeUnixNano: record.timeUnixNano,
388
+ observedTimeUnixNano: record.timeUnixNano,
389
+ severityNumber: record.severityNumber,
390
+ severityText: record.severityText,
391
+ body: { stringValue: record.message },
392
+ attributes: record.recordAttributes,
393
+ flags: 0,
394
+ droppedAttributesCount: 0
395
+ };
396
+ }
277
397
  function buildLogsEnvelope(records, resourceAttributes, scopeName, scopeVersion) {
278
398
  return { resourceLogs: [{
279
399
  resource: {
@@ -287,19 +407,24 @@ function buildLogsEnvelope(records, resourceAttributes, scopeName, scopeVersion)
287
407
  attributes: [],
288
408
  droppedAttributesCount: 0
289
409
  },
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
- }))
410
+ logRecords: records.map(toOtelLogRecord)
300
411
  }]
301
412
  }] };
302
413
  }
414
+ /**
415
+ * How many UTF-8 bytes one record adds to an envelope. We measure the real toOtelLogRecord output instead of
416
+ * reusing the cached BufferedLog estimate, because keepaliveMaxBytes is a hard browser limit and an estimate
417
+ * is not good enough.
418
+ *
419
+ * Uses flatJsonStringify to match Api.logs, which sends the envelope through the same encoder.
420
+ */
421
+ function otelLogRecordBytes(record) {
422
+ return utf8Bytes(flatJsonStringify(toOtelLogRecord(record)));
423
+ }
424
+ /** UTF-8 bytes of an empty envelope: the fixed overhead every batch has, before any records are added. */
425
+ function emptyLogsEnvelopeBytes(resourceAttributes, scopeName, scopeVersion) {
426
+ return utf8Bytes(flatJsonStringify(buildLogsEnvelope([], resourceAttributes, scopeName, scopeVersion)));
427
+ }
303
428
 
304
429
  //#endregion
305
430
  //#region src/logging/severity.ts
@@ -326,14 +451,42 @@ function isAtOrAboveMinimum(level, minimum) {
326
451
  //#endregion
327
452
  //#region src/logging/Logger.ts
328
453
  var Logger = class {
329
- buffer = [];
454
+ inner;
330
455
  resourceAttributes = {};
331
- timer;
332
- timerActive = false;
333
456
  constructor(deps) {
334
457
  this.deps = deps;
335
- const flush = (opts) => this.flush(opts);
336
- this.deps.scheduler.register(flush);
458
+ this.inner = new TelemetryBuffer({
459
+ getConfig: deps.getConfig,
460
+ scheduler: deps.scheduler
461
+ }, {
462
+ limits: (config) => ({
463
+ maxSize: config.maxLogBufferSize,
464
+ maxBytes: config.logFlushMaxBytes,
465
+ flushIntervalMs: config.logFlushIntervalMs
466
+ }),
467
+ enabled: (config) => config.enableLogs,
468
+ keepaliveBudget: () => deps.api.keepaliveBudgetRemaining(),
469
+ estimateBytes: (record) => this.estimateBytes(record),
470
+ emptyEnvelopeBytes: (resource) => {
471
+ const sdk = deps.getSdkInfo();
472
+ return emptyLogsEnvelopeBytes(resource, sdk.name, sdk.version);
473
+ },
474
+ recordBytes: (record) => otelLogRecordBytes(record),
475
+ oversizedMessage: "Flare: dropping oversized log record",
476
+ keepaliveDropMessage: (count) => `Flare: dropped ${count} log record(s) from keepalive envelope (over budget)`,
477
+ sendFailureMessage: "Flare: failed to send buffered log records",
478
+ resourceForFlush: () => this.resourceForFlush(),
479
+ buildEnvelope: (records, resource) => {
480
+ const sdk = deps.getSdkInfo();
481
+ return buildLogsEnvelope(records, resource, sdk.name, sdk.version);
482
+ },
483
+ send: (envelope, config, keepalive) => {
484
+ deps.track(deps.api.logs(envelope, config.logsIngestUrl, config.key, config.debug, keepalive));
485
+ },
486
+ onRecordBuffered: (record) => {
487
+ this.resourceAttributes = record.resourceAttributes;
488
+ }
489
+ });
337
490
  }
338
491
  debug(message, context = {}, attributes = {}) {
339
492
  this.record("debug", message, context, attributes);
@@ -360,7 +513,13 @@ var Logger = class {
360
513
  this.record("emergency", message, context, attributes);
361
514
  }
362
515
  bufferLength() {
363
- return this.buffer.length;
516
+ return this.inner.length();
517
+ }
518
+ flush(opts) {
519
+ this.inner.flush(opts);
520
+ }
521
+ clear() {
522
+ this.inner.clear();
364
523
  }
365
524
  record(level, message, context, attributes) {
366
525
  const config = this.deps.getConfig();
@@ -371,115 +530,21 @@ var Logger = class {
371
530
  ...attributes
372
531
  };
373
532
  const { record, resource } = this.deps.buildLogAttributes(userAttributes);
374
- const buffered = {
533
+ this.inner.add({
375
534
  timeUnixNano: String(Date.now()) + "000000",
376
535
  severityNumber: severityNumber(level),
377
536
  severityText: severityText(level),
378
537
  message,
379
538
  recordAttributes: attributesToOpenTelemetry(record),
380
539
  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);
540
+ });
449
541
  }
450
542
  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;
543
+ return buildResourceIdentity(this.resourceAttributes, this.deps.getConfig(), this.deps.getSdkInfo(), this.deps.getFramework());
476
544
  }
477
545
  estimateBytes(log) {
478
546
  return flatJsonStringify(log).length;
479
547
  }
480
- bufferBytes() {
481
- return this.buffer.reduce((sum, log) => sum + this.estimateBytes(log), 0);
482
- }
483
548
  };
484
549
 
485
550
  //#endregion
@@ -513,31 +578,17 @@ function partitionAttributes(attributes) {
513
578
 
514
579
  //#endregion
515
580
  //#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
- */
581
+ /** `USER_IDENTITY_KEYS` derives from this, so adding a field here can never leave the clear pass stale. */
522
582
  const USER_FIELD_KEYS = {
523
583
  id: "user.id",
524
584
  email: "user.email",
525
585
  fullName: "user.full_name",
526
586
  ipAddress: "client.address"
527
587
  };
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
- */
588
+ /** Every key `Flare.setUser` owns. Consumers stamping identity outside core's report pipeline (Electron's
589
+ * forwarded-renderer path) reuse this exact set. */
535
590
  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
- */
591
+ /** For reports that do not flow through `Flare.report()`, which would spread `pendingAttributes` itself. */
541
592
  function userIdentityAttributes(scope) {
542
593
  const attrs = {};
543
594
  for (const key of USER_IDENTITY_KEYS) {
@@ -547,39 +598,15 @@ function userIdentityAttributes(scope) {
547
598
  return attrs;
548
599
  }
549
600
  /**
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`.
601
+ * Per-call mutable state, split out of `Flare` so the consumer can choose one global `Scope` (browser, one
602
+ * user at a time) or one per request via AsyncLocalStorage (Node, where concurrent requests must not leak
603
+ * into each other). `@flareapp/node`'s `NodeScope` extends this with a `request` bucket.
569
604
  */
570
605
  var Scope = class {
571
606
  glows = [];
572
607
  pendingAttributes = {};
573
608
  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
- */
609
+ /** Caps at `maxGlowsPerReport` by dropping the oldest, so the payload stays bounded. */
583
610
  addGlow(glow, maxGlowsPerReport) {
584
611
  this.glows.push(glow);
585
612
  if (this.glows.length > maxGlowsPerReport) this.glows = this.glows.slice(this.glows.length - maxGlowsPerReport);
@@ -587,29 +614,15 @@ var Scope = class {
587
614
  clearGlows() {
588
615
  this.glows = [];
589
616
  }
590
- /**
591
- * Set a single attribute on this scope. Called from `Flare.addContext` and
592
- * `Flare.addContextGroup`. Last write wins.
593
- */
594
617
  setAttribute(key, value) {
595
618
  this.pendingAttributes[key] = value;
596
619
  }
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
- */
620
+ /** Shallow: last write wins per key, nested objects are not deep-merged. */
603
621
  mergeAttributes(partial) {
604
622
  Object.assign(this.pendingAttributes, partial);
605
623
  }
606
624
  };
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
- */
625
+ /** One `Scope` for the provider's lifetime. The right default for a browser tab or a CLI script. */
613
626
  var GlobalScopeProvider = class {
614
627
  scope = new Scope();
615
628
  active() {
@@ -636,11 +649,14 @@ function getCodeSnippet(fileReader, url, lineNumber, columnNumber) {
636
649
  });
637
650
  }
638
651
  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;
652
+ const cached = cachedFiles[url];
653
+ if (cached !== void 0) return cached;
654
+ const pending = fileReader.read(url).then((text) => {
655
+ if (text === null) delete cachedFiles[url];
642
656
  return text;
643
657
  });
658
+ cachedFiles[url] = pending;
659
+ return pending;
644
660
  }
645
661
  function readLinesFromFile(fileText, lineNumber, columnNumber, maxSnippetLineLength = 1e3, maxSnippetLines = 40) {
646
662
  const codeSnippet = {};
@@ -732,25 +748,11 @@ function isApplicationFrame(fileName) {
732
748
  //#endregion
733
749
  //#region src/stacktrace/NullFileReader.ts
734
750
  /**
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.
751
+ * No-op `FileReader` returning `null` for every URL. Default for `Flare`'s `fileReader` param so `new Flare()` builds
752
+ * reports without picking an environment; stack frames just omit source snippets. Consumer packages inject the real
753
+ * ones: `@flareapp/js` a fetch-based reader, `@flareapp/node` a disk reader. The `read(url) -> Promise<string | null>`
754
+ * interface lets the stack-trace builder treat all three the same (render on text, skip on null), so core needs no
755
+ * environment checks.
754
756
  */
755
757
  var NullFileReader = class {
756
758
  read(_url) {
@@ -758,12 +760,624 @@ var NullFileReader = class {
758
760
  }
759
761
  };
760
762
 
763
+ //#endregion
764
+ //#region src/tracing/context.ts
765
+ var InMemoryActiveSpanHolder = class {
766
+ active;
767
+ root;
768
+ getActive() {
769
+ return this.active ?? this.root;
770
+ }
771
+ withActive(span, fn) {
772
+ const previous = this.active;
773
+ this.active = span;
774
+ try {
775
+ return fn();
776
+ } finally {
777
+ this.active = previous;
778
+ }
779
+ }
780
+ setActiveRoot(span) {
781
+ this.root = span;
782
+ }
783
+ };
784
+
785
+ //#endregion
786
+ //#region src/tracing/ids.ts
787
+ function randomHex(bytes) {
788
+ const randomBytes = new Uint8Array(bytes);
789
+ const cryptoApi = globalThis.crypto;
790
+ if (cryptoApi && typeof cryptoApi.getRandomValues === "function") cryptoApi.getRandomValues(randomBytes);
791
+ else for (let i = 0; i < bytes; i++) randomBytes[i] = Math.floor(Math.random() * 256);
792
+ if (randomBytes.every((b) => b === 0)) randomBytes[bytes - 1] = 1;
793
+ let out = "";
794
+ for (let i = 0; i < bytes; i++) out += randomBytes[i].toString(16).padStart(2, "0");
795
+ return out;
796
+ }
797
+ function traceId() {
798
+ return randomHex(16);
799
+ }
800
+ function spanId() {
801
+ return randomHex(8);
802
+ }
803
+
804
+ //#endregion
805
+ //#region src/tracing/sampler.ts
806
+ function resolveSampling(samplingContext, config, rng = Math.random) {
807
+ if (samplingContext.parentSampled !== void 0) return samplingContext.parentSampled;
808
+ let rate;
809
+ if (config.tracesSampler) {
810
+ let result;
811
+ try {
812
+ result = config.tracesSampler(samplingContext);
813
+ } catch (error) {
814
+ if (config.debug) console.error("Flare: tracesSampler threw, treating span as not sampled", error);
815
+ return false;
816
+ }
817
+ if (typeof result === "boolean") return result;
818
+ rate = result;
819
+ } else rate = config.tracesSampleRate;
820
+ rate = Math.max(0, Math.min(1, rate));
821
+ if (rate <= 0) return false;
822
+ if (rate >= 1) return true;
823
+ return rng() < rate;
824
+ }
825
+
826
+ //#endregion
827
+ //#region src/tracing/Span.ts
828
+ var SpanImpl = class {
829
+ traceId;
830
+ spanId;
831
+ parentSpanId;
832
+ name;
833
+ isRecording;
834
+ epoch;
835
+ stateGeneration;
836
+ startTimeUnixNano;
837
+ scopeAttributes;
838
+ endTimeUnixNano = 0;
839
+ status = { code: SpanStatusCode.Unset };
840
+ attributes = {};
841
+ droppedAttributesCount = 0;
842
+ events = [];
843
+ droppedEventsCount = 0;
844
+ ended = false;
845
+ constructor(init, deps) {
846
+ this.deps = deps;
847
+ this.traceId = init.traceId;
848
+ this.spanId = init.spanId;
849
+ this.parentSpanId = init.parentSpanId;
850
+ this.name = init.name;
851
+ this.isRecording = init.recording;
852
+ this.epoch = init.epoch;
853
+ this.stateGeneration = init.stateGeneration;
854
+ this.startTimeUnixNano = init.startTimeUnixNano;
855
+ this.scopeAttributes = init.scopeAttributes;
856
+ }
857
+ setAttribute(key, value) {
858
+ if (this.ended) return this;
859
+ if (!(key in this.attributes) && Object.keys(this.attributes).length >= this.deps.maxAttributesPerSpan) {
860
+ this.droppedAttributesCount++;
861
+ return this;
862
+ }
863
+ this.attributes[key] = value;
864
+ return this;
865
+ }
866
+ setStatus(status) {
867
+ if (!this.ended) this.status = status;
868
+ return this;
869
+ }
870
+ addEvent(name, attributes = {}) {
871
+ if (this.ended) return this;
872
+ if (this.events.length >= this.deps.maxEventsPerSpan) {
873
+ this.droppedEventsCount++;
874
+ return this;
875
+ }
876
+ const capped = {};
877
+ let dropped = 0;
878
+ for (const [key, value] of Object.entries(attributes)) {
879
+ if (Object.keys(capped).length >= this.deps.maxAttributesPerSpanEvent) {
880
+ dropped++;
881
+ continue;
882
+ }
883
+ capped[key] = value;
884
+ }
885
+ this.events.push({
886
+ name,
887
+ timeUnixNano: this.deps.now(),
888
+ attributes: capped,
889
+ droppedAttributesCount: dropped
890
+ });
891
+ return this;
892
+ }
893
+ end(endTimeUnixNano) {
894
+ if (this.ended) return;
895
+ this.ended = true;
896
+ this.endTimeUnixNano = endTimeUnixNano ?? this.deps.now();
897
+ this.deps.onEnd(this);
898
+ }
899
+ };
900
+
901
+ //#endregion
902
+ //#region src/tracing/envelope.ts
903
+ function toOtelSpan(span) {
904
+ const status = span.status.message !== void 0 ? {
905
+ code: span.status.code,
906
+ message: span.status.message
907
+ } : { code: span.status.code };
908
+ return {
909
+ traceId: span.traceId,
910
+ spanId: span.spanId,
911
+ parentSpanId: span.parentSpanId,
912
+ name: span.name,
913
+ startTimeUnixNano: span.startTimeUnixNano,
914
+ endTimeUnixNano: span.endTimeUnixNano,
915
+ status,
916
+ attributes: span.recordAttributes,
917
+ events: span.events,
918
+ droppedAttributesCount: span.droppedAttributesCount,
919
+ droppedEventsCount: span.droppedEventsCount,
920
+ links: [],
921
+ droppedLinksCount: 0
922
+ };
923
+ }
924
+ function buildTracesEnvelope(spans, resourceAttributes, scopeName, scopeVersion) {
925
+ return { resourceSpans: [{
926
+ resource: {
927
+ attributes: attributesToOpenTelemetry(resourceAttributes),
928
+ droppedAttributesCount: 0
929
+ },
930
+ scopeSpans: [{
931
+ scope: {
932
+ name: scopeName,
933
+ version: scopeVersion,
934
+ attributes: [],
935
+ droppedAttributesCount: 0
936
+ },
937
+ spans: spans.map(toOtelSpan)
938
+ }]
939
+ }] };
940
+ }
941
+ /**
942
+ * How many UTF-8 bytes one span adds to an envelope. We measure the real toOtelSpan output instead of reusing
943
+ * the cached BufferedSpan estimate, because keepaliveMaxBytes is a hard browser limit and an estimate is not
944
+ * good enough.
945
+ *
946
+ * We use flatJsonStringify instead of JSON.stringify because a span keeps values the caller still owns, like
947
+ * status.message, and those can turn unserializable after the span ended. This runs from a visibilitychange
948
+ * listener with no try/catch around it, so a throw here loses the flush. flatJsonStringify handles the usual
949
+ * suspects (circular references, BigInt, a getter that throws on a plain object) but is not bulletproof: a
950
+ * class instance with a throwing getter goes through untouched and can still throw.
951
+ */
952
+ function otelSpanBytes(span) {
953
+ return utf8Bytes(flatJsonStringify(toOtelSpan(span)));
954
+ }
955
+ /** UTF-8 bytes of an empty envelope: the fixed overhead every batch has, before any spans are added. */
956
+ function emptyTracesEnvelopeBytes(resourceAttributes, scopeName, scopeVersion) {
957
+ return utf8Bytes(JSON.stringify(buildTracesEnvelope([], resourceAttributes, scopeName, scopeVersion)));
958
+ }
959
+
960
+ //#endregion
961
+ //#region src/tracing/SpanBuffer.ts
962
+ /** The span half of the shared telemetry buffer: names the config keys, the envelope and the ingest call. */
963
+ var SpanBuffer = class {
964
+ inner;
965
+ constructor(deps) {
966
+ this.deps = deps;
967
+ this.inner = new TelemetryBuffer({
968
+ getConfig: deps.getConfig,
969
+ scheduler: deps.scheduler
970
+ }, {
971
+ limits: (config) => ({
972
+ maxSize: config.maxSpanBufferSize,
973
+ maxBytes: config.spanFlushMaxBytes,
974
+ flushIntervalMs: config.spanFlushIntervalMs
975
+ }),
976
+ enabled: (config) => config.enableTracing,
977
+ keepaliveBudget: () => deps.api.keepaliveBudgetRemaining(),
978
+ estimateBytes: (span) => this.estimateBytes(span),
979
+ emptyEnvelopeBytes: (resource) => {
980
+ const sdk = deps.getSdkInfo();
981
+ return emptyTracesEnvelopeBytes(resource, sdk.name, sdk.version);
982
+ },
983
+ recordBytes: (span) => otelSpanBytes(span),
984
+ oversizedMessage: "Flare: dropping oversized span",
985
+ keepaliveDropMessage: (count) => `Flare: dropped ${count} span(s) from keepalive envelope (over budget)`,
986
+ sendFailureMessage: "Flare: failed to send buffered spans",
987
+ resourceForFlush: () => this.resourceForFlush(),
988
+ buildEnvelope: (spans, resource) => {
989
+ const sdk = deps.getSdkInfo();
990
+ return buildTracesEnvelope(spans, resource, sdk.name, sdk.version);
991
+ },
992
+ send: (envelope, config, keepalive) => {
993
+ deps.track(deps.api.traces(envelope, config.tracesIngestUrl, config.key, config.debug, keepalive));
994
+ }
995
+ });
996
+ }
997
+ length() {
998
+ return this.inner.length();
999
+ }
1000
+ add(span) {
1001
+ this.inner.add(span);
1002
+ }
1003
+ flush(opts) {
1004
+ this.inner.flush(opts);
1005
+ }
1006
+ clear() {
1007
+ this.inner.clear();
1008
+ }
1009
+ resourceForFlush() {
1010
+ return buildResourceIdentity(this.deps.getResourceAttributes(), this.deps.getConfig(), this.deps.getSdkInfo(), this.deps.getFramework());
1011
+ }
1012
+ estimateBytes(span) {
1013
+ return JSON.stringify(span).length;
1014
+ }
1015
+ };
1016
+
1017
+ //#endregion
1018
+ //#region src/tracing/traceparent.ts
1019
+ const HEX32 = /^[0-9a-f]{32}$/;
1020
+ const HEX16 = /^[0-9a-f]{16}$/;
1021
+ const HEX8 = /^[0-9a-f]{2}$/;
1022
+ const ZERO32 = "0".repeat(32);
1023
+ const ZERO16 = "0".repeat(16);
1024
+ function buildTraceparent(traceId, spanId, sampled) {
1025
+ return `00-${traceId}-${spanId}-${sampled ? "01" : "00"}`;
1026
+ }
1027
+ function parseTraceparent(header) {
1028
+ const parts = header.trim().split("-");
1029
+ if (parts.length !== 4) return null;
1030
+ const [version, traceId, spanId, flags] = parts;
1031
+ if (version !== "00") return null;
1032
+ if (!HEX32.test(traceId) || traceId === ZERO32) return null;
1033
+ if (!HEX16.test(spanId) || spanId === ZERO16) return null;
1034
+ if (!HEX8.test(flags)) return null;
1035
+ return {
1036
+ traceId,
1037
+ parentSpanId: spanId,
1038
+ sampled: (parseInt(flags, 16) & 1) === 1
1039
+ };
1040
+ }
1041
+
1042
+ //#endregion
1043
+ //#region src/tracing/Tracer.ts
1044
+ function isPromiseLike(value) {
1045
+ return (typeof value === "object" || typeof value === "function") && value !== null && typeof value.then === "function";
1046
+ }
1047
+ /** `SpanOptions.parent` is a structurally overlapping union; `isRecording` is what tells a real Span apart. */
1048
+ function isSpan(parent) {
1049
+ return "isRecording" in parent;
1050
+ }
1051
+ /** A SpanImpl carries the epoch it was created under; a hand-stitched `{traceId, spanId}` parent does not. */
1052
+ function hasEpoch(parent) {
1053
+ return "epoch" in parent && typeof parent.epoch === "number";
1054
+ }
1055
+ function defaultNowNano() {
1056
+ const performanceApi = globalThis.performance;
1057
+ const ms = performanceApi && typeof performanceApi.now === "function" && typeof performanceApi.timeOrigin === "number" ? performanceApi.timeOrigin + performanceApi.now() : Date.now();
1058
+ return Math.round(ms * 1e6);
1059
+ }
1060
+ /**
1061
+ * Both trace maps cap their size the same way: insertion order is LRU, so the first key is the one to drop.
1062
+ * Only evicts when `key` is not already in the map. A set() that overwrites an existing key does not grow the
1063
+ * map, so it must not evict an unrelated entry to make room for it.
1064
+ */
1065
+ function evictLruIfNew(map, key, cap) {
1066
+ if (map.has(key) || map.size < cap) return;
1067
+ const lru = map.keys().next().value;
1068
+ if (lru !== void 0) map.delete(lru);
1069
+ }
1070
+ const MAX_CLOSED_TRACES = 100;
1071
+ /** Bounded backstop for the live TraceState map: an app that never ends spans must not grow it forever. */
1072
+ const DEFAULT_MAX_LIVE_TRACES = 1e3;
1073
+ var Tracer = class {
1074
+ buffer;
1075
+ holder;
1076
+ traceStates = /* @__PURE__ */ new Map();
1077
+ closedTraces = /* @__PURE__ */ new Map();
1078
+ stateGeneration = 0;
1079
+ now;
1080
+ rng;
1081
+ maxLiveTraces;
1082
+ epoch = 0;
1083
+ pendingContinuation = null;
1084
+ spanListeners = /* @__PURE__ */ new Set();
1085
+ constructor(deps) {
1086
+ this.deps = deps;
1087
+ this.buffer = new SpanBuffer({
1088
+ api: deps.api,
1089
+ getConfig: deps.getConfig,
1090
+ getSdkInfo: deps.getSdkInfo,
1091
+ getFramework: deps.getFramework,
1092
+ getResourceAttributes: deps.getResourceAttributes,
1093
+ track: deps.track,
1094
+ scheduler: deps.scheduler
1095
+ });
1096
+ this.holder = deps.activeSpanHolder ?? new InMemoryActiveSpanHolder();
1097
+ this.now = deps.now ?? defaultNowNano;
1098
+ this.rng = deps.rng ?? Math.random;
1099
+ this.maxLiveTraces = deps.maxLiveTraces ?? DEFAULT_MAX_LIVE_TRACES;
1100
+ }
1101
+ getActiveSpan() {
1102
+ return this.holder.getActive();
1103
+ }
1104
+ setActiveRoot(span) {
1105
+ this.holder.setActiveRoot?.(span);
1106
+ }
1107
+ /**
1108
+ * Take one span against `traceId`'s cap up front, for a caller that publishes a span id before the
1109
+ * span exists (the component profilers do; their descendants record first). False means the trace is
1110
+ * full and the caller should stay transparent instead of handing out an id the cap will refuse.
1111
+ * Consumed by the matching `startSpan({ claimed: true })`.
1112
+ */
1113
+ claimSpanSlot(traceId) {
1114
+ const config = this.deps.getConfig();
1115
+ if (!config.enableTracing) return false;
1116
+ const state = this.traceStates.get(traceId);
1117
+ if (!state || !state.recording || state.startedSpanCount >= config.maxSpansPerTrace) return false;
1118
+ state.startedSpanCount++;
1119
+ return true;
1120
+ }
1121
+ addSpanListener(fn) {
1122
+ this.spanListeners.add(fn);
1123
+ return () => {
1124
+ this.spanListeners.delete(fn);
1125
+ };
1126
+ }
1127
+ emitSpanEvent(phase, span) {
1128
+ for (const fn of this.spanListeners) try {
1129
+ fn({
1130
+ phase,
1131
+ span
1132
+ });
1133
+ } catch {}
1134
+ }
1135
+ flush(opts) {
1136
+ this.buffer.flush(opts);
1137
+ }
1138
+ clear() {
1139
+ this.buffer.clear();
1140
+ this.traceStates.clear();
1141
+ this.closedTraces.clear();
1142
+ this.setActiveRoot(void 0);
1143
+ this.pendingContinuation = null;
1144
+ this.epoch++;
1145
+ }
1146
+ continueFromTraceparent(header) {
1147
+ this.pendingContinuation = parseTraceparent(header);
1148
+ }
1149
+ /**
1150
+ * Runs `fn` with the span active, so spans started inside auto-parent to it, then ends it.
1151
+ * Records an error status first if `fn` throws or its returned promise rejects.
1152
+ */
1153
+ withSpan(name, fn, opts = {}) {
1154
+ const span = this.startSpan(name, opts);
1155
+ const finishError = (error) => {
1156
+ span.setStatus({
1157
+ code: SpanStatusCode.Error,
1158
+ message: error instanceof Error ? error.message : String(error)
1159
+ });
1160
+ span.end();
1161
+ };
1162
+ return this.holder.withActive(span, () => {
1163
+ try {
1164
+ const result = fn(span);
1165
+ if (isPromiseLike(result)) return result.then((value) => {
1166
+ span.end();
1167
+ return value;
1168
+ }, (error) => {
1169
+ finishError(error);
1170
+ throw error;
1171
+ });
1172
+ span.end();
1173
+ return result;
1174
+ } catch (error) {
1175
+ finishError(error);
1176
+ throw error;
1177
+ }
1178
+ });
1179
+ }
1180
+ /** Starts a span the caller must end. Unlike `withSpan`, it does not become the active span,
1181
+ * so spans started after it do not auto-parent to it. */
1182
+ startSpan(name, opts = {}) {
1183
+ const config = this.deps.getConfig();
1184
+ const spanId$1 = opts.spanId ?? spanId();
1185
+ const continuation = this.pendingContinuation;
1186
+ this.pendingContinuation = null;
1187
+ if (!config.enableTracing) return this.startInertSpan(name, spanId$1, opts, config);
1188
+ const { traceId, parentSpanId, state } = this.resolveTrace(spanId$1, name, opts, config, continuation);
1189
+ let recording = state.recording;
1190
+ if (opts.claimed) {} else if (state.startedSpanCount >= config.maxSpansPerTrace) {
1191
+ recording = false;
1192
+ if (config.debug && !state.loggedCap) {
1193
+ state.loggedCap = true;
1194
+ console.error("Flare: maxSpansPerTrace reached, dropping span");
1195
+ }
1196
+ } else state.startedSpanCount++;
1197
+ state.openSpanCount++;
1198
+ const isLocalRoot = state.localRootSpanId === spanId$1;
1199
+ const span = this.makeSpan({
1200
+ traceId,
1201
+ spanId: spanId$1,
1202
+ parentSpanId,
1203
+ name,
1204
+ recording,
1205
+ isLocalRoot,
1206
+ stateGeneration: state.generation
1207
+ }, opts, config);
1208
+ this.emitSpanEvent("start", span);
1209
+ return span;
1210
+ }
1211
+ /** A real Span handle that records nothing, so callers never have to branch on whether tracing is on. */
1212
+ startInertSpan(name, spanId, opts, config) {
1213
+ const span = this.makeSpan({
1214
+ traceId: traceId(),
1215
+ spanId,
1216
+ parentSpanId: null,
1217
+ name,
1218
+ recording: false,
1219
+ isLocalRoot: true,
1220
+ stateGeneration: 0
1221
+ }, opts, config);
1222
+ this.emitSpanEvent("start", span);
1223
+ return span;
1224
+ }
1225
+ resolveTrace(spanId, name, opts, config, continuation) {
1226
+ let parent = opts.forceRoot ? opts.parent : opts.parent ?? this.holder.getActive();
1227
+ if (parent && hasEpoch(parent) && parent.epoch !== this.epoch) parent = void 0;
1228
+ if (parent && "spanId" in parent && "traceId" in parent) {
1229
+ const traceId = parent.traceId;
1230
+ const fallbackRecording = () => isSpan(parent) ? parent.isRecording : resolveSampling({
1231
+ name,
1232
+ attributes: opts.attributes ?? {},
1233
+ spanType: opts.spanType
1234
+ }, config, this.rng);
1235
+ const state = this.getOrSeedState(traceId, spanId, fallbackRecording);
1236
+ return {
1237
+ traceId,
1238
+ parentSpanId: parent.spanId,
1239
+ state
1240
+ };
1241
+ }
1242
+ if (continuation) {
1243
+ const recording = resolveSampling({
1244
+ name,
1245
+ parentSampled: continuation.sampled,
1246
+ attributes: opts.attributes ?? {},
1247
+ spanType: opts.spanType
1248
+ }, config, this.rng);
1249
+ const state = this.createState(continuation.traceId, spanId, recording);
1250
+ return {
1251
+ traceId: continuation.traceId,
1252
+ parentSpanId: continuation.parentSpanId,
1253
+ state
1254
+ };
1255
+ }
1256
+ const traceId$1 = traceId();
1257
+ const recording = resolveSampling({
1258
+ name,
1259
+ attributes: opts.attributes ?? {},
1260
+ spanType: opts.spanType
1261
+ }, config, this.rng);
1262
+ return {
1263
+ traceId: traceId$1,
1264
+ parentSpanId: null,
1265
+ state: this.createState(traceId$1, spanId, recording)
1266
+ };
1267
+ }
1268
+ getOrSeedState(traceId, localRootSpanId, fallbackRecording) {
1269
+ const existing = this.traceStates.get(traceId);
1270
+ if (existing) {
1271
+ this.traceStates.delete(traceId);
1272
+ this.traceStates.set(traceId, existing);
1273
+ return existing;
1274
+ }
1275
+ const closed = this.closedTraces.get(traceId);
1276
+ if (closed) {
1277
+ this.closedTraces.delete(traceId);
1278
+ const state = this.createState(traceId, closed.localRootSpanId, closed.recording);
1279
+ state.startedSpanCount = closed.startedSpanCount;
1280
+ state.rootEnded = true;
1281
+ return state;
1282
+ }
1283
+ return this.createState(traceId, localRootSpanId, fallbackRecording());
1284
+ }
1285
+ createState(traceId, localRootSpanId, recording) {
1286
+ evictLruIfNew(this.traceStates, traceId, this.maxLiveTraces);
1287
+ const state = {
1288
+ traceId,
1289
+ recording,
1290
+ localRootSpanId,
1291
+ rootEnded: false,
1292
+ startedSpanCount: 0,
1293
+ openSpanCount: 0,
1294
+ generation: ++this.stateGeneration,
1295
+ loggedCap: false
1296
+ };
1297
+ this.traceStates.set(traceId, state);
1298
+ return state;
1299
+ }
1300
+ makeSpan(init, opts, config) {
1301
+ const scopeAttributes = init.recording && init.isLocalRoot ? this.deps.getScopeAttributes() : {};
1302
+ const span = new SpanImpl({
1303
+ ...init,
1304
+ startTimeUnixNano: opts.startTimeUnixNano ?? this.now(),
1305
+ epoch: this.epoch,
1306
+ scopeAttributes
1307
+ }, {
1308
+ maxAttributesPerSpan: config.maxAttributesPerSpan,
1309
+ maxEventsPerSpan: config.maxEventsPerSpan,
1310
+ maxAttributesPerSpanEvent: config.maxAttributesPerSpanEvent,
1311
+ now: this.now,
1312
+ onEnd: (s) => this.onSpanEnd(s)
1313
+ });
1314
+ if (opts.spanType) span.setAttribute("flare.span_type", opts.spanType);
1315
+ if (opts.attributes) for (const [key, value] of Object.entries(opts.attributes)) span.setAttribute(key, value);
1316
+ return span;
1317
+ }
1318
+ /** Bounded, LRU by insertion order, like traceStates. Holds primitives only, never a span. */
1319
+ rememberClosed(state) {
1320
+ evictLruIfNew(this.closedTraces, state.traceId, MAX_CLOSED_TRACES);
1321
+ this.closedTraces.set(state.traceId, {
1322
+ localRootSpanId: state.localRootSpanId,
1323
+ recording: state.recording,
1324
+ startedSpanCount: state.startedSpanCount
1325
+ });
1326
+ }
1327
+ onSpanEnd(span) {
1328
+ this.emitSpanEvent("end", span);
1329
+ if (span.epoch !== this.epoch) return;
1330
+ const state = this.traceStates.get(span.traceId);
1331
+ if (state && state.generation === span.stateGeneration) {
1332
+ state.openSpanCount--;
1333
+ if (span.spanId === state.localRootSpanId) state.rootEnded = true;
1334
+ if (state.rootEnded && state.openSpanCount <= 0) {
1335
+ this.traceStates.delete(span.traceId);
1336
+ this.rememberClosed(state);
1337
+ }
1338
+ }
1339
+ if (!span.isRecording) return;
1340
+ if (!this.deps.getConfig().enableTracing) return;
1341
+ try {
1342
+ const record = {
1343
+ ...span.scopeAttributes,
1344
+ ...span.attributes
1345
+ };
1346
+ const buffered = {
1347
+ traceId: span.traceId,
1348
+ spanId: span.spanId,
1349
+ parentSpanId: span.parentSpanId,
1350
+ name: span.name,
1351
+ startTimeUnixNano: span.startTimeUnixNano,
1352
+ endTimeUnixNano: span.endTimeUnixNano,
1353
+ status: span.status,
1354
+ recordAttributes: attributesToOpenTelemetry(record),
1355
+ droppedAttributesCount: span.droppedAttributesCount,
1356
+ droppedEventsCount: span.droppedEventsCount,
1357
+ events: span.events.map((event) => ({
1358
+ name: event.name,
1359
+ timeUnixNano: event.timeUnixNano,
1360
+ attributes: attributesToOpenTelemetry(event.attributes),
1361
+ droppedAttributesCount: event.droppedAttributesCount
1362
+ }))
1363
+ };
1364
+ this.buffer.add(buffered);
1365
+ } catch (error) {
1366
+ if (this.deps.getConfig().debug) console.error("Flare: failed to buffer span", error);
1367
+ }
1368
+ }
1369
+ };
1370
+
761
1371
  //#endregion
762
1372
  //#region src/Flare.ts
1373
+ /** Scope attributes a span never inherits. Derived from `USER_IDENTITY_KEYS` so a future user field is
1374
+ * excluded automatically, without anyone needing to remember to list it here. See `getScopeAttributes`. */
1375
+ const SPAN_SCOPE_EXCLUDED_KEYS = USER_IDENTITY_KEYS.filter((key) => key !== USER_FIELD_KEYS.id);
763
1376
  const DEFAULT_SDK_NAME = "@flareapp/core";
764
1377
  var Flare = class {
765
1378
  inflight = /* @__PURE__ */ new Set();
766
1379
  _logger;
1380
+ _tracer;
767
1381
  _config = {
768
1382
  key: null,
769
1383
  version: "",
@@ -783,7 +1397,17 @@ var Flare = class {
783
1397
  maxLogBufferSize: 100,
784
1398
  logFlushIntervalMs: 5e3,
785
1399
  logFlushMaxBytes: 8e5,
786
- keepaliveMaxBytes: 6e4
1400
+ keepaliveMaxBytes: 6e4,
1401
+ enableTracing: false,
1402
+ tracesIngestUrl: "https://ingress.flareapp.io/v1/traces",
1403
+ tracesSampleRate: 1,
1404
+ maxSpanBufferSize: 100,
1405
+ spanFlushIntervalMs: 5e3,
1406
+ spanFlushMaxBytes: 8e5,
1407
+ maxSpansPerTrace: 1024,
1408
+ maxAttributesPerSpan: 128,
1409
+ maxEventsPerSpan: 128,
1410
+ maxAttributesPerSpanEvent: 128
787
1411
  };
788
1412
  sdkInfo = {
789
1413
  name: DEFAULT_SDK_NAME,
@@ -791,18 +1415,19 @@ var Flare = class {
791
1415
  };
792
1416
  framework = null;
793
1417
  /**
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.
1418
+ * @param api fetch transport for reports, logs and traces. Stateless: ingest url and
1419
+ * key are passed per call, so tests swap in a fake.
1420
+ * @param contextCollector per-report attributes (browser DOM, Node process). No-op by default.
1421
+ * @param fileReader source files for stack-trace snippets. Defaults to no snippets;
1422
+ * `@flareapp/js` injects a fetch reader, `@flareapp/node` a disk reader.
1423
+ * @param scopeProvider the current `Scope`. Browser uses one global scope; Node an
1424
+ * AsyncLocalStorage-backed provider so each request gets its own.
1425
+ * @param scheduler drains the log and span buffers when the host's lifecycle ends (browser
1426
+ * unload, process exit). No-op by default, leaving only size/timer flushes.
1427
+ * @param activeSpanHolder tracks the active span so new spans auto-parent to it. In-memory by
1428
+ * default; a platform can back it with AsyncLocalStorage instead.
804
1429
  */
805
- constructor(api = new Api(), contextCollector = () => ({}), fileReader = new NullFileReader(), scopeProvider = new GlobalScopeProvider(), scheduler = new NoopFlushScheduler()) {
1430
+ constructor(api = new Api(), contextCollector = () => ({}), fileReader = new NullFileReader(), scopeProvider = new GlobalScopeProvider(), scheduler = new NoopFlushScheduler(), activeSpanHolder = new InMemoryActiveSpanHolder()) {
806
1431
  this.api = api;
807
1432
  this.contextCollector = contextCollector;
808
1433
  this.fileReader = fileReader;
@@ -816,57 +1441,25 @@ var Flare = class {
816
1441
  track: (p) => this.track(p),
817
1442
  scheduler
818
1443
  });
1444
+ this._tracer = new Tracer({
1445
+ api: this.api,
1446
+ getConfig: () => this._config,
1447
+ getSdkInfo: () => this.sdkInfo,
1448
+ getFramework: () => this.framework,
1449
+ getScopeAttributes: () => this.getScopeAttributes(),
1450
+ getResourceAttributes: () => this.spanResourceAttributes(),
1451
+ track: (p) => this.track(p),
1452
+ scheduler,
1453
+ activeSpanHolder
1454
+ });
819
1455
  }
820
1456
  /**
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.
1457
+ * Register an in-flight report so `flush()` can wait for it. Every entry point wraps its whole async
1458
+ * pipeline, from beforeEvaluate through api.report, so flush() waits on all of it.
859
1459
  *
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.
1460
+ * What goes in the Set is a shadow promise that mirrors `p`'s timing but cannot reject, so a failed
1461
+ * report never surfaces as an unhandled rejection warning. `p` itself is returned untouched, so the
1462
+ * caller still observes real success or failure.
870
1463
  */
871
1464
  track(p) {
872
1465
  const tracked = p.then(() => void 0, () => void 0);
@@ -875,86 +1468,16 @@ var Flare = class {
875
1468
  return p;
876
1469
  }
877
1470
  /**
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.
1471
+ * Wait until every in-flight report settles or `timeoutMs` elapses. Always resolves, never rejects.
1472
+ * Written for `@flareapp/node`'s fatal handler, which awaits the fatal report itself then flushes to
1473
+ * drain any other concurrent reports before `process.exit`.
940
1474
  *
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.
944
- *
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.
1475
+ * Snapshotting the Set bounds the wait: reports started after this line are not awaited, so a handler
1476
+ * that keeps emitting during shutdown cannot block the process forever. Call flush again for those.
955
1477
  */
956
1478
  flush(timeoutMs = 2e3) {
957
1479
  this._logger.flush();
1480
+ this._tracer.flush();
958
1481
  const pending = [...this.inflight];
959
1482
  if (pending.length === 0) return Promise.resolve();
960
1483
  return new Promise((resolve) => {
@@ -974,22 +1497,42 @@ var Flare = class {
974
1497
  get logger() {
975
1498
  return this._logger;
976
1499
  }
1500
+ get tracer() {
1501
+ return this._tracer;
1502
+ }
1503
+ /** Starts a span the caller must end. Unlike `withSpan`, it does not become the active span,
1504
+ * so spans started after it do not auto-parent to it. */
1505
+ startSpan(name, opts) {
1506
+ return this._tracer.startSpan(name, opts);
1507
+ }
1508
+ /**
1509
+ * Runs `fn` with the span active, so spans started inside auto-parent to it, then ends it.
1510
+ * Records an error status first if `fn` throws or its returned promise rejects.
1511
+ */
1512
+ withSpan(name, fn, opts) {
1513
+ return this._tracer.withSpan(name, fn, opts);
1514
+ }
977
1515
  light(key = KEY, debug) {
978
1516
  this._config.key = key;
979
1517
  if (debug !== void 0) this._config.debug = debug;
980
1518
  this._logger.flush();
1519
+ this._tracer.flush();
981
1520
  return this;
982
1521
  }
983
1522
  configure(config) {
984
1523
  const wasLogsEnabled = this._config.enableLogs;
1524
+ const wasTracingEnabled = this._config.enableTracing;
985
1525
  this._config = {
986
1526
  ...this._config,
987
1527
  ...config
988
1528
  };
989
1529
  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);
1530
+ if (config.tracesSampleRate !== void 0) this._config.tracesSampleRate = Math.max(0, Math.min(1, config.tracesSampleRate));
1531
+ if (config.urlDenylist !== void 0 || config.replaceDefaultUrlDenylist !== void 0) this._config.urlDenylist = resolveDenylist(config.urlDenylist, config.replaceDefaultUrlDenylist ?? this._config.replaceDefaultUrlDenylist);
991
1532
  if (wasLogsEnabled && this._config.enableLogs === false) this._logger.clear();
992
1533
  if (config.key !== void 0) this._logger.flush();
1534
+ if (wasTracingEnabled && this._config.enableTracing === false) this._tracer.clear();
1535
+ if (config.key !== void 0) this._tracer.flush();
993
1536
  return this;
994
1537
  }
995
1538
  test() {
@@ -1029,11 +1572,8 @@ var Flare = class {
1029
1572
  return this;
1030
1573
  }
1031
1574
  /**
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.
1575
+ * Maps the known fields onto the keys the Flare backend reads (see `USER_FIELD_KEYS`) and bundles
1576
+ * anything else into `user.attributes`. Pass `null` to clear. In Node this targets the per-request scope.
1037
1577
  */
1038
1578
  setUser(user) {
1039
1579
  const scope = this.scopeProvider.active();
@@ -1066,7 +1606,7 @@ var Flare = class {
1066
1606
  async reportInternal(error, attributes = {}) {
1067
1607
  if (this._config.sampleRate < 1 && Math.random() >= this._config.sampleRate) return;
1068
1608
  const seenAtUnixNano = Date.now() * 1e6;
1069
- const coerced = error instanceof Error ? error : new Error(typeof error === "string" ? error : String(error));
1609
+ const coerced = error instanceof Error ? error : new Error(String(error));
1070
1610
  const errorToReport = await this._config.beforeEvaluate(coerced);
1071
1611
  if (!errorToReport) return;
1072
1612
  const report = await this.createReportFromError(errorToReport, attributes, seenAtUnixNano);
@@ -1148,7 +1688,10 @@ var Flare = class {
1148
1688
  const baseAttributes = includeBase ? this.buildBaseAttributes() : {};
1149
1689
  const entryPoint = activeScope.entryPoint;
1150
1690
  const entryPointOverrides = {};
1151
- if (entryPoint?.identifier !== void 0) entryPointOverrides["flare.entry_point.handler.identifier"] = entryPoint.identifier;
1691
+ if (entryPoint?.identifier !== void 0) {
1692
+ entryPointOverrides["flare.entry_point.handler.identifier"] = entryPoint.identifier;
1693
+ entryPointOverrides["http.route"] = entryPoint.identifier;
1694
+ }
1152
1695
  if (entryPoint?.type !== void 0) entryPointOverrides["flare.entry_point.handler.type"] = entryPoint.type;
1153
1696
  if (entryPoint?.name !== void 0) entryPointOverrides["flare.entry_point.handler.name"] = entryPoint.name;
1154
1697
  const attributes = {
@@ -1177,6 +1720,23 @@ var Flare = class {
1177
1720
  record: this.assembleAttributes(collectorRecord, userAttributes, false)
1178
1721
  };
1179
1722
  }
1723
+ /**
1724
+ * Local roots only, snapshotted by the Tracer at span START so a long-lived root does not drift into
1725
+ * the next page's scope. Children get none, and no span ever runs the DOM collector.
1726
+ *
1727
+ * Everything assembled is inherited except user identity (excluding the opaque `user.id`): a root span
1728
+ * goes out for every page view, so email, full name, IP and `user.attributes` would turn normal
1729
+ * browsing into PII traffic. The rest — `context.custom`, `addContextGroup` bags — stays in, because
1730
+ * the trace viewer renders any span attribute whose key does not start with `flare.`.
1731
+ */
1732
+ getScopeAttributes() {
1733
+ const scoped = { ...this.assembleAttributes({}, {}, false) };
1734
+ for (const key of SPAN_SCOPE_EXCLUDED_KEYS) delete scoped[key];
1735
+ return scoped;
1736
+ }
1737
+ spanResourceAttributes() {
1738
+ return partitionAttributes(this.contextCollector(this._config)).resource;
1739
+ }
1180
1740
  buildReport(input) {
1181
1741
  const activeScope = this.scopeProvider.active();
1182
1742
  const attributes = this.assembleAttributes(this.contextCollector(this._config), input.extraAttributes, true);
@@ -1203,4 +1763,4 @@ var Flare = class {
1203
1763
  };
1204
1764
 
1205
1765
  //#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 };
1766
+ export { Api, BrowserSpanType, DEFAULT_MAX_LIVE_TRACES, DEFAULT_URL_DENYLIST, Flare, FrameworkName, GlobalScopeProvider, InMemoryActiveSpanHolder, Logger, NoopFlushScheduler, NullFileReader, Scope, SpanStatusCode, Tracer, USER_IDENTITY_KEYS, assert, assertKey, buildTraceparent, buildTracesEnvelope, convertToError, createIdentityTagger, createStackTrace, defaultNowNano, describeRejectionReason, extractCode, flatJsonStringify, getCodeSnippet, glowsToEvents, now, parseTraceparent, readLinesFromFile, redactObjectValues, redactUrlQuery, resolveDenylist, routeRejection, safeClone, safeDecode, spanId, toCustomContext, urlAttributes, userIdentityAttributes };