@senzops/apm-node 1.3.1 → 1.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senzops/apm-node",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "Universal APM SDK for Senzor",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,14 +9,20 @@ import { sanitizeAttributes } from './sanitizer';
9
9
  import { startCapturedSpan } from '../instrumentation/span';
10
10
  import { RuntimeMetricsCollector } from '../instrumentation/runtime';
11
11
 
12
- // Memory-safe JSON stringifier to handle cyclical objects
13
- // (like Express 'req' objects) passed into console.log
12
+ const MAX_STRINGIFY_LENGTH = 8192;
13
+
14
14
  const safeStringify = (obj: any): string => {
15
- const cache = new Set();
16
- return JSON.stringify(obj, (key, value) => {
15
+ const seen = new Set();
16
+ let length = 0;
17
+ return JSON.stringify(obj, function (_key, value) {
18
+ if (length > MAX_STRINGIFY_LENGTH) return undefined;
19
+ if (typeof value === 'string') {
20
+ length += value.length;
21
+ if (value.length > 2048) return value.slice(0, 2048) + '...[truncated]';
22
+ }
17
23
  if (typeof value === 'object' && value !== null) {
18
- if (cache.has(value)) return '[Circular]';
19
- cache.add(value);
24
+ if (seen.has(value)) return '[Circular]';
25
+ seen.add(value);
20
26
  }
21
27
  return value;
22
28
  });
@@ -299,14 +305,37 @@ export class SenzorClient {
299
305
  }
300
306
  };
301
307
 
308
+ const flushAndExit = (code: number) => {
309
+ if (this.transport) {
310
+ const timeout = setTimeout(() => process.exit(code), 2000);
311
+ if (typeof timeout.unref === 'function') timeout.unref();
312
+ this.transport.flush().then(
313
+ () => process.exit(code),
314
+ () => process.exit(code)
315
+ );
316
+ } else {
317
+ process.exit(code);
318
+ }
319
+ };
320
+
321
+ // Monitor-only: captures for telemetry without altering default crash behavior.
322
+ // Node.js will still print the stack and exit after this listener runs.
302
323
  process.on('uncaughtExceptionMonitor', (error) => safeCapture(error, { type: 'uncaughtExceptionMonitor', severity: 'fatal' }));
303
- process.on('uncaughtException', (error) => safeCapture(error, { type: 'uncaughtException', severity: 'fatal' }));
324
+
304
325
  process.on('unhandledRejection', (reason) => safeCapture(reason, { type: 'unhandledRejection', severity: 'error' }));
305
326
  process.on('warning', (warning) => safeCapture(warning, { type: 'processWarning', severity: 'warning' }));
306
- process.on('multipleResolves', (type, promise, reason) => safeCapture(reason || new Error('Multiple promise resolves'), { type: 'multipleResolves', resolveType: type, severity: 'warning' }));
307
- process.on('rejectionHandled', (promise) => { if (this.options?.debug) { try { console.warn('[Senzor] rejectionHandled event detected'); } catch { } } });
308
- process.on('SIGTERM', () => safeCapture(new Error('Process received SIGTERM'), { type: 'processSignal', signal: 'SIGTERM' }));
309
- process.on('SIGINT', () => safeCapture(new Error('Process received SIGINT'), { type: 'processSignal', signal: 'SIGINT' }));
327
+
328
+ let shuttingDown = false;
329
+
330
+ const gracefulShutdown = (signal: string, exitCode: number) => {
331
+ if (shuttingDown) return;
332
+ shuttingDown = true;
333
+ safeCapture(new Error(`Process received ${signal}`), { type: 'processSignal', signal, severity: 'warning' });
334
+ flushAndExit(exitCode);
335
+ };
336
+
337
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM', 143));
338
+ process.on('SIGINT', () => gracefulShutdown('SIGINT', 130));
310
339
  }
311
340
 
312
341
  public startTrace<T>(data: Partial<ActiveTrace['data']> & { headers?: any }, next: () => T): T {
@@ -6,54 +6,50 @@ interface IStorage<T> {
6
6
  }
7
7
 
8
8
  /**
9
- * Async-safe fallback when AsyncLocalStorage is unavailable.
9
+ * Per-callback context tracking for runtimes without AsyncLocalStorage.
10
10
  *
11
- * Not concurrency-safe across truly parallel requests in a single isolate,
12
- * but correct for sequential and async/await patterns (e.g. Cloudflare Workers
13
- * where each request gets its own execution context).
11
+ * Uses a stack instead of a single variable so overlapping synchronous
12
+ * Context.run() calls (e.g. nested middleware) don't clobber each other.
13
+ * Async context is propagated by chaining onto the returned thenable.
14
+ *
15
+ * NOT safe for truly concurrent requests in a single isolate — use
16
+ * AsyncLocalStorage for that. This exists as a last-resort fallback.
14
17
  */
15
18
  class NaiveStorage<T> implements IStorage<T> {
16
- private store: T | undefined;
19
+ private stack: T[] = [];
17
20
 
18
21
  run<R>(store: T, callback: (...args: any[]) => R, ...args: any[]): R {
19
- const prev = this.store;
20
- this.store = store;
22
+ this.stack.push(store);
21
23
 
22
24
  let result: R;
23
25
  try {
24
26
  result = callback(...args);
25
27
  } catch (err) {
26
- this.store = prev;
28
+ this.stack.pop();
27
29
  throw err;
28
30
  }
29
31
 
30
- // If the callback returned a thenable (async handler), defer the restore
31
- // until the promise settles so Context.current() works across awaits.
32
32
  if (result != null && typeof (result as any).then === 'function') {
33
33
  const promise = (result as any).then(
34
- (val: any) => { this.store = prev; return val; },
35
- (err: any) => { this.store = prev; throw err; }
34
+ (val: any) => { this.stack.pop(); return val; },
35
+ (err: any) => { this.stack.pop(); throw err; }
36
36
  );
37
37
  return promise as R;
38
38
  }
39
39
 
40
- // Sync callback — restore immediately.
41
- this.store = prev;
40
+ this.stack.pop();
42
41
  return result;
43
42
  }
44
43
 
45
44
  getStore(): T | undefined {
46
- return this.store;
45
+ return this.stack.length > 0 ? this.stack[this.stack.length - 1] : undefined;
47
46
  }
48
47
  }
49
48
 
50
49
  /**
51
50
  * Resolve the best available async context storage.
52
51
  *
53
- * Uses lazy re-resolution: if the initial attempt (at module evaluation time)
54
- * falls back to NaiveStorage, subsequent calls to resolveStorage() will retry.
55
- * This handles runtimes where AsyncLocalStorage becomes available after module
56
- * init (e.g. some Workers configurations).
52
+ * Tries multiple resolution strategies to cover CJS, ESM, and edge runtimes.
57
53
  */
58
54
  const tryResolveALS = <T>(): IStorage<T> | null => {
59
55
  // 1. Check globalThis (Cloudflare Workers nodejs_compat_v2, Bun, Deno)
@@ -76,6 +72,17 @@ const tryResolveALS = <T>(): IStorage<T> | null => {
76
72
  }
77
73
  } catch {}
78
74
 
75
+ // 3. ESM fallback: indirect require via Function constructor
76
+ // In bundled ESM (tsup/esbuild), `require` is shimmed and steps 1-2 work.
77
+ // This step handles raw ESM where require is truly unavailable.
78
+ try {
79
+ if (typeof require === 'undefined' && typeof process !== 'undefined') {
80
+ const fn = new Function('try { var m = require("module"); return m.createRequire(process.cwd() + "/")("node:async_hooks"); } catch(e) { return null; }');
81
+ const mod = fn();
82
+ if (mod?.AsyncLocalStorage) return new mod.AsyncLocalStorage();
83
+ }
84
+ } catch {}
85
+
79
86
  return null;
80
87
  };
81
88
 
@@ -15,6 +15,9 @@ interface TaskPayload {
15
15
  logs: SenzorLog[];
16
16
  }
17
17
 
18
+ const MAX_BACKOFF_MS = 60_000;
19
+ const BASE_BACKOFF_MS = 1_000;
20
+
18
21
  export class Transport {
19
22
  private traceQueue: Trace[] = [];
20
23
  private apmErrorQueue: SenzorError[] = [];
@@ -33,6 +36,9 @@ export class Transport {
33
36
  private flushAgain = false;
34
37
  private droppedItems = 0;
35
38
 
39
+ private consecutiveFailures = 0;
40
+ private backoffUntil = 0;
41
+
36
42
  constructor(private config: SenzorOptions) {
37
43
  const baseEndpoint = config.endpoint || 'https://api.senzor.dev';
38
44
  this.apmEndpoint = baseEndpoint.includes('/api/ingest')
@@ -41,10 +47,6 @@ export class Transport {
41
47
  this.taskEndpoint = baseEndpoint.includes('/api/ingest')
42
48
  ? baseEndpoint.replace('/apm', '/task')
43
49
  : `${baseEndpoint}/api/ingest/task`;
44
-
45
- // Timer and shutdown flush are deferred to first enqueue.
46
- // Cloudflare Workers forbids setInterval / process access in global scope,
47
- // and init() may be called at module evaluation time (e.g. Nitro plugins).
48
50
  }
49
51
 
50
52
  private ensureTimer() {
@@ -94,7 +96,6 @@ export class Transport {
94
96
 
95
97
  public addRuntimeMetrics(payload: RuntimeMetricsPayload) {
96
98
  this.enqueue(this.runtimeMetricsQueue, payload);
97
- // Runtime metrics don't trigger immediate flush — they ride the next timer
98
99
  }
99
100
 
100
101
  private enqueue<T>(queue: T[], item: T) {
@@ -236,6 +237,8 @@ export class Transport {
236
237
  return;
237
238
  }
238
239
 
240
+ if (Date.now() < this.backoffUntil) return;
241
+
239
242
  this.isFlushing = true;
240
243
 
241
244
  try {
@@ -271,6 +274,21 @@ export class Transport {
271
274
  (result) => result.status === 'rejected'
272
275
  );
273
276
 
277
+ if (failures.length > 0) {
278
+ this.consecutiveFailures++;
279
+ const delay = Math.min(
280
+ BASE_BACKOFF_MS * Math.pow(2, this.consecutiveFailures - 1),
281
+ MAX_BACKOFF_MS
282
+ );
283
+ this.backoffUntil = Date.now() + delay;
284
+ if (this.config.debug) {
285
+ console.warn(`[Senzor] Flush failed (attempt ${this.consecutiveFailures}), backing off ${delay}ms`);
286
+ }
287
+ } else {
288
+ this.consecutiveFailures = 0;
289
+ this.backoffUntil = 0;
290
+ }
291
+
274
292
  if (this.config.debug) {
275
293
  console.log(
276
294
  `[Senzor] Flushed: APM(${apmPayload.traces.length} traces, ${apmPayload.logs.length} logs), Task(${taskPayload.runs.length} runs, ${taskPayload.logs.length} logs), failures=${failures.length}, dropped=${this.droppedItems}`
@@ -296,10 +314,10 @@ export class Transport {
296
314
  enumerable: false
297
315
  });
298
316
 
299
- const flushSyncBestEffort = () => {
317
+ const flushBestEffort = () => {
300
318
  void this.flush();
301
319
  };
302
320
 
303
- process.once('beforeExit', flushSyncBestEffort);
321
+ process.once('beforeExit', flushBestEffort);
304
322
  }
305
323
  }
@@ -194,7 +194,7 @@ const patchChannel = (channelProto: any, options?: SenzorOptions) => {
194
194
  function patchedConsume(
195
195
  this: any,
196
196
  queue: string,
197
- callback: (msg: any) => void,
197
+ callback: (msg: any) => any,
198
198
  consumeOptions?: any
199
199
  ) {
200
200
  if (typeof callback !== 'function') {
@@ -1,138 +1,120 @@
1
- type HookFn = (exports: unknown) => unknown | void;
2
- type HookMap = Map<string, HookFn[]>;
3
-
4
- let Module: any;
5
- let safeRequire: NodeRequire;
6
-
7
- try {
8
- Module = require('module');
9
- safeRequire = Module.createRequire(
10
- typeof __filename !== 'undefined'
11
- ? __filename
12
- : process.cwd() + '/'
13
- );
14
- } catch {}
15
-
16
- const SENZOR_PATCHED = Symbol.for('senzor.require.patched');
17
- const SENZOR_HOOKS = Symbol.for('senzor.require.hooks');
18
-
19
- function getHookRegistry(): HookMap {
20
- const mod = Module as unknown as Record<symbol, HookMap>;
21
-
22
- if (!mod[SENZOR_HOOKS]) {
23
- Object.defineProperty(mod, SENZOR_HOOKS, {
24
- value: new Map(),
25
- enumerable: false
26
- });
27
- }
28
-
29
- return mod[SENZOR_HOOKS];
30
- }
31
-
32
- function runHooks(moduleName: string, exports: unknown) {
33
- const registry = (Module as unknown as Record<symbol, HookMap>)[SENZOR_HOOKS];
34
- if (!registry) return exports;
35
-
36
- const hooks = registry.get(moduleName);
37
- if (!hooks?.length) return exports;
38
-
39
- let currentExports = exports;
40
-
41
- for (const hook of hooks) {
42
- try {
43
- const nextExports = hook(currentExports);
44
- if (nextExports !== undefined) {
45
- currentExports = nextExports;
46
- }
47
- } catch (err) {
48
- console.error(`[Senzor] instrumentation failed for ${moduleName}`, err);
49
- }
50
- }
51
-
52
- return currentExports;
53
- }
54
-
55
- function patchLoaderOnce() {
56
- const mod = Module as unknown as any;
57
-
58
- if (mod[SENZOR_PATCHED]) return;
59
-
60
- const previousLoad = mod._load;
61
-
62
- mod._load = function patchedLoad(
63
- request: string,
64
- parent: unknown,
65
- isMain: boolean
66
- ) {
67
- const exports = previousLoad.apply(this, arguments);
68
- return runHooks(request, exports);
69
- };
70
-
71
- Object.defineProperty(mod, SENZOR_PATCHED, {
72
- value: true,
73
- enumerable: false
74
- });
75
- }
76
-
77
- function patchCached(moduleName: string, hook: HookFn) {
78
- try {
79
- const resolved = safeRequire.resolve(moduleName);
80
- const cached = safeRequire.cache?.[resolved];
81
-
82
- if (cached?.exports) {
83
- const replacement = hook(cached.exports);
84
- if (replacement !== undefined) {
85
- cached.exports = replacement;
86
- }
87
- }
88
- } catch { }
89
- }
90
-
91
- function tryRequire(moduleName: string, hook: HookFn) {
92
- try {
93
- const mod = safeRequire(moduleName);
94
- if (mod) {
95
- hook(mod);
96
- }
97
- } catch { }
98
- }
99
-
100
- function retryPatch(moduleName: string, hook: HookFn) {
101
- let attempts = 0;
102
- const max = 5;
103
-
104
- const timer = setInterval(() => {
105
- attempts++;
106
-
107
- try {
108
- const mod = safeRequire(moduleName);
109
- if (mod) {
110
- hook(mod);
111
- clearInterval(timer);
112
- }
113
- } catch { }
114
-
115
- if (attempts >= max) {
116
- clearInterval(timer);
117
- }
118
- }, 200);
119
-
120
- if (typeof timer.unref === 'function') timer.unref();
121
- }
122
-
123
- export const hookRequire = (moduleName: string, onRequire: HookFn) => {
124
- if (!Module || !safeRequire) return;
125
-
126
- const registry = getHookRegistry();
127
-
128
- if (!registry.has(moduleName)) {
129
- registry.set(moduleName, []);
130
- }
131
-
132
- registry.get(moduleName)!.push(onRequire);
133
-
134
- patchLoaderOnce();
135
- patchCached(moduleName, onRequire);
136
- tryRequire(moduleName, onRequire);
137
- retryPatch(moduleName, onRequire);
138
- };
1
+ type HookFn = (exports: unknown) => unknown | void;
2
+ type HookMap = Map<string, HookFn[]>;
3
+
4
+ let Module: any = null;
5
+ let safeRequire: NodeRequire | null = null;
6
+
7
+ try {
8
+ Module = require('module');
9
+ safeRequire = Module.createRequire(
10
+ typeof __filename !== 'undefined'
11
+ ? __filename
12
+ : process.cwd() + '/'
13
+ );
14
+ } catch {}
15
+
16
+ // ESM fallback: if native require is unavailable, try to bootstrap via createRequire
17
+ if (!Module || !safeRequire) {
18
+ try {
19
+ const nodeModule = (Function('try { return require("module") } catch(e) { return null }'))();
20
+ if (nodeModule?.createRequire) {
21
+ Module = nodeModule;
22
+ const cwd = typeof process !== 'undefined' && process.cwd ? process.cwd() : '/';
23
+ safeRequire = nodeModule.createRequire(cwd + '/');
24
+ }
25
+ } catch {}
26
+ }
27
+
28
+ const SENZOR_PATCHED = Symbol.for('senzor.require.patched');
29
+ const SENZOR_HOOKS = Symbol.for('senzor.require.hooks');
30
+
31
+ function getHookRegistry(): HookMap {
32
+ const mod = Module as unknown as Record<symbol, HookMap>;
33
+
34
+ if (!mod[SENZOR_HOOKS]) {
35
+ Object.defineProperty(mod, SENZOR_HOOKS, {
36
+ value: new Map(),
37
+ enumerable: false
38
+ });
39
+ }
40
+
41
+ return mod[SENZOR_HOOKS];
42
+ }
43
+
44
+ function runHooks(moduleName: string, exports: unknown) {
45
+ const registry = (Module as unknown as Record<symbol, HookMap>)[SENZOR_HOOKS];
46
+ if (!registry) return exports;
47
+
48
+ const hooks = registry.get(moduleName);
49
+ if (!hooks?.length) return exports;
50
+
51
+ let currentExports = exports;
52
+
53
+ for (const hook of hooks) {
54
+ try {
55
+ const nextExports = hook(currentExports);
56
+ if (nextExports !== undefined) {
57
+ currentExports = nextExports;
58
+ }
59
+ } catch (err) {
60
+ console.error(`[Senzor] instrumentation failed for ${moduleName}`, err);
61
+ }
62
+ }
63
+
64
+ return currentExports;
65
+ }
66
+
67
+ function patchLoaderOnce() {
68
+ const mod = Module as unknown as any;
69
+
70
+ if (mod[SENZOR_PATCHED]) return;
71
+
72
+ // Module._load is CJS-specific; in pure ESM runtimes it may not exist
73
+ if (typeof mod._load !== 'function') return;
74
+
75
+ const previousLoad = mod._load;
76
+
77
+ mod._load = function patchedLoad(
78
+ request: string,
79
+ parent: unknown,
80
+ isMain: boolean
81
+ ) {
82
+ const exports = previousLoad.apply(this, arguments);
83
+ return runHooks(request, exports);
84
+ };
85
+
86
+ Object.defineProperty(mod, SENZOR_PATCHED, {
87
+ value: true,
88
+ enumerable: false
89
+ });
90
+ }
91
+
92
+ function patchCached(moduleName: string, hook: HookFn) {
93
+ if (!safeRequire) return;
94
+ try {
95
+ const resolved = safeRequire.resolve(moduleName);
96
+ const cached = safeRequire.cache?.[resolved];
97
+
98
+ if (cached?.exports) {
99
+ const replacement = hook(cached.exports);
100
+ if (replacement !== undefined) {
101
+ cached.exports = replacement;
102
+ }
103
+ }
104
+ } catch { }
105
+ }
106
+
107
+ export const hookRequire = (moduleName: string, onRequire: HookFn) => {
108
+ if (!Module || !safeRequire) return;
109
+
110
+ const registry = getHookRegistry();
111
+
112
+ if (!registry.has(moduleName)) {
113
+ registry.set(moduleName, []);
114
+ }
115
+
116
+ registry.get(moduleName)!.push(onRequire);
117
+
118
+ patchLoaderOnce();
119
+ patchCached(moduleName, onRequire);
120
+ };
@@ -70,6 +70,21 @@ const patchRequestLike = (
70
70
  return original.apply(this, arguments as any);
71
71
  }
72
72
 
73
+ // Skip if the request already has Senzor trace headers injected by
74
+ // the fetch instrumentation (Node.js fetch delegates to undici internally).
75
+ const existingHeaders = opts?.headers;
76
+ if (existingHeaders) {
77
+ let hasTrace = false;
78
+ if (typeof Headers !== 'undefined' && existingHeaders instanceof Headers) {
79
+ hasTrace = existingHeaders.has('x-senzor-trace-id');
80
+ } else if (Array.isArray(existingHeaders)) {
81
+ hasTrace = existingHeaders.some(([k]: [string, string]) => String(k).toLowerCase() === 'x-senzor-trace-id');
82
+ } else if (typeof existingHeaders === 'object') {
83
+ hasTrace = Object.keys(existingHeaders).some(k => k.toLowerCase() === 'x-senzor-trace-id');
84
+ }
85
+ if (hasTrace) return original.apply(this, arguments as any);
86
+ }
87
+
73
88
  const details = getUrlDetails(input?.origin ? input.origin : input);
74
89
  const method = String(opts?.method || 'GET').toUpperCase();
75
90
  const span = startCapturedSpan(