@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.
@@ -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'; // NEW
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 (NEW)
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 & Error Tracking enabled');
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
- safeCapture(error, {
125
- type: 'uncaughtExceptionMonitor',
126
- severity: 'fatal'
127
- });
128
- });
129
-
130
- process.on('uncaughtException', (error) => {
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', // Ensure we distinguish APM traces from Background Tasks
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, // Can be negative if GC ran!
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, // Extracted from options/metadata if provided
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
  }
@@ -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 = { traces: [...this.traceQueue], errors: [...this.apmErrorQueue] };
54
- const taskPayload = { runs: [...this.taskQueue], errors: [...this.taskErrorQueue] };
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
- if (apmPayload.traces.length > 0 || apmPayload.errors.length > 0) {
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} tasks`);
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.ip || req.socket?.remoteAddress,
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
+ };
@@ -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.ip,
14
+ ip: getClientIp(request),
14
15
  userAgent: request.headers['user-agent'],
15
16
  headers: request.headers // Pass headers
16
17
  }, () => next());
@@ -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.headers['x-forwarded-for'] || req.socket?.remoteAddress,
15
+ ip: getClientIp(req),
15
16
  userAgent: req.headers['user-agent'],
16
17
  headers: req.headers // Pass headers
17
18
  }, async () => {
@@ -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.headers['x-forwarded-for'] || req.socket?.remoteAddress,
65
+ ip: getClientIp(req),
65
66
  headers: req.headers // Standard Node headers work fine
66
67
  }, async () => {
67
68