@senzops/apm-node 1.2.5 → 1.2.6

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,175 +1,190 @@
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
+ /**
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
+ // ---------------------------------------------------------------------------
21
+ // Inline IP validation (replaces Node's net.isIP to support edge runtimes)
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const IPV4_PATTERN = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
25
+ const IPV6_PATTERN = /^(?:[a-fA-F0-9]{1,4}:){7}[a-fA-F0-9]{1,4}$|^::(?:[a-fA-F0-9]{1,4}:){0,5}[a-fA-F0-9]{1,4}$|^[a-fA-F0-9]{1,4}::(?:[a-fA-F0-9]{1,4}:){0,4}[a-fA-F0-9]{1,4}$|^(?:[a-fA-F0-9]{1,4}:){1,2}:(?:[a-fA-F0-9]{1,4}:){0,3}[a-fA-F0-9]{1,4}$|^(?:[a-fA-F0-9]{1,4}:){1,3}:(?:[a-fA-F0-9]{1,4}:){0,2}[a-fA-F0-9]{1,4}$|^(?:[a-fA-F0-9]{1,4}:){1,4}:(?:[a-fA-F0-9]{1,4}:)?[a-fA-F0-9]{1,4}$|^(?:[a-fA-F0-9]{1,4}:){1,5}:[a-fA-F0-9]{1,4}$|^(?:[a-fA-F0-9]{1,4}:){1,6}:$|^::$|^::1$|^fe80:.*$/i;
26
+
27
+ /**
28
+ * Returns 4 for IPv4, 6 for IPv6, 0 for invalid.
29
+ * Drop-in replacement for Node's net.isIP().
30
+ */
31
+ const isIP = (ip: string): number => {
32
+ if (IPV4_PATTERN.test(ip)) return 4;
33
+ if (IPV6_PATTERN.test(ip)) return 6;
34
+ return 0;
35
+ };
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /** Strip IPv4-mapped IPv6 prefix (::ffff:1.2.3.4 -> 1.2.3.4) */
42
+ const stripIPv6Mapped = (ip: string): string =>
43
+ ip.startsWith("::ffff:") ? ip.slice(7) : ip;
44
+
45
+ /** Strip optional port from an IPv4 address (1.2.3.4:5678 -> 1.2.3.4). */
46
+ const stripIPv4Port = (ip: string): string => {
47
+ const lastColon = ip.lastIndexOf(":");
48
+ if (lastColon === -1) return ip;
49
+ const maybeIP = ip.slice(0, lastColon);
50
+ return isIP(maybeIP) === 4 ? maybeIP : ip;
51
+ };
52
+
53
+ /** Strip brackets + optional port from an IPv6 address ([::1]:5678 -> ::1). */
54
+ const stripIPv6Brackets = (ip: string): string => {
55
+ const match = ip.match(/^\[([^\]]+)\](?::\d+)?$/);
56
+ return match ? match[1] : ip;
57
+ };
58
+
59
+ /** Normalise raw IP string into a clean, routable address (or null). */
60
+ export const normaliseIP = (raw: string | undefined | null): string | null => {
61
+ if (!raw) return null;
62
+ let ip = raw.trim();
63
+ if (!ip) return null;
64
+
65
+ ip = stripIPv6Brackets(ip);
66
+ ip = stripIPv4Port(ip);
67
+ ip = stripIPv6Mapped(ip);
68
+
69
+ return isIP(ip) !== 0 ? ip : null;
70
+ };
71
+
72
+ /**
73
+ * Returns true for IPs that will never produce a geo result:
74
+ * loopback, link-local, private ranges, and unspecified addresses.
75
+ */
76
+ export const isPrivateOrLoopback = (ip: string): boolean => {
77
+ // IPv4 private / loopback / link-local
78
+ if (
79
+ ip === "127.0.0.1" ||
80
+ ip.startsWith("10.") ||
81
+ ip.startsWith("192.168.") ||
82
+ ip.startsWith("169.254.") || // link-local
83
+ /^172\.(1[6-9]|2\d|3[01])\./.test(ip) // 172.16-31
84
+ )
85
+ return true;
86
+
87
+ // IPv6 loopback / unspecified / link-local / unique-local
88
+ if (
89
+ ip === "::1" ||
90
+ ip === "::" ||
91
+ ip.toLowerCase().startsWith("fe80:") || // link-local
92
+ ip.toLowerCase().startsWith("fc") || // unique-local
93
+ ip.toLowerCase().startsWith("fd") // unique-local
94
+ )
95
+ return true;
96
+
97
+ return false;
98
+ };
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // RFC 7239 "Forwarded" header parser
102
+ // e.g. Forwarded: for=192.0.2.60;proto=http, for="[2001:db8::cafe]"
103
+ // ---------------------------------------------------------------------------
104
+ const parseForwardedHeader = (header: string): string | null => {
105
+ const parts = header.split(",");
106
+ for (const part of parts) {
107
+ const forMatch = part.match(/for=["[]?([^\]",;>\s]+)/i);
108
+ if (forMatch) {
109
+ const ip = normaliseIP(forMatch[1]);
110
+ if (ip && !isPrivateOrLoopback(ip)) return ip;
111
+ }
112
+ }
113
+ return null;
114
+ };
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // X-Forwarded-For parser — pick the leftmost *public* IP
118
+ // e.g. X-Forwarded-For: client, proxy1, proxy2
119
+ // ---------------------------------------------------------------------------
120
+ const parseXForwardedFor = (header: string): string | null => {
121
+ const ips = header.split(",").map((s) => s.trim());
122
+ for (const raw of ips) {
123
+ const ip = normaliseIP(raw);
124
+ if (ip && !isPrivateOrLoopback(ip)) return ip;
125
+ }
126
+ // If every hop is private (intranet-only setup) fall back to first valid IP
127
+ for (const raw of ips) {
128
+ const ip = normaliseIP(raw);
129
+ if (ip) return ip;
130
+ }
131
+ return null;
132
+ };
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Main export
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /**
139
+ * Extract the best-available client IP from a request.
140
+ *
141
+ * Returns `null` if no valid IP can be determined.
142
+ */
143
+ export const getClientIp = (req: any): string | null => {
144
+ const h = req.headers;
145
+
146
+ // 2. Cloudflare single-IP header (most reliable when behind CF)
147
+ {
148
+ const ip = normaliseIP(h["cf-connecting-ip"] as string);
149
+ if (ip) return ip;
150
+ }
151
+
152
+ // 3. Cloudflare Enterprise / Akamai
153
+ {
154
+ const ip = normaliseIP(h["true-client-ip"] as string);
155
+ if (ip) return ip;
156
+ }
157
+
158
+ // 4. Nginx realip module (single, already-trusted IP)
159
+ {
160
+ const ip = normaliseIP(h["x-real-ip"] as string);
161
+ if (ip) return ip;
162
+ }
163
+
164
+ // 5. RFC 7239 Forwarded header
165
+ {
166
+ const fwd = h["forwarded"] as string;
167
+ if (fwd) {
168
+ const ip = parseForwardedHeader(fwd);
169
+ if (ip) return ip;
170
+ }
171
+ }
172
+
173
+ // 6. De-facto standard XFF
174
+ {
175
+ const xff = h["x-forwarded-for"] as string;
176
+ if (xff) {
177
+ const ip = parseXForwardedFor(xff);
178
+ if (ip) return ip;
179
+ }
180
+ }
181
+
182
+ // 7. Direct TCP connection (local dev / no proxy)
183
+ {
184
+ const raw = req.socket?.remoteAddress;
185
+ const ip = normaliseIP(raw);
186
+ if (ip) return ip;
187
+ }
188
+
189
+ return null;
190
+ };
package/src/utils/ids.ts CHANGED
@@ -1,7 +1,24 @@
1
- import { randomUUID } from 'crypto';
1
+ const getRandomUUID = (): string => {
2
+ if (typeof globalThis !== 'undefined' && globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
3
+ return globalThis.crypto.randomUUID();
4
+ }
5
+
6
+ try {
7
+ if (typeof require !== 'undefined') {
8
+ const { randomUUID } = require('node:crypto');
9
+ if (randomUUID) return randomUUID();
10
+ }
11
+ } catch {}
12
+
13
+ // Fallback: RFC4122 v4 UUID via Math.random (last resort)
14
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
15
+ const r = (Math.random() * 16) | 0;
16
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
17
+ });
18
+ };
2
19
 
3
20
  export const generateTraceId = (): string =>
4
- randomUUID().replace(/-/g, '');
21
+ getRandomUUID().replace(/-/g, '');
5
22
 
6
23
  export const generateSpanId = (): string =>
7
- randomUUID().replace(/-/g, '').slice(0, 16);
24
+ getRandomUUID().replace(/-/g, '').slice(0, 16);
@@ -0,0 +1,60 @@
1
+ import { client } from '../core/client';
2
+ import { getRoute, normalizePath } from '../core/normalizer';
3
+ import { getClientIp } from '../utils/getClientIp';
4
+
5
+ export interface NitroApp {
6
+ h3App: {
7
+ handler: (event: any) => Promise<any>;
8
+ stack: any[];
9
+ };
10
+ hooks: any;
11
+ [key: string]: any;
12
+ }
13
+
14
+ export const nitroPlugin = (nitroApp: NitroApp) => {
15
+ if (!nitroApp.h3App || !nitroApp.h3App.handler) return;
16
+
17
+ const originalHandler = nitroApp.h3App.handler;
18
+
19
+ nitroApp.h3App.handler = async (event: any) => {
20
+ const req = event.node?.req || event.req;
21
+ const path = req?.originalUrl || req?.url || (event.path as string) || '/';
22
+ const method = req?.method || (event.method as string) || 'GET';
23
+
24
+ const headers = req?.headers || {};
25
+
26
+ return client.startTrace({
27
+ method,
28
+ path,
29
+ route: normalizePath(path),
30
+ ip: getClientIp({ headers, socket: req?.socket }),
31
+ userAgent: headers['user-agent'],
32
+ headers
33
+ }, async () => {
34
+ let status = 200;
35
+ try {
36
+ const response = await originalHandler(event);
37
+
38
+ if (event.node?.res?.statusCode) status = event.node.res.statusCode;
39
+ if (response?.status) status = response.status;
40
+
41
+ client.endTrace(status, { route: getRoute(event, path) });
42
+ return response;
43
+ } catch (err: any) {
44
+ status = err.statusCode || err.status || 500;
45
+ client.captureError(err);
46
+ client.endTrace(status, { route: getRoute(event, path) });
47
+ throw err;
48
+ } finally {
49
+ const cfCtx = event.context?.cloudflare?.context || event.context?.cf || event.context;
50
+ const waitUntil = cfCtx?.waitUntil || event.waitUntil;
51
+
52
+ if (waitUntil && typeof waitUntil === 'function') {
53
+ waitUntil(client.flush());
54
+ } else {
55
+ client.flush().catch(() => {});
56
+ }
57
+ }
58
+ });
59
+ };
60
+ };
@@ -0,0 +1,45 @@
1
+ import { client } from '../core/client';
2
+ import { normalizePath } from '../core/normalizer';
3
+ import { getClientIp } from '../utils/getClientIp';
4
+
5
+ type WorkerHandler = (request: Request, env: any, ctx: any) => Promise<Response>;
6
+
7
+ export const wrapWorker = (handler: WorkerHandler) => {
8
+ return async (request: Request, env: any, ctx: any) => {
9
+ const url = new URL(request.url);
10
+ const path = url.pathname;
11
+ const method = request.method || 'GET';
12
+
13
+ const headers: Record<string, string> = {};
14
+ request.headers.forEach((value, key) => {
15
+ headers[key] = value;
16
+ });
17
+
18
+ return client.startTrace({
19
+ method,
20
+ path,
21
+ route: normalizePath(path),
22
+ ip: getClientIp({ headers }),
23
+ userAgent: headers['user-agent'],
24
+ headers
25
+ }, async () => {
26
+ let status = 500;
27
+ try {
28
+ const response = await handler(request, env, ctx);
29
+ status = response.status;
30
+ return response;
31
+ } catch (err: any) {
32
+ client.captureError(err);
33
+ throw err;
34
+ } finally {
35
+ client.endTrace(status, { route: normalizePath(path) });
36
+
37
+ if (ctx && typeof ctx.waitUntil === 'function') {
38
+ ctx.waitUntil(client.flush());
39
+ } else {
40
+ await client.flush();
41
+ }
42
+ }
43
+ });
44
+ };
45
+ };
package/tsup.config.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { defineConfig } from 'tsup';
2
2
 
3
+ const NODE_BUILTINS = [
4
+ 'http', 'https', 'url', 'net', 'module', 'crypto', 'async_hooks',
5
+ 'node:http', 'node:https', 'node:url', 'node:net', 'node:module',
6
+ 'node:crypto', 'node:async_hooks'
7
+ ];
8
+
3
9
  export default defineConfig([
4
10
  {
5
11
  entry: ['src/index.ts', 'src/register.ts'],
@@ -9,6 +15,8 @@ export default defineConfig([
9
15
  minify: true,
10
16
  sourcemap: true,
11
17
  splitting: false,
18
+ external: NODE_BUILTINS,
19
+ noExternal: [],
12
20
  },
13
21
  {
14
22
  entry: ['src/index.ts'],
@@ -17,5 +25,6 @@ export default defineConfig([
17
25
  minify: true,
18
26
  sourcemap: true,
19
27
  splitting: false,
28
+ external: NODE_BUILTINS,
20
29
  }
21
30
  ]);