@senzops/apm-node 1.2.0 → 1.2.2
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/.claude/worktrees/infallible-chatelet-f3fb36/.claude/settings.local.json +9 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/CHANGELOG.md +49 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/README.md +398 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/package-lock.json +1494 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/package.json +42 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/core/client.ts +451 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/core/context.ts +48 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/core/normalizer.ts +44 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/core/sanitizer.ts +203 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/core/transport.ts +273 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/core/types.ts +106 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/index.ts +36 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/bullmq.ts +195 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/cron.ts +204 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/express.ts +338 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/fastify.ts +296 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/framework.ts +301 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/hook.ts +134 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/http.ts +530 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/koa.ts +173 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/mongo.ts +202 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/mongoose.ts +156 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/mysql.ts +169 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/patch.ts +56 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/pg.ts +131 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/redis.ts +109 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/span.ts +73 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/instrumentation/undici.ts +189 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/middleware/express.ts +48 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/register.ts +58 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/utils/getClientIp.ts +175 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/utils/ids.ts +7 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/utils/internal.ts +1 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/utils/sdkMeta.ts +6 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/utils/traceContext.ts +44 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/wrappers/fastify.ts +35 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/wrappers/h3.ts +59 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/src/wrappers/next.ts +131 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/tsconfig.json +15 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/tsup.config.ts +21 -0
- package/.claude/worktrees/infallible-chatelet-f3fb36/wiki.md +852 -0
- package/CHANGELOG.md +8 -0
- package/README.md +12 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/register.js +1 -1
- package/dist/register.js.map +1 -1
- package/dist/register.mjs +1 -1
- package/dist/register.mjs.map +1 -1
- package/package.json +1 -1
- package/src/core/client.ts +8 -2
- package/src/core/types.ts +5 -0
- package/src/instrumentation/express.ts +338 -0
- package/src/instrumentation/fastify.ts +296 -0
- package/src/instrumentation/framework.ts +301 -0
- package/src/instrumentation/hook.ts +79 -192
- package/src/instrumentation/koa.ts +173 -0
- package/src/register.ts +16 -0
- package/src/wrappers/fastify.ts +10 -7
- package/src/wrappers/h3.ts +40 -16
- package/src/wrappers/next.ts +68 -21
- package/wiki.md +8 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@senzops/apm-node",
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "Universal APM SDK for Senzor",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"require": "./dist/index.js",
|
|
11
|
+
"import": "./dist/index.mjs"
|
|
12
|
+
},
|
|
13
|
+
"./register": {
|
|
14
|
+
"types": "./dist/register.d.ts",
|
|
15
|
+
"require": "./dist/register.js",
|
|
16
|
+
"import": "./dist/register.mjs"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^20.19.41",
|
|
25
|
+
"tsup": "^8.0.0",
|
|
26
|
+
"typescript": "^5.0.0"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18.0.0"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"apm",
|
|
33
|
+
"monitoring",
|
|
34
|
+
"senzor",
|
|
35
|
+
"node",
|
|
36
|
+
"javascript",
|
|
37
|
+
"api",
|
|
38
|
+
"observability"
|
|
39
|
+
],
|
|
40
|
+
"author": "Senzops",
|
|
41
|
+
"license": "MIT"
|
|
42
|
+
}
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { Transport } from './transport';
|
|
2
|
+
import { Context } from './context';
|
|
3
|
+
import { SenzorOptions, ActiveTrace, TaskRun, SenzorLog } from './types';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
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';
|
|
22
|
+
|
|
23
|
+
// Memory-safe JSON stringifier to handle cyclical objects
|
|
24
|
+
// (like Express 'req' objects) passed into console.log
|
|
25
|
+
const safeStringify = (obj: any): string => {
|
|
26
|
+
const cache = new Set();
|
|
27
|
+
return JSON.stringify(obj, (key, value) => {
|
|
28
|
+
if (typeof value === 'object' && value !== null) {
|
|
29
|
+
if (cache.has(value)) return '[Circular]';
|
|
30
|
+
cache.add(value);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
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
|
+
}
|
|
59
|
+
this.options = options;
|
|
60
|
+
const endpoint = options.endpoint || 'https://api.senzor.dev/api/ingest/apm';
|
|
61
|
+
const debug = options.debug || false;
|
|
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
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Enterprise Auto-Log Interception ---
|
|
101
|
+
private setupLogInterception() {
|
|
102
|
+
if (this.options?.autoLogs === false) return; // Opt-out check
|
|
103
|
+
|
|
104
|
+
const levels = ['log', 'info', 'warn', 'error', 'debug'] as const;
|
|
105
|
+
const originalConsole = {
|
|
106
|
+
log: console.log,
|
|
107
|
+
info: console.info,
|
|
108
|
+
warn: console.warn,
|
|
109
|
+
error: console.error,
|
|
110
|
+
debug: console.debug
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
let isIntercepting = false; // Lock to prevent SDK internal logs from looping infinitely
|
|
114
|
+
|
|
115
|
+
levels.forEach(level => {
|
|
116
|
+
console[level] = (...args: any[]) => {
|
|
117
|
+
// Always execute original console so user's terminal isn't broken
|
|
118
|
+
originalConsole[level].apply(console, args);
|
|
119
|
+
|
|
120
|
+
if (isIntercepting || !this.transport) return;
|
|
121
|
+
isIntercepting = true;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
let message = '';
|
|
125
|
+
let attributes: Record<string, any> = {};
|
|
126
|
+
|
|
127
|
+
args.forEach(arg => {
|
|
128
|
+
if (typeof arg === 'string') {
|
|
129
|
+
message += (message ? ' ' : '') + arg;
|
|
130
|
+
} else if (arg instanceof Error) {
|
|
131
|
+
message += (message ? ' ' : '') + arg.message;
|
|
132
|
+
attributes.errorStack = arg.stack;
|
|
133
|
+
attributes.errorName = arg.name;
|
|
134
|
+
} else if (typeof arg === 'object' && arg !== null) {
|
|
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
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
message += (message ? ' ' : '') + String(arg);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Fallback if the user purely logged an object without text e.g., console.log({ user: 123 })
|
|
148
|
+
if (!message && Object.keys(attributes).length > 0) {
|
|
149
|
+
message = 'Object Log';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Attach to Active Context seamlessly (Works for BOTH APM and Tasks!)
|
|
153
|
+
const currentTrace = Context.current();
|
|
154
|
+
const logType = currentTrace?.contextType === 'task' ? 'task' : 'apm';
|
|
155
|
+
|
|
156
|
+
const logPayload: SenzorLog = {
|
|
157
|
+
message: message || 'Empty log',
|
|
158
|
+
level: level === 'log' ? 'info' : level, // Map generic log -> info
|
|
159
|
+
attributes,
|
|
160
|
+
timestamp: new Date().toISOString()
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Attach the specific contextual ID
|
|
164
|
+
if (currentTrace) {
|
|
165
|
+
if (logType === 'task') logPayload.runId = currentTrace.id;
|
|
166
|
+
else logPayload.traceId = currentTrace.id;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this.transport.addLog(logPayload, logType);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
// Absolute failure isolation. Never crash host app during logging.
|
|
172
|
+
} finally {
|
|
173
|
+
isIntercepting = false; // Release lock
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private setupGlobalErrorHandlers() {
|
|
180
|
+
if ((process as any).__senzorGlobalHandlersInstalled) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
(process as any).__senzorGlobalHandlersInstalled = true;
|
|
185
|
+
|
|
186
|
+
const getProcessContext = () => {
|
|
187
|
+
try {
|
|
188
|
+
return {
|
|
189
|
+
pid: process.pid,
|
|
190
|
+
ppid: process.ppid,
|
|
191
|
+
platform: process.platform,
|
|
192
|
+
uptimeSec: Math.floor(process.uptime()),
|
|
193
|
+
env: process.env.NODE_ENV || 'unknown'
|
|
194
|
+
};
|
|
195
|
+
} catch {
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const getMemoryContext = () => {
|
|
201
|
+
try {
|
|
202
|
+
const mem = process.memoryUsage();
|
|
203
|
+
return {
|
|
204
|
+
rss: mem.rss,
|
|
205
|
+
heapTotal: mem.heapTotal,
|
|
206
|
+
heapUsed: mem.heapUsed,
|
|
207
|
+
external: mem.external,
|
|
208
|
+
arrayBuffers: mem.arrayBuffers
|
|
209
|
+
};
|
|
210
|
+
} catch {
|
|
211
|
+
return {};
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const safeCapture = (error: unknown, meta: any = {}) => {
|
|
216
|
+
try {
|
|
217
|
+
let parsedError: Error;
|
|
218
|
+
if (error instanceof Error) {
|
|
219
|
+
parsedError = error;
|
|
220
|
+
} else if (typeof error === 'string') {
|
|
221
|
+
parsedError = new Error(error);
|
|
222
|
+
} else {
|
|
223
|
+
try {
|
|
224
|
+
parsedError = new Error(JSON.stringify(error));
|
|
225
|
+
} catch {
|
|
226
|
+
parsedError = new Error('Non-serializable rejection reason');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const enrichedMeta = {
|
|
230
|
+
...meta,
|
|
231
|
+
runtime: { name: 'node', version: process.version },
|
|
232
|
+
process: getProcessContext(),
|
|
233
|
+
memory: getMemoryContext(),
|
|
234
|
+
sdk: { name: SDK_META.name, version: SDK_META.version }
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
this.captureError(parsedError, enrichedMeta);
|
|
238
|
+
} catch (internalFailure) {
|
|
239
|
+
try {
|
|
240
|
+
if (this.options?.debug) {
|
|
241
|
+
console.error('[Senzor] Error handler failure:', internalFailure);
|
|
242
|
+
}
|
|
243
|
+
} catch { }
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
process.on('uncaughtExceptionMonitor', (error) => safeCapture(error, { type: 'uncaughtExceptionMonitor', severity: 'fatal' }));
|
|
248
|
+
process.on('uncaughtException', (error) => safeCapture(error, { type: 'uncaughtException', severity: 'fatal' }));
|
|
249
|
+
process.on('unhandledRejection', (reason) => safeCapture(reason, { type: 'unhandledRejection', severity: 'error' }));
|
|
250
|
+
process.on('warning', (warning) => safeCapture(warning, { type: 'processWarning', severity: 'warning' }));
|
|
251
|
+
process.on('multipleResolves', (type, promise, reason) => safeCapture(reason || new Error('Multiple promise resolves'), { type: 'multipleResolves', resolveType: type, severity: 'warning' }));
|
|
252
|
+
process.on('rejectionHandled', (promise) => { if (this.options?.debug) { try { console.warn('[Senzor] rejectionHandled event detected'); } catch { } } });
|
|
253
|
+
process.on('SIGTERM', () => safeCapture(new Error('Process received SIGTERM'), { type: 'processSignal', signal: 'SIGTERM' }));
|
|
254
|
+
process.on('SIGINT', () => safeCapture(new Error('Process received SIGINT'), { type: 'processSignal', signal: 'SIGINT' }));
|
|
255
|
+
}
|
|
256
|
+
|
|
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;
|
|
271
|
+
|
|
272
|
+
if (data.headers) {
|
|
273
|
+
const getHeader = (key: string) => {
|
|
274
|
+
if (data.headers[key]) return data.headers[key];
|
|
275
|
+
if (data.headers[key.toLowerCase()]) return data.headers[key.toLowerCase()];
|
|
276
|
+
return undefined;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const traceparent = getHeader('traceparent');
|
|
280
|
+
const parsedContext = parseTraceparent(traceparent);
|
|
281
|
+
|
|
282
|
+
if (parsedContext) {
|
|
283
|
+
inheritedTraceId = parsedContext.traceId;
|
|
284
|
+
inheritedParentSpanId = parsedContext.parentSpanId;
|
|
285
|
+
} else {
|
|
286
|
+
const rawTrace = getHeader('x-senzor-trace-id');
|
|
287
|
+
const rawSpan = getHeader('x-senzor-parent-span-id');
|
|
288
|
+
inheritedTraceId = Array.isArray(rawTrace) ? rawTrace[0] : rawTrace;
|
|
289
|
+
inheritedParentSpanId = Array.isArray(rawSpan) ? rawSpan[0] : rawSpan;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
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
|
+
};
|
|
307
|
+
|
|
308
|
+
return Context.run(trace, next);
|
|
309
|
+
}
|
|
310
|
+
|
|
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
|
+
};
|
|
331
|
+
this.transport.addTrace(payload);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- TASK MONITORING METHODS ---
|
|
335
|
+
public startTask<T>(name: string, type: 'cron' | 'queue' | 'pipeline' | 'custom', options: any, next: () => T): T {
|
|
336
|
+
if (!this.transport) return next();
|
|
337
|
+
|
|
338
|
+
const currentContext = Context.current();
|
|
339
|
+
const triggerTraceId = currentContext?.contextType === 'apm' ? currentContext.id : undefined;
|
|
340
|
+
|
|
341
|
+
const startMemory = process.memoryUsage ? process.memoryUsage().heapUsed : 0;
|
|
342
|
+
const startCpu = process.cpuUsage ? process.cpuUsage() : undefined;
|
|
343
|
+
|
|
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
|
+
}
|
|
359
|
+
|
|
360
|
+
public endTask(status: 'success' | 'failed', extraMetadata: any = {}) {
|
|
361
|
+
const task = Context.current();
|
|
362
|
+
if (!task || task.contextType !== 'task' || !this.transport) return;
|
|
363
|
+
|
|
364
|
+
let resourceMetrics;
|
|
365
|
+
if (process.memoryUsage && task.startMemory !== undefined && process.cpuUsage && task.startCpu) {
|
|
366
|
+
const endMemory = process.memoryUsage().heapUsed;
|
|
367
|
+
const cpuDelta = process.cpuUsage(task.startCpu);
|
|
368
|
+
|
|
369
|
+
resourceMetrics = {
|
|
370
|
+
memoryDeltaBytes: endMemory - task.startMemory,
|
|
371
|
+
cpuUserUs: cpuDelta.user,
|
|
372
|
+
cpuSystemUs: cpuDelta.system
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const payload: TaskRun = {
|
|
377
|
+
runId: task.id,
|
|
378
|
+
taskName: task.data.taskName,
|
|
379
|
+
taskType: task.data.taskType,
|
|
380
|
+
triggerTraceId: task.data.triggerTraceId,
|
|
381
|
+
queueDelay: task.data.queueDelay,
|
|
382
|
+
attempts: task.data.attempts,
|
|
383
|
+
isDeadLetter: task.data.isDeadLetter,
|
|
384
|
+
metadata: { ...task.data.metadata, ...extraMetadata, droppedSpans: task.droppedSpans },
|
|
385
|
+
resourceMetrics,
|
|
386
|
+
status,
|
|
387
|
+
duration: performance.now() - task.startTime,
|
|
388
|
+
spans: task.spans,
|
|
389
|
+
timestamp: new Date().toISOString()
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
this.transport.addTask(payload);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
public wrapTask<T extends (...args: any[]) => any>(name: string, type: 'cron' | 'queue' | 'pipeline' | 'custom', options: any = {}, fn: T): T {
|
|
396
|
+
return (async (...args: any[]) => {
|
|
397
|
+
return this.startTask(name, type, options, async () => {
|
|
398
|
+
try {
|
|
399
|
+
const result = await fn(...args);
|
|
400
|
+
this.endTask('success');
|
|
401
|
+
return result;
|
|
402
|
+
} catch (error) {
|
|
403
|
+
this.captureError(error, { taskName: name });
|
|
404
|
+
this.endTask('failed');
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}) as unknown as T;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
public captureError(error: unknown, context: any = {}) {
|
|
412
|
+
if (!this.transport) return;
|
|
413
|
+
|
|
414
|
+
let parsedError: Error;
|
|
415
|
+
if (error instanceof Error) {
|
|
416
|
+
parsedError = error;
|
|
417
|
+
} else {
|
|
418
|
+
parsedError = new Error(String(error));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const currentTrace = Context.current();
|
|
422
|
+
|
|
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
|
+
};
|
|
430
|
+
|
|
431
|
+
if (currentTrace?.contextType === 'task') {
|
|
432
|
+
this.transport.addError({ ...errPayload, runId: currentTrace.id }, 'task');
|
|
433
|
+
} else {
|
|
434
|
+
this.transport.addError({ ...errPayload, traceId: currentTrace?.id }, 'apm');
|
|
435
|
+
}
|
|
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
|
+
}
|
|
447
|
+
|
|
448
|
+
public async flush() { if (this.transport) await this.transport.flush(); }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export const client = new SenzorClient();
|
|
@@ -0,0 +1,48 @@
|
|
|
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,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic URL Normalizer
|
|
3
|
+
* Converts raw paths with IDs into generic patterns to prevent high cardinality.
|
|
4
|
+
* Example: /users/123/orders/abc-def -> /users/:id/orders/:uuid
|
|
5
|
+
*/
|
|
6
|
+
export const normalizePath = (path: string): string => {
|
|
7
|
+
if (!path || path === '/') return '/';
|
|
8
|
+
|
|
9
|
+
return path
|
|
10
|
+
// Replace UUIDs (long alphanumeric strings)
|
|
11
|
+
.replace(
|
|
12
|
+
/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g,
|
|
13
|
+
':uuid'
|
|
14
|
+
)
|
|
15
|
+
// Replace MongoDB ObjectIds (24 hex chars)
|
|
16
|
+
.replace(/[0-9a-fA-F]{24}/g, ':objectId')
|
|
17
|
+
// Replace pure numeric IDs (e.g., /123)
|
|
18
|
+
.replace(/\/(\d+)(?=\/|$)/g, '/:id')
|
|
19
|
+
// Remove query strings
|
|
20
|
+
.split('?')[0];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Tries to extract route from Framework internals, falls back to heuristic
|
|
25
|
+
*/
|
|
26
|
+
export const getRoute = (req: any, fallbackPath: string): string => {
|
|
27
|
+
// Express / Connect
|
|
28
|
+
if (req.route && req.route.path) {
|
|
29
|
+
return (req.baseUrl || '') + req.route.path;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// H3 / Nitro (Nuxt)
|
|
33
|
+
if (req.context && req.context.matchedRoute) {
|
|
34
|
+
return req.context.matchedRoute.path;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Fastify
|
|
38
|
+
if (req.routerPath) {
|
|
39
|
+
return req.routerPath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Fallback: Heuristic Normalization
|
|
43
|
+
return normalizePath(fallbackPath);
|
|
44
|
+
};
|