@senzops/apm-node 1.1.16 → 1.1.18
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/CHANGELOG.md +8 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.d.ts +1 -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/package.json +1 -1
- package/src/core/client.ts +111 -91
- package/src/core/transport.ts +30 -9
- package/src/core/types.ts +12 -0
- package/src/middleware/express.ts +2 -1
- package/src/utils/getClientIp.ts +175 -0
- package/src/wrappers/fastify.ts +2 -1
- package/src/wrappers/h3.ts +2 -1
- package/src/wrappers/next.ts +3 -2
package/src/core/client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Transport } from './transport';
|
|
2
2
|
import { Context } from './context';
|
|
3
|
-
import { SenzorOptions, ActiveTrace, TaskRun } from './types';
|
|
3
|
+
import { SenzorOptions, ActiveTrace, TaskRun, SenzorLog } from './types';
|
|
4
4
|
import { randomUUID } from 'crypto';
|
|
5
5
|
import { instrumentHttp, instrumentFetch } from '../instrumentation/http';
|
|
6
6
|
import { instrumentMongo } from '../instrumentation/mongo';
|
|
@@ -8,10 +8,23 @@ import { instrumentPg } from '../instrumentation/pg';
|
|
|
8
8
|
import { instrumentBullMQ } from '../instrumentation/bullmq';
|
|
9
9
|
import { instrumentNodeCron } from '../instrumentation/cron';
|
|
10
10
|
import { SDK_META } from '../utils/sdkMeta';
|
|
11
|
-
import { parseTraceparent } from '../utils/traceContext';
|
|
11
|
+
import { parseTraceparent } from '../utils/traceContext';
|
|
12
12
|
|
|
13
13
|
const generateW3CTraceId = () => randomUUID().replace(/-/g, '');
|
|
14
14
|
|
|
15
|
+
// Memory-safe JSON stringifier to handle cyclical objects
|
|
16
|
+
// (like Express 'req' objects) passed into console.log
|
|
17
|
+
const safeStringify = (obj: any): string => {
|
|
18
|
+
const cache = new Set();
|
|
19
|
+
return JSON.stringify(obj, (key, value) => {
|
|
20
|
+
if (typeof value === 'object' && value !== null) {
|
|
21
|
+
if (cache.has(value)) return '[Circular]';
|
|
22
|
+
cache.add(value);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
15
28
|
export class SenzorClient {
|
|
16
29
|
private transport: Transport | null = null;
|
|
17
30
|
private options: SenzorOptions | null = null;
|
|
@@ -30,21 +43,101 @@ export class SenzorClient {
|
|
|
30
43
|
|
|
31
44
|
if (!this.isInstrumented) {
|
|
32
45
|
this.setupGlobalErrorHandlers();
|
|
46
|
+
this.setupLogInterception(); // Fire up Auto Log Instrumentation
|
|
33
47
|
|
|
34
48
|
try { instrumentHttp(endpoint, debug); } catch (e) { }
|
|
35
49
|
try { instrumentFetch(endpoint, debug); } catch (e) { }
|
|
36
50
|
try { instrumentMongo(debug); } catch (e) { }
|
|
37
51
|
try { instrumentPg(); } catch (e) { }
|
|
38
52
|
|
|
39
|
-
// Task Integrations
|
|
53
|
+
// Task Integrations
|
|
40
54
|
try { instrumentBullMQ(this, debug); } catch (e) { }
|
|
41
55
|
try { instrumentNodeCron(this, debug); } catch (e) { }
|
|
42
56
|
|
|
43
57
|
this.isInstrumented = true;
|
|
44
|
-
if (debug) console.log('[Senzor] Auto-instrumentation
|
|
58
|
+
if (debug) console.log('[Senzor] Auto-instrumentation enabled');
|
|
45
59
|
}
|
|
46
60
|
}
|
|
47
61
|
|
|
62
|
+
// --- Enterprise Auto-Log Interception ---
|
|
63
|
+
private setupLogInterception() {
|
|
64
|
+
if (this.options?.autoLogs === false) return; // Opt-out check
|
|
65
|
+
|
|
66
|
+
const levels = ['log', 'info', 'warn', 'error', 'debug'] as const;
|
|
67
|
+
const originalConsole = {
|
|
68
|
+
log: console.log,
|
|
69
|
+
info: console.info,
|
|
70
|
+
warn: console.warn,
|
|
71
|
+
error: console.error,
|
|
72
|
+
debug: console.debug
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
let isIntercepting = false; // Lock to prevent SDK internal logs from looping infinitely
|
|
76
|
+
|
|
77
|
+
levels.forEach(level => {
|
|
78
|
+
console[level] = (...args: any[]) => {
|
|
79
|
+
// Always execute original console so user's terminal isn't broken
|
|
80
|
+
originalConsole[level].apply(console, args);
|
|
81
|
+
|
|
82
|
+
if (isIntercepting || !this.transport) return;
|
|
83
|
+
isIntercepting = true;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
let message = '';
|
|
87
|
+
let attributes: Record<string, any> = {};
|
|
88
|
+
|
|
89
|
+
args.forEach(arg => {
|
|
90
|
+
if (typeof arg === 'string') {
|
|
91
|
+
message += (message ? ' ' : '') + arg;
|
|
92
|
+
} else if (arg instanceof Error) {
|
|
93
|
+
message += (message ? ' ' : '') + arg.message;
|
|
94
|
+
attributes.errorStack = arg.stack;
|
|
95
|
+
attributes.errorName = arg.name;
|
|
96
|
+
} else if (typeof arg === 'object' && arg !== null) {
|
|
97
|
+
try {
|
|
98
|
+
// New Relic Style Destructuring: Merge all object keys into `attributes`
|
|
99
|
+
const parsed = JSON.parse(safeStringify(arg));
|
|
100
|
+
attributes = { ...attributes, ...parsed };
|
|
101
|
+
} catch (e) {
|
|
102
|
+
attributes.unparseableObject = true;
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
message += (message ? ' ' : '') + String(arg);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Fallback if the user purely logged an object without text e.g., console.log({ user: 123 })
|
|
110
|
+
if (!message && Object.keys(attributes).length > 0) {
|
|
111
|
+
message = 'Object Log';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Attach to Active Context seamlessly (Works for BOTH APM and Tasks!)
|
|
115
|
+
const currentTrace = Context.current();
|
|
116
|
+
const logType = currentTrace?.contextType === 'task' ? 'task' : 'apm';
|
|
117
|
+
|
|
118
|
+
const logPayload: SenzorLog = {
|
|
119
|
+
message: message || 'Empty log',
|
|
120
|
+
level: level === 'log' ? 'info' : level, // Map generic log -> info
|
|
121
|
+
attributes,
|
|
122
|
+
timestamp: new Date().toISOString()
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Attach the specific contextual ID
|
|
126
|
+
if (currentTrace) {
|
|
127
|
+
if (logType === 'task') logPayload.runId = currentTrace.id;
|
|
128
|
+
else logPayload.traceId = currentTrace.id;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.transport.addLog(logPayload, logType);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
// Absolute failure isolation. Never crash host app during logging.
|
|
134
|
+
} finally {
|
|
135
|
+
isIntercepting = false; // Release lock
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
48
141
|
private setupGlobalErrorHandlers() {
|
|
49
142
|
if ((process as any).__senzorGlobalHandlersInstalled) {
|
|
50
143
|
return;
|
|
@@ -97,21 +190,14 @@ export class SenzorClient {
|
|
|
97
190
|
}
|
|
98
191
|
const enrichedMeta = {
|
|
99
192
|
...meta,
|
|
100
|
-
runtime: {
|
|
101
|
-
name: 'node',
|
|
102
|
-
version: process.version
|
|
103
|
-
},
|
|
193
|
+
runtime: { name: 'node', version: process.version },
|
|
104
194
|
process: getProcessContext(),
|
|
105
195
|
memory: getMemoryContext(),
|
|
106
|
-
sdk: {
|
|
107
|
-
name: SDK_META.name,
|
|
108
|
-
version: SDK_META.version
|
|
109
|
-
}
|
|
196
|
+
sdk: { name: SDK_META.name, version: SDK_META.version }
|
|
110
197
|
};
|
|
111
198
|
|
|
112
199
|
this.captureError(parsedError, enrichedMeta);
|
|
113
200
|
} catch (internalFailure) {
|
|
114
|
-
// NEVER allow SDK to crash host app
|
|
115
201
|
try {
|
|
116
202
|
if (this.options?.debug) {
|
|
117
203
|
console.error('[Senzor] Error handler failure:', internalFailure);
|
|
@@ -120,69 +206,14 @@ export class SenzorClient {
|
|
|
120
206
|
}
|
|
121
207
|
};
|
|
122
208
|
|
|
123
|
-
process.on('uncaughtExceptionMonitor', (error) => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
process.on('
|
|
131
|
-
safeCapture(error, {
|
|
132
|
-
type: 'uncaughtException',
|
|
133
|
-
severity: 'fatal'
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
process.on('unhandledRejection', (reason) => {
|
|
138
|
-
safeCapture(reason, {
|
|
139
|
-
type: 'unhandledRejection',
|
|
140
|
-
severity: 'error'
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
process.on('warning', (warning) => {
|
|
145
|
-
safeCapture(warning, {
|
|
146
|
-
type: 'processWarning',
|
|
147
|
-
severity: 'warning'
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
process.on('multipleResolves', (type, promise, reason) => {
|
|
152
|
-
safeCapture(reason || new Error('Multiple promise resolves'), {
|
|
153
|
-
type: 'multipleResolves',
|
|
154
|
-
resolveType: type,
|
|
155
|
-
severity: 'warning'
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
process.on('rejectionHandled', (promise) => {
|
|
160
|
-
if (this.options?.debug) {
|
|
161
|
-
try {
|
|
162
|
-
console.warn('[Senzor] rejectionHandled event detected');
|
|
163
|
-
} catch { }
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
process.on('SIGTERM', () => {
|
|
168
|
-
safeCapture(
|
|
169
|
-
new Error('Process received SIGTERM'),
|
|
170
|
-
{
|
|
171
|
-
type: 'processSignal',
|
|
172
|
-
signal: 'SIGTERM'
|
|
173
|
-
}
|
|
174
|
-
);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
process.on('SIGINT', () => {
|
|
178
|
-
safeCapture(
|
|
179
|
-
new Error('Process received SIGINT'),
|
|
180
|
-
{
|
|
181
|
-
type: 'processSignal',
|
|
182
|
-
signal: 'SIGINT'
|
|
183
|
-
}
|
|
184
|
-
);
|
|
185
|
-
});
|
|
209
|
+
process.on('uncaughtExceptionMonitor', (error) => safeCapture(error, { type: 'uncaughtExceptionMonitor', severity: 'fatal' }));
|
|
210
|
+
process.on('uncaughtException', (error) => safeCapture(error, { type: 'uncaughtException', severity: 'fatal' }));
|
|
211
|
+
process.on('unhandledRejection', (reason) => safeCapture(reason, { type: 'unhandledRejection', severity: 'error' }));
|
|
212
|
+
process.on('warning', (warning) => safeCapture(warning, { type: 'processWarning', severity: 'warning' }));
|
|
213
|
+
process.on('multipleResolves', (type, promise, reason) => safeCapture(reason || new Error('Multiple promise resolves'), { type: 'multipleResolves', resolveType: type, severity: 'warning' }));
|
|
214
|
+
process.on('rejectionHandled', (promise) => { if (this.options?.debug) { try { console.warn('[Senzor] rejectionHandled event detected'); } catch { } } });
|
|
215
|
+
process.on('SIGTERM', () => safeCapture(new Error('Process received SIGTERM'), { type: 'processSignal', signal: 'SIGTERM' }));
|
|
216
|
+
process.on('SIGINT', () => safeCapture(new Error('Process received SIGINT'), { type: 'processSignal', signal: 'SIGINT' }));
|
|
186
217
|
}
|
|
187
218
|
|
|
188
219
|
public startTrace<T>(data: Partial<ActiveTrace['data']> & { headers?: any }, next: () => T): T {
|
|
@@ -198,7 +229,6 @@ export class SenzorClient {
|
|
|
198
229
|
return undefined;
|
|
199
230
|
};
|
|
200
231
|
|
|
201
|
-
// 1. Prioritize standard W3C Context (e.g., from RUM Frontend)
|
|
202
232
|
const traceparent = getHeader('traceparent');
|
|
203
233
|
const parsedContext = parseTraceparent(traceparent);
|
|
204
234
|
|
|
@@ -206,7 +236,6 @@ export class SenzorClient {
|
|
|
206
236
|
inheritedTraceId = parsedContext.traceId;
|
|
207
237
|
inheritedParentSpanId = parsedContext.parentSpanId;
|
|
208
238
|
} else {
|
|
209
|
-
// 2. Fallback to legacy proprietary headers
|
|
210
239
|
const rawTrace = getHeader('x-senzor-trace-id');
|
|
211
240
|
const rawSpan = getHeader('x-senzor-parent-span-id');
|
|
212
241
|
inheritedTraceId = Array.isArray(rawTrace) ? rawTrace[0] : rawTrace;
|
|
@@ -214,18 +243,13 @@ export class SenzorClient {
|
|
|
214
243
|
}
|
|
215
244
|
}
|
|
216
245
|
|
|
217
|
-
// Crucial: ADOPT the inherited traceId to perfectly link Frontend & Backend
|
|
218
246
|
const activeTraceId = inheritedTraceId || generateW3CTraceId();
|
|
219
247
|
|
|
220
248
|
const trace: ActiveTrace = {
|
|
221
249
|
id: activeTraceId,
|
|
222
|
-
contextType: 'apm',
|
|
250
|
+
contextType: 'apm',
|
|
223
251
|
startTime: performance.now(),
|
|
224
|
-
data: {
|
|
225
|
-
...data,
|
|
226
|
-
parentTraceId: inheritedTraceId,
|
|
227
|
-
parentSpanId: inheritedParentSpanId
|
|
228
|
-
},
|
|
252
|
+
data: { ...data, parentTraceId: inheritedTraceId, parentSpanId: inheritedParentSpanId },
|
|
229
253
|
spans: []
|
|
230
254
|
};
|
|
231
255
|
|
|
@@ -255,7 +279,6 @@ export class SenzorClient {
|
|
|
255
279
|
const currentContext = Context.current();
|
|
256
280
|
const triggerTraceId = currentContext?.contextType === 'apm' ? currentContext.id : undefined;
|
|
257
281
|
|
|
258
|
-
// Snapshot system resources before execution
|
|
259
282
|
const startMemory = process.memoryUsage ? process.memoryUsage().heapUsed : 0;
|
|
260
283
|
const startCpu = process.cpuUsage ? process.cpuUsage() : undefined;
|
|
261
284
|
|
|
@@ -275,14 +298,13 @@ export class SenzorClient {
|
|
|
275
298
|
const task = Context.current();
|
|
276
299
|
if (!task || task.contextType !== 'task' || !this.transport) return;
|
|
277
300
|
|
|
278
|
-
// Calculate resource deltas
|
|
279
301
|
let resourceMetrics;
|
|
280
302
|
if (process.memoryUsage && task.startMemory !== undefined && process.cpuUsage && task.startCpu) {
|
|
281
303
|
const endMemory = process.memoryUsage().heapUsed;
|
|
282
304
|
const cpuDelta = process.cpuUsage(task.startCpu);
|
|
283
305
|
|
|
284
306
|
resourceMetrics = {
|
|
285
|
-
memoryDeltaBytes: endMemory - task.startMemory,
|
|
307
|
+
memoryDeltaBytes: endMemory - task.startMemory,
|
|
286
308
|
cpuUserUs: cpuDelta.user,
|
|
287
309
|
cpuSystemUs: cpuDelta.system
|
|
288
310
|
};
|
|
@@ -295,7 +317,7 @@ export class SenzorClient {
|
|
|
295
317
|
triggerTraceId: task.data.triggerTraceId,
|
|
296
318
|
queueDelay: task.data.queueDelay,
|
|
297
319
|
attempts: task.data.attempts,
|
|
298
|
-
isDeadLetter: task.data.isDeadLetter,
|
|
320
|
+
isDeadLetter: task.data.isDeadLetter,
|
|
299
321
|
metadata: { ...task.data.metadata, ...extraMetadata },
|
|
300
322
|
resourceMetrics,
|
|
301
323
|
status,
|
|
@@ -323,7 +345,6 @@ export class SenzorClient {
|
|
|
323
345
|
}) as unknown as T;
|
|
324
346
|
}
|
|
325
347
|
|
|
326
|
-
// --- MODIFIED: Context-Aware Error Capture ---
|
|
327
348
|
public captureError(error: unknown, context: any = {}) {
|
|
328
349
|
if (!this.transport) return;
|
|
329
350
|
|
|
@@ -360,7 +381,6 @@ export class SenzorClient {
|
|
|
360
381
|
if (!trace) return { end: () => { } };
|
|
361
382
|
const startTime = performance.now() - trace.startTime;
|
|
362
383
|
const spanStartAbs = performance.now();
|
|
363
|
-
// Use 16 char hex for span IDs for W3C compatibility
|
|
364
384
|
const spanId = randomUUID().replace(/-/g, '').slice(0, 16);
|
|
365
385
|
return { end: (meta?: any, status?: number) => { Context.addSpan({ spanId, name, type, startTime, duration: performance.now() - spanStartAbs, status, meta }); } };
|
|
366
386
|
}
|
package/src/core/transport.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { SenzorOptions, Trace, TaskRun, SenzorError } from './types';
|
|
1
|
+
import { SenzorOptions, Trace, TaskRun, SenzorError, SenzorLog } from './types';
|
|
2
2
|
|
|
3
3
|
export class Transport {
|
|
4
4
|
private traceQueue: Trace[] = [];
|
|
5
5
|
private apmErrorQueue: SenzorError[] = [];
|
|
6
|
+
private apmLogQueue: SenzorLog[] = []; // APM Logs
|
|
6
7
|
|
|
7
8
|
private taskQueue: TaskRun[] = [];
|
|
8
9
|
private taskErrorQueue: SenzorError[] = [];
|
|
10
|
+
private taskLogQueue: SenzorLog[] = []; // Task Logs
|
|
9
11
|
|
|
10
12
|
private timer: NodeJS.Timeout | null = null;
|
|
11
13
|
private apmEndpoint: string;
|
|
@@ -41,40 +43,59 @@ export class Transport {
|
|
|
41
43
|
this.checkFlush();
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
// Add captured log to the correct batch queue
|
|
47
|
+
public addLog(log: SenzorLog, type: 'apm' | 'task' = 'apm') {
|
|
48
|
+
if (type === 'task') this.taskLogQueue.push(log);
|
|
49
|
+
else this.apmLogQueue.push(log);
|
|
50
|
+
this.checkFlush();
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
private checkFlush() {
|
|
45
|
-
const totalApm = this.traceQueue.length + this.apmErrorQueue.length;
|
|
46
|
-
const totalTask = this.taskQueue.length + this.taskErrorQueue.length;
|
|
54
|
+
const totalApm = this.traceQueue.length + this.apmErrorQueue.length + this.apmLogQueue.length;
|
|
55
|
+
const totalTask = this.taskQueue.length + this.taskErrorQueue.length + this.taskLogQueue.length;
|
|
47
56
|
if (totalApm >= (this.config.batchSize || 100) || totalTask >= (this.config.batchSize || 100)) {
|
|
48
57
|
this.flush();
|
|
49
58
|
}
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
public async flush() {
|
|
53
|
-
const apmPayload = {
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
const apmPayload = {
|
|
63
|
+
traces: [...this.traceQueue],
|
|
64
|
+
errors: [...this.apmErrorQueue],
|
|
65
|
+
logs: [...this.apmLogQueue]
|
|
66
|
+
};
|
|
67
|
+
const taskPayload = {
|
|
68
|
+
runs: [...this.taskQueue],
|
|
69
|
+
errors: [...this.taskErrorQueue],
|
|
70
|
+
logs: [...this.taskLogQueue]
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Reset Queues instantly
|
|
56
74
|
this.traceQueue = [];
|
|
57
75
|
this.apmErrorQueue = [];
|
|
76
|
+
this.apmLogQueue = [];
|
|
58
77
|
this.taskQueue = [];
|
|
59
78
|
this.taskErrorQueue = [];
|
|
79
|
+
this.taskLogQueue = [];
|
|
60
80
|
|
|
61
81
|
const headers = { 'Content-Type': 'application/json', 'x-service-api-key': this.config.apiKey };
|
|
62
82
|
|
|
63
83
|
try {
|
|
64
84
|
const promises = [];
|
|
65
85
|
|
|
66
|
-
|
|
86
|
+
// Piggyback logs onto APM/Task batch ingestion to bypass extra network round-trips
|
|
87
|
+
if (apmPayload.traces.length > 0 || apmPayload.errors.length > 0 || apmPayload.logs.length > 0) {
|
|
67
88
|
promises.push(fetch(this.apmEndpoint, { method: 'POST', headers, body: JSON.stringify(apmPayload), keepalive: true }));
|
|
68
89
|
}
|
|
69
90
|
|
|
70
|
-
if (taskPayload.runs.length > 0 || taskPayload.errors.length > 0) {
|
|
91
|
+
if (taskPayload.runs.length > 0 || taskPayload.errors.length > 0 || taskPayload.logs.length > 0) {
|
|
71
92
|
promises.push(fetch(this.taskEndpoint, { method: 'POST', headers, body: JSON.stringify(taskPayload), keepalive: true }));
|
|
72
93
|
}
|
|
73
94
|
|
|
74
95
|
await Promise.allSettled(promises);
|
|
75
96
|
|
|
76
97
|
if (this.config.debug) {
|
|
77
|
-
console.log(`[Senzor] Flushed: ${apmPayload.traces.length} traces, ${taskPayload.runs.length}
|
|
98
|
+
console.log(`[Senzor] Flushed: APM(${apmPayload.traces.length} traces, ${apmPayload.logs.length} logs), Task(${taskPayload.runs.length} runs, ${taskPayload.logs.length} logs)`);
|
|
78
99
|
}
|
|
79
100
|
} catch (err) {
|
|
80
101
|
if (this.config.debug) console.error('[Senzor] Transport Flush Error:', err);
|
package/src/core/types.ts
CHANGED
|
@@ -4,6 +4,7 @@ export interface SenzorOptions {
|
|
|
4
4
|
batchSize?: number;
|
|
5
5
|
flushInterval?: number;
|
|
6
6
|
debug?: boolean;
|
|
7
|
+
autoLogs?: boolean;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export interface Span {
|
|
@@ -26,6 +27,17 @@ export interface SenzorError {
|
|
|
26
27
|
timestamp: string;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
// NEW: Enterprise Log Payload
|
|
31
|
+
export interface SenzorLog {
|
|
32
|
+
message: string;
|
|
33
|
+
level: 'info' | 'warn' | 'error' | 'debug' | 'fatal';
|
|
34
|
+
attributes: Record<string, any>;
|
|
35
|
+
traceId?: string; // Used if context is APM
|
|
36
|
+
runId?: string; // Used if context is Task
|
|
37
|
+
spanId?: string;
|
|
38
|
+
timestamp: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
export interface Trace {
|
|
30
42
|
traceId: string;
|
|
31
43
|
parentTraceId?: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { client } from '../core/client';
|
|
2
|
+
import { getClientIp } from '../utils/getClientIp';
|
|
2
3
|
|
|
3
4
|
// 1. Request Handler (Place before routes)
|
|
4
5
|
export const expressMiddleware = () => {
|
|
@@ -6,7 +7,7 @@ export const expressMiddleware = () => {
|
|
|
6
7
|
client.startTrace({
|
|
7
8
|
method: req.method,
|
|
8
9
|
path: req.originalUrl || req.url,
|
|
9
|
-
ip: req
|
|
10
|
+
ip: getClientIp(req),
|
|
10
11
|
userAgent: req.headers['user-agent'],
|
|
11
12
|
headers: req.headers
|
|
12
13
|
}, () => {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* getClientIp.ts
|
|
3
|
+
*
|
|
4
|
+
* Robust, proxy-aware client IP extraction.
|
|
5
|
+
*
|
|
6
|
+
* Priority order (mirrors Umami + industry best practice):
|
|
7
|
+
* 1. ENV-configured custom header (CLIENT_IP_HEADER)
|
|
8
|
+
* 2. CF-Connecting-IP (Cloudflare — single trusted IP)
|
|
9
|
+
* 3. True-Client-IP (Cloudflare Enterprise / Akamai)
|
|
10
|
+
* 4. X-Real-IP (Nginx realip module)
|
|
11
|
+
* 5. Forwarded (RFC 7239 — "for=" field)
|
|
12
|
+
* 6. X-Forwarded-For (De-facto standard — leftmost public IP)
|
|
13
|
+
* 7. req.socket.remoteAddress (Direct connection fallback)
|
|
14
|
+
*
|
|
15
|
+
* Security note: headers 2-6 can be spoofed by clients when your server is
|
|
16
|
+
* directly internet-facing. If that is a concern, restrict extraction to the
|
|
17
|
+
* header your trusted reverse-proxy injects (CLIENT_IP_HEADER or X-Real-IP).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { isIP } from "net";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Helpers
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** Strip IPv4-mapped IPv6 prefix (::ffff:1.2.3.4 → 1.2.3.4) */
|
|
27
|
+
const stripIPv6Mapped = (ip: string): string =>
|
|
28
|
+
ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
|
29
|
+
|
|
30
|
+
/** Strip optional port from an IPv4 address (1.2.3.4:5678 → 1.2.3.4). */
|
|
31
|
+
const stripIPv4Port = (ip: string): string => {
|
|
32
|
+
const lastColon = ip.lastIndexOf(":");
|
|
33
|
+
if (lastColon === -1) return ip;
|
|
34
|
+
const maybeIP = ip.slice(0, lastColon);
|
|
35
|
+
return isIP(maybeIP) === 4 ? maybeIP : ip;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Strip brackets + optional port from an IPv6 address ([::1]:5678 → ::1). */
|
|
39
|
+
const stripIPv6Brackets = (ip: string): string => {
|
|
40
|
+
const match = ip.match(/^\[([^\]]+)\](?::\d+)?$/);
|
|
41
|
+
return match ? match[1] : ip;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Normalise raw IP string into a clean, routable address (or null). */
|
|
45
|
+
export const normaliseIP = (raw: string | undefined | null): string | null => {
|
|
46
|
+
if (!raw) return null;
|
|
47
|
+
let ip = raw.trim();
|
|
48
|
+
if (!ip) return null;
|
|
49
|
+
|
|
50
|
+
ip = stripIPv6Brackets(ip);
|
|
51
|
+
ip = stripIPv4Port(ip);
|
|
52
|
+
ip = stripIPv6Mapped(ip);
|
|
53
|
+
|
|
54
|
+
return isIP(ip) !== 0 ? ip : null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Returns true for IPs that will never produce a geo result:
|
|
59
|
+
* loopback, link-local, private ranges, and unspecified addresses.
|
|
60
|
+
*/
|
|
61
|
+
export const isPrivateOrLoopback = (ip: string): boolean => {
|
|
62
|
+
// IPv4 private / loopback / link-local
|
|
63
|
+
if (
|
|
64
|
+
ip === "127.0.0.1" ||
|
|
65
|
+
ip.startsWith("10.") ||
|
|
66
|
+
ip.startsWith("192.168.") ||
|
|
67
|
+
ip.startsWith("169.254.") || // link-local
|
|
68
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(ip) // 172.16–31
|
|
69
|
+
)
|
|
70
|
+
return true;
|
|
71
|
+
|
|
72
|
+
// IPv6 loopback / unspecified / link-local / unique-local
|
|
73
|
+
if (
|
|
74
|
+
ip === "::1" ||
|
|
75
|
+
ip === "::" ||
|
|
76
|
+
ip.toLowerCase().startsWith("fe80:") || // link-local
|
|
77
|
+
ip.toLowerCase().startsWith("fc") || // unique-local
|
|
78
|
+
ip.toLowerCase().startsWith("fd") // unique-local
|
|
79
|
+
)
|
|
80
|
+
return true;
|
|
81
|
+
|
|
82
|
+
return false;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// RFC 7239 "Forwarded" header parser
|
|
87
|
+
// e.g. Forwarded: for=192.0.2.60;proto=http, for="[2001:db8::cafe]"
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
const parseForwardedHeader = (header: string): string | null => {
|
|
90
|
+
const parts = header.split(",");
|
|
91
|
+
for (const part of parts) {
|
|
92
|
+
const forMatch = part.match(/for=["[]?([^\]",;>\s]+)/i);
|
|
93
|
+
if (forMatch) {
|
|
94
|
+
const ip = normaliseIP(forMatch[1]);
|
|
95
|
+
if (ip && !isPrivateOrLoopback(ip)) return ip;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// X-Forwarded-For parser — pick the leftmost *public* IP
|
|
103
|
+
// e.g. X-Forwarded-For: client, proxy1, proxy2
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
const parseXForwardedFor = (header: string): string | null => {
|
|
106
|
+
const ips = header.split(",").map((s) => s.trim());
|
|
107
|
+
for (const raw of ips) {
|
|
108
|
+
const ip = normaliseIP(raw);
|
|
109
|
+
if (ip && !isPrivateOrLoopback(ip)) return ip;
|
|
110
|
+
}
|
|
111
|
+
// If every hop is private (intranet-only setup) fall back to first valid IP
|
|
112
|
+
for (const raw of ips) {
|
|
113
|
+
const ip = normaliseIP(raw);
|
|
114
|
+
if (ip) return ip;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Main export
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Extract the best-available client IP from a request.
|
|
125
|
+
*
|
|
126
|
+
* Returns `null` if no valid IP can be determined.
|
|
127
|
+
*/
|
|
128
|
+
export const getClientIp = (req: any): string | null => {
|
|
129
|
+
const h = req.headers;
|
|
130
|
+
|
|
131
|
+
// 2. Cloudflare single-IP header (most reliable when behind CF)
|
|
132
|
+
{
|
|
133
|
+
const ip = normaliseIP(h["cf-connecting-ip"] as string);
|
|
134
|
+
if (ip) return ip;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 3. Cloudflare Enterprise / Akamai
|
|
138
|
+
{
|
|
139
|
+
const ip = normaliseIP(h["true-client-ip"] as string);
|
|
140
|
+
if (ip) return ip;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 4. Nginx realip module (single, already-trusted IP)
|
|
144
|
+
{
|
|
145
|
+
const ip = normaliseIP(h["x-real-ip"] as string);
|
|
146
|
+
if (ip) return ip;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 5. RFC 7239 Forwarded header
|
|
150
|
+
{
|
|
151
|
+
const fwd = h["forwarded"] as string;
|
|
152
|
+
if (fwd) {
|
|
153
|
+
const ip = parseForwardedHeader(fwd);
|
|
154
|
+
if (ip) return ip;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 6. De-facto standard XFF
|
|
159
|
+
{
|
|
160
|
+
const xff = h["x-forwarded-for"] as string;
|
|
161
|
+
if (xff) {
|
|
162
|
+
const ip = parseXForwardedFor(xff);
|
|
163
|
+
if (ip) return ip;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 7. Direct TCP connection (local dev / no proxy)
|
|
168
|
+
{
|
|
169
|
+
const raw = req.socket?.remoteAddress;
|
|
170
|
+
const ip = normaliseIP(raw);
|
|
171
|
+
if (ip) return ip;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return null;
|
|
175
|
+
};
|
package/src/wrappers/fastify.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { client } from '../core/client';
|
|
2
2
|
import { SenzorOptions } from '../core/types';
|
|
3
|
+
import { getClientIp } from '../utils/getClientIp';
|
|
3
4
|
|
|
4
5
|
export const senzorPlugin = (fastify: any, options: SenzorOptions, done: Function) => {
|
|
5
6
|
if (options && options.apiKey) {
|
|
@@ -10,7 +11,7 @@ export const senzorPlugin = (fastify: any, options: SenzorOptions, done: Functio
|
|
|
10
11
|
client.startTrace({
|
|
11
12
|
method: request.method,
|
|
12
13
|
path: request.raw.url || request.url,
|
|
13
|
-
ip: request
|
|
14
|
+
ip: getClientIp(request),
|
|
14
15
|
userAgent: request.headers['user-agent'],
|
|
15
16
|
headers: request.headers // Pass headers
|
|
16
17
|
}, () => next());
|
package/src/wrappers/h3.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { client } from '../core/client';
|
|
2
2
|
import { getRoute } from '../core/normalizer';
|
|
3
|
+
import { getClientIp } from '../utils/getClientIp';
|
|
3
4
|
|
|
4
5
|
type EventHandler = (event: any) => any;
|
|
5
6
|
|
|
@@ -11,7 +12,7 @@ export const wrapH3 = (handler: EventHandler) => {
|
|
|
11
12
|
return client.startTrace({
|
|
12
13
|
method: req.method || 'GET',
|
|
13
14
|
path: path,
|
|
14
|
-
ip: req
|
|
15
|
+
ip: getClientIp(req),
|
|
15
16
|
userAgent: req.headers['user-agent'],
|
|
16
17
|
headers: req.headers // Pass headers
|
|
17
18
|
}, async () => {
|
package/src/wrappers/next.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { client } from '../core/client';
|
|
2
2
|
import { normalizePath } from '../core/normalizer';
|
|
3
|
+
import { getClientIp } from '../utils/getClientIp';
|
|
3
4
|
|
|
4
5
|
// --- App Router Wrapper ---
|
|
5
6
|
export const wrapNextRoute = (handler: Function) => {
|
|
@@ -34,7 +35,7 @@ export const wrapNextRoute = (handler: Function) => {
|
|
|
34
35
|
method,
|
|
35
36
|
path: url.pathname,
|
|
36
37
|
userAgent: ua,
|
|
37
|
-
ip: ip,
|
|
38
|
+
ip: ip || getClientIp(req),
|
|
38
39
|
headers: headers // Pass extracted headers
|
|
39
40
|
}, async () => {
|
|
40
41
|
try {
|
|
@@ -61,7 +62,7 @@ export const wrapNextPages = (handler: Function) => {
|
|
|
61
62
|
method: req.method || 'GET',
|
|
62
63
|
path: path,
|
|
63
64
|
userAgent: req.headers['user-agent'],
|
|
64
|
-
ip: req
|
|
65
|
+
ip: getClientIp(req),
|
|
65
66
|
headers: req.headers // Standard Node headers work fine
|
|
66
67
|
}, async () => {
|
|
67
68
|
|