@orbitai/orbitrace 1.1.11 → 1.2.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.
Files changed (3) hide show
  1. package/README.md +66 -1
  2. package/dist/index.js +334 -2
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -59,7 +59,6 @@ try {
59
59
  ```javascript
60
60
  await orbitrace.captureMessage("User registered", {
61
61
  userId: "123",
62
- email: "user@example.com",
63
62
  });
64
63
  ```
65
64
 
@@ -78,6 +77,72 @@ In short-lived processes (scripts, CI jobs, serverless functions), call `flush()
78
77
  await orbitrace.flush();
79
78
  ```
80
79
 
80
+ ### 5. Automatic Capture
81
+
82
+ Pass `autoCapture: true` to `getInstance` (SDK 1.2.0 or newer) to capture what nobody handled and record breadcrumbs automatically. It is off unless you turn it on, so upgrading never changes how an application crashes or logs.
83
+
84
+ ```javascript
85
+ orbitrace.getInstance({ ...config, autoCapture: true });
86
+ // or choose parts:
87
+ orbitrace.getInstance({ ...config, autoCapture: { exceptions: true, console: true, fetch: false } });
88
+ ```
89
+
90
+ - **Uncaught exceptions and unhandled rejections** are captured. The process still crashes as Node would: the SDK waits up to 2 seconds for the event to send, prints the error and exits with code 1, and respects `--unhandled-rejections`. If the application registers its own `uncaughtException` or `unhandledRejection` handlers, they decide instead.
91
+ - **Breadcrumbs** are recorded from `console` calls and `fetch` requests (URLs without credentials, query string or fragment). The SDK's own logs and requests are skipped.
92
+ - `orbitrace.close()` removes everything automatic capture installed.
93
+
94
+ ### 6. Breadcrumbs
95
+
96
+ Breadcrumbs are the last 30 things that happened before an event, sent with it. Add your own at important steps:
97
+
98
+ ```javascript
99
+ orbitrace.addBreadcrumb({
100
+ category: "checkout",
101
+ message: "Payment authorized",
102
+ level: "info", // debug, info, warning or error
103
+ data: { orderId: "ord_1" },
104
+ });
105
+ ```
106
+
107
+ Breadcrumb data is filtered like metadata. Work that runs concurrently should have its own breadcrumbs: use the Express handlers or `wrap()` below, or `orbitrace.withScope(() => work())`.
108
+
109
+ ### 7. Express
110
+
111
+ ```javascript
112
+ app.use(orbitrace.expressRequestHandler()); // before the routes: breadcrumbs per request
113
+ app.post("/checkout", async (req, res, next) => {
114
+ try {
115
+ res.json(await checkout(req.body));
116
+ } catch (error) {
117
+ next(error); // Express 4 does not pass errors from async routes on by itself
118
+ }
119
+ });
120
+ app.use(orbitrace.expressErrorHandler()); // after the routes: captures with method, path and status, then calls next(error)
121
+ ```
122
+
123
+ ### 8. Cloud Functions and Other Serverless Handlers
124
+
125
+ The runtime may freeze as soon as a handler returns. `wrap()` gives each call its own breadcrumbs, captures a thrown error (and rethrows it), and sends pending events before returning:
126
+
127
+ ```javascript
128
+ exports.checkout = onRequest(orbitrace.wrap(async (req, res) => {
129
+ res.json(await checkout(req.body));
130
+ }));
131
+ ```
132
+
133
+ ## Data Collected
134
+
135
+ Each event stores:
136
+
137
+ - The error message (truncated to 200 characters) and stack trace, or the message text.
138
+ - The `metadata` object you pass, plus `env`, `version` and `service` from your configuration.
139
+ - Breadcrumbs, when there are any: the last 30 recorded before the event.
140
+ - The IP address and user agent of the server that sent the request, with an approximate location derived from that IP.
141
+
142
+ Before sending, metadata values whose key contains `password`, `passwd`, `secret`, `token`, `authorization`, `cookie`, `apikey`, `privatekey`, `credential` or `session` (ignoring case, `-` and `_`) are replaced with `[Filtered]`, at any depth. Error messages and stack traces are sent as they are.
143
+
144
+ Filtering is a safety net, not a guarantee. Do not put personal data (emails, names, addresses) or secrets in metadata or error messages; send stable identifiers such as a user id instead.
145
+
81
146
  ## Environment Variables
82
147
 
83
148
  ```env
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ const { AsyncLocalStorage } = require("node:async_hooks");
2
+ const util = require("node:util");
3
+
1
4
  // Reporting must never slow down or break the host application, so every failure is logged and swallowed.
2
5
  const REQUEST_TIMEOUT_MS = 5000;
3
6
  // Repeats of the same event inside this window are counted locally and sent once as a single request.
@@ -5,16 +8,103 @@ const DEDUPE_WINDOW_MS = 10000;
5
8
  // Caps concurrent requests so a loop of distinct errors cannot exhaust the host's sockets or memory.
6
9
  const MAX_IN_FLIGHT = 10;
7
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).
88
+ function unhandledRejectionMode() {
89
+ const flags = [...process.execArgv, ...(process.env.NODE_OPTIONS || "").split(/\s+/)].join(" ");
90
+ return flags.match(/--unhandled-rejections[= ](\S+)/)?.[1] || "throw";
91
+ }
8
92
 
9
93
  class Orbitrace {
10
94
  static #config = null;
11
95
  static #pending = new Map();
12
96
  static #inFlight = new Set();
13
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;
14
103
 
15
104
  static getInstance(config) {
16
105
  this.assertServerRuntime();
17
106
  this.validateConfig(config);
107
+ this.close();
18
108
 
19
109
  this.#config = {
20
110
  apiKey: config.apiKey,
@@ -26,6 +116,12 @@ class Orbitrace {
26
116
  service: config.service || "not-specified",
27
117
  disabled: config.disabled || false,
28
118
  };
119
+
120
+ const autoCapture = this.#autoCaptureOptions(config.autoCapture);
121
+
122
+ if (autoCapture && !this.#config.disabled) {
123
+ this.#installAutoCapture(autoCapture);
124
+ }
29
125
  }
30
126
 
31
127
  static validateConfig(config) {
@@ -61,6 +157,71 @@ class Orbitrace {
61
157
  return this.#capture("capture_message", { message, metadata });
62
158
  }
63
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
+
64
225
  // Sends counts still held by the dedupe window and waits for in-flight requests.
65
226
  // Call it before a short-lived process or serverless invocation ends.
66
227
  static async flush() {
@@ -68,6 +229,175 @@ class Orbitrace {
68
229
  await Promise.all(this.#inFlight);
69
230
  }
70
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
+
71
401
  static async #capture(event, { metadata, ...fields }) {
72
402
  if (!this.#config) {
73
403
  console.error("Orbitrace: call Orbitrace.getInstance() before capturing events");
@@ -79,14 +409,16 @@ class Orbitrace {
79
409
  return;
80
410
  }
81
411
 
412
+ const breadcrumbs = [...(scopes.getStore()?.breadcrumbs ?? this.#breadcrumbs)];
82
413
  const payload = {
83
414
  ...fields,
84
415
  metadata: {
85
- ...metadata,
416
+ ...filterSensitive(metadata),
86
417
  env: this.#config.environment,
87
418
  version: this.#config.version,
88
419
  service: this.#config.service,
89
420
  },
421
+ ...(breadcrumbs.length > 0 && { breadcrumbs }),
90
422
  };
91
423
  const key = JSON.stringify([event, payload.message, payload.stack ?? null]);
92
424
  const pending = this.#pending.get(key);
@@ -136,7 +468,7 @@ class Orbitrace {
136
468
 
137
469
  static async #post(event, payload, count) {
138
470
  try {
139
- const response = await fetch(this.#config.endpoint, {
471
+ const response = await (this.#fetch || fetch)(this.#config.endpoint, {
140
472
  method: "POST",
141
473
  headers: {
142
474
  "Content-Type": "application/json",
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@orbitai/orbitrace",
3
- "version": "1.1.11",
4
- "description": "Error tracking and monitoring for backend Node.js applications",
3
+ "version": "1.2.0",
4
+ "description": "Error tracking for Node.js services: manual and automatic capture with breadcrumbs",
5
5
  "main": "dist/index.js",
6
6
  "files": [
7
7
  "dist"
8
8
  ],
9
9
  "scripts": {
10
+ "test": "node --test test/*.test.js",
10
11
  "build": "mkdir -p dist && cp src/index.js dist/index.js",
11
12
  "deploy": "npm version patch && npm run build && npm publish --access public"
12
13
  },