@senzops/apm-node 1.1.18 → 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.
@@ -4,13 +4,18 @@ import { SenzorOptions, ActiveTrace, TaskRun, SenzorLog } from './types';
4
4
  import { randomUUID } from 'crypto';
5
5
  import { instrumentHttp, instrumentFetch } from '../instrumentation/http';
6
6
  import { instrumentMongo } from '../instrumentation/mongo';
7
- import { instrumentPg } from '../instrumentation/pg';
8
- import { instrumentBullMQ } from '../instrumentation/bullmq';
9
- import { instrumentNodeCron } from '../instrumentation/cron';
10
- import { SDK_META } from '../utils/sdkMeta';
11
- import { parseTraceparent } from '../utils/traceContext';
12
-
13
- const generateW3CTraceId = () => randomUUID().replace(/-/g, '');
7
+ import { instrumentPg } from '../instrumentation/pg';
8
+ import { instrumentUndici } from '../instrumentation/undici';
9
+ import { instrumentRedis } from '../instrumentation/redis';
10
+ import { instrumentMysql } from '../instrumentation/mysql';
11
+ import { instrumentMongoose } from '../instrumentation/mongoose';
12
+ import { instrumentBullMQ } from '../instrumentation/bullmq';
13
+ import { instrumentNodeCron } from '../instrumentation/cron';
14
+ import { SDK_META } from '../utils/sdkMeta';
15
+ import { parseTraceparent } from '../utils/traceContext';
16
+ import { generateSpanId, generateTraceId } from '../utils/ids';
17
+ import { sanitizeAttributes } from './sanitizer';
18
+ import { startCapturedSpan } from '../instrumentation/span';
14
19
 
15
20
  // Memory-safe JSON stringifier to handle cyclical objects
16
21
  // (like Express 'req' objects) passed into console.log
@@ -25,39 +30,66 @@ const safeStringify = (obj: any): string => {
25
30
  });
26
31
  };
27
32
 
28
- export class SenzorClient {
29
- private transport: Transport | null = null;
30
- private options: SenzorOptions | null = null;
31
- private isInstrumented = false;
32
-
33
- public init(options: SenzorOptions) {
34
- if (!options.apiKey) {
35
- console.warn('[Senzor] API Key missing. SDK disabled.');
36
- return;
37
- }
33
+ export class SenzorClient {
34
+ private transport: Transport | null = null;
35
+ private options: SenzorOptions | null = null;
36
+ private isInstrumented = false;
37
+
38
+ public preload(options: Partial<SenzorOptions> = {}) {
39
+ const endpoint = options.endpoint || 'https://api.senzor.dev/api/ingest/apm';
40
+ const debug = options.debug || false;
41
+
42
+ this.options = {
43
+ apiKey: '',
44
+ ...this.options,
45
+ ...options
46
+ };
47
+
48
+ this.installNativeInstrumentations(endpoint, debug);
49
+ }
50
+
51
+ public init(options: SenzorOptions) {
52
+ if (!options.apiKey) {
53
+ console.warn('[Senzor] API Key missing. SDK disabled.');
54
+ return;
55
+ }
38
56
  this.options = options;
39
57
  const endpoint = options.endpoint || 'https://api.senzor.dev/api/ingest/apm';
40
58
  const debug = options.debug || false;
41
-
42
- this.transport = new Transport({ ...options, endpoint });
43
-
44
- if (!this.isInstrumented) {
45
- this.setupGlobalErrorHandlers();
46
- this.setupLogInterception(); // Fire up Auto Log Instrumentation
47
-
48
- try { instrumentHttp(endpoint, debug); } catch (e) { }
49
- try { instrumentFetch(endpoint, debug); } catch (e) { }
50
- try { instrumentMongo(debug); } catch (e) { }
51
- try { instrumentPg(); } catch (e) { }
52
-
53
- // Task Integrations
54
- try { instrumentBullMQ(this, debug); } catch (e) { }
55
- try { instrumentNodeCron(this, debug); } catch (e) { }
56
-
57
- this.isInstrumented = true;
58
- if (debug) console.log('[Senzor] Auto-instrumentation enabled');
59
- }
60
- }
59
+
60
+ this.transport = new Transport({ ...options, endpoint });
61
+ this.installNativeInstrumentations(endpoint, debug);
62
+ }
63
+
64
+ private isInstrumentationEnabled(name: string): boolean {
65
+ const setting = this.options?.instrumentations;
66
+ if (setting === false) return false;
67
+ if (Array.isArray(setting)) return setting.includes(name);
68
+ return true;
69
+ }
70
+
71
+ private installNativeInstrumentations(endpoint: string, debug: boolean) {
72
+ if (!this.isInstrumented) {
73
+ this.setupGlobalErrorHandlers();
74
+ this.setupLogInterception(); // Fire up Auto Log Instrumentation
75
+
76
+ try { if (this.isInstrumentationEnabled('http')) instrumentHttp(this, endpoint, this.options || undefined); } catch (e) { }
77
+ try { if (this.isInstrumentationEnabled('fetch')) instrumentFetch(endpoint, this.options || undefined); } catch (e) { }
78
+ try { if (this.isInstrumentationEnabled('undici')) instrumentUndici(this.options || undefined); } catch (e) { }
79
+ try { if (this.isInstrumentationEnabled('mongo')) instrumentMongo(this.options || undefined); } catch (e) { }
80
+ try { if (this.isInstrumentationEnabled('mongoose')) instrumentMongoose(this.options || undefined); } catch (e) { }
81
+ try { if (this.isInstrumentationEnabled('pg')) instrumentPg(this.options || undefined); } catch (e) { }
82
+ try { if (this.isInstrumentationEnabled('mysql')) instrumentMysql(this.options || undefined); } catch (e) { }
83
+ try { if (this.isInstrumentationEnabled('redis')) instrumentRedis(this.options || undefined); } catch (e) { }
84
+
85
+ // Task Integrations
86
+ try { if (this.isInstrumentationEnabled('bullmq')) instrumentBullMQ(this, debug); } catch (e) { }
87
+ try { if (this.isInstrumentationEnabled('cron')) instrumentNodeCron(this, debug); } catch (e) { }
88
+
89
+ this.isInstrumented = true;
90
+ if (debug) console.log('[Senzor] Auto-instrumentation enabled');
91
+ }
92
+ }
61
93
 
62
94
  // --- Enterprise Auto-Log Interception ---
63
95
  private setupLogInterception() {
@@ -94,13 +126,13 @@ export class SenzorClient {
94
126
  attributes.errorStack = arg.stack;
95
127
  attributes.errorName = arg.name;
96
128
  } else if (typeof arg === 'object' && arg !== null) {
97
- try {
98
- // New Relic Style Destructuring: Merge all object keys into `attributes`
99
- const parsed = JSON.parse(safeStringify(arg));
100
- attributes = { ...attributes, ...parsed };
101
- } catch (e) {
102
- attributes.unparseableObject = true;
103
- }
129
+ try {
130
+ // New Relic Style Destructuring: Merge all object keys into `attributes`
131
+ const parsed = JSON.parse(safeStringify(arg));
132
+ attributes = { ...attributes, ...sanitizeAttributes(parsed, this.options || undefined) };
133
+ } catch (e) {
134
+ attributes.unparseableObject = true;
135
+ }
104
136
  } else {
105
137
  message += (message ? ' ' : '') + String(arg);
106
138
  }
@@ -216,11 +248,20 @@ export class SenzorClient {
216
248
  process.on('SIGINT', () => safeCapture(new Error('Process received SIGINT'), { type: 'processSignal', signal: 'SIGINT' }));
217
249
  }
218
250
 
219
- public startTrace<T>(data: Partial<ActiveTrace['data']> & { headers?: any }, next: () => T): T {
220
- if (!this.transport) return next();
221
-
222
- let inheritedTraceId: string | undefined = undefined;
223
- let inheritedParentSpanId: string | undefined = undefined;
251
+ public startTrace<T>(data: Partial<ActiveTrace['data']> & { headers?: any }, next: () => T): T {
252
+ if (!this.transport) return next();
253
+
254
+ const existingTrace = Context.current();
255
+ if (existingTrace?.contextType === 'apm') {
256
+ existingTrace.data = {
257
+ ...existingTrace.data,
258
+ ...data
259
+ };
260
+ return next();
261
+ }
262
+
263
+ let inheritedTraceId: string | undefined = undefined;
264
+ let inheritedParentSpanId: string | undefined = undefined;
224
265
 
225
266
  if (data.headers) {
226
267
  const getHeader = (key: string) => {
@@ -243,32 +284,44 @@ export class SenzorClient {
243
284
  }
244
285
  }
245
286
 
246
- const activeTraceId = inheritedTraceId || generateW3CTraceId();
247
-
248
- const trace: ActiveTrace = {
249
- id: activeTraceId,
250
- contextType: 'apm',
251
- startTime: performance.now(),
252
- data: { ...data, parentTraceId: inheritedTraceId, parentSpanId: inheritedParentSpanId },
253
- spans: []
254
- };
287
+ const activeTraceId = inheritedTraceId || generateTraceId();
288
+ const rootSpanId = generateSpanId();
289
+
290
+ const trace: ActiveTrace = {
291
+ id: activeTraceId,
292
+ contextType: 'apm',
293
+ startTime: performance.now(),
294
+ rootSpanId,
295
+ activeSpanId: rootSpanId,
296
+ data: { ...data, parentTraceId: inheritedTraceId, parentSpanId: inheritedParentSpanId, rootSpanId },
297
+ spans: [],
298
+ maxSpans: this.options?.maxSpansPerTrace ?? 500,
299
+ droppedSpans: 0
300
+ };
255
301
 
256
302
  return Context.run(trace, next);
257
303
  }
258
304
 
259
- public endTrace(status: number, extraData: any = {}) {
260
- const trace = Context.current();
261
- if (!trace || trace.contextType !== 'apm' || !this.transport) return;
262
- const duration = performance.now() - trace.startTime;
263
-
264
- const payload = {
265
- traceId: trace.id,
266
- parentTraceId: trace.data.parentTraceId,
267
- parentSpanId: trace.data.parentSpanId,
268
- ...trace.data,
269
- ...extraData,
270
- status, duration, spans: trace.spans, timestamp: new Date().toISOString()
271
- };
305
+ public endTrace(status: number, extraData: any = {}) {
306
+ const trace = Context.current();
307
+ if (!trace || trace.contextType !== 'apm' || !this.transport) return;
308
+ if (trace.ended) return;
309
+ trace.ended = true;
310
+ const duration = performance.now() - trace.startTime;
311
+
312
+ const payload = {
313
+ traceId: trace.id,
314
+ parentTraceId: trace.data.parentTraceId,
315
+ parentSpanId: trace.data.parentSpanId,
316
+ rootSpanId: trace.rootSpanId,
317
+ ...trace.data,
318
+ ...extraData,
319
+ status,
320
+ duration,
321
+ spans: trace.spans,
322
+ droppedSpans: trace.droppedSpans,
323
+ timestamp: new Date().toISOString()
324
+ };
272
325
  this.transport.addTrace(payload);
273
326
  }
274
327
 
@@ -282,17 +335,21 @@ export class SenzorClient {
282
335
  const startMemory = process.memoryUsage ? process.memoryUsage().heapUsed : 0;
283
336
  const startCpu = process.cpuUsage ? process.cpuUsage() : undefined;
284
337
 
285
- const task: ActiveTrace = {
286
- id: randomUUID(),
287
- contextType: 'task',
288
- startTime: performance.now(),
289
- startMemory,
290
- startCpu,
291
- data: { taskName: name, taskType: type, triggerTraceId, ...options },
292
- spans: []
293
- };
294
- return Context.run(task, next);
295
- }
338
+ const task: ActiveTrace = {
339
+ id: randomUUID(),
340
+ contextType: 'task',
341
+ startTime: performance.now(),
342
+ rootSpanId: generateSpanId(),
343
+ startMemory,
344
+ startCpu,
345
+ data: { taskName: name, taskType: type, triggerTraceId, ...options },
346
+ spans: [],
347
+ maxSpans: this.options?.maxSpansPerTrace ?? 500,
348
+ droppedSpans: 0
349
+ };
350
+ task.activeSpanId = task.rootSpanId;
351
+ return Context.run(task, next);
352
+ }
296
353
 
297
354
  public endTask(status: 'success' | 'failed', extraMetadata: any = {}) {
298
355
  const task = Context.current();
@@ -318,7 +375,7 @@ export class SenzorClient {
318
375
  queueDelay: task.data.queueDelay,
319
376
  attempts: task.data.attempts,
320
377
  isDeadLetter: task.data.isDeadLetter,
321
- metadata: { ...task.data.metadata, ...extraMetadata },
378
+ metadata: { ...task.data.metadata, ...extraMetadata, droppedSpans: task.droppedSpans },
322
379
  resourceMetrics,
323
380
  status,
324
381
  duration: performance.now() - task.startTime,
@@ -357,35 +414,32 @@ export class SenzorClient {
357
414
 
358
415
  const currentTrace = Context.current();
359
416
 
360
- const errPayload = {
361
- errorClass: parsedError.name || 'Error',
362
- message: parsedError.message,
363
- stackTrace: parsedError.stack,
364
- context,
365
- timestamp: new Date().toISOString()
366
- };
417
+ const errPayload = {
418
+ errorClass: parsedError.name || 'Error',
419
+ message: parsedError.message,
420
+ stackTrace: parsedError.stack,
421
+ context: sanitizeAttributes(context, this.options || undefined),
422
+ timestamp: new Date().toISOString()
423
+ };
367
424
 
368
425
  if (currentTrace?.contextType === 'task') {
369
426
  this.transport.addError({ ...errPayload, runId: currentTrace.id }, 'task');
370
427
  } else {
371
428
  this.transport.addError({ ...errPayload, traceId: currentTrace?.id }, 'apm');
372
429
  }
373
- }
374
-
375
- public track(data: any) {
376
- this.transport?.addTrace({ traceId: generateW3CTraceId(), ...data, spans: [], timestamp: new Date().toISOString() });
377
- }
378
-
379
- public startSpan(name: string, type: 'db' | 'http' | 'function' | 'custom' = 'custom') {
380
- const trace = Context.current();
381
- if (!trace) return { end: () => { } };
382
- const startTime = performance.now() - trace.startTime;
383
- const spanStartAbs = performance.now();
384
- const spanId = randomUUID().replace(/-/g, '').slice(0, 16);
385
- return { end: (meta?: any, status?: number) => { Context.addSpan({ spanId, name, type, startTime, duration: performance.now() - spanStartAbs, status, meta }); } };
386
- }
430
+ }
431
+
432
+ public track(data: any) {
433
+ this.transport?.addTrace({ traceId: generateTraceId(), ...data, spans: [], timestamp: new Date().toISOString() });
434
+ }
435
+
436
+ public startSpan(name: string, type: 'db' | 'http' | 'function' | 'custom' = 'custom') {
437
+ const span = startCapturedSpan(name, type, {}, this.options || undefined);
438
+ if (!span) return { end: () => { } };
439
+ return { end: (meta?: any, status?: number) => span.end(status, meta) };
440
+ }
387
441
 
388
442
  public async flush() { if (this.transport) await this.transport.flush(); }
389
443
  }
390
444
 
391
- export const client = new SenzorClient();
445
+ export const client = new SenzorClient();
@@ -1,21 +1,48 @@
1
- import { AsyncLocalStorage } from 'async_hooks';
2
- import { ActiveTrace } from './types';
3
-
4
- export const storage = new AsyncLocalStorage<ActiveTrace>();
5
-
6
- export const Context = {
7
- run: <T>(trace: ActiveTrace, fn: () => T): T => {
8
- return storage.run(trace, fn);
9
- },
10
-
11
- current: (): ActiveTrace | undefined => {
12
- return storage.getStore();
13
- },
14
-
15
- addSpan: (span: any) => {
16
- const store = storage.getStore();
17
- if (store) {
18
- store.spans.push(span);
19
- }
20
- }
21
- };
1
+ import { AsyncLocalStorage } from 'async_hooks';
2
+ import { ActiveTrace, Span } from './types';
3
+
4
+ export const storage = new AsyncLocalStorage<ActiveTrace>();
5
+
6
+ export const Context = {
7
+ run: <T>(trace: ActiveTrace, fn: () => T): T => {
8
+ return storage.run(trace, fn);
9
+ },
10
+
11
+ withActiveSpan: <T>(spanId: string, fn: () => T): T => {
12
+ const store = storage.getStore();
13
+ if (!store) return fn();
14
+
15
+ return storage.run(
16
+ {
17
+ ...store,
18
+ activeSpanId: spanId,
19
+ data: store.data,
20
+ spans: store.spans
21
+ },
22
+ fn
23
+ );
24
+ },
25
+
26
+ current: (): ActiveTrace | undefined => {
27
+ return storage.getStore();
28
+ },
29
+
30
+ addSpan: (span: Span) => {
31
+ const store = storage.getStore();
32
+ if (store) {
33
+ Context.addSpanToTrace(store, span);
34
+ }
35
+ },
36
+
37
+ addSpanToTrace: (trace: ActiveTrace, span: Span) => {
38
+ if (trace.ended) return;
39
+
40
+ const maxSpans = trace.maxSpans ?? 500;
41
+ if (trace.spans.length >= maxSpans) {
42
+ trace.droppedSpans = (trace.droppedSpans ?? 0) + 1;
43
+ return;
44
+ }
45
+
46
+ trace.spans.push(span);
47
+ }
48
+ };
@@ -0,0 +1,203 @@
1
+ import { SenzorOptions } from './types';
2
+
3
+ const DEFAULT_MAX_ATTRIBUTES = 64;
4
+ const DEFAULT_MAX_ATTRIBUTE_LENGTH = 2048;
5
+ const MAX_DEPTH = 4;
6
+ const MAX_ARRAY_ITEMS = 20;
7
+
8
+ const SENSITIVE_KEY_PATTERN =
9
+ /(^|[-_.])(authorization|cookie|set-cookie|password|passwd|pwd|secret|token|api[-_.]?key|x-api-key|access[-_.]?token|refresh[-_.]?token|client[-_.]?secret|private[-_.]?key)([-_.]|$)/i;
10
+
11
+ export interface SanitizerOptions {
12
+ maxAttributes?: number;
13
+ maxAttributeLength?: number;
14
+ }
15
+
16
+ const getLimits = (options?: SanitizerOptions | SenzorOptions) => ({
17
+ maxAttributes: options?.maxAttributes ?? DEFAULT_MAX_ATTRIBUTES,
18
+ maxAttributeLength:
19
+ options?.maxAttributeLength ?? DEFAULT_MAX_ATTRIBUTE_LENGTH
20
+ });
21
+
22
+ export const truncate = (
23
+ value: string,
24
+ maxLength = DEFAULT_MAX_ATTRIBUTE_LENGTH
25
+ ): string => {
26
+ if (value.length <= maxLength) return value;
27
+ return `${value.slice(0, Math.max(0, maxLength - 15))}...[truncated]`;
28
+ };
29
+
30
+ export const isSensitiveKey = (key: string): boolean =>
31
+ SENSITIVE_KEY_PATTERN.test(key);
32
+
33
+ const sanitizePrimitive = (
34
+ value: unknown,
35
+ maxLength: number
36
+ ): string | number | boolean | null | undefined => {
37
+ if (value === null || value === undefined) return value;
38
+
39
+ if (
40
+ typeof value === 'number' ||
41
+ typeof value === 'boolean'
42
+ ) {
43
+ return value;
44
+ }
45
+
46
+ if (typeof value === 'bigint') {
47
+ return value.toString();
48
+ }
49
+
50
+ if (typeof value === 'string') {
51
+ return truncate(value, maxLength);
52
+ }
53
+
54
+ return undefined;
55
+ };
56
+
57
+ const sanitizeValue = (
58
+ key: string,
59
+ value: unknown,
60
+ options: Required<SanitizerOptions>,
61
+ depth: number
62
+ ): unknown => {
63
+ if (isSensitiveKey(key)) return '[REDACTED]';
64
+
65
+ const primitive =
66
+ sanitizePrimitive(value, options.maxAttributeLength);
67
+
68
+ if (primitive !== undefined || value === undefined) {
69
+ return primitive;
70
+ }
71
+
72
+ if (value instanceof Error) {
73
+ return {
74
+ name: truncate(value.name, options.maxAttributeLength),
75
+ message: truncate(value.message, options.maxAttributeLength),
76
+ stack: value.stack
77
+ ? truncate(value.stack, options.maxAttributeLength)
78
+ : undefined
79
+ };
80
+ }
81
+
82
+ if (depth >= MAX_DEPTH) {
83
+ return '[MaxDepth]';
84
+ }
85
+
86
+ if (Array.isArray(value)) {
87
+ return value
88
+ .slice(0, MAX_ARRAY_ITEMS)
89
+ .map((item) =>
90
+ sanitizeValue(key, item, options, depth + 1)
91
+ );
92
+ }
93
+
94
+ if (typeof value === 'object') {
95
+ const output: Record<string, unknown> = {};
96
+ let count = 0;
97
+
98
+ for (const [childKey, childValue] of Object.entries(
99
+ value as Record<string, unknown>
100
+ )) {
101
+ if (count >= options.maxAttributes) {
102
+ output.__truncated = true;
103
+ break;
104
+ }
105
+
106
+ output[childKey] = sanitizeValue(
107
+ childKey,
108
+ childValue,
109
+ options,
110
+ depth + 1
111
+ );
112
+ count++;
113
+ }
114
+
115
+ return output;
116
+ }
117
+
118
+ return truncate(String(value), options.maxAttributeLength);
119
+ };
120
+
121
+ export const sanitizeAttributes = (
122
+ attributes: Record<string, unknown> = {},
123
+ options?: SanitizerOptions | SenzorOptions
124
+ ): Record<string, unknown> => {
125
+ const limits = getLimits(options);
126
+ const normalizedOptions: Required<SanitizerOptions> = {
127
+ maxAttributes: limits.maxAttributes,
128
+ maxAttributeLength: limits.maxAttributeLength
129
+ };
130
+
131
+ const output: Record<string, unknown> = {};
132
+ let count = 0;
133
+
134
+ for (const [key, value] of Object.entries(attributes)) {
135
+ if (count >= normalizedOptions.maxAttributes) {
136
+ output.__truncated = true;
137
+ break;
138
+ }
139
+
140
+ output[key] = sanitizeValue(
141
+ key,
142
+ value,
143
+ normalizedOptions,
144
+ 0
145
+ );
146
+ count++;
147
+ }
148
+
149
+ return output;
150
+ };
151
+
152
+ export const sanitizeHeaders = (
153
+ headers: unknown,
154
+ options?: SanitizerOptions | SenzorOptions
155
+ ): Record<string, unknown> => {
156
+ if (!headers || typeof headers !== 'object') return {};
157
+
158
+ const plainHeaders: Record<string, unknown> = {};
159
+
160
+ if (typeof (headers as any).forEach === 'function') {
161
+ (headers as any).forEach((value: unknown, key: string) => {
162
+ plainHeaders[key.toLowerCase()] = value;
163
+ });
164
+ } else {
165
+ for (const [key, value] of Object.entries(
166
+ headers as Record<string, unknown>
167
+ )) {
168
+ plainHeaders[key.toLowerCase()] = Array.isArray(value)
169
+ ? value.join(', ')
170
+ : value;
171
+ }
172
+ }
173
+
174
+ return sanitizeAttributes(plainHeaders, options);
175
+ };
176
+
177
+ export const normalizeSql = (
178
+ sql: unknown,
179
+ options?: SenzorOptions
180
+ ): string | undefined => {
181
+ if (typeof sql !== 'string') return undefined;
182
+
183
+ const collapsed = sql.replace(/\s+/g, ' ').trim();
184
+ if (!collapsed) return undefined;
185
+
186
+ const withoutLiterals = collapsed
187
+ .replace(/'(?:''|[^'])*'/g, '?')
188
+ .replace(/"(?:\\"|[^"])*"/g, '?')
189
+ .replace(/\b\d+(\.\d+)?\b/g, '?');
190
+
191
+ return truncate(
192
+ options?.captureDbStatement === false
193
+ ? withoutLiterals.split(' ').slice(0, 6).join(' ')
194
+ : withoutLiterals,
195
+ options?.maxAttributeLength ?? DEFAULT_MAX_ATTRIBUTE_LENGTH
196
+ );
197
+ };
198
+
199
+ export const getSqlOperation = (sql: unknown): string | undefined => {
200
+ if (typeof sql !== 'string') return undefined;
201
+ const match = sql.trim().match(/^([a-z]+)/i);
202
+ return match?.[1]?.toUpperCase();
203
+ };