@orbitai/orbitrace 1.2.0 → 1.3.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.js CHANGED
@@ -1,506 +1,483 @@
1
- const { AsyncLocalStorage } = require("node:async_hooks");
2
- const util = require("node:util");
3
-
4
- // Reporting must never slow down or break the host application, so every failure is logged and swallowed.
5
- const REQUEST_TIMEOUT_MS = 5000;
6
- // Repeats of the same event inside this window are counted locally and sent once as a single request.
7
- const DEDUPE_WINDOW_MS = 10000;
8
- // Caps concurrent requests so a loop of distinct errors cannot exhaust the host's sockets or memory.
9
- const MAX_IN_FLIGHT = 10;
10
- const DEFAULT_RETRY_AFTER_SECONDS = 60;
11
- // Breadcrumbs kept per scope; the oldest is dropped when a new one arrives.
12
- const MAX_BREADCRUMBS = 30;
13
- const MAX_BREADCRUMB_MESSAGE_LENGTH = 200;
14
- const BREADCRUMB_LEVELS = ["debug", "info", "warning", "error"];
15
- // How long a crashing process waits for its last event before exiting.
16
- const CRASH_FLUSH_TIMEOUT_MS = 2000;
17
- const CONSOLE_LEVELS = { debug: "debug", log: "info", info: "info", warn: "warning", error: "error" };
18
- // Metadata keys containing any of these (ignoring case, "-" and "_") have their values replaced before sending.
19
- const SENSITIVE_KEY_PARTS = [
20
- "password",
21
- "passwd",
22
- "secret",
23
- "token",
24
- "authorization",
25
- "cookie",
26
- "apikey",
27
- "privatekey",
28
- "credential",
29
- "session",
30
- ];
31
- const FILTERED_VALUE = "[Filtered]";
32
-
33
- // Each request or wrapped invocation gets its own breadcrumbs, so concurrent work never mixes histories.
34
- const scopes = new AsyncLocalStorage();
35
-
36
- function isSensitiveKey(key) {
37
- const normalized = key.toLowerCase().replace(/[-_]/g, "");
38
- return SENSITIVE_KEY_PARTS.some((part) => normalized.includes(part));
39
- }
40
-
41
- // Recurses only into plain objects and arrays so values like Date keep their JSON form.
42
- // `ancestors` breaks reference cycles, which would otherwise make the request body unserializable.
43
- function filterSensitive(value, ancestors = new WeakSet()) {
44
- if (value === null || typeof value !== "object") {
45
- return value;
46
- }
47
-
48
- if (ancestors.has(value)) {
49
- return "[Circular]";
50
- }
51
-
52
- const isArray = Array.isArray(value);
53
- const prototype = Object.getPrototypeOf(value);
54
-
55
- if (!isArray && prototype !== Object.prototype && prototype !== null) {
56
- return value;
57
- }
58
-
59
- ancestors.add(value);
60
- const filtered = isArray
61
- ? value.map((item) => filterSensitive(item, ancestors))
62
- : Object.fromEntries(
63
- Object.entries(value).map(([key, item]) => [
64
- key,
65
- isSensitiveKey(key) ? FILTERED_VALUE : filterSensitive(item, ancestors),
66
- ])
67
- );
68
- ancestors.delete(value);
69
-
70
- return filtered;
71
- }
72
-
73
- function truncate(value, maxLength) {
74
- return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
75
- }
76
-
77
- // URLs are recorded without credentials, query string or fragment, which often carry tokens or personal data.
78
- function cleanUrl(url) {
79
- try {
80
- const parsed = new URL(url);
81
- return `${parsed.origin}${parsed.pathname}`;
82
- } catch {
83
- return String(url).split(/[?#]/)[0];
84
- }
85
- }
86
-
87
- // What Node does with an unhandled rejection when nothing else handles it (--unhandled-rejections).
1
+ var __getOwnPropNames = Object.getOwnPropertyNames;
2
+ var __commonJS = (cb, mod) => function __require() {
3
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
4
+ };
5
+
6
+ // src/shared.js
7
+ var require_shared = __commonJS({
8
+ "src/shared.js"(exports2, module2) {
9
+ var REQUEST_TIMEOUT_MS = 5e3;
10
+ var DEDUPE_WINDOW_MS = 1e4;
11
+ var MAX_IN_FLIGHT = 10;
12
+ var DEFAULT_RETRY_AFTER_SECONDS = 60;
13
+ var MAX_BREADCRUMBS = 30;
14
+ var MAX_BREADCRUMB_MESSAGE_LENGTH2 = 200;
15
+ var BREADCRUMB_LEVELS = ["debug", "info", "warning", "error"];
16
+ var CONSOLE_LEVELS = { debug: "debug", log: "info", info: "info", warn: "warning", error: "error" };
17
+ var SENSITIVE_KEY_PARTS = [
18
+ "password",
19
+ "passwd",
20
+ "secret",
21
+ "token",
22
+ "authorization",
23
+ "cookie",
24
+ "apikey",
25
+ "privatekey",
26
+ "credential",
27
+ "session"
28
+ ];
29
+ var FILTERED_VALUE = "[Filtered]";
30
+ function isSensitiveKey(key) {
31
+ const normalized = key.toLowerCase().replace(/[-_]/g, "");
32
+ return SENSITIVE_KEY_PARTS.some((part) => normalized.includes(part));
33
+ }
34
+ function filterSensitive2(value, ancestors = /* @__PURE__ */ new WeakSet()) {
35
+ if (value === null || typeof value !== "object") {
36
+ return value;
37
+ }
38
+ if (ancestors.has(value)) {
39
+ return "[Circular]";
40
+ }
41
+ const isArray = Array.isArray(value);
42
+ const prototype = Object.getPrototypeOf(value);
43
+ if (!isArray && prototype !== Object.prototype && prototype !== null) {
44
+ return value;
45
+ }
46
+ ancestors.add(value);
47
+ const filtered = isArray ? value.map((item) => filterSensitive2(item, ancestors)) : Object.fromEntries(
48
+ Object.entries(value).map(([key, item]) => [
49
+ key,
50
+ isSensitiveKey(key) ? FILTERED_VALUE : filterSensitive2(item, ancestors)
51
+ ])
52
+ );
53
+ ancestors.delete(value);
54
+ return filtered;
55
+ }
56
+ function truncate(value, maxLength) {
57
+ return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
58
+ }
59
+ function cleanUrl(url) {
60
+ try {
61
+ const parsed = new URL(url);
62
+ return `${parsed.origin}${parsed.pathname}`;
63
+ } catch {
64
+ return String(url).split(/[?#]/)[0];
65
+ }
66
+ }
67
+ function pushBreadcrumb2(breadcrumbs, { category = "manual", message = "", level = "info", data } = {}) {
68
+ try {
69
+ breadcrumbs.push({
70
+ timestamp: Date.now(),
71
+ category: String(category).slice(0, 64),
72
+ level: BREADCRUMB_LEVELS.includes(level) ? level : "info",
73
+ message: truncate(String(message), MAX_BREADCRUMB_MESSAGE_LENGTH2),
74
+ ...data !== void 0 && { data: filterSensitive2(data) }
75
+ });
76
+ if (breadcrumbs.length > MAX_BREADCRUMBS) {
77
+ breadcrumbs.shift();
78
+ }
79
+ } catch {
80
+ }
81
+ }
82
+ function httpBreadcrumb({ method, url, status, error, started }) {
83
+ const data = { method, url: cleanUrl(url), durationMs: Date.now() - started };
84
+ if (error) {
85
+ return { category: "http", level: "error", message: `${method} ${data.url} failed`, data: { ...data, error } };
86
+ }
87
+ return {
88
+ category: "http",
89
+ level: status >= 500 ? "error" : status >= 400 ? "warning" : "info",
90
+ message: `${method} ${data.url} ${status}`,
91
+ data: { ...data, status }
92
+ };
93
+ }
94
+ function recordFetch2({ endpoint, addBreadcrumb, restore }) {
95
+ if (typeof globalThis.fetch !== "function") {
96
+ return null;
97
+ }
98
+ const originalFetch = globalThis.fetch;
99
+ globalThis.fetch = async function orbitraceFetch(input, init) {
100
+ const url = typeof input === "string" || input instanceof URL ? String(input) : input?.url;
101
+ if (!url || url.startsWith(endpoint)) {
102
+ return originalFetch.call(this, input, init);
103
+ }
104
+ const method = String(init?.method || input?.method || "GET").toUpperCase();
105
+ const started = Date.now();
106
+ try {
107
+ const response = await originalFetch.call(this, input, init);
108
+ addBreadcrumb(httpBreadcrumb({ method, url, status: response.status, started }));
109
+ return response;
110
+ } catch (error) {
111
+ addBreadcrumb(httpBreadcrumb({ method, url, error: error?.message, started }));
112
+ throw error;
113
+ }
114
+ };
115
+ restore.push(() => {
116
+ globalThis.fetch = originalFetch;
117
+ });
118
+ return originalFetch;
119
+ }
120
+ function recordConsole2({ format, addBreadcrumb, restore }) {
121
+ let recording = false;
122
+ for (const [method, level] of Object.entries(CONSOLE_LEVELS)) {
123
+ const original = console[method];
124
+ if (typeof original !== "function") {
125
+ continue;
126
+ }
127
+ console[method] = function orbitraceConsole(...args) {
128
+ if (!recording) {
129
+ recording = true;
130
+ try {
131
+ const message = format(args);
132
+ if (!message.startsWith("Orbitrace:")) {
133
+ addBreadcrumb({ category: "console", level, message });
134
+ }
135
+ } catch {
136
+ } finally {
137
+ recording = false;
138
+ }
139
+ }
140
+ return original.apply(this, args);
141
+ };
142
+ restore.push(() => {
143
+ console[method] = original;
144
+ });
145
+ }
146
+ }
147
+ var Sender2 = class {
148
+ #endpoint;
149
+ #headers;
150
+ #getFetch;
151
+ #requestOptions;
152
+ #pending = /* @__PURE__ */ new Map();
153
+ #inFlight = /* @__PURE__ */ new Set();
154
+ #pausedUntil = 0;
155
+ // `getFetch` returns the fetch to use at send time, so a wrapped global fetch is never recorded.
156
+ // `requestOptions(body)` adds fetch options for one request, such as keepalive in browsers.
157
+ constructor({ endpoint, headers, getFetch = () => globalThis.fetch, requestOptions = () => ({}) }) {
158
+ this.#endpoint = endpoint;
159
+ this.#headers = headers;
160
+ this.#getFetch = getFetch;
161
+ this.#requestOptions = requestOptions;
162
+ }
163
+ // Queues the event and returns at once; it is sent in the background. flush() waits for delivery.
164
+ capture(event, payload) {
165
+ const key = JSON.stringify([event, payload.message, payload.stack ?? null]);
166
+ const pending = this.#pending.get(key);
167
+ if (pending) {
168
+ pending.count++;
169
+ return;
170
+ }
171
+ if (!this.#canSend()) {
172
+ return;
173
+ }
174
+ const entry = { event, payload, count: 0 };
175
+ entry.timer = setTimeout(() => this.#flushPending(key), DEDUPE_WINDOW_MS);
176
+ entry.timer.unref?.();
177
+ this.#pending.set(key, entry);
178
+ this.#send(event, payload, 1);
179
+ }
180
+ // Sends counts still held by the dedupe window and waits for in-flight requests.
181
+ async flush() {
182
+ await Promise.all([...this.#pending.keys()].map((key) => this.#flushPending(key)));
183
+ await Promise.all(this.#inFlight);
184
+ }
185
+ async #flushPending(key) {
186
+ const entry = this.#pending.get(key);
187
+ if (!entry) {
188
+ return;
189
+ }
190
+ this.#pending.delete(key);
191
+ clearTimeout(entry.timer);
192
+ if (entry.count > 0 && this.#canSend()) {
193
+ await this.#send(entry.event, entry.payload, entry.count);
194
+ }
195
+ }
196
+ #canSend() {
197
+ return Date.now() >= this.#pausedUntil && this.#inFlight.size < MAX_IN_FLIGHT;
198
+ }
199
+ #send(event, payload, count) {
200
+ const request = this.#post(event, payload, count).finally(() => this.#inFlight.delete(request));
201
+ this.#inFlight.add(request);
202
+ return request;
203
+ }
204
+ async #post(event, payload, count) {
205
+ try {
206
+ const body = JSON.stringify({ event, ...count > 1 && { count }, payload });
207
+ const response = await this.#getFetch()(this.#endpoint, {
208
+ method: "POST",
209
+ headers: { "Content-Type": "application/json", ...this.#headers },
210
+ body,
211
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
212
+ ...this.#requestOptions(body)
213
+ });
214
+ if (response.status === 429) {
215
+ const retryAfterSeconds = Number(response.headers.get("retry-after")) || DEFAULT_RETRY_AFTER_SECONDS;
216
+ this.#pausedUntil = Date.now() + retryAfterSeconds * 1e3;
217
+ console.warn(`Orbitrace: rate limited, dropping events for ${retryAfterSeconds}s`);
218
+ return;
219
+ }
220
+ if (!response.ok) {
221
+ console.error(`Orbitrace: API responded with status ${response.status}`);
222
+ return;
223
+ }
224
+ return await response.json();
225
+ } catch (error) {
226
+ console.error("Orbitrace: Error sending data to API", error.message);
227
+ }
228
+ }
229
+ };
230
+ module2.exports = {
231
+ MAX_BREADCRUMB_MESSAGE_LENGTH: MAX_BREADCRUMB_MESSAGE_LENGTH2,
232
+ Sender: Sender2,
233
+ cleanUrl,
234
+ filterSensitive: filterSensitive2,
235
+ pushBreadcrumb: pushBreadcrumb2,
236
+ recordConsole: recordConsole2,
237
+ recordFetch: recordFetch2,
238
+ truncate
239
+ };
240
+ }
241
+ });
242
+
243
+ // src/index.js
244
+ var { AsyncLocalStorage } = require("node:async_hooks");
245
+ var util = require("node:util");
246
+ var {
247
+ MAX_BREADCRUMB_MESSAGE_LENGTH,
248
+ Sender,
249
+ filterSensitive,
250
+ pushBreadcrumb,
251
+ recordConsole,
252
+ recordFetch
253
+ } = require_shared();
254
+ var CRASH_FLUSH_TIMEOUT_MS = 2e3;
255
+ var scopes = new AsyncLocalStorage();
88
256
  function unhandledRejectionMode() {
89
- const flags = [...process.execArgv, ...(process.env.NODE_OPTIONS || "").split(/\s+/)].join(" ");
90
- return flags.match(/--unhandled-rejections[= ](\S+)/)?.[1] || "throw";
91
- }
92
-
93
- class Orbitrace {
94
- static #config = null;
95
- static #pending = new Map();
96
- static #inFlight = new Set();
97
- static #pausedUntil = 0;
98
- static #breadcrumbs = [];
99
- // Undo functions for everything automatic capture installed; close() runs them.
100
- static #restore = [];
101
- static #fetch = null;
102
- static #handlingCrash = false;
103
-
104
- static getInstance(config) {
105
- this.assertServerRuntime();
106
- this.validateConfig(config);
107
- this.close();
108
-
109
- this.#config = {
110
- apiKey: config.apiKey,
111
- orgId: config.orgId,
112
- projectId: config.projectId,
113
- endpoint: config.endpoint,
114
- environment: config.environment || "production",
115
- version: config.version || "1.0.0",
116
- service: config.service || "not-specified",
117
- disabled: config.disabled || false,
118
- };
119
-
120
- const autoCapture = this.#autoCaptureOptions(config.autoCapture);
121
-
122
- if (autoCapture && !this.#config.disabled) {
123
- this.#installAutoCapture(autoCapture);
124
- }
125
- }
126
-
127
- static validateConfig(config) {
128
- const requiredFields = ["apiKey", "orgId", "projectId", "endpoint"];
129
- const missingFields = requiredFields.filter((field) => !config[field]);
130
-
131
- if (missingFields.length > 0) {
132
- throw new Error(
133
- `Missing required configuration fields: ${missingFields.join(", ")}`
134
- );
135
- }
136
- }
137
-
138
- static assertServerRuntime() {
139
- if (typeof window !== "undefined" || typeof document !== "undefined") {
140
- throw new Error(
141
- "Orbitrace can only be initialized in backend/server runtimes"
142
- );
143
- }
144
- }
145
-
146
- static async captureException(error, metadata = {}) {
147
- const message = error?.message || error?.name || String(error);
148
-
149
- return this.#capture("capture_exception", {
150
- message,
151
- stack: error?.stack || message,
152
- metadata,
153
- });
154
- }
155
-
156
- static async captureMessage(message, metadata = {}) {
157
- return this.#capture("capture_message", { message, metadata });
158
- }
159
-
160
- // Records something that happened, sent with the next event of the current scope.
161
- static addBreadcrumb({ category = "manual", message = "", level = "info", data } = {}) {
162
- try {
163
- const breadcrumbs = scopes.getStore()?.breadcrumbs ?? this.#breadcrumbs;
164
-
165
- breadcrumbs.push({
166
- timestamp: Date.now(),
167
- category: String(category).slice(0, 64),
168
- level: BREADCRUMB_LEVELS.includes(level) ? level : "info",
169
- message: truncate(String(message), MAX_BREADCRUMB_MESSAGE_LENGTH),
170
- ...(data !== undefined && { data: filterSensitive(data) }),
171
- });
172
-
173
- if (breadcrumbs.length > MAX_BREADCRUMBS) {
174
- breadcrumbs.shift();
175
- }
176
- } catch {
177
- // A breadcrumb that cannot be recorded is skipped.
178
- }
179
- }
180
-
181
- // Runs callback with its own breadcrumbs, for work that is not an Express request or wrapped function.
182
- static withScope(callback) {
183
- return scopes.run({ breadcrumbs: [] }, callback);
184
- }
185
-
186
- // Wraps a serverless handler: each invocation gets its own breadcrumbs, a thrown error is captured, and
187
- // pending events are sent before the handler returns, because the runtime may freeze right after.
188
- static wrap(handler) {
189
- return function orbitraceWrapped(...args) {
190
- return Orbitrace.withScope(async () => {
191
- try {
192
- return await handler.apply(this, args);
193
- } catch (error) {
194
- await Orbitrace.captureException(error, { mechanism: "wrap" });
195
- throw error;
196
- } finally {
197
- await Orbitrace.flush();
198
- }
199
- });
200
- };
201
- }
202
-
203
- // Express middleware, registered before the routes, that gives each request its own breadcrumbs.
204
- static expressRequestHandler() {
205
- return function orbitraceRequestHandler(req, res, next) {
206
- Orbitrace.withScope(() => next());
207
- };
208
- }
209
-
210
- // Express error middleware, registered after the routes: captures the error and passes it on unchanged.
211
- static expressErrorHandler() {
212
- return function orbitraceErrorHandler(error, req, res, next) {
213
- Orbitrace.captureException(error, {
214
- mechanism: "express",
215
- request: {
216
- method: req.method,
217
- path: String(req.originalUrl || req.url || "").split(/[?#]/)[0],
218
- statusCode: error?.status || error?.statusCode || 500,
219
- },
220
- });
221
- next(error);
222
- };
223
- }
224
-
225
- // Sends counts still held by the dedupe window and waits for in-flight requests.
226
- // Call it before a short-lived process or serverless invocation ends.
227
- static async flush() {
228
- await Promise.all([...this.#pending.keys()].map((key) => this.#flushPending(key)));
229
- await Promise.all(this.#inFlight);
230
- }
231
-
232
- // Removes the handlers and wrappers automatic capture installed and forgets the global breadcrumbs.
233
- static close() {
234
- for (const undo of this.#restore.splice(0).reverse()) {
235
- try {
236
- undo();
237
- } catch {
238
- // Restoring one hook must not stop the others.
239
- }
240
- }
241
-
242
- this.#fetch = null;
243
- this.#breadcrumbs = [];
244
- this.#handlingCrash = false;
245
- }
246
-
247
- // autoCapture: true, or { exceptions, console, fetch } to switch parts off. Off unless asked for, so
248
- // upgrading never changes how an existing application handles crashes or logs.
249
- static #autoCaptureOptions(value) {
250
- if (!value) {
251
- return null;
252
- }
253
-
254
- const options = value === true ? {} : value;
255
-
256
- return {
257
- exceptions: options.exceptions !== false,
258
- console: options.console !== false,
259
- fetch: options.fetch !== false,
260
- };
261
- }
262
-
263
- static #installAutoCapture(options) {
264
- if (options.exceptions) {
265
- const onUncaughtException = (error) => this.#handleCrash(error, "uncaughtException");
266
- const onUnhandledRejection = (reason) => this.#handleCrash(reason, "unhandledRejection");
267
-
268
- process.on("uncaughtException", onUncaughtException);
269
- process.on("unhandledRejection", onUnhandledRejection);
270
- this.#restore.push(() => {
271
- process.removeListener("uncaughtException", onUncaughtException);
272
- process.removeListener("unhandledRejection", onUnhandledRejection);
273
- });
274
- }
275
-
276
- if (options.console) {
277
- let recording = false;
278
-
279
- for (const [method, level] of Object.entries(CONSOLE_LEVELS)) {
280
- const original = console[method];
281
-
282
- if (typeof original !== "function") {
283
- continue;
284
- }
285
-
286
- console[method] = function orbitraceConsole(...args) {
287
- // The guard stops a breadcrumb that logs from recording itself again.
288
- if (!recording) {
289
- recording = true;
290
- try {
291
- const message = util.formatWithOptions(
292
- {
293
- depth: 1,
294
- maxArrayLength: 10,
295
- maxStringLength: MAX_BREADCRUMB_MESSAGE_LENGTH,
296
- breakLength: Infinity,
297
- },
298
- ...args
299
- );
300
-
301
- if (!message.startsWith("Orbitrace:")) {
302
- Orbitrace.addBreadcrumb({ category: "console", level, message });
303
- }
304
- } catch {
305
- // Unformattable arguments only lose their breadcrumb.
306
- } finally {
307
- recording = false;
308
- }
309
- }
310
-
311
- return original.apply(this, args);
312
- };
313
- this.#restore.push(() => {
314
- console[method] = original;
315
- });
316
- }
317
- }
318
-
319
- if (options.fetch && typeof globalThis.fetch === "function") {
320
- const originalFetch = globalThis.fetch;
321
- const endpoint = this.#config.endpoint;
322
-
323
- // Orbitrace's own requests use the original fetch, so they are never recorded.
324
- this.#fetch = originalFetch;
325
- globalThis.fetch = async function orbitraceFetch(input, init) {
326
- const url = typeof input === "string" || input instanceof URL ? String(input) : input?.url;
327
-
328
- if (!url || url.startsWith(endpoint)) {
329
- return originalFetch.call(this, input, init);
330
- }
331
-
332
- const method = String(init?.method || input?.method || "GET").toUpperCase();
333
- const started = Date.now();
334
- const data = { method, url: cleanUrl(url) };
335
-
336
- try {
337
- const response = await originalFetch.call(this, input, init);
338
-
339
- Orbitrace.addBreadcrumb({
340
- category: "http",
341
- level: response.status >= 500 ? "error" : response.status >= 400 ? "warning" : "info",
342
- message: `${method} ${data.url} ${response.status}`,
343
- data: { ...data, status: response.status, durationMs: Date.now() - started },
344
- });
345
-
346
- return response;
347
- } catch (error) {
348
- Orbitrace.addBreadcrumb({
349
- category: "http",
350
- level: "error",
351
- message: `${method} ${data.url} failed`,
352
- data: { ...data, error: error?.message, durationMs: Date.now() - started },
353
- });
354
-
355
- throw error;
356
- }
357
- };
358
- this.#restore.push(() => {
359
- globalThis.fetch = originalFetch;
360
- });
361
- }
362
- }
363
-
364
- // Captures a crash, then does what Node would have done without this handler: exit with code 1, or follow
365
- // --unhandled-rejections. When the application registered its own handlers, it decides instead.
366
- static #handleCrash(reason, kind) {
367
- if (this.#handlingCrash) {
368
- return;
369
- }
370
-
371
- const decidesExit = process.listenerCount(kind) === 1;
372
- const mode = kind === "unhandledRejection" ? unhandledRejectionMode() : "throw";
373
- const error = reason instanceof Error ? reason : new Error(`Unhandled rejection: ${util.inspect(reason)}`);
374
- const exits = decidesExit && ["throw", "strict"].includes(mode);
375
-
376
- if (exits) {
377
- this.#handlingCrash = true;
378
- }
379
-
380
- const delivered = Promise.race([
381
- this.captureException(error, { mechanism: kind }).then(() => this.flush()),
382
- // Not unref'd: this timer keeps a crashing process alive until it exits with code 1.
383
- new Promise((resolve) => setTimeout(resolve, CRASH_FLUSH_TIMEOUT_MS)),
384
- ]).catch(() => {});
385
-
386
- if (!decidesExit || mode === "none") {
387
- return;
388
- }
389
-
390
- process.stderr.write(`${error.stack || error.message}\n`);
391
-
392
- if (mode === "warn-with-error-code") {
393
- process.exitCode = 1;
394
- }
395
-
396
- if (exits) {
397
- delivered.finally(() => process.exit(1));
398
- }
399
- }
400
-
401
- static async #capture(event, { metadata, ...fields }) {
402
- if (!this.#config) {
403
- console.error("Orbitrace: call Orbitrace.getInstance() before capturing events");
404
- return;
405
- }
406
-
407
- if (this.#config.disabled) {
408
- console.log("Orbitrace: Logging is disabled");
409
- return;
410
- }
411
-
412
- const breadcrumbs = [...(scopes.getStore()?.breadcrumbs ?? this.#breadcrumbs)];
413
- const payload = {
414
- ...fields,
415
- metadata: {
416
- ...filterSensitive(metadata),
417
- env: this.#config.environment,
418
- version: this.#config.version,
419
- service: this.#config.service,
420
- },
421
- ...(breadcrumbs.length > 0 && { breadcrumbs }),
422
- };
423
- const key = JSON.stringify([event, payload.message, payload.stack ?? null]);
424
- const pending = this.#pending.get(key);
425
-
426
- if (pending) {
427
- pending.count++;
428
- return;
429
- }
430
-
431
- if (!this.#canSend()) {
432
- return;
433
- }
434
-
435
- const entry = { event, payload, count: 0 };
436
- entry.timer = setTimeout(() => this.#flushPending(key), DEDUPE_WINDOW_MS);
437
- entry.timer.unref?.();
438
- this.#pending.set(key, entry);
439
-
440
- return this.#send(event, payload, 1);
441
- }
442
-
443
- static async #flushPending(key) {
444
- const entry = this.#pending.get(key);
445
-
446
- if (!entry) {
447
- return;
448
- }
449
-
450
- this.#pending.delete(key);
451
- clearTimeout(entry.timer);
452
-
453
- if (entry.count > 0 && this.#canSend()) {
454
- await this.#send(entry.event, entry.payload, entry.count);
455
- }
456
- }
457
-
458
- static #canSend() {
459
- return Date.now() >= this.#pausedUntil && this.#inFlight.size < MAX_IN_FLIGHT;
460
- }
461
-
462
- static #send(event, payload, count) {
463
- const request = this.#post(event, payload, count).finally(() => this.#inFlight.delete(request));
464
- this.#inFlight.add(request);
465
-
466
- return request;
467
- }
468
-
469
- static async #post(event, payload, count) {
470
- try {
471
- const response = await (this.#fetch || fetch)(this.#config.endpoint, {
472
- method: "POST",
473
- headers: {
474
- "Content-Type": "application/json",
475
- "x-orbitrace-api-key": this.#config.apiKey,
476
- "x-orbitrace-org-id": this.#config.orgId,
477
- "x-orbitrace-project-id": this.#config.projectId,
478
- },
479
- body: JSON.stringify({
480
- event,
481
- ...(count > 1 && { count }),
482
- payload,
483
- }),
484
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
485
- });
486
-
487
- if (response.status === 429) {
488
- const retryAfterSeconds = Number(response.headers.get("retry-after")) || DEFAULT_RETRY_AFTER_SECONDS;
489
- this.#pausedUntil = Date.now() + retryAfterSeconds * 1000;
490
- console.warn(`Orbitrace: rate limited, dropping events for ${retryAfterSeconds}s`);
491
- return;
492
- }
493
-
494
- if (!response.ok) {
495
- console.error(`Orbitrace: API responded with status ${response.status}`);
496
- return;
497
- }
498
-
499
- return await response.json();
500
- } catch (error) {
501
- console.error("Orbitrace: Error sending data to API", error.message);
502
- }
503
- }
257
+ const flags = [...process.execArgv, ...(process.env.NODE_OPTIONS || "").split(/\s+/)].join(" ");
258
+ return flags.match(/--unhandled-rejections[= ](\S+)/)?.[1] || "throw";
504
259
  }
505
-
260
+ var Orbitrace = class _Orbitrace {
261
+ static #config = null;
262
+ static #sender = null;
263
+ static #breadcrumbs = [];
264
+ // Undo functions for everything automatic capture installed; close() runs them.
265
+ static #restore = [];
266
+ static #fetch = null;
267
+ static #handlingCrash = false;
268
+ static getInstance(config) {
269
+ this.assertServerRuntime();
270
+ this.validateConfig(config);
271
+ this.close();
272
+ this.#config = {
273
+ apiKey: config.apiKey,
274
+ orgId: config.orgId,
275
+ projectId: config.projectId,
276
+ endpoint: config.endpoint,
277
+ environment: config.environment || "production",
278
+ version: config.version || "1.0.0",
279
+ service: config.service || "not-specified",
280
+ disabled: config.disabled || false
281
+ };
282
+ this.#sender = new Sender({
283
+ endpoint: config.endpoint,
284
+ headers: {
285
+ "x-orbitrace-api-key": config.apiKey,
286
+ "x-orbitrace-org-id": config.orgId,
287
+ "x-orbitrace-project-id": config.projectId
288
+ },
289
+ // Orbitrace's own requests use the original fetch, so they are never recorded.
290
+ getFetch: () => this.#fetch || fetch
291
+ });
292
+ const autoCapture = this.#autoCaptureOptions(config.autoCapture);
293
+ if (autoCapture && !this.#config.disabled) {
294
+ this.#installAutoCapture(autoCapture);
295
+ }
296
+ }
297
+ static validateConfig(config) {
298
+ const requiredFields = ["apiKey", "orgId", "projectId", "endpoint"];
299
+ const missingFields = requiredFields.filter((field) => !config[field]);
300
+ if (missingFields.length > 0) {
301
+ throw new Error(
302
+ `Missing required configuration fields: ${missingFields.join(", ")}`
303
+ );
304
+ }
305
+ }
306
+ static assertServerRuntime() {
307
+ if (typeof window !== "undefined" || typeof document !== "undefined") {
308
+ throw new Error(
309
+ "Orbitrace can only be initialized in backend/server runtimes; use @orbitai/orbitrace/browser in the browser"
310
+ );
311
+ }
312
+ }
313
+ // Queues the event and returns at once; it is sent in the background. flush() waits for delivery.
314
+ static async captureException(error, metadata = {}) {
315
+ const message = error?.message || error?.name || String(error);
316
+ return this.#capture("capture_exception", {
317
+ message,
318
+ stack: error?.stack || message,
319
+ metadata
320
+ });
321
+ }
322
+ static async captureMessage(message, metadata = {}) {
323
+ return this.#capture("capture_message", { message, metadata });
324
+ }
325
+ // Records something that happened, sent with the next event of the current scope.
326
+ static addBreadcrumb(breadcrumb) {
327
+ pushBreadcrumb(scopes.getStore()?.breadcrumbs ?? this.#breadcrumbs, breadcrumb);
328
+ }
329
+ // Runs callback with its own breadcrumbs, for work that is not an Express request or wrapped function.
330
+ static withScope(callback) {
331
+ return scopes.run({ breadcrumbs: [] }, callback);
332
+ }
333
+ // Wraps a serverless handler: each invocation gets its own breadcrumbs, a thrown error is captured, and
334
+ // pending events are sent before the handler returns, because the runtime may freeze right after.
335
+ static wrap(handler) {
336
+ return function orbitraceWrapped(...args) {
337
+ return _Orbitrace.withScope(async () => {
338
+ try {
339
+ return await handler.apply(this, args);
340
+ } catch (error) {
341
+ await _Orbitrace.captureException(error, { mechanism: "wrap" });
342
+ throw error;
343
+ } finally {
344
+ await _Orbitrace.flush();
345
+ }
346
+ });
347
+ };
348
+ }
349
+ // Express middleware, registered before the routes, that gives each request its own breadcrumbs.
350
+ static expressRequestHandler() {
351
+ return function orbitraceRequestHandler(req, res, next) {
352
+ _Orbitrace.withScope(() => next());
353
+ };
354
+ }
355
+ // Express error middleware, registered after the routes: captures the error and passes it on unchanged.
356
+ static expressErrorHandler() {
357
+ return function orbitraceErrorHandler(error, req, res, next) {
358
+ _Orbitrace.captureException(error, {
359
+ mechanism: "express",
360
+ request: {
361
+ method: req.method,
362
+ path: String(req.originalUrl || req.url || "").split(/[?#]/)[0],
363
+ statusCode: error?.status || error?.statusCode || 500
364
+ }
365
+ });
366
+ next(error);
367
+ };
368
+ }
369
+ // Sends counts still held by the dedupe window and waits for in-flight requests.
370
+ // Call it before a short-lived process or serverless invocation ends.
371
+ static async flush() {
372
+ await this.#sender?.flush();
373
+ }
374
+ // Removes the handlers and wrappers automatic capture installed and forgets the global breadcrumbs.
375
+ static close() {
376
+ for (const undo of this.#restore.splice(0).reverse()) {
377
+ try {
378
+ undo();
379
+ } catch {
380
+ }
381
+ }
382
+ this.#fetch = null;
383
+ this.#breadcrumbs = [];
384
+ this.#handlingCrash = false;
385
+ }
386
+ // autoCapture: true, or { exceptions, console, fetch } to switch parts off. Off unless asked for, so
387
+ // upgrading never changes how an existing application handles crashes or logs.
388
+ static #autoCaptureOptions(value) {
389
+ if (!value) {
390
+ return null;
391
+ }
392
+ const options = value === true ? {} : value;
393
+ return {
394
+ exceptions: options.exceptions !== false,
395
+ console: options.console !== false,
396
+ fetch: options.fetch !== false
397
+ };
398
+ }
399
+ static #installAutoCapture(options) {
400
+ const addBreadcrumb = (breadcrumb) => this.addBreadcrumb(breadcrumb);
401
+ if (options.exceptions) {
402
+ const onUncaughtException = (error) => this.#handleCrash(error, "uncaughtException");
403
+ const onUnhandledRejection = (reason) => this.#handleCrash(reason, "unhandledRejection");
404
+ process.on("uncaughtException", onUncaughtException);
405
+ process.on("unhandledRejection", onUnhandledRejection);
406
+ this.#restore.push(() => {
407
+ process.removeListener("uncaughtException", onUncaughtException);
408
+ process.removeListener("unhandledRejection", onUnhandledRejection);
409
+ });
410
+ }
411
+ if (options.console) {
412
+ recordConsole({
413
+ addBreadcrumb,
414
+ restore: this.#restore,
415
+ format: (args) => util.formatWithOptions(
416
+ {
417
+ depth: 1,
418
+ maxArrayLength: 10,
419
+ maxStringLength: MAX_BREADCRUMB_MESSAGE_LENGTH,
420
+ breakLength: Infinity
421
+ },
422
+ ...args
423
+ )
424
+ });
425
+ }
426
+ if (options.fetch) {
427
+ this.#fetch = recordFetch({ endpoint: this.#config.endpoint, addBreadcrumb, restore: this.#restore });
428
+ }
429
+ }
430
+ // Captures a crash, then does what Node would have done without this handler: exit with code 1, or follow
431
+ // --unhandled-rejections. When the application registered its own handlers, it decides instead.
432
+ static #handleCrash(reason, kind) {
433
+ if (this.#handlingCrash) {
434
+ return;
435
+ }
436
+ const decidesExit = process.listenerCount(kind) === 1;
437
+ const mode = kind === "unhandledRejection" ? unhandledRejectionMode() : "throw";
438
+ const error = reason instanceof Error ? reason : new Error(`Unhandled rejection: ${util.inspect(reason)}`);
439
+ const exits = decidesExit && ["throw", "strict"].includes(mode);
440
+ if (exits) {
441
+ this.#handlingCrash = true;
442
+ }
443
+ const delivered = Promise.race([
444
+ this.captureException(error, { mechanism: kind }).then(() => this.flush()),
445
+ // Not unref'd: this timer keeps a crashing process alive until it exits with code 1.
446
+ new Promise((resolve) => setTimeout(resolve, CRASH_FLUSH_TIMEOUT_MS))
447
+ ]).catch(() => {
448
+ });
449
+ if (!decidesExit || mode === "none") {
450
+ return;
451
+ }
452
+ process.stderr.write(`${error.stack || error.message}
453
+ `);
454
+ if (mode === "warn-with-error-code") {
455
+ process.exitCode = 1;
456
+ }
457
+ if (exits) {
458
+ delivered.finally(() => process.exit(1));
459
+ }
460
+ }
461
+ static async #capture(event, { metadata, ...fields }) {
462
+ if (!this.#config) {
463
+ console.error("Orbitrace: call Orbitrace.getInstance() before capturing events");
464
+ return;
465
+ }
466
+ if (this.#config.disabled) {
467
+ console.log("Orbitrace: Logging is disabled");
468
+ return;
469
+ }
470
+ const breadcrumbs = [...scopes.getStore()?.breadcrumbs ?? this.#breadcrumbs];
471
+ this.#sender.capture(event, {
472
+ ...fields,
473
+ metadata: {
474
+ ...filterSensitive(metadata),
475
+ env: this.#config.environment,
476
+ version: this.#config.version,
477
+ service: this.#config.service
478
+ },
479
+ ...breadcrumbs.length > 0 && { breadcrumbs }
480
+ });
481
+ }
482
+ };
506
483
  module.exports = Orbitrace;