@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.
@@ -1,162 +1,530 @@
1
- import http from 'http';
2
- import https from 'https';
3
- import { URL } from 'url';
4
- import { Context } from '../core/context';
5
- import { randomUUID } from 'crypto';
6
- import { generateTraceparent } from '../utils/traceContext';
7
-
8
- const shimmer = (module: any, methodName: string, wrapper: (original: Function) => Function) => {
9
- if (!module[methodName]) return;
10
- const original = module[methodName];
11
- module[methodName] = wrapper(original);
12
- };
13
-
14
- // 16-char hex for W3C standard spans
15
- const generateSpanId = () => randomUUID().replace(/-/g, '').slice(0, 16);
16
-
17
- // --- FETCH INSTRUMENTATION ---
18
- export const instrumentFetch = (ingestUrl: string, debug = false) => {
19
- if (!globalThis.fetch) return;
20
-
21
- let ingestHost = '';
22
- try { ingestHost = new URL(ingestUrl).hostname; } catch (e) { }
23
-
24
- const originalFetch = globalThis.fetch;
25
-
26
- // @ts-ignore
27
- globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
28
- let urlStr = '';
29
- if (typeof input === 'string') urlStr = input;
30
- else if (input instanceof URL) urlStr = input.toString();
31
- else if (input && (input as any).url) urlStr = (input as any).url;
32
-
33
- if (ingestHost && urlStr.includes(ingestHost)) {
34
- return originalFetch(input, init);
35
- }
36
-
37
- const trace = Context.current();
38
- if (!trace) {
39
- return originalFetch(input, init);
40
- }
41
-
42
- const method = (init?.method || 'GET').toUpperCase();
43
- const startTime = performance.now() - trace.startTime;
44
- const spanStartAbs = performance.now();
45
- const spanId = generateSpanId();
46
-
47
- let hostname = 'unknown';
48
- try { hostname = new URL(urlStr).hostname; } catch (e) { }
49
-
50
- const newInit = { ...init } as RequestInit;
51
- if (!newInit.headers) newInit.headers = {};
52
-
53
- const setHeader = (key: string, value: string) => {
54
- if (newInit.headers instanceof Headers) {
55
- newInit.headers.set(key, value);
56
- } else if (Array.isArray(newInit.headers)) {
57
- newInit.headers.push([key, value]);
58
- } else {
59
- (newInit.headers as any)[key] = value;
60
- }
61
- };
62
-
63
- // W3C Trace Context Injection
64
- setHeader('traceparent', generateTraceparent(trace.id, spanId));
65
-
66
- // Legacy fallback for older Senzor services
67
- setHeader('x-senzor-trace-id', trace.id);
68
- setHeader('x-senzor-parent-span-id', spanId);
69
-
70
- try {
71
- const response = await originalFetch(input, newInit);
72
- const duration = performance.now() - spanStartAbs;
73
- Context.addSpan({ spanId, name: `${method} ${hostname}`, type: 'http', startTime, duration, status: response.status, meta: { url: urlStr, method, library: 'fetch' } });
74
- return response;
75
- } catch (err: any) {
76
- const duration = performance.now() - spanStartAbs;
77
- Context.addSpan({ spanId, name: `${method} ${hostname}`, type: 'http', startTime, duration, status: 500, meta: { error: err.message, url: urlStr, library: 'fetch' } });
78
- throw err;
79
- }
80
- };
81
- };
82
-
83
- // --- HTTP/HTTPS INSTRUMENTATION ---
84
- export const instrumentHttp = (ingestUrl: string, debug = false) => {
85
- let ingestHost = '';
86
- try { ingestHost = new URL(ingestUrl).hostname; } catch (e) { }
87
-
88
- const requestWrapper = (original: Function) => {
89
- return function (this: any, ...args: any[]) {
90
- let options: any = {};
91
- let urlStr = '';
92
- let optionsIndex = 0;
93
-
94
- if (typeof args[0] === 'string' || args[0] instanceof URL) {
95
- urlStr = args[0].toString();
96
- optionsIndex = 1;
97
- } else {
98
- optionsIndex = 0;
99
- }
100
-
101
- if (!args[optionsIndex] || typeof args[optionsIndex] !== 'object') {
102
- args[optionsIndex] = {};
103
- }
104
- options = args[optionsIndex];
105
-
106
- if (!urlStr) {
107
- const protocol = options.protocol || (options.port === 443 ? 'https:' : 'http:');
108
- const host = options.hostname || options.host || 'localhost';
109
- const path = options.path || '/';
110
- urlStr = `${protocol}//${host}${path}`;
111
- }
112
-
113
- if (ingestHost && (urlStr.includes(ingestHost) || (options.hostname && options.hostname.includes(ingestHost)))) {
114
- return original.apply(this, args);
115
- }
116
-
117
- const trace = Context.current();
118
- if (!trace) return original.apply(this, args);
119
-
120
- const method = (options.method || 'GET').toUpperCase();
121
- const startTime = performance.now() - trace.startTime;
122
- const spanStartAbs = performance.now();
123
- const spanId = generateSpanId();
124
-
125
- let hostname = 'unknown';
126
- try { hostname = new URL(urlStr).hostname; } catch (e) { hostname = options.hostname || 'unknown'; }
127
-
128
- if (!options.headers) options.headers = {};
129
-
130
- // W3C Trace Context Injection
131
- options.headers['traceparent'] = generateTraceparent(trace.id, spanId);
132
-
133
- // Legacy fallback
134
- options.headers['x-senzor-trace-id'] = trace.id;
135
- options.headers['x-senzor-parent-span-id'] = spanId;
136
-
137
- if (debug) console.log(`[Senzor] Injecting W3C traceparent headers to ${urlStr}`);
138
-
139
- const req = original.apply(this, args);
140
-
141
- const captureSpan = (res: any, error?: Error) => {
142
- const duration = performance.now() - spanStartAbs;
143
- Context.addSpan({ spanId, name: `${method} ${hostname}`, type: 'http', startTime, duration, status: error ? 500 : res?.statusCode || 0, meta: { url: urlStr, method, library: 'http' } });
144
- };
145
-
146
- req.on('response', (res: any) => {
147
- res.once('end', () => captureSpan(res));
148
- res.once('close', () => captureSpan(res));
149
- res.once('error', (err: Error) => captureSpan(res, err));
150
- });
151
-
152
- req.on('error', (err: Error) => captureSpan(null, err));
153
-
154
- return req;
155
- };
156
- };
157
-
158
- shimmer(http, 'request', requestWrapper);
159
- shimmer(http, 'get', requestWrapper);
160
- shimmer(https, 'request', requestWrapper);
161
- shimmer(https, 'get', requestWrapper);
162
- };
1
+ import http from 'http';
2
+ import https from 'https';
3
+ import { URL } from 'url';
4
+ import type { SenzorClient } from '../core/client';
5
+ import { Context } from '../core/context';
6
+ import { getRoute, normalizePath } from '../core/normalizer';
7
+ import { sanitizeHeaders } from '../core/sanitizer';
8
+ import { SenzorOptions } from '../core/types';
9
+ import { getClientIp } from '../utils/getClientIp';
10
+ import { SENZOR_INTERNAL_HEADER } from '../utils/internal';
11
+ import { generateTraceparent } from '../utils/traceContext';
12
+ import { patchMethod } from './patch';
13
+ import { runWithCapturedSpan, startCapturedSpan } from './span';
14
+
15
+ const getDebug = (options?: SenzorOptions): boolean =>
16
+ Boolean(options?.debug);
17
+
18
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
19
+ typeof value === 'object' &&
20
+ value !== null &&
21
+ !(value instanceof URL) &&
22
+ !(value instanceof Function) &&
23
+ !Array.isArray(value);
24
+
25
+ const headerValue = (
26
+ headers: unknown,
27
+ key: string
28
+ ): unknown => {
29
+ if (!headers) return undefined;
30
+
31
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
32
+ return headers.get(key);
33
+ }
34
+
35
+ if (Array.isArray(headers)) {
36
+ const found = headers.find(
37
+ ([name]) => String(name).toLowerCase() === key.toLowerCase()
38
+ );
39
+ return found?.[1];
40
+ }
41
+
42
+ if (typeof headers === 'object') {
43
+ const normalizedKey = key.toLowerCase();
44
+ for (const [name, value] of Object.entries(headers)) {
45
+ if (name.toLowerCase() === normalizedKey) return value;
46
+ }
47
+ }
48
+
49
+ return undefined;
50
+ };
51
+
52
+ const hasInternalHeader = (headers: unknown): boolean =>
53
+ String(headerValue(headers, SENZOR_INTERNAL_HEADER) || '').toLowerCase() ===
54
+ 'true';
55
+
56
+ const shouldIgnoreUrl = (
57
+ urlString: string,
58
+ ingestUrl: string,
59
+ headers?: unknown
60
+ ): boolean => {
61
+ if (hasInternalHeader(headers)) return true;
62
+ if (!urlString) return false;
63
+
64
+ try {
65
+ const url = new URL(urlString);
66
+ const ingest = new URL(ingestUrl);
67
+ return (
68
+ url.hostname === ingest.hostname &&
69
+ url.pathname.startsWith('/api/ingest')
70
+ );
71
+ } catch {
72
+ return ingestUrl ? urlString.includes(ingestUrl) : false;
73
+ }
74
+ };
75
+
76
+ const cloneHeaders = (headers: unknown): Record<string, unknown> => {
77
+ if (!headers) return {};
78
+
79
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
80
+ const cloned: Record<string, unknown> = {};
81
+ headers.forEach((value, key) => {
82
+ cloned[key] = value;
83
+ });
84
+ return cloned;
85
+ }
86
+
87
+ if (Array.isArray(headers)) {
88
+ return headers.reduce<Record<string, unknown>>((acc, [key, value]) => {
89
+ acc[key] = value;
90
+ return acc;
91
+ }, {});
92
+ }
93
+
94
+ if (typeof headers === 'object') {
95
+ return { ...(headers as Record<string, unknown>) };
96
+ }
97
+
98
+ return {};
99
+ };
100
+
101
+ const setHeader = (
102
+ headers: Record<string, unknown>,
103
+ key: string,
104
+ value: string
105
+ ) => {
106
+ const existingKey = Object.keys(headers).find(
107
+ (header) => header.toLowerCase() === key.toLowerCase()
108
+ );
109
+ headers[existingKey || key] = value;
110
+ };
111
+
112
+ interface PreparedRequest {
113
+ args: any[];
114
+ options: Record<string, any>;
115
+ url: string;
116
+ method: string;
117
+ hostname: string;
118
+ path: string;
119
+ }
120
+
121
+ const prepareRequestArgs = (
122
+ args: any[],
123
+ defaultProtocol: 'http:' | 'https:'
124
+ ): PreparedRequest => {
125
+ const nextArgs = [...args];
126
+ let optionsIndex = 0;
127
+ let options: Record<string, any> = {};
128
+ let urlFromArg: URL | null = null;
129
+
130
+ if (typeof nextArgs[0] === 'string' || nextArgs[0] instanceof URL) {
131
+ try {
132
+ urlFromArg = new URL(nextArgs[0].toString());
133
+ } catch {
134
+ urlFromArg = null;
135
+ }
136
+
137
+ if (isPlainObject(nextArgs[1])) {
138
+ optionsIndex = 1;
139
+ options = {
140
+ ...nextArgs[1],
141
+ headers: cloneHeaders(nextArgs[1].headers)
142
+ };
143
+ nextArgs[1] = options;
144
+ } else {
145
+ optionsIndex = 1;
146
+ options = { headers: {} };
147
+ nextArgs.splice(1, 0, options);
148
+ }
149
+ } else if (isPlainObject(nextArgs[0])) {
150
+ optionsIndex = 0;
151
+ options = {
152
+ ...nextArgs[0],
153
+ headers: cloneHeaders(nextArgs[0].headers)
154
+ };
155
+ nextArgs[0] = options;
156
+ } else {
157
+ optionsIndex = 0;
158
+ options = { headers: {} };
159
+ nextArgs[0] = options;
160
+ }
161
+
162
+ if (!options.headers) options.headers = {};
163
+ nextArgs[optionsIndex] = options;
164
+
165
+ const protocol =
166
+ options.protocol ||
167
+ urlFromArg?.protocol ||
168
+ (options.port === 443 ? 'https:' : defaultProtocol);
169
+ const hostname =
170
+ options.hostname ||
171
+ options.host ||
172
+ urlFromArg?.hostname ||
173
+ 'localhost';
174
+ const path =
175
+ options.path ||
176
+ `${urlFromArg?.pathname || '/'}${urlFromArg?.search || ''}`;
177
+ const url = urlFromArg
178
+ ? urlFromArg.toString()
179
+ : `${protocol}//${hostname}${path}`;
180
+ const method = String(options.method || 'GET').toUpperCase();
181
+
182
+ return {
183
+ args: nextArgs,
184
+ options,
185
+ url,
186
+ method,
187
+ hostname: String(hostname).replace(/:\d+$/, ''),
188
+ path
189
+ };
190
+ };
191
+
192
+ const resolveIncomingRoute = (
193
+ req: any,
194
+ res: any,
195
+ path: string
196
+ ): string => {
197
+ if (res?.statusCode === 404) return 'Not Found';
198
+
199
+ try {
200
+ return getRoute(req, path);
201
+ } catch {
202
+ return normalizePath(path);
203
+ }
204
+ };
205
+
206
+ const patchIncomingServer = (
207
+ proto: any,
208
+ protocol: 'http' | 'https',
209
+ client: SenzorClient,
210
+ options?: SenzorOptions
211
+ ) => {
212
+ patchMethod(
213
+ proto,
214
+ 'emit',
215
+ `senzor.${protocol}.server`,
216
+ (original) =>
217
+ function patchedEmit(this: any, event: string, ...args: any[]) {
218
+ if (event !== 'request') {
219
+ return original.call(this, event, ...args);
220
+ }
221
+
222
+ const req = args[0];
223
+ const res = args[1];
224
+
225
+ if (!req || !res || Context.current()?.contextType === 'apm') {
226
+ return original.call(this, event, ...args);
227
+ }
228
+
229
+ const rawPath = req.originalUrl || req.url || '/';
230
+ const path = String(rawPath).split('?')[0] || '/';
231
+ const headers = req.headers || {};
232
+
233
+ if (hasInternalHeader(headers)) {
234
+ return original.call(this, event, ...args);
235
+ }
236
+
237
+ return client.startTrace(
238
+ {
239
+ method: req.method || 'GET',
240
+ path: rawPath,
241
+ route: normalizePath(path),
242
+ ip: getClientIp(req),
243
+ userAgent: headers['user-agent'],
244
+ headers,
245
+ meta: {
246
+ protocol,
247
+ httpVersion: req.httpVersion,
248
+ headers: options?.captureHeaders
249
+ ? sanitizeHeaders(headers, options)
250
+ : undefined
251
+ }
252
+ },
253
+ () => {
254
+ const trace = Context.current();
255
+ let finalized = false;
256
+
257
+ const finalize = (reason: 'finish' | 'close' | 'error') => {
258
+ if (finalized || !trace) return;
259
+ finalized = true;
260
+
261
+ setImmediate(() => {
262
+ if (trace.ended) return;
263
+
264
+ Context.run(trace, () => {
265
+ client.endTrace(res.statusCode || 0, {
266
+ route: resolveIncomingRoute(req, res, path),
267
+ statusMessage: res.statusMessage,
268
+ meta: {
269
+ ...trace.data.meta,
270
+ endReason: reason
271
+ }
272
+ });
273
+ });
274
+ });
275
+ };
276
+
277
+ res.once('finish', () => finalize('finish'));
278
+ res.once('close', () => finalize('close'));
279
+ res.once('error', (error: Error) => {
280
+ client.captureError(error, {
281
+ instrumentation: `${protocol}.server`
282
+ });
283
+ finalize('error');
284
+ });
285
+
286
+ try {
287
+ return original.call(this, event, ...args);
288
+ } catch (error) {
289
+ client.captureError(error, {
290
+ instrumentation: `${protocol}.server`
291
+ });
292
+ finalize('error');
293
+ throw error;
294
+ }
295
+ }
296
+ );
297
+ }
298
+ );
299
+ };
300
+
301
+ const patchOutgoing = (
302
+ moduleRef: typeof http | typeof https,
303
+ protocol: 'http:' | 'https:',
304
+ ingestUrl: string,
305
+ options?: SenzorOptions
306
+ ) => {
307
+ const patchKeyPrefix = protocol === 'https:' ? 'senzor.https' : 'senzor.http';
308
+
309
+ const requestWrapper = (original: Function) =>
310
+ function patchedRequest(this: any, ...args: any[]) {
311
+ const prepared = prepareRequestArgs(args, protocol);
312
+
313
+ if (
314
+ shouldIgnoreUrl(
315
+ prepared.url,
316
+ ingestUrl,
317
+ prepared.options.headers
318
+ )
319
+ ) {
320
+ return original.apply(this, args);
321
+ }
322
+
323
+ const trace = Context.current();
324
+ if (!trace) return original.apply(this, args);
325
+
326
+ const span = startCapturedSpan(
327
+ `${prepared.method} ${prepared.hostname}`,
328
+ 'http',
329
+ {
330
+ url: prepared.url,
331
+ method: prepared.method,
332
+ library: protocol === 'https:' ? 'https' : 'http',
333
+ 'http.request.method': prepared.method,
334
+ 'url.full': prepared.url,
335
+ 'url.path': prepared.path,
336
+ 'server.address': prepared.hostname
337
+ },
338
+ options
339
+ );
340
+
341
+ if (span) {
342
+ setHeader(
343
+ prepared.options.headers,
344
+ 'traceparent',
345
+ generateTraceparent(trace.id, span.spanId)
346
+ );
347
+ setHeader(prepared.options.headers, 'x-senzor-trace-id', trace.id);
348
+ setHeader(
349
+ prepared.options.headers,
350
+ 'x-senzor-parent-span-id',
351
+ span.spanId
352
+ );
353
+ }
354
+
355
+ const invoke = () => {
356
+ const req = original.apply(this, prepared.args);
357
+ if (!span || !req || typeof req.once !== 'function') {
358
+ return req;
359
+ }
360
+
361
+ let completed = false;
362
+ const endSpan = (
363
+ status: number,
364
+ extraMeta: Record<string, unknown> = {}
365
+ ) => {
366
+ if (completed) return;
367
+ completed = true;
368
+ span.end(status, extraMeta);
369
+ };
370
+
371
+ req.once('response', (res: any) => {
372
+ const statusCode = res?.statusCode || 0;
373
+ const finish = () =>
374
+ endSpan(statusCode, {
375
+ 'http.response.status_code': statusCode
376
+ });
377
+
378
+ res.once('end', finish);
379
+ res.once('close', finish);
380
+ res.once('error', (error: Error) =>
381
+ endSpan(500, {
382
+ error: error.message,
383
+ 'error.type': error.name
384
+ })
385
+ );
386
+ });
387
+
388
+ req.once('timeout', () =>
389
+ endSpan(504, {
390
+ error: 'Request timed out',
391
+ 'error.type': 'TimeoutError'
392
+ })
393
+ );
394
+ req.once('error', (error: Error) =>
395
+ endSpan(500, {
396
+ error: error.message,
397
+ 'error.type': error.name
398
+ })
399
+ );
400
+
401
+ return req;
402
+ };
403
+
404
+ if (getDebug(options)) {
405
+ console.log(`[Senzor] Injecting trace headers to ${prepared.url}`);
406
+ }
407
+
408
+ return runWithCapturedSpan(span, invoke);
409
+ };
410
+
411
+ patchMethod(
412
+ moduleRef,
413
+ 'request',
414
+ `${patchKeyPrefix}.request`,
415
+ requestWrapper
416
+ );
417
+ patchMethod(
418
+ moduleRef,
419
+ 'get',
420
+ `${patchKeyPrefix}.get`,
421
+ requestWrapper
422
+ );
423
+ };
424
+
425
+ export const instrumentFetch = (
426
+ ingestUrl: string,
427
+ options?: SenzorOptions
428
+ ) => {
429
+ if (!globalThis.fetch) return;
430
+
431
+ patchMethod(
432
+ globalThis,
433
+ 'fetch',
434
+ 'senzor.fetch',
435
+ (original) =>
436
+ async function patchedFetch(
437
+ this: any,
438
+ input: any,
439
+ init?: any
440
+ ): Promise<Response> {
441
+ const urlString =
442
+ typeof input === 'string'
443
+ ? input
444
+ : input instanceof URL
445
+ ? input.toString()
446
+ : input?.url || '';
447
+
448
+ const originalHeaders = init?.headers || input?.headers;
449
+ if (shouldIgnoreUrl(urlString, ingestUrl, originalHeaders)) {
450
+ return original.call(this, input, init);
451
+ }
452
+
453
+ const trace = Context.current();
454
+ if (!trace) return original.call(this, input, init);
455
+
456
+ let hostname = 'unknown';
457
+ let path = '/';
458
+ try {
459
+ const url = new URL(urlString);
460
+ hostname = url.hostname;
461
+ path = `${url.pathname}${url.search}`;
462
+ } catch { }
463
+
464
+ const method = String(
465
+ init?.method || input?.method || 'GET'
466
+ ).toUpperCase();
467
+ const span = startCapturedSpan(
468
+ `${method} ${hostname}`,
469
+ 'http',
470
+ {
471
+ url: urlString,
472
+ method,
473
+ library: 'fetch',
474
+ 'http.request.method': method,
475
+ 'url.full': urlString,
476
+ 'url.path': path,
477
+ 'server.address': hostname
478
+ },
479
+ options
480
+ );
481
+
482
+ if (!span) return original.call(this, input, init);
483
+
484
+ const nextInit = { ...(init || {}) };
485
+ const headers =
486
+ typeof Headers !== 'undefined'
487
+ ? new Headers(originalHeaders || undefined)
488
+ : cloneHeaders(originalHeaders);
489
+
490
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
491
+ headers.set('traceparent', generateTraceparent(trace.id, span.spanId));
492
+ headers.set('x-senzor-trace-id', trace.id);
493
+ headers.set('x-senzor-parent-span-id', span.spanId);
494
+ } else {
495
+ setHeader(headers as Record<string, unknown>, 'traceparent', generateTraceparent(trace.id, span.spanId));
496
+ setHeader(headers as Record<string, unknown>, 'x-senzor-trace-id', trace.id);
497
+ setHeader(headers as Record<string, unknown>, 'x-senzor-parent-span-id', span.spanId);
498
+ }
499
+ nextInit.headers = headers;
500
+
501
+ return runWithCapturedSpan(span, async () => {
502
+ try {
503
+ const response = await original.call(this, input, nextInit);
504
+ span.end(response.status, {
505
+ 'http.response.status_code': response.status
506
+ });
507
+ return response;
508
+ } catch (error: any) {
509
+ span.end(500, {
510
+ error: error?.message,
511
+ 'error.type': error?.name || 'Error'
512
+ });
513
+ throw error;
514
+ }
515
+ });
516
+ }
517
+ );
518
+ };
519
+
520
+ export const instrumentHttp = (
521
+ client: SenzorClient,
522
+ ingestUrl: string,
523
+ options?: SenzorOptions
524
+ ) => {
525
+ patchIncomingServer(http.Server?.prototype, 'http', client, options);
526
+ patchIncomingServer(https.Server?.prototype, 'https', client, options);
527
+
528
+ patchOutgoing(http, 'http:', ingestUrl, options);
529
+ patchOutgoing(https, 'https:', ingestUrl, options);
530
+ };