@senzops/apm-node 1.1.3 → 1.1.5

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,5 +1,5 @@
1
1
  import { AsyncLocalStorage } from 'async_hooks';
2
- import { ActiveTrace } from './types';
2
+ import { ActiveTrace, TraceError } from './types';
3
3
 
4
4
  export const storage = new AsyncLocalStorage<ActiveTrace>();
5
5
 
@@ -16,11 +16,18 @@ export const Context = {
16
16
  const store = storage.getStore();
17
17
  if (store) {
18
18
  store.spans.push(span);
19
- } else {
20
- // If we are here, something tried to add a span but lost context
21
- // This is common if users await inside a callback that wasn't bound
22
- // However, usually silent failure is preferred in production APM
23
- console.warn('[Senzor] Lost context for span:', span.name);
19
+ }
20
+ },
21
+
22
+ // Attach error to current trace
23
+ setError: (error: Error) => {
24
+ const store = storage.getStore();
25
+ if (store) {
26
+ store.error = {
27
+ name: error.name,
28
+ message: error.message,
29
+ stack: error.stack
30
+ };
24
31
  }
25
32
  }
26
33
  };
package/src/core/types.ts CHANGED
@@ -2,36 +2,49 @@ export interface SenzorOptions {
2
2
  apiKey: string;
3
3
  endpoint?: string;
4
4
  batchSize?: number;
5
- flushInterval?: number; // ms
5
+ flushInterval?: number;
6
6
  debug?: boolean;
7
7
  }
8
8
 
9
9
  export interface Span {
10
+ spanId: string; // NEW: Unique ID for this span
10
11
  name: string;
11
12
  type: 'db' | 'http' | 'function' | 'custom';
12
- startTime: number; // Relative to trace start
13
+ startTime: number;
13
14
  duration: number;
14
15
  status?: number;
15
16
  meta?: Record<string, any>;
16
17
  }
17
18
 
19
+ export interface TraceError {
20
+ name: string;
21
+ message: string;
22
+ stack?: string;
23
+ }
24
+
18
25
  export interface Trace {
19
26
  traceId: string;
27
+
28
+ // Distributed Tracing Fields
29
+ parentTraceId?: string; // NEW
30
+ parentSpanId?: string; // NEW
31
+
20
32
  method: string;
21
- route: string; // Normalized
22
- path: string; // Raw
33
+ route: string;
34
+ path: string;
23
35
  status: number;
24
36
  duration: number;
25
37
  ip?: string;
26
38
  userAgent?: string;
27
39
  timestamp: string;
28
40
  spans: Span[];
41
+ error?: TraceError;
29
42
  }
30
43
 
31
- // Internal interface for an active trace object
32
44
  export interface ActiveTrace {
33
45
  id: string;
34
46
  startTime: number;
35
47
  data: Partial<Trace>;
36
48
  spans: Span[];
49
+ error?: TraceError;
37
50
  }
package/src/index.ts CHANGED
@@ -1,23 +1,26 @@
1
1
  import { client } from './core/client';
2
- import { expressMiddleware } from './middleware/express';
2
+ import { expressMiddleware, expressErrorHandler } from './middleware/express';
3
3
  import { wrapH3 } from './wrappers/h3';
4
4
  import { wrapNextRoute, wrapNextPages } from './wrappers/next';
5
5
  import { senzorPlugin } from './wrappers/fastify';
6
6
  import { SenzorOptions } from './core/types';
7
7
 
8
8
  const Senzor = {
9
- // Core
10
9
  init: (options: SenzorOptions) => client.init(options),
11
10
  flush: () => client.flush(),
11
+ track: client.track.bind(client),
12
+ startSpan: client.startSpan.bind(client),
13
+ captureException: client.captureError.bind(client),
12
14
 
13
- // Express / Connect
15
+ // Express
14
16
  requestHandler: expressMiddleware,
17
+ errorHandler: expressErrorHandler,
15
18
 
16
- // Next.js
17
- wrapNextRoute, // For App Router (Route Handlers)
18
- wrapNextPages, // For Pages Router (API Routes)
19
+ // Next
20
+ wrapNextRoute,
21
+ wrapNextPages,
19
22
 
20
- // H3 / Nuxt / Nitro
23
+ // H3
21
24
  wrapH3,
22
25
 
23
26
  // Fastify
@@ -2,6 +2,7 @@ import http from 'http';
2
2
  import https from 'https';
3
3
  import { URL } from 'url';
4
4
  import { Context } from '../core/context';
5
+ import { randomUUID } from 'crypto';
5
6
 
6
7
  const shimmer = (module: any, methodName: string, wrapper: (original: Function) => Function) => {
7
8
  if (!module[methodName]) return;
@@ -9,7 +10,7 @@ const shimmer = (module: any, methodName: string, wrapper: (original: Function)
9
10
  module[methodName] = wrapper(original);
10
11
  };
11
12
 
12
- // --- Native Fetch Instrumentation (Node 18+) ---
13
+ // --- FETCH INSTRUMENTATION (Node 18+ / Edge) ---
13
14
  export const instrumentFetch = (ingestUrl: string, debug = false) => {
14
15
  if (!globalThis.fetch) return;
15
16
 
@@ -24,7 +25,7 @@ export const instrumentFetch = (ingestUrl: string, debug = false) => {
24
25
  let urlStr = '';
25
26
  if (typeof input === 'string') urlStr = input;
26
27
  else if (input instanceof URL) urlStr = input.toString();
27
- else if (input && input.url) urlStr = input.url; // Request object
28
+ else if (input && (input as any).url) urlStr = (input as any).url;
28
29
 
29
30
  // 2. Infinite Loop Guard
30
31
  if (ingestHost && urlStr.includes(ingestHost)) {
@@ -37,21 +38,45 @@ export const instrumentFetch = (ingestUrl: string, debug = false) => {
37
38
  return originalFetch(input, init);
38
39
  }
39
40
 
40
- // 4. Start Span
41
+ // 4. Prepare Metadata & ID
41
42
  const method = (init?.method || 'GET').toUpperCase();
42
43
  const startTime = performance.now() - trace.startTime;
43
44
  const spanStartAbs = performance.now();
45
+ const spanId = randomUUID(); // New ID for this specific outbound call
46
+
44
47
  let hostname = 'unknown';
45
48
  try { hostname = new URL(urlStr).hostname; } catch (e) { }
46
49
 
47
- if (debug) console.log(`[Senzor] Tracking Fetch: ${method} ${hostname}`);
50
+ if (debug) console.log(`[Senzor] Fetch: ${method} ${hostname}`);
51
+
52
+ // 5. Inject Distributed Tracing Headers
53
+ // We need to clone init or create it to avoid mutating original ref unexpectedly
54
+ const newInit = { ...init };
55
+ if (!newInit.headers) {
56
+ newInit.headers = {};
57
+ }
58
+
59
+ // Handle different Header formats (Headers object vs plain object)
60
+ if (newInit.headers instanceof Headers) {
61
+ newInit.headers.set('x-senzor-trace-id', trace.id);
62
+ newInit.headers.set('x-senzor-parent-span-id', spanId);
63
+ } else if (Array.isArray(newInit.headers)) {
64
+ newInit.headers.push(['x-senzor-trace-id', trace.id]);
65
+ newInit.headers.push(['x-senzor-parent-span-id', spanId]);
66
+ } else {
67
+ // Plain object
68
+ (newInit.headers as any)['x-senzor-trace-id'] = trace.id;
69
+ (newInit.headers as any)['x-senzor-parent-span-id'] = spanId;
70
+ }
48
71
 
49
72
  try {
50
- const response = await originalFetch(input, init);
73
+ // 6. Execute Fetch
74
+ const response = await originalFetch(input, newInit);
51
75
 
52
- // 5. End Span
76
+ // 7. Record Span
53
77
  const duration = performance.now() - spanStartAbs;
54
78
  Context.addSpan({
79
+ spanId,
55
80
  name: `${method} ${hostname}`,
56
81
  type: 'http',
57
82
  startTime,
@@ -64,6 +89,7 @@ export const instrumentFetch = (ingestUrl: string, debug = false) => {
64
89
  } catch (err: any) {
65
90
  const duration = performance.now() - spanStartAbs;
66
91
  Context.addSpan({
92
+ spanId,
67
93
  name: `${method} ${hostname}`,
68
94
  type: 'http',
69
95
  startTime,
@@ -76,12 +102,10 @@ export const instrumentFetch = (ingestUrl: string, debug = false) => {
76
102
  };
77
103
  };
78
104
 
79
- // --- Standard HTTP/HTTPS Instrumentation ---
105
+ // --- HTTP/HTTPS INSTRUMENTATION ---
80
106
  export const instrumentHttp = (ingestUrl: string, debug = false) => {
81
107
  let ingestHost = '';
82
- try {
83
- ingestHost = new URL(ingestUrl).hostname;
84
- } catch (e) { }
108
+ try { ingestHost = new URL(ingestUrl).hostname; } catch (e) { }
85
109
 
86
110
  const requestWrapper = (original: Function) => {
87
111
  return function (this: any, ...args: any[]) {
@@ -109,14 +133,22 @@ export const instrumentHttp = (ingestUrl: string, debug = false) => {
109
133
  const method = (options.method || 'GET').toUpperCase();
110
134
  const startTime = performance.now() - trace.startTime;
111
135
  const spanStartAbs = performance.now();
136
+ const spanId = randomUUID(); // Generate ID
137
+
112
138
  let hostname = 'unknown';
113
139
  try { hostname = new URL(urlStr).hostname; } catch (e) { hostname = options.hostname || 'unknown'; }
114
140
 
141
+ // Inject Headers
142
+ if (!options.headers) options.headers = {};
143
+ options.headers['x-senzor-trace-id'] = trace.id;
144
+ options.headers['x-senzor-parent-span-id'] = spanId;
145
+
115
146
  const req = original.apply(this, args);
116
147
 
117
148
  const captureSpan = (res: any, error?: Error) => {
118
149
  const duration = performance.now() - spanStartAbs;
119
150
  Context.addSpan({
151
+ spanId,
120
152
  name: `${method} ${hostname}`,
121
153
  type: 'http',
122
154
  startTime,
@@ -128,6 +160,7 @@ export const instrumentHttp = (ingestUrl: string, debug = false) => {
128
160
 
129
161
  req.on('response', (res: any) => {
130
162
  res.once('end', () => captureSpan(res));
163
+ res.once('close', () => captureSpan(res)); // Safety if stream not consumed
131
164
  res.once('error', (err: Error) => captureSpan(res, err));
132
165
  });
133
166
 
@@ -1,18 +1,21 @@
1
1
  import { client } from '../core/client';
2
2
 
3
+ // 1. Request Handler (Place before routes)
3
4
  export const expressMiddleware = () => {
4
5
  return (req: any, res: any, next: () => void) => {
5
- // We MUST use startTrace to enable Auto-Instrumentation for this request
6
6
  client.startTrace({
7
7
  method: req.method,
8
8
  path: req.originalUrl || req.url,
9
9
  ip: req.ip || req.socket?.remoteAddress,
10
10
  userAgent: req.headers['user-agent'],
11
+ headers: req.headers
11
12
  }, () => {
12
13
 
14
+ // Auto-detect status code on finish
13
15
  res.once('finish', () => {
14
16
  try {
15
17
  let route = 'UNKNOWN';
18
+ // Express populates req.route only if a route matched
16
19
  if (req.route && req.route.path) {
17
20
  route = (req.baseUrl || '') + req.route.path;
18
21
  } else if (res.statusCode === 404) {
@@ -22,12 +25,23 @@ export const expressMiddleware = () => {
22
25
  }
23
26
 
24
27
  client.endTrace(res.statusCode, { route });
25
- } catch (e) {
26
- // Fail open
27
- }
28
+ } catch (e) { /* Fail open */ }
28
29
  });
29
30
 
30
31
  next();
31
32
  });
32
33
  };
34
+ };
35
+
36
+ // 2. Error Handler (Place after routes)
37
+ // This is required in Express to capture the actual Error Object (Stack Trace)
38
+ export const expressErrorHandler = () => {
39
+ return (err: any, req: any, res: any, next: (err?: any) => void) => {
40
+
41
+ // 1. Capture the exception context
42
+ client.captureError(err);
43
+
44
+ // 2. Pass it to the next error handler (don't swallow it)
45
+ next(err);
46
+ };
33
47
  };
@@ -1,37 +1,29 @@
1
1
  import { client } from '../core/client';
2
2
  import { SenzorOptions } from '../core/types';
3
3
 
4
- // We don't import Fastify types to keep zero-deps, but structure matches
5
4
  export const senzorPlugin = (fastify: any, options: SenzorOptions, done: Function) => {
6
-
7
- // Init if options provided inline, otherwise assume global init
8
5
  if (options && options.apiKey) {
9
6
  client.init(options);
10
7
  }
11
8
 
12
- // Hook: On Request (Start Timer)
13
9
  fastify.addHook('onRequest', (request: any, reply: any, next: Function) => {
14
- request.senzorStart = performance.now();
15
- next();
16
- });
17
-
18
- // Hook: On Response (End Timer & Track)
19
- fastify.addHook('onResponse', (request: any, reply: any, next: Function) => {
20
- const duration = performance.now() - (request.senzorStart || performance.now());
21
-
22
- // Fastify provides 'routerPath' (e.g. /user/:id)
23
- const route = request.routeOptions?.url || request.routerPath;
24
-
25
- client.track({
10
+ client.startTrace({
26
11
  method: request.method,
27
- route: route || 'UNKNOWN',
28
12
  path: request.raw.url || request.url,
29
- status: reply.statusCode,
30
- duration: duration,
31
13
  ip: request.ip,
32
- userAgent: request.headers['user-agent']
33
- });
14
+ userAgent: request.headers['user-agent'],
15
+ headers: request.headers // Pass headers
16
+ }, () => next());
17
+ });
18
+
19
+ fastify.addHook('onError', (request: any, reply: any, error: any, next: Function) => {
20
+ client.captureError(error);
21
+ next();
22
+ });
34
23
 
24
+ fastify.addHook('onResponse', (request: any, reply: any, next: Function) => {
25
+ const route = request.routeOptions?.url || request.routerPath || 'UNKNOWN';
26
+ client.endTrace(reply.statusCode, { route });
35
27
  next();
36
28
  });
37
29
 
@@ -1,7 +1,6 @@
1
1
  import { client } from '../core/client';
2
2
  import { getRoute } from '../core/normalizer';
3
3
 
4
- // Minimal types for H3 to avoid peer-deps
5
4
  type EventHandler = (event: any) => any;
6
5
 
7
6
  export const wrapH3 = (handler: EventHandler) => {
@@ -9,25 +8,23 @@ export const wrapH3 = (handler: EventHandler) => {
9
8
  const req = event.node.req;
10
9
  const path = req.originalUrl || req.url || '/';
11
10
 
12
- // Start Trace Context
13
11
  return client.startTrace({
14
12
  method: req.method || 'GET',
15
13
  path: path,
16
14
  ip: req.headers['x-forwarded-for'] || req.socket?.remoteAddress,
17
15
  userAgent: req.headers['user-agent'],
16
+ headers: req.headers // Pass headers
18
17
  }, async () => {
19
18
  try {
20
19
  const response = await handler(event);
21
-
22
- // H3/Nitro response status
23
20
  let status = 200;
24
21
  if (event.node.res.statusCode) status = event.node.res.statusCode;
25
- // Check if response is an error object
26
22
  if (response && response.statusCode) status = response.statusCode;
27
23
 
28
24
  client.endTrace(status, { route: getRoute(event, path) });
29
25
  return response;
30
26
  } catch (err: any) {
27
+ client.captureError(err);
31
28
  const status = err.statusCode || err.status || 500;
32
29
  client.endTrace(status, { route: getRoute(event, path) });
33
30
  throw err;
@@ -1,29 +1,50 @@
1
1
  import { client } from '../core/client';
2
2
  import { normalizePath } from '../core/normalizer';
3
3
 
4
- // --- App Router Wrapper (Route Handlers) ---
4
+ // --- App Router Wrapper ---
5
5
  export const wrapNextRoute = (handler: Function) => {
6
6
  return async (req: Request | any, context?: any) => {
7
- // 1. Extract Info
7
+
8
+ // Extract info from Web Standard Request
8
9
  const url = req.url ? new URL(req.url) : { pathname: '/' };
9
10
  const method = req.method || 'GET';
10
- const ua = req.headers.get ? req.headers.get('user-agent') : undefined;
11
- const ip = req.headers.get ? req.headers.get('x-forwarded-for') : undefined;
12
11
 
13
- // 2. Run in Context
12
+ // Header Extraction
13
+ let headers: Record<string, string> = {};
14
+ let ua: string | undefined;
15
+ let ip: string | undefined;
16
+
17
+ if (typeof req.headers.get === 'function') {
18
+ // It's a Web Request Object
19
+ ua = req.headers.get('user-agent');
20
+ ip = req.headers.get('x-forwarded-for');
21
+
22
+ // Convert to plain object for trace context extraction
23
+ req.headers.forEach((value: string, key: string) => {
24
+ headers[key] = value;
25
+ });
26
+ } else {
27
+ // It's a Node Request Object (rare in App router but possible)
28
+ headers = req.headers;
29
+ ua = headers['user-agent'];
30
+ ip = headers['x-forwarded-for'] as string;
31
+ }
32
+
14
33
  return client.startTrace({
15
34
  method,
16
35
  path: url.pathname,
17
36
  userAgent: ua,
18
- ip: ip
37
+ ip: ip,
38
+ headers: headers // Pass extracted headers
19
39
  }, async () => {
20
40
  try {
21
41
  const response = await handler(req, context);
22
42
  const status = response?.status || 200;
23
-
43
+
24
44
  client.endTrace(status, { route: normalizePath(url.pathname) });
25
45
  return response;
26
46
  } catch (err: any) {
47
+ client.captureError(err);
27
48
  client.endTrace(500, { route: normalizePath(url.pathname) });
28
49
  throw err;
29
50
  }
@@ -31,33 +52,30 @@ export const wrapNextRoute = (handler: Function) => {
31
52
  };
32
53
  };
33
54
 
34
- // --- Pages Router Wrapper (API Routes) ---
55
+ // --- Pages Router Wrapper ---
35
56
  export const wrapNextPages = (handler: Function) => {
36
57
  return async (req: any, res: any) => {
37
58
  const path = req.url ? req.url.split('?')[0] : '/';
38
-
39
- // 1. Run in Context
59
+
40
60
  return client.startTrace({
41
61
  method: req.method || 'GET',
42
62
  path: path,
43
63
  userAgent: req.headers['user-agent'],
44
64
  ip: req.headers['x-forwarded-for'] || req.socket?.remoteAddress,
65
+ headers: req.headers // Standard Node headers work fine
45
66
  }, async () => {
46
-
47
- // 2. Hook Response
67
+
48
68
  const done = () => {
49
69
  client.endTrace(res.statusCode || 200, { route: normalizePath(path) });
50
70
  };
51
-
71
+
52
72
  res.once('finish', done);
53
- res.once('close', done); // Fallback if finish doesn't fire
73
+ res.once('close', done);
54
74
 
55
- // 3. Execute
56
75
  try {
57
76
  return await handler(req, res);
58
- } catch (e) {
59
- // Next.js Pages router usually handles errors internally,
60
- // but we ensure we catch sync errors here
77
+ } catch (e: any) {
78
+ client.captureError(e);
61
79
  throw e;
62
80
  }
63
81
  });