@sentienguard/apm 1.0.8 → 1.0.9

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,231 +1,374 @@
1
- /**
2
- * Dependency Tracking
3
- * Automatically times outgoing HTTP calls (fetch, axios, node http/https).
4
- *
5
- * For each dependency:
6
- * - name (e.g., "OpenAI API", "api.example.com")
7
- * - type (http, db, cache)
8
- * - response time
9
- * - error flag
10
- */
11
-
12
- import http from 'http';
13
- import https from 'https';
14
- import { getAggregator } from './aggregator.js';
15
- import { debug, getConfig } from './config.js';
16
-
17
- let isInstrumented = false;
18
- let originalHttpRequest = null;
19
- let originalHttpsRequest = null;
20
-
21
- // Known service patterns for better naming
22
- const KNOWN_SERVICES = [
23
- { pattern: /openai\.com/i, name: 'OpenAI API' },
24
- { pattern: /anthropic\.com/i, name: 'Anthropic API' },
25
- { pattern: /api\.stripe\.com/i, name: 'Stripe API' },
26
- { pattern: /api\.sendgrid\.com/i, name: 'SendGrid' },
27
- { pattern: /api\.twilio\.com/i, name: 'Twilio API' },
28
- { pattern: /s3\.amazonaws\.com/i, name: 'AWS S3' },
29
- { pattern: /dynamodb\..+\.amazonaws\.com/i, name: 'DynamoDB' },
30
- { pattern: /sqs\..+\.amazonaws\.com/i, name: 'AWS SQS' },
31
- { pattern: /sns\..+\.amazonaws\.com/i, name: 'AWS SNS' },
32
- { pattern: /mongodb\.net/i, name: 'MongoDB Atlas' },
33
- { pattern: /redis/i, name: 'Redis' },
34
- { pattern: /postgresql|postgres/i, name: 'PostgreSQL' },
35
- { pattern: /mysql/i, name: 'MySQL' }
36
- ];
37
-
38
- /**
39
- * Extract a friendly name from hostname
40
- */
41
- function getServiceName(hostname) {
42
- if (!hostname) return 'unknown';
43
-
44
- // Check known services
45
- for (const service of KNOWN_SERVICES) {
46
- if (service.pattern.test(hostname)) {
47
- return service.name;
48
- }
49
- }
50
-
51
- // Default to hostname
52
- return hostname;
53
- }
54
-
55
- /**
56
- * Determine dependency type from hostname/path
57
- */
58
- function getDependencyType(hostname, path) {
59
- const lowerHost = (hostname || '').toLowerCase();
60
-
61
- // Database indicators
62
- if (/mongodb|postgres|mysql|dynamodb|redis|memcache/i.test(lowerHost)) {
63
- return 'db';
64
- }
65
-
66
- // Cache indicators
67
- if (/redis|memcache|elasticache/i.test(lowerHost)) {
68
- return 'cache';
69
- }
70
-
71
- // Storage indicators
72
- if (/s3\.amazonaws|storage\.googleapis|blob\.core\.windows/i.test(lowerHost)) {
73
- return 'storage';
74
- }
75
-
76
- // Default to HTTP
77
- return 'http';
78
- }
79
-
80
- /**
81
- * Check if this request should be excluded from tracking
82
- */
83
- function shouldExclude(hostname) {
84
- const config = getConfig();
85
-
86
- // Don't track our own APM endpoint
87
- if (config.endpoint) {
88
- try {
89
- const endpointUrl = new URL(config.endpoint);
90
- if (hostname === endpointUrl.hostname) {
91
- return true;
92
- }
93
- } catch {
94
- // Invalid endpoint URL, continue
95
- }
96
- }
97
-
98
- // Exclude localhost by default (internal services)
99
- return hostname === 'localhost' || hostname === '127.0.0.1';
100
- }
101
-
102
- /**
103
- * Wrap http/https request to track dependencies
104
- */
105
- function wrapRequest(original, protocol) {
106
- return function instrumentedRequest(options, callback) {
107
- // Parse options to get hostname
108
- let hostname = '';
109
- let path = '/';
110
-
111
- if (typeof options === 'string') {
112
- try {
113
- const url = new URL(options);
114
- hostname = url.hostname;
115
- path = url.pathname;
116
- } catch {
117
- // Invalid URL
118
- }
119
- } else if (options) {
120
- hostname = options.hostname || options.host || '';
121
- path = options.path || '/';
122
- // Remove port from host if present
123
- hostname = hostname.split(':')[0];
124
- }
125
-
126
- // Check exclusions
127
- if (shouldExclude(hostname)) {
128
- return original.apply(this, arguments);
129
- }
130
-
131
- const startTime = process.hrtime.bigint();
132
- const serviceName = getServiceName(hostname);
133
- const depType = getDependencyType(hostname, path);
134
-
135
- // Call original
136
- const req = original.apply(this, arguments);
137
-
138
- // Track response
139
- req.on('response', (res) => {
140
- const endTime = process.hrtime.bigint();
141
- const latencyMs = Number(endTime - startTime) / 1e6;
142
- const isError = res.statusCode >= 400;
143
-
144
- const aggregator = getAggregator();
145
- aggregator.recordDependency(serviceName, depType, latencyMs, isError);
146
-
147
- debug(`Dependency: ${serviceName} (${depType}) ${res.statusCode} ${latencyMs.toFixed(2)}ms`);
148
- });
149
-
150
- // Track errors
151
- req.on('error', () => {
152
- const endTime = process.hrtime.bigint();
153
- const latencyMs = Number(endTime - startTime) / 1e6;
154
-
155
- const aggregator = getAggregator();
156
- aggregator.recordDependency(serviceName, depType, latencyMs, true);
157
-
158
- debug(`Dependency error: ${serviceName} (${depType}) ${latencyMs.toFixed(2)}ms`);
159
- });
160
-
161
- return req;
162
- };
163
- }
164
-
165
- /**
166
- * Instrument outgoing HTTP requests
167
- */
168
- export function instrumentDependencies() {
169
- if (isInstrumented) {
170
- debug('Dependencies already instrumented, skipping');
171
- return;
172
- }
173
-
174
- // Store originals
175
- originalHttpRequest = http.request;
176
- originalHttpsRequest = https.request;
177
-
178
- // Patch http.request
179
- http.request = wrapRequest(originalHttpRequest, 'http');
180
- http.get = function (options, callback) {
181
- const req = http.request(options, callback);
182
- req.end();
183
- return req;
184
- };
185
-
186
- // Patch https.request
187
- https.request = wrapRequest(originalHttpsRequest, 'https');
188
- https.get = function (options, callback) {
189
- const req = https.request(options, callback);
190
- req.end();
191
- return req;
192
- };
193
-
194
- isInstrumented = true;
195
- debug('Dependency instrumentation enabled');
196
- }
197
-
198
- /**
199
- * Remove instrumentation (for testing/cleanup)
200
- */
201
- export function uninstrumentDependencies() {
202
- if (!isInstrumented) return;
203
-
204
- if (originalHttpRequest) {
205
- http.request = originalHttpRequest;
206
- http.get = function (options, callback) {
207
- const req = http.request(options, callback);
208
- req.end();
209
- return req;
210
- };
211
- }
212
-
213
- if (originalHttpsRequest) {
214
- https.request = originalHttpsRequest;
215
- https.get = function (options, callback) {
216
- const req = https.request(options, callback);
217
- req.end();
218
- return req;
219
- };
220
- }
221
-
222
- isInstrumented = false;
223
- debug('Dependency instrumentation disabled');
224
- }
225
-
226
- export default {
227
- instrumentDependencies,
228
- uninstrumentDependencies,
229
- getServiceName,
230
- getDependencyType
231
- };
1
+ /**
2
+ * Dependency Tracking
3
+ * Automatically times outgoing HTTP calls (fetch, axios, node http/https).
4
+ *
5
+ * For each dependency:
6
+ * - name (e.g., "OpenAI API", "api.example.com")
7
+ * - type (http, db, cache)
8
+ * - response time
9
+ * - error flag
10
+ */
11
+
12
+ import http from 'http';
13
+ import https from 'https';
14
+ import { context, propagation } from '@opentelemetry/api';
15
+ import { getAggregator } from './aggregator.js';
16
+ import { debug, getConfig } from './config.js';
17
+
18
+ let isInstrumented = false;
19
+ let originalHttpRequest = null;
20
+ let originalHttpsRequest = null;
21
+ let originalFetch = null;
22
+ let isFetchInstrumented = false;
23
+
24
+ // Known service patterns for better naming
25
+ const KNOWN_SERVICES = [
26
+ { pattern: /openai\.com/i, name: 'OpenAI API' },
27
+ { pattern: /anthropic\.com/i, name: 'Anthropic API' },
28
+ { pattern: /api\.stripe\.com/i, name: 'Stripe API' },
29
+ { pattern: /api\.sendgrid\.com/i, name: 'SendGrid' },
30
+ { pattern: /api\.twilio\.com/i, name: 'Twilio API' },
31
+ { pattern: /s3\.amazonaws\.com/i, name: 'AWS S3' },
32
+ { pattern: /dynamodb\..+\.amazonaws\.com/i, name: 'DynamoDB' },
33
+ { pattern: /sqs\..+\.amazonaws\.com/i, name: 'AWS SQS' },
34
+ { pattern: /sns\..+\.amazonaws\.com/i, name: 'AWS SNS' },
35
+ { pattern: /mongodb\.net/i, name: 'MongoDB Atlas' },
36
+ { pattern: /redis/i, name: 'Redis' },
37
+ { pattern: /postgresql|postgres/i, name: 'PostgreSQL' },
38
+ { pattern: /mysql/i, name: 'MySQL' }
39
+ ];
40
+
41
+ /**
42
+ * Label for outgoing HTTP dependency (matches spanExporter peer labels when tracing local services).
43
+ */
44
+ function resolveOutgoingPeerLabel(hostname, options) {
45
+ const cfg = getConfig();
46
+ const h = (hostname || '').split(':')[0];
47
+ const isLocal = h === 'localhost' || h === '127.0.0.1' || h === '::1';
48
+ if (isLocal && cfg.tracing?.traceLocalHttp) {
49
+ let port = '';
50
+ if (typeof options === 'string') {
51
+ try {
52
+ const u = new URL(options);
53
+ port = u.port || (u.protocol === 'https:' ? '443' : '80');
54
+ } catch {
55
+ // ignore
56
+ }
57
+ } else if (options && typeof options === 'object') {
58
+ port = options.port ? String(options.port) : '';
59
+ }
60
+ const map = cfg.tracing?.peerServiceMap || {};
61
+ if (port && map[port]) return map[port];
62
+ if (port) return `localhost:${port}`;
63
+ }
64
+ return getServiceName(hostname);
65
+ }
66
+
67
+ /**
68
+ * Extract a friendly name from hostname
69
+ */
70
+ function getServiceName(hostname) {
71
+ if (!hostname) return 'unknown';
72
+
73
+ // Check known services
74
+ for (const service of KNOWN_SERVICES) {
75
+ if (service.pattern.test(hostname)) {
76
+ return service.name;
77
+ }
78
+ }
79
+
80
+ // Default to hostname
81
+ return hostname;
82
+ }
83
+
84
+ /**
85
+ * Determine dependency type from hostname/path
86
+ */
87
+ function getDependencyType(hostname, path) {
88
+ const lowerHost = (hostname || '').toLowerCase();
89
+
90
+ // Database indicators
91
+ if (/mongodb|postgres|mysql|dynamodb|redis|memcache/i.test(lowerHost)) {
92
+ return 'db';
93
+ }
94
+
95
+ // Cache indicators
96
+ if (/redis|memcache|elasticache/i.test(lowerHost)) {
97
+ return 'cache';
98
+ }
99
+
100
+ // Storage indicators
101
+ if (/s3\.amazonaws|storage\.googleapis|blob\.core\.windows/i.test(lowerHost)) {
102
+ return 'storage';
103
+ }
104
+
105
+ // Default to HTTP
106
+ return 'http';
107
+ }
108
+
109
+ /**
110
+ * Check if this request should be excluded from tracking
111
+ */
112
+ function resolvePortFromOptions(optionsOrUrl) {
113
+ try {
114
+ if (!optionsOrUrl) return '';
115
+ if (optionsOrUrl instanceof URL) {
116
+ return optionsOrUrl.port || (optionsOrUrl.protocol === 'https:' ? '443' : '80');
117
+ }
118
+ if (typeof optionsOrUrl === 'string') {
119
+ const u = new URL(optionsOrUrl);
120
+ return u.port || (u.protocol === 'https:' ? '443' : '80');
121
+ }
122
+ if (typeof optionsOrUrl === 'object') {
123
+ if (optionsOrUrl.port) return String(optionsOrUrl.port);
124
+ // Node supports host like "localhost:3001"
125
+ const host = optionsOrUrl.host || optionsOrUrl.hostname || '';
126
+ if (typeof host === 'string') {
127
+ const idx = host.lastIndexOf(':');
128
+ if (idx > -1) return host.slice(idx + 1);
129
+ }
130
+ }
131
+ } catch {
132
+ // ignore
133
+ }
134
+ return '';
135
+ }
136
+
137
+ function resolvePortFromEndpoint(endpointUrl) {
138
+ if (!endpointUrl) return '';
139
+ return endpointUrl.port || (endpointUrl.protocol === 'https:' ? '443' : '80');
140
+ }
141
+
142
+ function shouldExclude(hostname, optionsOrUrl) {
143
+ const config = getConfig();
144
+
145
+ // Don't track our own APM endpoint
146
+ if (config.endpoint) {
147
+ try {
148
+ const endpointUrl = new URL(config.endpoint);
149
+ if (hostname === endpointUrl.hostname) {
150
+ const reqPort = resolvePortFromOptions(optionsOrUrl);
151
+ const ingestPort = resolvePortFromEndpoint(endpointUrl);
152
+ // If we can't determine request port, be conservative and exclude.
153
+ if (!reqPort || reqPort === ingestPort) return true;
154
+ }
155
+ } catch {
156
+ // Invalid endpoint URL, continue
157
+ }
158
+ }
159
+
160
+ const h = (hostname || '').toLowerCase();
161
+ const isLocal = h === 'localhost' || h === '127.0.0.1' || h === '::1';
162
+ // Exclude localhost unless tracing local HTTP peers (multi-service dev)
163
+ if (isLocal) return !config.tracing?.traceLocalHttp;
164
+
165
+ return false;
166
+ }
167
+
168
+ function safeParseUrl(input) {
169
+ try {
170
+ return new URL(String(input));
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ function shouldExcludeUrl(u) {
177
+ if (!u) return true;
178
+ return shouldExclude(u.hostname, u);
179
+ }
180
+
181
+ function injectTraceHeaders(init) {
182
+ const headers = new Headers((init && init.headers) || {});
183
+ try {
184
+ propagation.inject(context.active(), headers, {
185
+ set: (carrier, key, value) => {
186
+ carrier.set(key, value);
187
+ }
188
+ });
189
+ } catch {
190
+ // ignore
191
+ }
192
+ return { ...(init || {}), headers };
193
+ }
194
+
195
+ /**
196
+ * Instrument global fetch (Node 18+ / undici) to record dependency edges.
197
+ * This is needed because OTel HttpInstrumentation doesn't always cover fetch.
198
+ */
199
+ export function instrumentFetch() {
200
+ if (isFetchInstrumented) return;
201
+ const f = globalThis.fetch;
202
+ if (typeof f !== 'function') return;
203
+ originalFetch = f;
204
+
205
+ globalThis.fetch = async function sentienguardFetch(input, init) {
206
+ const u = safeParseUrl(typeof input === 'string' ? input : input?.url);
207
+ if (shouldExcludeUrl(u)) {
208
+ return originalFetch.call(this, input, init);
209
+ }
210
+
211
+ const startTime = process.hrtime.bigint();
212
+ const cfg = getConfig();
213
+ const caller = cfg.service || 'unknown';
214
+ const hostname = u?.hostname || '';
215
+ const peerLabel = resolveOutgoingPeerLabel(hostname, u ? u.toString() : '');
216
+ const depType = getDependencyType(hostname, u?.pathname || '/');
217
+
218
+ try {
219
+ const res = await originalFetch.call(this, input, injectTraceHeaders(init));
220
+ const endTime = process.hrtime.bigint();
221
+ const latencyMs = Number(endTime - startTime) / 1e6;
222
+ const isError = (res?.status || 0) >= 400;
223
+ getAggregator().recordDependency(peerLabel, depType, latencyMs, isError);
224
+ debug(`Service call: ${caller} -> ${peerLabel} ${latencyMs.toFixed(2)}ms (${depType}) ${res?.status}`);
225
+ return res;
226
+ } catch (err) {
227
+ const endTime = process.hrtime.bigint();
228
+ const latencyMs = Number(endTime - startTime) / 1e6;
229
+ getAggregator().recordDependency(peerLabel, depType, latencyMs, true);
230
+ debug(`Service call: ${caller} -> ${peerLabel} ${latencyMs.toFixed(2)}ms (${depType}) error`);
231
+ throw err;
232
+ }
233
+ };
234
+
235
+ isFetchInstrumented = true;
236
+ debug('Fetch dependency instrumentation enabled');
237
+ }
238
+
239
+ /**
240
+ * Wrap http/https request to track dependencies
241
+ */
242
+ function wrapRequest(original, protocol) {
243
+ return function instrumentedRequest(options, callback) {
244
+ // Parse options to get hostname
245
+ let hostname = '';
246
+ let path = '/';
247
+
248
+ if (typeof options === 'string') {
249
+ try {
250
+ const url = new URL(options);
251
+ hostname = url.hostname;
252
+ path = url.pathname;
253
+ } catch {
254
+ // Invalid URL
255
+ }
256
+ } else if (options) {
257
+ hostname = options.hostname || options.host || '';
258
+ path = options.path || '/';
259
+ // Remove port from host if present
260
+ hostname = hostname.split(':')[0];
261
+ }
262
+
263
+ // Check exclusions
264
+ if (shouldExclude(hostname, options)) {
265
+ return original.apply(this, arguments);
266
+ }
267
+
268
+ const startTime = process.hrtime.bigint();
269
+ const peerLabel = resolveOutgoingPeerLabel(hostname, options);
270
+ const depType = getDependencyType(hostname, path);
271
+ const caller = getConfig().service || 'unknown';
272
+
273
+ // Call original
274
+ const req = original.apply(this, arguments);
275
+
276
+ // Track response
277
+ req.on('response', (res) => {
278
+ const endTime = process.hrtime.bigint();
279
+ const latencyMs = Number(endTime - startTime) / 1e6;
280
+ const isError = res.statusCode >= 400;
281
+
282
+ const aggregator = getAggregator();
283
+ aggregator.recordDependency(peerLabel, depType, latencyMs, isError);
284
+
285
+ debug(
286
+ `Service call: ${caller} -> ${peerLabel} ${latencyMs.toFixed(2)}ms (${depType}) ${res.statusCode}`
287
+ );
288
+ });
289
+
290
+ // Track errors
291
+ req.on('error', () => {
292
+ const endTime = process.hrtime.bigint();
293
+ const latencyMs = Number(endTime - startTime) / 1e6;
294
+
295
+ const aggregator = getAggregator();
296
+ aggregator.recordDependency(peerLabel, depType, latencyMs, true);
297
+
298
+ debug(`Service call: ${caller} -> ${peerLabel} ${latencyMs.toFixed(2)}ms (${depType}) error`);
299
+ });
300
+
301
+ return req;
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Instrument outgoing HTTP requests
307
+ */
308
+ export function instrumentDependencies() {
309
+ if (isInstrumented) {
310
+ debug('Dependencies already instrumented, skipping');
311
+ return;
312
+ }
313
+
314
+ // Store originals
315
+ originalHttpRequest = http.request;
316
+ originalHttpsRequest = https.request;
317
+
318
+ // Patch http.request
319
+ http.request = wrapRequest(originalHttpRequest, 'http');
320
+ http.get = function (options, callback) {
321
+ const req = http.request(options, callback);
322
+ req.end();
323
+ return req;
324
+ };
325
+
326
+ // Patch https.request
327
+ https.request = wrapRequest(originalHttpsRequest, 'https');
328
+ https.get = function (options, callback) {
329
+ const req = https.request(options, callback);
330
+ req.end();
331
+ return req;
332
+ };
333
+
334
+ isInstrumented = true;
335
+ debug('Dependency instrumentation enabled');
336
+ }
337
+
338
+ /**
339
+ * Remove instrumentation (for testing/cleanup)
340
+ */
341
+ export function uninstrumentDependencies() {
342
+ if (!isInstrumented) return;
343
+
344
+ if (originalHttpRequest) {
345
+ http.request = originalHttpRequest;
346
+ http.get = function (options, callback) {
347
+ const req = http.request(options, callback);
348
+ req.end();
349
+ return req;
350
+ };
351
+ }
352
+
353
+ if (originalHttpsRequest) {
354
+ https.request = originalHttpsRequest;
355
+ https.get = function (options, callback) {
356
+ const req = https.request(options, callback);
357
+ req.end();
358
+ return req;
359
+ };
360
+ }
361
+
362
+ isInstrumented = false;
363
+ debug('Dependency instrumentation disabled');
364
+ }
365
+
366
+ export { getServiceName, getDependencyType };
367
+
368
+ export default {
369
+ instrumentDependencies,
370
+ instrumentFetch,
371
+ uninstrumentDependencies,
372
+ getServiceName,
373
+ getDependencyType
374
+ };