@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.cjs +1180 -560
- package/dist/index.d.cts +198 -453
- package/dist/index.d.mts +198 -453
- package/dist/index.mjs +1137 -538
- package/dist/urlAttributes-CEP6bOBn.cjs +566 -0
- package/dist/urlAttributes-CYJKlJKi.d.mts +463 -0
- package/dist/urlAttributes-DSPpBmH-.d.cts +463 -0
- package/dist/urlAttributes-qNkR9fIF.mjs +392 -0
- package/dist/util/index.cjs +25 -0
- package/dist/util/index.d.cts +2 -0
- package/dist/util/index.d.mts +2 -0
- package/dist/util/index.mjs +3 -0
- package/package.json +16 -2
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/
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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/
|
|
96
|
-
|
|
97
|
-
|
|
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/
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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/
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
193
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
476
|
+
inner;
|
|
330
477
|
resourceAttributes = {};
|
|
331
|
-
timer;
|
|
332
|
-
timerActive = false;
|
|
333
478
|
constructor(deps) {
|
|
334
479
|
this.deps = deps;
|
|
335
|
-
|
|
336
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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`
|
|
736
|
-
*
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
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
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
* @param fileReader
|
|
798
|
-
*
|
|
799
|
-
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
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.
|
|
822
|
-
*
|
|
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
|
-
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
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
|
|
879
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
1033
|
-
*
|
|
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(
|
|
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)
|
|
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:
|
|
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 };
|