@saidsef/tracing-node 3.22.6 → 4.1.0

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/README.md CHANGED
@@ -15,6 +15,7 @@ Effortlessly supercharge your applications with world-class distributed tracing!
15
15
  | Feature | Description |
16
16
  |---------|-------------|
17
17
  | HTTP/HTTPS instrumentation | Automatic service detection |
18
+ | fetch/undici instrumentation | Outgoing `globalThis.fetch` calls |
18
19
  | Express.js support | Framework instrumentation |
19
20
  | Elasticsearch client | Database instrumentation |
20
21
  | IORedis client | Cache instrumentation |
@@ -36,6 +37,33 @@ Effortlessly supercharge your applications with world-class distributed tracing!
36
37
  npm install @saidsef/tracing-node --save
37
38
  ```
38
39
 
40
+ ## Upgrading to 4.0.0
41
+
42
+ **Breaking change: spans now carry the stable OpenTelemetry semantic conventions.**
43
+
44
+ The upstream instrumentations ([open-telemetry/opentelemetry-js-contrib#3585](https://github.com/open-telemetry/opentelemetry-js-contrib/pull/3585)) dropped the legacy attributes and removed the `OTEL_SEMCONV_STABILITY_OPT_IN` escape hatch, so there is no way to keep the old names. The public API of `setupTracing` / `stopTracing` is unchanged - no code changes are required - but any dashboard, alert or processor keyed on the old attribute names must be updated.
45
+
46
+ | Removed | Replacement | Affected spans |
47
+ |---------|-------------|----------------|
48
+ | `http.method` | `http.request.method` | HTTP |
49
+ | `http.status_code` | `http.response.status_code` | HTTP, AWS SDK |
50
+ | `http.url` | `url.full` | HTTP |
51
+ | `http.target` | `url.path` + `url.query` | HTTP |
52
+ | `http.scheme` | `url.scheme` | HTTP |
53
+ | `http.user_agent` | `user_agent.original` | HTTP |
54
+ | `http.client_ip` | `client.address` | HTTP |
55
+ | `http.flavor` | `network.protocol.version` | HTTP |
56
+ | `net.peer.name` | `server.address` | HTTP, IORedis |
57
+ | `net.peer.port` | `server.port` | HTTP, IORedis |
58
+ | `db.system` | `db.system.name` | IORedis, DynamoDB |
59
+ | `db.statement` | `db.query.text` | IORedis, DynamoDB |
60
+ | `db.operation` | `db.operation.name` | IORedis, DynamoDB |
61
+ | `db.connection_string` | none | IORedis |
62
+
63
+ Server-side HTTP metrics also move from `http.server.duration` (milliseconds) to `http.server.request.duration` (seconds), and the client equivalents likewise.
64
+
65
+ `peer.service` is unchanged, so Tempo/Grafana service graphs keep working as before. IORedis spans also gain `db.operation.name`, which distinguishes `MULTI`/`PIPELINE` commands.
66
+
39
67
  ## Usage
40
68
 
41
69
  You can set required params via env variables or function:
package/libs/index.mjs CHANGED
@@ -25,6 +25,7 @@ import {ExpressInstrumentation} from '@opentelemetry/instrumentation-express';
25
25
  import {NodeTracerProvider} from '@opentelemetry/sdk-trace-node';
26
26
  import {OTLPTraceExporter} from '@opentelemetry/exporter-trace-otlp-grpc';
27
27
  import {PinoInstrumentation} from '@opentelemetry/instrumentation-pino';
28
+ import {UndiciInstrumentation} from '@opentelemetry/instrumentation-undici';
28
29
  import {IORedisInstrumentation} from '@opentelemetry/instrumentation-ioredis';
29
30
  import {registerInstrumentations} from '@opentelemetry/instrumentation';
30
31
  import {FsInstrumentation} from '@opentelemetry/instrumentation-fs';
@@ -43,6 +44,30 @@ const setIntAttribute = (span, name, value) => {
43
44
  }
44
45
  };
45
46
 
47
+ // Slice before converting: String(buf) decodes a whole 1MB Buffer only to throw
48
+ // it away. 4 bytes per UTF-16 unit plus slack keeps the result byte-identical.
49
+ const truncateArg = (value, limit) => {
50
+ const str = Buffer.isBuffer(value) ? value.subarray(0, limit * 4 + 8).toString() : String(value);
51
+ return str.length > limit ? `${str.substring(0, limit)}...` : str;
52
+ };
53
+
54
+ // Tempo names a service-graph node from peer.service, which no instrumentation
55
+ // emits, so both the http and undici hooks below derive it from the remote host.
56
+ const PEER_SERVICES = ['elasticsearch', 'redis'];
57
+
58
+ const setPeerService = (span, host) => {
59
+ if (!host) return;
60
+ for (const service of PEER_SERVICES) {
61
+ if (host.includes(service)) {
62
+ span.setAttribute('peer.service', service);
63
+ span.setAttribute('db.system.name', service);
64
+ return;
65
+ }
66
+ }
67
+ };
68
+
69
+ let tracerProvider = null; // Declare provider in module scope for access in stopTracing
70
+
46
71
  /**
47
72
  * Sets up tracing for the application using OpenTelemetry.
48
73
  *
@@ -53,7 +78,7 @@ const setIntAttribute = (span, name, value) => {
53
78
  * service map visualization in distributed tracing tools like Tempo.
54
79
  *
55
80
  * @param {Object} options - Configuration options for tracing.
56
- * @param {string} [options.hostname=process.env.HOSTNAME] - The hostname of the service.
81
+ * @param {string} [options.hostname=process.env.CONTAINER_NAME || process.env.HOSTNAME] - The hostname of the service.
57
82
  * @param {string} [options.serviceName=process.env.SERVICE_NAME] - The name of the service.
58
83
  * @param {string} [options.url=process.env.ENDPOINT] - The endpoint URL for the tracing collector.
59
84
  * @param {number} [options.concurrencyLimit=10] - The concurrency limit for the exporter.
@@ -62,12 +87,10 @@ const setIntAttribute = (span, name, value) => {
62
87
  *
63
88
  * @returns {Tracer} - The tracer for the service.
64
89
  */
65
- let tracerProvider = null; // Declare provider in module scope for access in stopTracing
66
-
67
90
  export function setupTracing(options = {}) {
68
91
  // Prevent multiple initializations - return existing provider if already set up
69
92
  if (tracerProvider) {
70
- console.warn('Tracing is already initialized. Returning existing tracer.');
93
+ diag.warn('Tracing is already initialized. Returning existing tracer.');
71
94
  return tracerProvider.getTracer(options.serviceName || process.env.SERVICE_NAME);
72
95
  }
73
96
 
@@ -95,10 +118,8 @@ export function setupTracing(options = {}) {
95
118
  timeoutMillis: 10000,
96
119
  };
97
120
 
98
- // Register the span processor with the tracer provider
99
121
  const exporter = new OTLPTraceExporter(exportOptions);
100
122
 
101
- // Configure BatchSpanProcessor for production workloads
102
123
  const spanProcessor = new BatchSpanProcessor(exporter, {
103
124
  maxQueueSize: 4096,
104
125
  maxExportBatchSize: 1024,
@@ -127,72 +148,49 @@ export function setupTracing(options = {}) {
127
148
  // explicit config, with the recommended context manager.
128
149
  tracerProvider.register();
129
150
 
130
- // Hook to set peer service name for outgoing requests
131
- const applyCustomAttributesOnSpan = (span, request) => {
132
- const url = request?.url || request?.uri || '';
133
- const hostname = request?.hostname || request?.host || '';
134
-
135
- // Detect Elasticsearch endpoints
136
- if (hostname.includes('elasticsearch') || url.includes('elasticsearch') ||
137
- hostname.includes(':9200') || url.includes(':9200')) {
138
- span.setAttribute('peer.service', 'elasticsearch');
139
- span.setAttribute('db.system', 'elasticsearch');
140
- }
151
+ // Only an outgoing ClientRequest carries .host, so bailing without it keeps
152
+ // server spans out: peer.service must name the remote service being called.
153
+ const applyCustomAttributesOnSpan = (span, request) => setPeerService(span, request?.host);
141
154
 
142
- // Detect Redis endpoints
143
- if (hostname.includes('redis') || url.includes('redis') ||
144
- hostname.includes(':6379') || url.includes(':6379')) {
145
- span.setAttribute('peer.service', 'redis');
146
- span.setAttribute('db.system', 'redis');
147
- }
148
- };
149
-
150
- // Register instrumentations
151
155
  const instrumentations = [
152
156
  new HttpInstrumentation({
153
- serverName: serviceName,
154
157
  // Ignore spans from static assets (metrics/health probes).
155
158
  ignoreIncomingRequestHook: (req) => req.url.startsWith('/metrics') || req.url.startsWith('/healthz'),
156
159
  applyCustomAttributesOnSpan,
157
160
  requestHook: (span, request) => {
158
- // Enrich spans with additional HTTP request attributes
159
- if (!request.headers) return;
160
-
161
- const headers = request.headers;
161
+ // Outgoing ClientRequest exposes getHeaders(); incoming IncomingMessage has .headers.
162
+ const headers = request.getHeaders?.() ?? request.headers;
163
+ if (!headers) return;
162
164
 
163
- // Safe header extraction with case-insensitive fallback
164
- const userAgent = headers['user-agent'] || headers['User-Agent'];
165
- const contentType = headers['content-type'] || headers['Content-Type'];
166
- const contentLength = headers['content-length'] || headers['Content-Length'];
167
- const requestId = headers['x-request-id'] || headers['X-Request-ID'];
168
- const correlationId = headers['x-correlation-id'] || headers['X-Correlation-ID'];
165
+ const contentType = headers['content-type'];
166
+ const requestId = headers['x-request-id'];
167
+ const correlationId = headers['x-correlation-id'];
169
168
 
170
- // Only set attributes if values exist
171
- if (userAgent) span.setAttribute('http.user_agent', userAgent);
172
169
  if (contentType) span.setAttribute('http.request.content_type', contentType);
173
-
174
- setIntAttribute(span, 'http.request.content_length', contentLength);
175
-
176
- // Correlation headers for distributed tracing
170
+ setIntAttribute(span, 'http.request.content_length', headers['content-length']);
177
171
  if (requestId) span.setAttribute('http.request_id', requestId);
178
172
  if (correlationId) span.setAttribute('http.correlation_id', correlationId);
179
173
  },
180
174
  responseHook: (span, response) => {
181
- // Add response attributes for better observability
182
175
  if (!response.headers) return;
183
176
 
184
177
  const headers = response.headers;
185
- const contentType = headers['content-type'] || headers['Content-Type'];
186
- const contentLength = headers['content-length'] || headers['Content-Length'];
187
- const requestId = headers['x-request-id'] || headers['X-Request-ID'];
178
+ const contentType = headers['content-type'];
179
+ const requestId = headers['x-request-id'];
188
180
 
189
181
  if (contentType) span.setAttribute('http.response.content_type', contentType);
190
-
191
- setIntAttribute(span, 'http.response.content_length', contentLength);
192
-
182
+ setIntAttribute(span, 'http.response.content_length', headers['content-length']);
193
183
  if (requestId) span.setAttribute('http.request_id', requestId);
194
184
  },
195
185
  }),
186
+ // globalThis.fetch runs on undici, which never touches the http/https
187
+ // modules HttpInstrumentation patches. Without this, fetch calls produce no
188
+ // client span and inject no traceparent, so the callee starts a new trace
189
+ // and the two services can never be paired into a service-graph edge.
190
+ new UndiciInstrumentation({
191
+ // UndiciRequest exposes origin, not the .host the http hook reads.
192
+ requestHook: (span, request) => setPeerService(span, request?.origin),
193
+ }),
196
194
  new ExpressInstrumentation({
197
195
  requestHook: (span, info) => {
198
196
  // info is ExpressRequestInfo: { request, route, layerType }
@@ -249,17 +247,12 @@ export function setupTracing(options = {}) {
249
247
  new IORedisInstrumentation({
250
248
  requireParentSpan: false,
251
249
  requestHook: (span, {cmdName, cmdArgs}) => {
252
- // requestInfo is IORedisRequestHookInformation: { cmdName, cmdArgs }.
253
- // Set peer.service for service graph visualization - CRITICAL for Tempo.
254
- // The span is already created with SpanKind.CLIENT and net.peer.name is
255
- // already set to the real host by the instrumentation, so we do not
256
- // override those here.
250
+ // peer.service drives the Tempo service graph and is never emitted by
251
+ // the instrumentation, so it has to be set here. db.system.name,
252
+ // db.operation.name and server.* already come from the instrumentation.
257
253
  span.setAttribute('peer.service', 'redis');
258
- span.setAttribute('db.system', 'redis');
259
254
 
260
- // Add command details for better observability
261
255
  if (cmdName) {
262
- span.setAttribute('db.operation', cmdName.toUpperCase());
263
256
  span.updateName(`redis.${cmdName.toUpperCase()}`);
264
257
  }
265
258
 
@@ -274,9 +267,8 @@ export function setupTracing(options = {}) {
274
267
  }
275
268
  },
276
269
  responseHook: (span, cmdName, cmdArgs, response) => {
277
- // peer.service, db.system and db.operation are already set on this span
278
- // by requestHook and persist for the span's lifetime, so they are not
279
- // re-set here. Record only the response shape for observability.
270
+ // peer.service is already set by requestHook and persists for the
271
+ // span's lifetime, so only the response shape is recorded here.
280
272
  if (response !== undefined && response !== null) {
281
273
  span.setAttribute('db.response.type', typeof response);
282
274
  if (Array.isArray(response)) {
@@ -285,37 +277,22 @@ export function setupTracing(options = {}) {
285
277
  }
286
278
  },
287
279
  dbStatementSerializer: (cmdName, cmdArgs) => {
288
- // Serialize command for better observability (limit arg length to avoid huge spans)
289
- const args = cmdArgs.map(arg => {
290
- const str = String(arg);
291
- return str.length > 100 ? `${str.substring(0, 100)}...` : str;
292
- });
280
+ const args = cmdArgs.map(arg => truncateArg(arg, 100));
293
281
  return `${cmdName} ${args.join(' ')}`;
294
282
  },
295
283
  }),
296
284
  new ElasticsearchInstrumentation(),
285
+ // Spread so the optional instrumentations are constructed only when enabled:
286
+ // FsInstrumentation patches fs on construction.
287
+ ...(enableFsInstrumentation ? [new FsInstrumentation()] : []),
288
+ // DnsInstrumentationConfig accepts only ignoreHostnames; it has no hooks.
289
+ ...(enableDnsInstrumentation ? [new DnsInstrumentation({ignoreHostnames: ['localhost', '127.0.0.1', '::1']})] : []),
297
290
  ];
298
291
 
299
- if (enableFsInstrumentation) {
300
- // Enable fs instrumentation if specified
301
- // This instrumentation is useful for tracing file system operations.
302
- instrumentations.push(new FsInstrumentation());
303
- }
304
-
305
- if (enableDnsInstrumentation) {
306
- // Enable DNS instrumentation if specified
307
- // This instrumentation is useful for tracing DNS operations.
308
- // DnsInstrumentationConfig only supports ignoreHostnames; it has no
309
- // request/response/error hooks.
310
- instrumentations.push(new DnsInstrumentation({
311
- ignoreHostnames: ['localhost', '127.0.0.1', '::1'],
312
- }));
313
- }
314
-
315
292
  // Register instrumentations
316
293
  registerInstrumentations({
317
- tracerProvider: tracerProvider,
318
- instrumentations: instrumentations,
294
+ tracerProvider,
295
+ instrumentations,
319
296
  });
320
297
 
321
298
  // Return the tracer for the service
@@ -336,12 +313,12 @@ export async function stopTracing() {
336
313
  try {
337
314
  await tracerProvider.shutdown();
338
315
  tracerProvider = null;
339
- console.info('Tracing has been successfully shut down.');
316
+ diag.info('Tracing has been successfully shut down.');
340
317
  } catch (error) {
341
- console.error('Error during tracing shutdown:', error);
318
+ diag.error('Error during tracing shutdown:', error);
342
319
  }
343
320
  } else {
344
- console.warn('Tracer provider is not initialized.');
321
+ diag.warn('Tracer provider is not initialized.');
345
322
  }
346
323
  }
347
324
 
@@ -1,48 +1,38 @@
1
1
  // index.test.mjs
2
2
  import { describe, it, beforeEach, afterEach } from 'node:test';
3
3
  import assert from 'node:assert';
4
+ import { setupTracing, stopTracing, __resetTracingForTesting } from './index.mjs';
4
5
 
5
6
  describe('setupTracing', () => {
6
- let stopTracing;
7
-
8
7
  // Clear environment and reset tracing state before each test
9
- beforeEach(async () => {
8
+ beforeEach(() => {
10
9
  delete process.env.SERVICE_NAME;
11
10
  delete process.env.ENDPOINT;
12
11
  delete process.env.HOSTNAME;
13
12
  delete process.env.CONTAINER_NAME;
14
13
 
15
- // Import and store stopTracing for cleanup
16
- const tracing = await import('./index.mjs');
17
- stopTracing = tracing.stopTracing;
18
-
19
14
  // Reset singleton for test isolation
20
- tracing.__resetTracingForTesting();
15
+ __resetTracingForTesting();
21
16
  });
22
17
 
23
18
  // Clean up tracing after each test
24
19
  afterEach(async () => {
25
- if (stopTracing) {
26
- await stopTracing();
27
- }
20
+ await stopTracing();
28
21
  });
29
22
 
30
- it('should throw error when serviceName is not provided', async () => {
31
- const { setupTracing } = await import('./index.mjs');
23
+ it('should throw error when serviceName is not provided', () => {
32
24
  assert.throws(() => {
33
25
  setupTracing({ url: 'http://localhost:4317' });
34
26
  }, /serviceName is required/);
35
27
  });
36
28
 
37
- it('should throw error when url is not provided', async () => {
38
- const { setupTracing } = await import('./index.mjs');
29
+ it('should throw error when url is not provided', () => {
39
30
  assert.throws(() => {
40
31
  setupTracing({ serviceName: 'test-service' });
41
32
  }, /url is required/);
42
33
  });
43
34
 
44
- it('should create a tracer with required parameters', async () => {
45
- const { setupTracing } = await import('./index.mjs');
35
+ it('should create a tracer with required parameters', () => {
46
36
  const tracer = setupTracing({
47
37
  serviceName: 'test-service',
48
38
  url: 'http://localhost:4317',
@@ -50,8 +40,7 @@ describe('setupTracing', () => {
50
40
  assert.ok(tracer, 'tracer should be defined');
51
41
  });
52
42
 
53
- it('should accept hostname parameter', async () => {
54
- const { setupTracing } = await import('./index.mjs');
43
+ it('should accept hostname parameter', () => {
55
44
  const tracer = setupTracing({
56
45
  serviceName: 'test-service',
57
46
  url: 'http://localhost:4317',
@@ -60,8 +49,21 @@ describe('setupTracing', () => {
60
49
  assert.ok(tracer, 'tracer should be defined');
61
50
  });
62
51
 
63
- it('should accept optional instrumentations', async () => {
64
- const { setupTracing } = await import('./index.mjs');
52
+ // globalThis.fetch runs on undici, so it is invisible to HttpInstrumentation.
53
+ // Registering it must not disturb setup, and the patch has to land on the
54
+ // global fetch itself - otherwise outgoing calls carry no traceparent.
55
+ it('should instrument global fetch', () => {
56
+ const before = globalThis.fetch;
57
+ const tracer = setupTracing({
58
+ serviceName: 'test-service',
59
+ url: 'http://localhost:4317',
60
+ });
61
+ assert.ok(tracer, 'tracer should be defined');
62
+ assert.strictEqual(typeof globalThis.fetch, 'function', 'global fetch should still be callable');
63
+ assert.ok(before, 'global fetch should exist on a supported runtime');
64
+ });
65
+
66
+ it('should accept optional instrumentations', () => {
65
67
  const tracer = setupTracing({
66
68
  serviceName: 'test-service',
67
69
  url: 'http://localhost:4317',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saidsef/tracing-node",
3
- "version": "3.22.6",
3
+ "version": "4.1.0",
4
4
  "description": "tracing NodeJS - Wrapper for OpenTelemetry instrumentation packages",
5
5
  "main": "libs/index.mjs",
6
6
  "scripts": {
@@ -23,7 +23,7 @@
23
23
  "author": "Said Sef <saidsef@gmail.com>",
24
24
  "license": "Apache-2.0",
25
25
  "engines": {
26
- "node": ">= 20"
26
+ "node": ">= 20.6.0"
27
27
  },
28
28
  "bugs": {
29
29
  "url": "https://github.com/saidsef/tracing-node/issues"
@@ -31,30 +31,31 @@
31
31
  "homepage": "https://github.com/saidsef/tracing-node#readme",
32
32
  "dependencies": {
33
33
  "@opentelemetry/api": "^1.9.0",
34
- "@opentelemetry/exporter-trace-otlp-grpc": "^0.220.0",
35
- "@opentelemetry/instrumentation": "^0.220.0",
36
- "@opentelemetry/instrumentation-aws-sdk": "^0.75.0",
37
- "@opentelemetry/instrumentation-connect": "^0.63.0",
34
+ "@opentelemetry/exporter-trace-otlp-grpc": "^0.221.0",
35
+ "@opentelemetry/instrumentation": "^0.221.0",
36
+ "@opentelemetry/instrumentation-aws-sdk": "^0.76.0",
37
+ "@opentelemetry/instrumentation-connect": "^0.64.0",
38
38
  "@opentelemetry/instrumentation-dns": "^0.64.0",
39
- "@opentelemetry/instrumentation-express": "^0.67.0",
39
+ "@opentelemetry/instrumentation-express": "^0.69.0",
40
40
  "@opentelemetry/instrumentation-fs": "^0.40.0",
41
- "@opentelemetry/instrumentation-http": "^0.220.0",
42
- "@opentelemetry/instrumentation-ioredis": "^0.68.0",
43
- "@opentelemetry/instrumentation-pino": "^0.66.0",
44
- "@opentelemetry/resources": "^2.8.0",
45
- "@opentelemetry/sdk-trace-base": "^2.8.0",
46
- "@opentelemetry/sdk-trace-node": "^2.8.0",
47
- "@opentelemetry/semantic-conventions": "^1.41.1",
41
+ "@opentelemetry/instrumentation-http": "^0.221.0",
42
+ "@opentelemetry/instrumentation-ioredis": "^0.69.0",
43
+ "@opentelemetry/instrumentation-pino": "^0.67.0",
44
+ "@opentelemetry/instrumentation-undici": "^0.31.0",
45
+ "@opentelemetry/resources": "^2.10.0",
46
+ "@opentelemetry/sdk-trace-base": "^2.10.0",
47
+ "@opentelemetry/sdk-trace-node": "^2.10.0",
48
+ "@opentelemetry/semantic-conventions": "^1.43.0",
48
49
  "opentelemetry-instrumentation-elasticsearch": "^0.41.0"
49
50
  },
50
51
  "devDependencies": {
51
- "eslint": "^10.5.0"
52
+ "eslint": "^10.8.0"
52
53
  },
53
54
  "overrides": {
54
55
  "protobufjs": "^7.5.3",
55
- "@opentelemetry/core": "^2.8.0"
56
+ "@opentelemetry/core": "^2.10.0"
56
57
  },
57
58
  "allowScripts": {
58
- "protobufjs@7.6.4": true
59
+ "protobufjs@7.6.5": true
59
60
  }
60
61
  }