@senzops/apm-node 1.1.18 → 1.2.1

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 (45) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +398 -48
  3. package/dist/index.d.mts +14 -0
  4. package/dist/index.d.ts +14 -0
  5. package/dist/index.global.js +1 -1
  6. package/dist/index.global.js.map +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +1 -1
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/register.d.mts +2 -0
  12. package/dist/register.d.ts +2 -0
  13. package/dist/register.js +2 -0
  14. package/dist/register.js.map +1 -0
  15. package/dist/register.mjs +2 -0
  16. package/dist/register.mjs.map +1 -0
  17. package/package.json +15 -4
  18. package/src/core/client.ts +167 -107
  19. package/src/core/context.ts +48 -21
  20. package/src/core/sanitizer.ts +203 -0
  21. package/src/core/transport.ts +273 -104
  22. package/src/core/types.ts +43 -24
  23. package/src/index.ts +5 -4
  24. package/src/instrumentation/express.ts +338 -0
  25. package/src/instrumentation/fastify.ts +296 -0
  26. package/src/instrumentation/framework.ts +301 -0
  27. package/src/instrumentation/hook.ts +49 -31
  28. package/src/instrumentation/http.ts +530 -162
  29. package/src/instrumentation/koa.ts +173 -0
  30. package/src/instrumentation/mongo.ts +202 -105
  31. package/src/instrumentation/mongoose.ts +156 -0
  32. package/src/instrumentation/mysql.ts +169 -0
  33. package/src/instrumentation/patch.ts +56 -0
  34. package/src/instrumentation/pg.ts +131 -41
  35. package/src/instrumentation/redis.ts +109 -0
  36. package/src/instrumentation/span.ts +73 -0
  37. package/src/instrumentation/undici.ts +189 -0
  38. package/src/register.ts +58 -0
  39. package/src/utils/ids.ts +7 -0
  40. package/src/utils/internal.ts +1 -0
  41. package/src/wrappers/fastify.ts +10 -7
  42. package/src/wrappers/h3.ts +40 -16
  43. package/src/wrappers/next.ts +68 -21
  44. package/tsup.config.ts +21 -11
  45. package/wiki.md +852 -120
@@ -2,15 +2,23 @@ import { Transport } from './transport';
2
2
  import { Context } from './context';
3
3
  import { SenzorOptions, ActiveTrace, TaskRun, SenzorLog } from './types';
4
4
  import { randomUUID } from 'crypto';
5
- import { instrumentHttp, instrumentFetch } from '../instrumentation/http';
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, '');
5
+ import { instrumentHttp, instrumentFetch } from '../instrumentation/http';
6
+ import { instrumentExpress } from '../instrumentation/express';
7
+ import { instrumentFastify } from '../instrumentation/fastify';
8
+ import { instrumentKoa } from '../instrumentation/koa';
9
+ import { instrumentMongo } from '../instrumentation/mongo';
10
+ import { instrumentPg } from '../instrumentation/pg';
11
+ import { instrumentUndici } from '../instrumentation/undici';
12
+ import { instrumentRedis } from '../instrumentation/redis';
13
+ import { instrumentMysql } from '../instrumentation/mysql';
14
+ import { instrumentMongoose } from '../instrumentation/mongoose';
15
+ import { instrumentBullMQ } from '../instrumentation/bullmq';
16
+ import { instrumentNodeCron } from '../instrumentation/cron';
17
+ import { SDK_META } from '../utils/sdkMeta';
18
+ import { parseTraceparent } from '../utils/traceContext';
19
+ import { generateSpanId, generateTraceId } from '../utils/ids';
20
+ import { sanitizeAttributes } from './sanitizer';
21
+ import { startCapturedSpan } from '../instrumentation/span';
14
22
 
15
23
  // Memory-safe JSON stringifier to handle cyclical objects
16
24
  // (like Express 'req' objects) passed into console.log
@@ -25,39 +33,69 @@ const safeStringify = (obj: any): string => {
25
33
  });
26
34
  };
27
35
 
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
- }
36
+ export class SenzorClient {
37
+ private transport: Transport | null = null;
38
+ private options: SenzorOptions | null = null;
39
+ private isInstrumented = false;
40
+
41
+ public preload(options: Partial<SenzorOptions> = {}) {
42
+ const endpoint = options.endpoint || 'https://api.senzor.dev/api/ingest/apm';
43
+ const debug = options.debug || false;
44
+
45
+ this.options = {
46
+ apiKey: '',
47
+ ...this.options,
48
+ ...options
49
+ };
50
+
51
+ this.installNativeInstrumentations(endpoint, debug);
52
+ }
53
+
54
+ public init(options: SenzorOptions) {
55
+ if (!options.apiKey) {
56
+ console.warn('[Senzor] API Key missing. SDK disabled.');
57
+ return;
58
+ }
38
59
  this.options = options;
39
60
  const endpoint = options.endpoint || 'https://api.senzor.dev/api/ingest/apm';
40
61
  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
- }
62
+
63
+ this.transport = new Transport({ ...options, endpoint });
64
+ this.installNativeInstrumentations(endpoint, debug);
65
+ }
66
+
67
+ private isInstrumentationEnabled(name: string): boolean {
68
+ const setting = this.options?.instrumentations;
69
+ if (setting === false) return false;
70
+ if (Array.isArray(setting)) return setting.includes(name);
71
+ return true;
72
+ }
73
+
74
+ private installNativeInstrumentations(endpoint: string, debug: boolean) {
75
+ if (!this.isInstrumented) {
76
+ this.setupGlobalErrorHandlers();
77
+ this.setupLogInterception(); // Fire up Auto Log Instrumentation
78
+
79
+ try { if (this.isInstrumentationEnabled('http')) instrumentHttp(this, endpoint, this.options || undefined); } catch (e) { }
80
+ try { if (this.isInstrumentationEnabled('express')) instrumentExpress(this.options || undefined); } catch (e) { }
81
+ try { if (this.isInstrumentationEnabled('fastify')) instrumentFastify(this.options || undefined); } catch (e) { }
82
+ try { if (this.isInstrumentationEnabled('koa')) instrumentKoa(this.options || undefined); } catch (e) { }
83
+ try { if (this.isInstrumentationEnabled('fetch')) instrumentFetch(endpoint, this.options || undefined); } catch (e) { }
84
+ try { if (this.isInstrumentationEnabled('undici')) instrumentUndici(this.options || undefined); } catch (e) { }
85
+ try { if (this.isInstrumentationEnabled('mongo')) instrumentMongo(this.options || undefined); } catch (e) { }
86
+ try { if (this.isInstrumentationEnabled('mongoose')) instrumentMongoose(this.options || undefined); } catch (e) { }
87
+ try { if (this.isInstrumentationEnabled('pg')) instrumentPg(this.options || undefined); } catch (e) { }
88
+ try { if (this.isInstrumentationEnabled('mysql')) instrumentMysql(this.options || undefined); } catch (e) { }
89
+ try { if (this.isInstrumentationEnabled('redis')) instrumentRedis(this.options || undefined); } catch (e) { }
90
+
91
+ // Task Integrations
92
+ try { if (this.isInstrumentationEnabled('bullmq')) instrumentBullMQ(this, debug); } catch (e) { }
93
+ try { if (this.isInstrumentationEnabled('cron')) instrumentNodeCron(this, debug); } catch (e) { }
94
+
95
+ this.isInstrumented = true;
96
+ if (debug) console.log('[Senzor] Auto-instrumentation enabled');
97
+ }
98
+ }
61
99
 
62
100
  // --- Enterprise Auto-Log Interception ---
63
101
  private setupLogInterception() {
@@ -94,13 +132,13 @@ export class SenzorClient {
94
132
  attributes.errorStack = arg.stack;
95
133
  attributes.errorName = arg.name;
96
134
  } 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
- }
135
+ try {
136
+ // New Relic Style Destructuring: Merge all object keys into `attributes`
137
+ const parsed = JSON.parse(safeStringify(arg));
138
+ attributes = { ...attributes, ...sanitizeAttributes(parsed, this.options || undefined) };
139
+ } catch (e) {
140
+ attributes.unparseableObject = true;
141
+ }
104
142
  } else {
105
143
  message += (message ? ' ' : '') + String(arg);
106
144
  }
@@ -216,11 +254,20 @@ export class SenzorClient {
216
254
  process.on('SIGINT', () => safeCapture(new Error('Process received SIGINT'), { type: 'processSignal', signal: 'SIGINT' }));
217
255
  }
218
256
 
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;
257
+ public startTrace<T>(data: Partial<ActiveTrace['data']> & { headers?: any }, next: () => T): T {
258
+ if (!this.transport) return next();
259
+
260
+ const existingTrace = Context.current();
261
+ if (existingTrace?.contextType === 'apm') {
262
+ existingTrace.data = {
263
+ ...existingTrace.data,
264
+ ...data
265
+ };
266
+ return next();
267
+ }
268
+
269
+ let inheritedTraceId: string | undefined = undefined;
270
+ let inheritedParentSpanId: string | undefined = undefined;
224
271
 
225
272
  if (data.headers) {
226
273
  const getHeader = (key: string) => {
@@ -243,32 +290,44 @@ export class SenzorClient {
243
290
  }
244
291
  }
245
292
 
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
- };
293
+ const activeTraceId = inheritedTraceId || generateTraceId();
294
+ const rootSpanId = generateSpanId();
295
+
296
+ const trace: ActiveTrace = {
297
+ id: activeTraceId,
298
+ contextType: 'apm',
299
+ startTime: performance.now(),
300
+ rootSpanId,
301
+ activeSpanId: rootSpanId,
302
+ data: { ...data, parentTraceId: inheritedTraceId, parentSpanId: inheritedParentSpanId, rootSpanId },
303
+ spans: [],
304
+ maxSpans: this.options?.maxSpansPerTrace ?? 500,
305
+ droppedSpans: 0
306
+ };
255
307
 
256
308
  return Context.run(trace, next);
257
309
  }
258
310
 
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
- };
311
+ public endTrace(status: number, extraData: any = {}) {
312
+ const trace = Context.current();
313
+ if (!trace || trace.contextType !== 'apm' || !this.transport) return;
314
+ if (trace.ended) return;
315
+ trace.ended = true;
316
+ const duration = performance.now() - trace.startTime;
317
+
318
+ const payload = {
319
+ traceId: trace.id,
320
+ parentTraceId: trace.data.parentTraceId,
321
+ parentSpanId: trace.data.parentSpanId,
322
+ rootSpanId: trace.rootSpanId,
323
+ ...trace.data,
324
+ ...extraData,
325
+ status,
326
+ duration,
327
+ spans: trace.spans,
328
+ droppedSpans: trace.droppedSpans,
329
+ timestamp: new Date().toISOString()
330
+ };
272
331
  this.transport.addTrace(payload);
273
332
  }
274
333
 
@@ -282,17 +341,21 @@ export class SenzorClient {
282
341
  const startMemory = process.memoryUsage ? process.memoryUsage().heapUsed : 0;
283
342
  const startCpu = process.cpuUsage ? process.cpuUsage() : undefined;
284
343
 
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
- }
344
+ const task: ActiveTrace = {
345
+ id: randomUUID(),
346
+ contextType: 'task',
347
+ startTime: performance.now(),
348
+ rootSpanId: generateSpanId(),
349
+ startMemory,
350
+ startCpu,
351
+ data: { taskName: name, taskType: type, triggerTraceId, ...options },
352
+ spans: [],
353
+ maxSpans: this.options?.maxSpansPerTrace ?? 500,
354
+ droppedSpans: 0
355
+ };
356
+ task.activeSpanId = task.rootSpanId;
357
+ return Context.run(task, next);
358
+ }
296
359
 
297
360
  public endTask(status: 'success' | 'failed', extraMetadata: any = {}) {
298
361
  const task = Context.current();
@@ -318,7 +381,7 @@ export class SenzorClient {
318
381
  queueDelay: task.data.queueDelay,
319
382
  attempts: task.data.attempts,
320
383
  isDeadLetter: task.data.isDeadLetter,
321
- metadata: { ...task.data.metadata, ...extraMetadata },
384
+ metadata: { ...task.data.metadata, ...extraMetadata, droppedSpans: task.droppedSpans },
322
385
  resourceMetrics,
323
386
  status,
324
387
  duration: performance.now() - task.startTime,
@@ -357,35 +420,32 @@ export class SenzorClient {
357
420
 
358
421
  const currentTrace = Context.current();
359
422
 
360
- const errPayload = {
361
- errorClass: parsedError.name || 'Error',
362
- message: parsedError.message,
363
- stackTrace: parsedError.stack,
364
- context,
365
- timestamp: new Date().toISOString()
366
- };
423
+ const errPayload = {
424
+ errorClass: parsedError.name || 'Error',
425
+ message: parsedError.message,
426
+ stackTrace: parsedError.stack,
427
+ context: sanitizeAttributes(context, this.options || undefined),
428
+ timestamp: new Date().toISOString()
429
+ };
367
430
 
368
431
  if (currentTrace?.contextType === 'task') {
369
432
  this.transport.addError({ ...errPayload, runId: currentTrace.id }, 'task');
370
433
  } else {
371
434
  this.transport.addError({ ...errPayload, traceId: currentTrace?.id }, 'apm');
372
435
  }
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
- }
436
+ }
437
+
438
+ public track(data: any) {
439
+ this.transport?.addTrace({ traceId: generateTraceId(), ...data, spans: [], timestamp: new Date().toISOString() });
440
+ }
441
+
442
+ public startSpan(name: string, type: 'db' | 'http' | 'function' | 'custom' = 'custom') {
443
+ const span = startCapturedSpan(name, type, {}, this.options || undefined);
444
+ if (!span) return { end: () => { } };
445
+ return { end: (meta?: any, status?: number) => span.end(status, meta) };
446
+ }
387
447
 
388
448
  public async flush() { if (this.transport) await this.transport.flush(); }
389
449
  }
390
450
 
391
- export const client = new SenzorClient();
451
+ 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
+ };