@senzops/apm-node 1.1.2 → 1.1.4

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/src/core/types.ts CHANGED
@@ -2,36 +2,43 @@ 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
10
  name: string;
11
11
  type: 'db' | 'http' | 'function' | 'custom';
12
- startTime: number; // Relative to trace start
12
+ startTime: number;
13
13
  duration: number;
14
14
  status?: number;
15
15
  meta?: Record<string, any>;
16
16
  }
17
17
 
18
+ export interface TraceError {
19
+ name: string;
20
+ message: string;
21
+ stack?: string;
22
+ }
23
+
18
24
  export interface Trace {
19
25
  traceId: string;
20
26
  method: string;
21
- route: string; // Normalized
22
- path: string; // Raw
27
+ route: string;
28
+ path: string;
23
29
  status: number;
24
30
  duration: number;
25
31
  ip?: string;
26
32
  userAgent?: string;
27
33
  timestamp: string;
28
34
  spans: Span[];
35
+ error?: TraceError;
29
36
  }
30
37
 
31
- // Internal interface for an active trace object
32
38
  export interface ActiveTrace {
33
39
  id: string;
34
40
  startTime: number;
35
41
  data: Partial<Trace>;
36
42
  spans: Span[];
43
+ error?: TraceError;
37
44
  }
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
@@ -9,15 +9,79 @@ const shimmer = (module: any, methodName: string, wrapper: (original: Function)
9
9
  module[methodName] = wrapper(original);
10
10
  };
11
11
 
12
+ // --- Native Fetch Instrumentation (Node 18+) ---
13
+ export const instrumentFetch = (ingestUrl: string, debug = false) => {
14
+ if (!globalThis.fetch) return;
15
+
16
+ let ingestHost = '';
17
+ try { ingestHost = new URL(ingestUrl).hostname; } catch (e) { }
18
+
19
+ const originalFetch = globalThis.fetch;
20
+
21
+ // @ts-ignore
22
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
23
+ // 1. Extract URL
24
+ let urlStr = '';
25
+ if (typeof input === 'string') urlStr = input;
26
+ else if (input instanceof URL) urlStr = input.toString();
27
+ else if (input && input.url) urlStr = input.url; // Request object
28
+
29
+ // 2. Infinite Loop Guard
30
+ if (ingestHost && urlStr.includes(ingestHost)) {
31
+ return originalFetch(input, init);
32
+ }
33
+
34
+ // 3. Context Check
35
+ const trace = Context.current();
36
+ if (!trace) {
37
+ return originalFetch(input, init);
38
+ }
39
+
40
+ // 4. Start Span
41
+ const method = (init?.method || 'GET').toUpperCase();
42
+ const startTime = performance.now() - trace.startTime;
43
+ const spanStartAbs = performance.now();
44
+ let hostname = 'unknown';
45
+ try { hostname = new URL(urlStr).hostname; } catch (e) { }
46
+
47
+ if (debug) console.log(`[Senzor] Tracking Fetch: ${method} ${hostname}`);
48
+
49
+ try {
50
+ const response = await originalFetch(input, init);
51
+
52
+ // 5. End Span
53
+ const duration = performance.now() - spanStartAbs;
54
+ Context.addSpan({
55
+ name: `${method} ${hostname}`,
56
+ type: 'http',
57
+ startTime,
58
+ duration,
59
+ status: response.status,
60
+ meta: { url: urlStr, method, library: 'fetch' }
61
+ });
62
+
63
+ return response;
64
+ } catch (err: any) {
65
+ const duration = performance.now() - spanStartAbs;
66
+ Context.addSpan({
67
+ name: `${method} ${hostname}`,
68
+ type: 'http',
69
+ startTime,
70
+ duration,
71
+ status: 500,
72
+ meta: { error: err.message, url: urlStr, library: 'fetch' }
73
+ });
74
+ throw err;
75
+ }
76
+ };
77
+ };
78
+
79
+ // --- Standard HTTP/HTTPS Instrumentation ---
12
80
  export const instrumentHttp = (ingestUrl: string, debug = false) => {
13
81
  let ingestHost = '';
14
82
  try {
15
83
  ingestHost = new URL(ingestUrl).hostname;
16
- if (debug) console.log(`[Senzor] HTTP Instrumentation ignoring host: ${ingestHost}`);
17
- } catch (e) {
18
- // If invalid URL passed, we can't filter loop safely, so we might skip instrumentation
19
- if (debug) console.error('[Senzor] Invalid Ingest URL for HTTP instrumentation');
20
- }
84
+ } catch (e) { }
21
85
 
22
86
  const requestWrapper = (original: Function) => {
23
87
  return function (this: any, ...args: any[]) {
@@ -35,16 +99,12 @@ export const instrumentHttp = (ingestUrl: string, debug = false) => {
35
99
  urlStr = `${protocol}//${host}${path}`;
36
100
  }
37
101
 
38
- // Safety Guard: Ignore calls to Senzor Ingest API
39
102
  if (ingestHost && (urlStr.includes(ingestHost) || (options.hostname && options.hostname.includes(ingestHost)))) {
40
103
  return original.apply(this, args);
41
104
  }
42
105
 
43
106
  const trace = Context.current();
44
- if (!trace) {
45
- // Not inside a tracked request
46
- return original.apply(this, args);
47
- }
107
+ if (!trace) return original.apply(this, args);
48
108
 
49
109
  const method = (options.method || 'GET').toUpperCase();
50
110
  const startTime = performance.now() - trace.startTime;
@@ -52,8 +112,6 @@ export const instrumentHttp = (ingestUrl: string, debug = false) => {
52
112
  let hostname = 'unknown';
53
113
  try { hostname = new URL(urlStr).hostname; } catch (e) { hostname = options.hostname || 'unknown'; }
54
114
 
55
- if (debug) console.log(`[Senzor] Tracking HTTP: ${method} ${hostname}`);
56
-
57
115
  const req = original.apply(this, args);
58
116
 
59
117
  const captureSpan = (res: any, error?: Error) => {
@@ -64,21 +122,16 @@ export const instrumentHttp = (ingestUrl: string, debug = false) => {
64
122
  startTime,
65
123
  duration,
66
124
  status: error ? 500 : res?.statusCode || 0,
67
- meta: { url: urlStr, method }
125
+ meta: { url: urlStr, method, library: 'http', error: error ? error.message : undefined }
68
126
  });
69
127
  };
70
128
 
71
129
  req.on('response', (res: any) => {
72
- // We capture on 'response' (headers received) to be safe.
73
- // Waiting for 'end' might miss requests where body isn't consumed.
74
130
  res.once('end', () => captureSpan(res));
75
- // Fallback if 'end' doesn't fire fast enough
76
- // setTimeout(() => captureSpan(res), 5000);
131
+ res.once('error', (err: Error) => captureSpan(res, err));
77
132
  });
78
133
 
79
- req.on('error', (err: Error) => {
80
- captureSpan(null, err);
81
- });
134
+ req.on('error', (err: Error) => captureSpan(null, err));
82
135
 
83
136
  return req;
84
137
  };
@@ -2,25 +2,35 @@ import { Context } from '../core/context';
2
2
 
3
3
  export const instrumentMongo = (debug = false) => {
4
4
  try {
5
- // Attempt to load the user's installed mongodb driver
6
- // This works for both native driver users and Mongoose users (as mongoose depends on this)
7
5
  const mongodb = require('mongodb');
8
6
  const Collection = mongodb.Collection;
9
7
 
10
- if (debug) console.log('[Senzor] Instrumenting MongoDB...');
11
-
12
- // Methods that return Promises
13
- const promiseMethods = [
14
- 'insertOne', 'insertMany', 'updateOne', 'updateMany',
15
- 'replaceOne', 'deleteOne', 'deleteMany', 'count', 'countDocuments',
16
- 'estimatedDocumentCount', 'distinct'
17
- ];
18
-
19
- // Methods that return Cursors (need special handling)
20
- const cursorMethods = ['find', 'aggregate'];
21
-
22
- // 1. Instrument Promise-based methods
23
- promiseMethods.forEach((method) => {
8
+ // Attempt to get Cursor classes
9
+ // Note: The location of these classes varies by driver version,
10
+ // checking common locations
11
+ const FindCursor = mongodb.FindCursor || require('mongodb/lib/cursor/find_cursor').FindCursor;
12
+ const AggregationCursor = mongodb.AggregationCursor || require('mongodb/lib/cursor/aggregation_cursor').AggregationCursor;
13
+
14
+ if (debug) console.log('[Senzor] Instrumenting MongoDB (Collection + Cursors)...');
15
+
16
+ // --- Helper to Record Span ---
17
+ const recordSpan = (name: string, operation: string, collection: string, startAbs: number, traceStart: number, err?: Error) => {
18
+ const duration = performance.now() - startAbs;
19
+ Context.addSpan({
20
+ name: `MongoDB ${name}`,
21
+ type: 'db',
22
+ startTime: performance.now() - traceStart - duration, // Adjust start time to when op actually started
23
+ duration,
24
+ status: err ? 500 : 0,
25
+ meta: { collection, operation, error: err ? err.message : undefined }
26
+ });
27
+ if (debug) console.log(`[Senzor] Captured Mongo: ${name} (${duration.toFixed(2)}ms)`);
28
+ };
29
+
30
+ // --- 1. Instrument Immediate Operations (Insert/Update/Delete) ---
31
+ const immediateMethods = ['insertOne', 'insertMany', 'updateOne', 'updateMany', 'deleteOne', 'deleteMany', 'countDocuments'];
32
+
33
+ immediateMethods.forEach((method) => {
24
34
  if (!Collection.prototype[method]) return;
25
35
  const original = Collection.prototype[method];
26
36
 
@@ -28,68 +38,68 @@ export const instrumentMongo = (debug = false) => {
28
38
  const trace = Context.current();
29
39
  if (!trace) return original.apply(this, args);
30
40
 
31
- const startTime = performance.now() - trace.startTime;
32
41
  const spanStartAbs = performance.now();
33
- const collectionName = this.collectionName;
34
-
35
- const endSpan = (err?: Error) => {
36
- const duration = performance.now() - spanStartAbs;
37
- Context.addSpan({
38
- name: `MongoDB ${method} (${collectionName})`,
39
- type: 'db',
40
- startTime,
41
- duration,
42
- status: err ? 500 : 0,
43
- meta: { collection: collectionName, operation: method }
44
- });
45
- if (debug) console.log(`[Senzor] Captured Mongo Span: ${method}`);
46
- };
42
+ const traceStart = trace.startTime;
43
+ const collName = this.collectionName;
47
44
 
48
45
  try {
49
46
  const result = original.apply(this, args);
50
47
  if (result && typeof result.then === 'function') {
51
48
  return result.then(
52
- (res: any) => { endSpan(); return res; },
53
- (err: any) => { endSpan(err); throw err; }
49
+ (res: any) => { recordSpan(method, method, collName, spanStartAbs, traceStart); return res; },
50
+ (err: any) => { recordSpan(method, method, collName, spanStartAbs, traceStart, err); throw err; }
54
51
  );
55
52
  }
56
53
  return result;
57
54
  } catch (err: any) {
58
- endSpan(err);
55
+ recordSpan(method, method, collName, spanStartAbs, traceStart, err);
59
56
  throw err;
60
57
  }
61
58
  };
62
59
  });
63
60
 
64
- // 2. Instrument Cursor-based methods (find, aggregate)
65
- // We trace the *creation* of the cursor, not the fetching, as fetching is async/streamed
66
- cursorMethods.forEach((method) => {
67
- if (!Collection.prototype[method]) return;
68
- const original = Collection.prototype[method];
61
+ // --- 2. Instrument Cursor Execution (find -> toArray) ---
62
+ const patchCursor = (CursorClass: any, label: string) => {
63
+ if (!CursorClass || !CursorClass.prototype.toArray) return;
69
64
 
70
- Collection.prototype[method] = function (...args: any[]) {
71
- const trace = Context.current();
72
- if (!trace) return original.apply(this, args);
65
+ const originalToArray = CursorClass.prototype.toArray;
73
66
 
74
- const startTime = performance.now() - trace.startTime;
67
+ CursorClass.prototype.toArray = function (...args: any[]) {
68
+ const trace = Context.current();
69
+ // Cursors are often created in context but executed later.
70
+ // We check context at execution time.
71
+ if (!trace) return originalToArray.apply(this, args);
75
72
 
76
- // Record the intent to query
77
- Context.addSpan({
78
- name: `MongoDB ${method} (${this.collectionName})`,
79
- type: 'db',
80
- startTime,
81
- duration: 0, // Placeholder, as cursor creation is instant
82
- status: 0,
83
- meta: { collection: this.collectionName, operation: method }
84
- });
73
+ const spanStartAbs = performance.now();
74
+ const traceStart = trace.startTime;
75
+ // Attempt to get collection name from cursor internal state
76
+ const collName = this.namespace?.collection || 'unknown';
85
77
 
86
- if (debug) console.log(`[Senzor] Captured Mongo Cursor: ${method}`);
78
+ const onSuccess = (res: any) => {
79
+ recordSpan(label, label, collName, spanStartAbs, traceStart);
80
+ return res;
81
+ };
82
+ const onError = (err: any) => {
83
+ recordSpan(label, label, collName, spanStartAbs, traceStart, err);
84
+ throw err;
85
+ };
87
86
 
88
- return original.apply(this, args);
87
+ try {
88
+ const result = originalToArray.apply(this, args);
89
+ if (result && typeof result.then === 'function') {
90
+ return result.then(onSuccess, onError);
91
+ }
92
+ return onSuccess(result);
93
+ } catch (e) {
94
+ onError(e);
95
+ }
89
96
  };
90
- });
97
+ };
98
+
99
+ patchCursor(FindCursor, 'find');
100
+ patchCursor(AggregationCursor, 'aggregate');
91
101
 
92
102
  } catch (e: any) {
93
- if (debug) console.warn('[Senzor] MongoDB instrumentation skipped:', e.message);
103
+ if (debug) console.warn('[Senzor] MongoDB instrumentation warning:', e.message);
94
104
  }
95
105
  };
@@ -1,8 +1,8 @@
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,
@@ -10,9 +10,11 @@ export const expressMiddleware = () => {
10
10
  userAgent: req.headers['user-agent'],
11
11
  }, () => {
12
12
 
13
+ // Auto-detect status code on finish
13
14
  res.once('finish', () => {
14
15
  try {
15
16
  let route = 'UNKNOWN';
17
+ // Express populates req.route only if a route matched
16
18
  if (req.route && req.route.path) {
17
19
  route = (req.baseUrl || '') + req.route.path;
18
20
  } else if (res.statusCode === 404) {
@@ -22,12 +24,23 @@ export const expressMiddleware = () => {
22
24
  }
23
25
 
24
26
  client.endTrace(res.statusCode, { route });
25
- } catch (e) {
26
- // Fail open
27
- }
27
+ } catch (e) { /* Fail open */ }
28
28
  });
29
29
 
30
30
  next();
31
31
  });
32
32
  };
33
+ };
34
+
35
+ // 2. Error Handler (Place after routes)
36
+ // This is required in Express to capture the actual Error Object (Stack Trace)
37
+ export const expressErrorHandler = () => {
38
+ return (err: any, req: any, res: any, next: (err?: any) => void) => {
39
+
40
+ // 1. Capture the exception context
41
+ client.captureError(err);
42
+
43
+ // 2. Pass it to the next error handler (don't swallow it)
44
+ next(err);
45
+ };
33
46
  };
@@ -1,37 +1,33 @@
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
5
 
7
- // Init if options provided inline, otherwise assume global init
8
6
  if (options && options.apiKey) {
9
7
  client.init(options);
10
8
  }
11
9
 
12
- // Hook: On Request (Start Timer)
10
+ // 1. Start Trace
13
11
  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({
12
+ // FIX: Wrap next in a closure to satisfy TS types
13
+ client.startTrace({
26
14
  method: request.method,
27
- route: route || 'UNKNOWN',
28
15
  path: request.raw.url || request.url,
29
- status: reply.statusCode,
30
- duration: duration,
31
16
  ip: request.ip,
32
17
  userAgent: request.headers['user-agent']
33
- });
18
+ }, () => next());
19
+ });
34
20
 
21
+ // 2. Capture Errors
22
+ fastify.addHook('onError', (request: any, reply: any, error: any, next: Function) => {
23
+ client.captureError(error);
24
+ next();
25
+ });
26
+
27
+ // 3. End Trace
28
+ fastify.addHook('onResponse', (request: any, reply: any, next: Function) => {
29
+ const route = request.routeOptions?.url || request.routerPath || 'UNKNOWN';
30
+ client.endTrace(reply.statusCode, { route });
35
31
  next();
36
32
  });
37
33
 
@@ -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,7 +8,6 @@ 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,
@@ -18,16 +16,16 @@ export const wrapH3 = (handler: EventHandler) => {
18
16
  }, async () => {
19
17
  try {
20
18
  const response = await handler(event);
21
-
22
- // H3/Nitro response status
23
19
  let status = 200;
24
20
  if (event.node.res.statusCode) status = event.node.res.statusCode;
25
- // Check if response is an error object
26
21
  if (response && response.statusCode) status = response.statusCode;
27
22
 
28
23
  client.endTrace(status, { route: getRoute(event, path) });
29
24
  return response;
30
25
  } catch (err: any) {
26
+ // AUTOMATIC ERROR CAPTURE
27
+ client.captureError(err);
28
+
31
29
  const status = err.statusCode || err.status || 500;
32
30
  client.endTrace(status, { route: getRoute(event, path) });
33
31
  throw err;
@@ -1,16 +1,14 @@
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
8
7
  const url = req.url ? new URL(req.url) : { pathname: '/' };
9
8
  const method = req.method || 'GET';
10
9
  const ua = req.headers.get ? req.headers.get('user-agent') : undefined;
11
10
  const ip = req.headers.get ? req.headers.get('x-forwarded-for') : undefined;
12
11
 
13
- // 2. Run in Context
14
12
  return client.startTrace({
15
13
  method,
16
14
  path: url.pathname,
@@ -20,44 +18,44 @@ export const wrapNextRoute = (handler: Function) => {
20
18
  try {
21
19
  const response = await handler(req, context);
22
20
  const status = response?.status || 200;
23
-
24
21
  client.endTrace(status, { route: normalizePath(url.pathname) });
25
22
  return response;
26
23
  } catch (err: any) {
24
+ // AUTOMATIC ERROR CAPTURE
25
+ client.captureError(err);
27
26
  client.endTrace(500, { route: normalizePath(url.pathname) });
28
- throw err;
27
+ throw err; // Re-throw so Next.js handles the error page
29
28
  }
30
29
  });
31
30
  };
32
31
  };
33
32
 
34
- // --- Pages Router Wrapper (API Routes) ---
33
+ // --- Pages Router Wrapper ---
35
34
  export const wrapNextPages = (handler: Function) => {
36
35
  return async (req: any, res: any) => {
37
36
  const path = req.url ? req.url.split('?')[0] : '/';
38
-
39
- // 1. Run in Context
37
+
40
38
  return client.startTrace({
41
39
  method: req.method || 'GET',
42
40
  path: path,
43
41
  userAgent: req.headers['user-agent'],
44
42
  ip: req.headers['x-forwarded-for'] || req.socket?.remoteAddress,
45
43
  }, async () => {
46
-
47
- // 2. Hook Response
44
+
48
45
  const done = () => {
49
46
  client.endTrace(res.statusCode || 200, { route: normalizePath(path) });
50
47
  };
51
-
48
+
52
49
  res.once('finish', done);
53
- res.once('close', done); // Fallback if finish doesn't fire
50
+ res.once('close', done);
54
51
 
55
- // 3. Execute
56
52
  try {
57
53
  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
54
+ } catch (e: any) {
55
+ // AUTOMATIC ERROR CAPTURE
56
+ client.captureError(e);
57
+ // Note: In Pages dir, we rely on 'finish' listener above to close trace,
58
+ // but capturing here ensures the error data is attached.
61
59
  throw e;
62
60
  }
63
61
  });