@senzops/apm-node 1.0.1 → 1.1.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,43 +1,73 @@
1
1
  import { Transport } from './transport';
2
-
3
- export interface SenzorOptions {
4
- apiKey: string;
5
- endpoint?: string;
6
- batchSize?: number;
7
- flushInterval?: number;
8
- debug?: boolean;
9
- }
2
+ import { Context } from './context';
3
+ import { SenzorOptions, ActiveTrace } from './types';
4
+ import { randomUUID } from 'crypto';
5
+ import { instrumentHttp } from '../instrumentation/http';
6
+ import { instrumentMongo } from '../instrumentation/mongo';
7
+ import { instrumentPg } from '../instrumentation/pg';
10
8
 
11
9
  export class SenzorClient {
12
10
  private transport: Transport | null = null;
13
11
  private options: SenzorOptions | null = null;
12
+ private isInstrumented = false;
14
13
 
15
14
  public init(options: SenzorOptions) {
16
15
  if (!options.apiKey) {
17
16
  console.warn('[Senzor] API Key missing. SDK disabled.');
18
17
  return;
19
18
  }
20
-
21
- this.options = {
22
- endpoint: 'https://api.senzor.dev/api/ingest/apm',
23
- batchSize: 100,
24
- flushInterval: 10000,
25
- debug: false,
26
- ...options
27
- };
19
+ this.options = options;
20
+ const endpoint = options.endpoint || 'https://api.senzor.dev/api/ingest/apm';
28
21
 
29
22
  this.transport = new Transport({
30
- apiKey: this.options.apiKey,
31
- endpoint: this.options.endpoint!,
32
- batchSize: this.options.batchSize!,
33
- flushInterval: this.options.flushInterval!,
34
- debug: this.options.debug || false
23
+ ...options,
24
+ endpoint
35
25
  });
36
26
 
37
- if (this.options.debug) console.log('[Senzor] Initialized');
27
+ if (!this.isInstrumented) {
28
+ try { instrumentHttp(endpoint); } catch (e) { }
29
+ try { instrumentMongo(); } catch (e) { }
30
+ try { instrumentPg(); } catch (e) { }
31
+
32
+ this.isInstrumented = true;
33
+ if (options.debug) console.log('[Senzor] Auto-instrumentation enabled');
34
+ }
35
+
36
+ if (options.debug) console.log('[Senzor] Initialized');
37
+ }
38
+
39
+ public startTrace<T>(data: Partial<ActiveTrace['data']>, next: () => T): T {
40
+ if (!this.transport) return next();
41
+
42
+ const trace: ActiveTrace = {
43
+ id: randomUUID(),
44
+ startTime: performance.now(),
45
+ data: data,
46
+ spans: []
47
+ };
48
+
49
+ return Context.run(trace, next);
50
+ }
51
+
52
+ public endTrace(status: number, extraData: any = {}) {
53
+ const trace = Context.current();
54
+ if (!trace || !this.transport) return;
55
+
56
+ const duration = performance.now() - trace.startTime;
57
+
58
+ const payload = {
59
+ traceId: trace.id,
60
+ ...trace.data,
61
+ ...extraData,
62
+ status,
63
+ duration,
64
+ spans: trace.spans,
65
+ timestamp: new Date().toISOString()
66
+ };
67
+
68
+ this.transport.add(payload);
38
69
  }
39
70
 
40
- // --- Manual Tracking (For any framework) ---
41
71
  public track(data: {
42
72
  method: string;
43
73
  route: string;
@@ -48,14 +78,37 @@ export class SenzorClient {
48
78
  userAgent?: string;
49
79
  }) {
50
80
  if (!this.transport) return;
51
-
52
- this.transport.add({
81
+ const payload = {
82
+ traceId: randomUUID(),
53
83
  ...data,
84
+ spans: [],
54
85
  timestamp: new Date().toISOString()
55
- });
86
+ };
87
+ this.transport.add(payload);
88
+ }
89
+
90
+ public startSpan(name: string, type: 'db' | 'http' | 'function' | 'custom' = 'custom') {
91
+ const trace = Context.current();
92
+ if (!trace) return { end: () => { } };
93
+
94
+ const startTime = performance.now() - trace.startTime;
95
+ const spanStartAbs = performance.now();
96
+
97
+ return {
98
+ end: (meta?: any, status?: number) => {
99
+ const duration = performance.now() - spanStartAbs;
100
+ Context.addSpan({
101
+ name,
102
+ type,
103
+ startTime,
104
+ duration,
105
+ status,
106
+ meta
107
+ });
108
+ }
109
+ };
56
110
  }
57
111
 
58
- // --- Force Flush (For Serverless/Lambda) ---
59
112
  public async flush() {
60
113
  if (this.transport) await this.transport.flush();
61
114
  }
@@ -0,0 +1,26 @@
1
+ import { AsyncLocalStorage } from 'async_hooks';
2
+ import { ActiveTrace } from './types';
3
+
4
+ // Storage to hold the current Trace for any async operation
5
+ export const storage = new AsyncLocalStorage<ActiveTrace>();
6
+
7
+ export const Context = {
8
+ // Run a function within a trace context
9
+ // Updated to be Generic <T> to allow returning values (Promises, etc)
10
+ run: <T>(trace: ActiveTrace, fn: () => T): T => {
11
+ return storage.run(trace, fn);
12
+ },
13
+
14
+ // Get current trace (safe)
15
+ current: (): ActiveTrace | undefined => {
16
+ return storage.getStore();
17
+ },
18
+
19
+ // Add a span to the current trace
20
+ addSpan: (span: any) => {
21
+ const store = storage.getStore();
22
+ if (store) {
23
+ store.spans.push(span);
24
+ }
25
+ }
26
+ };
@@ -1,31 +1,21 @@
1
- export interface TransportConfig {
2
- apiKey: string;
3
- endpoint: string;
4
- batchSize: number;
5
- flushInterval: number;
6
- debug: boolean;
7
- }
1
+ import { SenzorOptions } from './types';
8
2
 
9
3
  export class Transport {
10
4
  private queue: any[] = [];
11
- private config: TransportConfig;
12
- private timer: any = null;
5
+ private timer: NodeJS.Timeout | null = null;
13
6
 
14
- constructor(config: TransportConfig) {
15
- this.config = config;
16
- // Only start timer in non-serverless environments (long running processes)
7
+ constructor(private config: SenzorOptions) {
17
8
  if (typeof setInterval !== 'undefined') {
18
- this.timer = setInterval(() => this.flush(), this.config.flushInterval);
19
- // Unref if in Node.js to allow process exit
9
+ this.timer = setInterval(() => this.flush(), config.flushInterval || 10000);
20
10
  if (this.timer && typeof this.timer.unref === 'function') {
21
- this.timer.unref();
11
+ this.timer.unref(); // Don't block process exit
22
12
  }
23
13
  }
24
14
  }
25
15
 
26
- public add(event: any) {
27
- this.queue.push(event);
28
- if (this.queue.length >= this.config.batchSize) {
16
+ public add(trace: any) {
17
+ this.queue.push(trace);
18
+ if (this.queue.length >= (this.config.batchSize || 100)) {
29
19
  this.flush();
30
20
  }
31
21
  }
@@ -37,22 +27,21 @@ export class Transport {
37
27
  this.queue = [];
38
28
 
39
29
  try {
40
- // Use native fetch (Node 18+, Edge, Browser)
41
- await fetch(this.config.endpoint, {
30
+ // Use global fetch (Node 18+)
31
+ await fetch(this.config.endpoint || 'https://api.senzor.dev/api/ingest/apm', {
42
32
  method: 'POST',
43
33
  headers: {
44
34
  'Content-Type': 'application/json',
45
35
  'x-service-api-key': this.config.apiKey,
46
36
  },
47
37
  body: JSON.stringify(batch),
48
- // keepalive ensures connection stays open even if function ends (vital for APM)
49
38
  keepalive: true,
50
39
  });
51
-
40
+
52
41
  if (this.config.debug) console.log(`[Senzor] Flushed ${batch.length} traces`);
53
42
  } catch (err) {
54
43
  if (this.config.debug) console.error('[Senzor] Ingestion Error:', err);
55
- // We drop data on failure to prevent memory leaks in the app
44
+ // Dropping data to prevent memory leaks is preferred in APM
56
45
  }
57
46
  }
58
47
  }
@@ -0,0 +1,37 @@
1
+ export interface SenzorOptions {
2
+ apiKey: string;
3
+ endpoint?: string;
4
+ batchSize?: number;
5
+ flushInterval?: number; // ms
6
+ debug?: boolean;
7
+ }
8
+
9
+ export interface Span {
10
+ name: string;
11
+ type: 'db' | 'http' | 'function' | 'custom';
12
+ startTime: number; // Relative to trace start
13
+ duration: number;
14
+ status?: number;
15
+ meta?: Record<string, any>;
16
+ }
17
+
18
+ export interface Trace {
19
+ traceId: string;
20
+ method: string;
21
+ route: string; // Normalized
22
+ path: string; // Raw
23
+ status: number;
24
+ duration: number;
25
+ ip?: string;
26
+ userAgent?: string;
27
+ timestamp: string;
28
+ spans: Span[];
29
+ }
30
+
31
+ // Internal interface for an active trace object
32
+ export interface ActiveTrace {
33
+ id: string;
34
+ startTime: number;
35
+ data: Partial<Trace>;
36
+ spans: Span[];
37
+ }
package/src/index.ts CHANGED
@@ -1,14 +1,14 @@
1
- import { client, SenzorOptions } from './core/client';
1
+ import { client } from './core/client';
2
2
  import { expressMiddleware } 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
+ import { SenzorOptions } from './core/types';
6
7
 
7
8
  const Senzor = {
8
9
  // Core
9
10
  init: (options: SenzorOptions) => client.init(options),
10
11
  flush: () => client.flush(),
11
- track: client.track.bind(client),
12
12
 
13
13
  // Express / Connect
14
14
  requestHandler: expressMiddleware,
@@ -0,0 +1,94 @@
1
+ import http from 'http';
2
+ import https from 'https';
3
+ import { URL } from 'url';
4
+ import { Context } from '../core/context';
5
+
6
+ // Helper to safely wrap modules
7
+ const shimmer = (module: any, methodName: string, wrapper: (original: Function) => Function) => {
8
+ if (!module[methodName]) return;
9
+ const original = module[methodName];
10
+ module[methodName] = wrapper(original);
11
+ };
12
+
13
+ export const instrumentHttp = (ingestUrl: string) => {
14
+ const ingestHost = new URL(ingestUrl).hostname;
15
+
16
+ const requestWrapper = (original: Function) => {
17
+ return function (this: any, ...args: any[]) {
18
+ // 1. Parse Arguments to get URL
19
+ let options: any = {};
20
+ let urlStr = '';
21
+
22
+ if (typeof args[0] === 'string' || args[0] instanceof URL) {
23
+ urlStr = args[0].toString();
24
+ options = args[1] || {};
25
+ } else {
26
+ options = args[0] || {};
27
+ const protocol = options.protocol || 'http:';
28
+ const host = options.hostname || options.host || 'localhost';
29
+ const path = options.path || '/';
30
+ urlStr = `${protocol}//${host}${path}`;
31
+ }
32
+
33
+ // 2. SAFETY GUARD: Ignore calls to Senzor Ingest API (Prevent Infinite Loop)
34
+ if (urlStr.includes(ingestHost) || (options.hostname && options.hostname.includes(ingestHost))) {
35
+ return original.apply(this, args);
36
+ }
37
+
38
+ // 3. Check if we are inside an Active Trace
39
+ // If we are not handling a user request, don't trace background http calls
40
+ const trace = Context.current();
41
+ if (!trace) {
42
+ return original.apply(this, args);
43
+ }
44
+
45
+ // 4. Start Span
46
+ const method = (options.method || 'GET').toUpperCase();
47
+ const startTime = performance.now() - trace.startTime; // Relative to trace start
48
+ const spanStartAbs = performance.now();
49
+
50
+ // 5. Execute Request
51
+ const req = original.apply(this, args);
52
+
53
+ // 6. Hook into Response/Error
54
+ req.on('response', (res: any) => {
55
+ // Wait for end of stream to calculate full duration (TTFB + Download)
56
+ res.on('end', () => {
57
+ const duration = performance.now() - spanStartAbs;
58
+
59
+ Context.addSpan({
60
+ name: `${method} ${new URL(urlStr).hostname}`, // e.g. "GET api.stripe.com"
61
+ type: 'http',
62
+ startTime,
63
+ duration,
64
+ status: res.statusCode,
65
+ meta: {
66
+ url: urlStr,
67
+ method: method,
68
+ }
69
+ });
70
+ });
71
+ });
72
+
73
+ req.on('error', (err: Error) => {
74
+ const duration = performance.now() - spanStartAbs;
75
+ Context.addSpan({
76
+ name: `${method} ${urlStr}`,
77
+ type: 'http',
78
+ startTime,
79
+ duration,
80
+ status: 500, // Client Error
81
+ meta: { error: err.message }
82
+ });
83
+ });
84
+
85
+ return req;
86
+ };
87
+ };
88
+
89
+ // Apply patches
90
+ shimmer(http, 'request', requestWrapper);
91
+ shimmer(http, 'get', requestWrapper);
92
+ shimmer(https, 'request', requestWrapper);
93
+ shimmer(https, 'get', requestWrapper);
94
+ };
@@ -0,0 +1,77 @@
1
+ import { Context } from '../core/context';
2
+
3
+ export const instrumentMongo = () => {
4
+ try {
5
+ const mongodb = require('mongodb');
6
+ const Collection = mongodb.Collection;
7
+
8
+ const methods = [
9
+ 'find',
10
+ 'findOne',
11
+ 'insertOne',
12
+ 'insertMany',
13
+ 'updateOne',
14
+ 'updateMany',
15
+ 'deleteOne',
16
+ 'deleteMany',
17
+ 'aggregate',
18
+ 'countDocuments'
19
+ ];
20
+
21
+ methods.forEach((method) => {
22
+ if (!Collection.prototype[method]) return;
23
+
24
+ const original = Collection.prototype[method];
25
+
26
+ Collection.prototype[method] = function (...args: any[]) {
27
+ const trace = Context.current();
28
+ // If not inside a tracked request, just run normally
29
+ if (!trace) return original.apply(this, args);
30
+
31
+ const startTime = performance.now() - trace.startTime;
32
+ const spanStartAbs = performance.now();
33
+ const collectionName = this.collectionName;
34
+
35
+ // Helper to finish span
36
+ const endSpan = (err?: Error) => {
37
+ const duration = performance.now() - spanStartAbs;
38
+ Context.addSpan({
39
+ name: `MongoDB ${method} (${collectionName})`,
40
+ type: 'db',
41
+ startTime,
42
+ duration,
43
+ status: err ? 500 : 0,
44
+ meta: {
45
+ collection: collectionName,
46
+ operation: method,
47
+ error: err ? err.message : undefined
48
+ }
49
+ });
50
+ };
51
+
52
+ try {
53
+ const result = original.apply(this, args);
54
+
55
+ // Handle Promise/Cursor
56
+ if (result && typeof result.then === 'function') {
57
+ return result.then(
58
+ (res: any) => { endSpan(); return res; },
59
+ (err: any) => { endSpan(err); throw err; }
60
+ );
61
+ }
62
+ // Handle FindCursor (it doesn't execute immediately, but for APM visuals we mark start/return)
63
+ // Ideally we'd wrap toArray() but that's complex. For 'find', we just mark the creation.
64
+ endSpan();
65
+ return result;
66
+
67
+ } catch (err: any) {
68
+ endSpan(err);
69
+ throw err;
70
+ }
71
+ };
72
+ });
73
+
74
+ } catch (e) {
75
+ // User doesn't use mongodb, ignore
76
+ }
77
+ };
@@ -0,0 +1,41 @@
1
+ import { Context } from '../core/context';
2
+
3
+ // Simple shim for 'pg' library
4
+ export const instrumentPg = () => {
5
+ try {
6
+ // Try to require pg (it might not be installed by user)
7
+ const pg = require('pg');
8
+ const originalQuery = pg.Client.prototype.query;
9
+
10
+ pg.Client.prototype.query = function (...args: any[]) {
11
+ const trace = Context.current();
12
+ if (!trace) return originalQuery.apply(this, args);
13
+
14
+ const startTime = performance.now() - trace.startTime;
15
+ const spanStartAbs = performance.now();
16
+
17
+ // Extract SQL (first arg usually string or config object)
18
+ const sql = typeof args[0] === 'string' ? args[0] : args[0].text;
19
+
20
+ // Wrap callback if present, or handle Promise
21
+ const result = originalQuery.apply(this, args);
22
+
23
+ if (result && typeof result.then === 'function') {
24
+ return result.then((res: any) => {
25
+ const duration = performance.now() - spanStartAbs;
26
+ Context.addSpan({
27
+ name: 'Postgres Query',
28
+ type: 'db',
29
+ startTime,
30
+ duration,
31
+ meta: { query: sql }
32
+ });
33
+ return res;
34
+ });
35
+ }
36
+ return result;
37
+ };
38
+ } catch (e) {
39
+ // User doesn't use pg, ignore
40
+ }
41
+ };
@@ -2,42 +2,32 @@ import { client } from '../core/client';
2
2
 
3
3
  export const expressMiddleware = () => {
4
4
  return (req: any, res: any, next: () => void) => {
5
- // Start Timer (High Precision)
6
- const start = performance.now();
5
+ // We MUST use startTrace to enable Auto-Instrumentation for this request
6
+ client.startTrace({
7
+ method: req.method,
8
+ path: req.originalUrl || req.url,
9
+ ip: req.ip || req.socket?.remoteAddress,
10
+ userAgent: req.headers['user-agent'],
11
+ }, () => {
7
12
 
8
- // Hook into response finish
9
- res.once('finish', () => {
10
- try {
11
- const duration = performance.now() - start;
13
+ res.once('finish', () => {
14
+ try {
15
+ let route = 'UNKNOWN';
16
+ if (req.route && req.route.path) {
17
+ route = (req.baseUrl || '') + req.route.path;
18
+ } else if (res.statusCode === 404) {
19
+ route = 'Not Found';
20
+ } else {
21
+ route = req.path || 'Wildcard';
22
+ }
12
23
 
13
- // Route Normalization Logic
14
- // Express stores route info in req.route
15
- let route = 'UNKNOWN';
16
-
17
- if (req.route && req.route.path) {
18
- // Combined baseUrl (if mounted on /api) + path (/:id)
19
- route = (req.baseUrl || '') + req.route.path;
20
- } else if (res.statusCode === 404) {
21
- route = 'Not Found';
22
- } else {
23
- // Fallback for unmapped routes or static files
24
- route = req.path || 'Wildcard';
24
+ client.endTrace(res.statusCode, { route });
25
+ } catch (e) {
26
+ // Fail open
25
27
  }
28
+ });
26
29
 
27
- client.track({
28
- method: req.method,
29
- route: route,
30
- path: req.originalUrl || req.url,
31
- status: res.statusCode,
32
- duration: duration,
33
- ip: req.ip || req.socket?.remoteAddress,
34
- userAgent: req.headers['user-agent'],
35
- });
36
- } catch (e) {
37
- // Fail open
38
- }
30
+ next();
39
31
  });
40
-
41
- next();
42
32
  };
43
33
  };
@@ -1,5 +1,5 @@
1
1
  import { client } from '../core/client';
2
- import { SenzorOptions } from '../core/client';
2
+ import { SenzorOptions } from '../core/types';
3
3
 
4
4
  // We don't import Fastify types to keep zero-deps, but structure matches
5
5
  export const senzorPlugin = (fastify: any, options: SenzorOptions, done: Function) => {
@@ -1,49 +1,37 @@
1
1
  import { client } from '../core/client';
2
2
  import { getRoute } from '../core/normalizer';
3
3
 
4
- // Types for H3 (Mocked to avoid heavy peer dependencies)
4
+ // Minimal types for H3 to avoid peer-deps
5
5
  type EventHandler = (event: any) => any;
6
6
 
7
7
  export const wrapH3 = (handler: EventHandler) => {
8
- return async (event: any) => {
9
- const start = performance.now();
10
- let status = 200;
11
- let error: any = null;
8
+ return (event: any) => {
9
+ const req = event.node.req;
10
+ const path = req.originalUrl || req.url || '/';
12
11
 
13
- try {
14
- const response = await handler(event);
15
- // Try to determine status from response or event
16
- if (event.node?.res?.statusCode) {
17
- status = event.node.res.statusCode;
18
- }
19
- return response;
20
- } catch (err: any) {
21
- error = err;
22
- status = err.statusCode || err.status || 500;
23
- throw err;
24
- } finally {
25
- // Non-blocking collection
26
- const duration = performance.now() - start;
27
- const req = event.node.req;
28
-
29
- const path = req.originalUrl || req.url || '/';
12
+ // Start Trace Context
13
+ return client.startTrace({
14
+ method: req.method || 'GET',
15
+ path: path,
16
+ ip: req.headers['x-forwarded-for'] || req.socket?.remoteAddress,
17
+ userAgent: req.headers['user-agent'],
18
+ }, async () => {
19
+ try {
20
+ const response = await handler(event);
30
21
 
31
- client.track({
32
- method: req.method || 'GET',
33
- route: getRoute(event, path), // H3 often attaches context to event
34
- path: path,
35
- status: status,
36
- duration: duration,
37
- ip: getIp(req),
38
- userAgent: req.headers['user-agent'],
39
- });
22
+ // H3/Nitro response status
23
+ let status = 200;
24
+ if (event.node.res.statusCode) status = event.node.res.statusCode;
25
+ // Check if response is an error object
26
+ if (response && response.statusCode) status = response.statusCode;
40
27
 
41
- // If serverless, we might need to await flush, but for general H3 usage (Node preset)
42
- // we assume the process stays alive or uses ctx.waitUntil
43
- }
28
+ client.endTrace(status, { route: getRoute(event, path) });
29
+ return response;
30
+ } catch (err: any) {
31
+ const status = err.statusCode || err.status || 500;
32
+ client.endTrace(status, { route: getRoute(event, path) });
33
+ throw err;
34
+ }
35
+ });
44
36
  };
45
- };
46
-
47
- const getIp = (req: any) => {
48
- return req.headers['x-forwarded-for'] || req.socket?.remoteAddress;
49
37
  };