diff --git a/doc/api/cli.md b/doc/api/cli.md index 651e06f6910ebe..9aa6bbe8c25e38 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -3531,6 +3531,47 @@ added: v6.11.0 When set to `1`, process warnings are silenced. +### `NODE_OTEL=value` + + + +> Stability: 1 - Experimental + +When set to a non-empty value, enables the built-in OpenTelemetry tracing +subsystem using the default collector endpoint (`http://localhost:4318`). Also +makes the `node:otel` module available without the `--experimental-otel` flag. +If `NODE_OTEL_ENDPOINT` is also set, it takes precedence for the endpoint. See +the [`node:otel`][] documentation for details. + +### `NODE_OTEL_ENDPOINT=url` + + + +> Stability: 1 - Experimental + +When set to a non-empty value, enables the built-in OpenTelemetry tracing +subsystem and directs spans to the specified OTLP/HTTP collector endpoint. The +`/v1/traces` path is appended automatically. Also makes the `node:otel` module +available without the `--experimental-otel` flag. See the [`node:otel`][] +documentation for details. + +### `NODE_OTEL_FILTER=module[,…]` + + + +> Stability: 1 - Experimental + +Comma-separated list of core modules to instrument when OpenTelemetry tracing +is active. When not set, all supported modules are instrumented. Supported +values: `node:http`, `node:undici`, `node:fetch`. See the [`node:otel`][] +documentation for details. + ### `NODE_OPTIONS=options...` + + + +> Stability: 1 - Experimental + + + +The `node:otel` module provides built-in [OpenTelemetry][] tracing support for +Node.js core components. When enabled, it automatically creates spans for HTTP +server and client operations and exports them using the [OTLP/HTTP JSON][] +protocol. + +To access it: + +```cjs +const otel = require('node:otel'); +``` + +```mjs +import otel from 'node:otel'; +``` + +This module is only available under the `node:` scheme. It requires the +`--experimental-otel` CLI flag or one of the `NODE_OTEL` / `NODE_OTEL_ENDPOINT` +environment variables. + +## Activation + +There are two ways to activate OpenTelemetry tracing: + +### Environment variables + +Setting `NODE_OTEL=1` enables tracing with the default collector endpoint +(`http://localhost:4318`): + +```bash +NODE_OTEL=1 node app.js +``` + +To use a custom collector endpoint, set `NODE_OTEL_ENDPOINT` instead (or in +addition): + +```bash +NODE_OTEL_ENDPOINT=http://collector.example.com:4318 node app.js +``` + +Both variables also make the `node:otel` module available without requiring the +`--experimental-otel` flag. + +### Programmatic API + +```cjs +const otel = require('node:otel'); + +// Start with the default endpoint (http://localhost:4318): +otel.start(); + +// Or with a custom endpoint: +otel.start({ endpoint: 'http://collector.example.com:4318' }); + +// ... application code ... +otel.stop(); +``` + +## Environment variables + +### `NODE_OTEL` + +When set to a non-empty value, enables the OpenTelemetry tracing subsystem +using the default collector endpoint (`http://localhost:4318`). If +`NODE_OTEL_ENDPOINT` is also set, it takes precedence. + +### `NODE_OTEL_ENDPOINT` + +When set to a non-empty value, enables the OpenTelemetry tracing subsystem and +directs spans to the specified OTLP collector endpoint. The endpoint should be +the base URL of an +OTLP/HTTP collector (e.g. `http://localhost:4318`). The `/v1/traces` path is +appended automatically. + +### `NODE_OTEL_FILTER` + +Accepts a comma-separated list of core modules to instrument. When not set, all +supported modules are instrumented. For example, setting +`NODE_OTEL_FILTER=node:http` would enable tracing only for the `node:http` +module. + +Supported module filter values: + +* `node:http` — HTTP server and client operations +* `node:undici` — Undici HTTP client operations +* `node:fetch` — Fetch API operations (alias for undici) + +### `OTEL_SERVICE_NAME` + +Standard OpenTelemetry environment variable used to set the service name in +exported resource attributes. Defaults to `node-`. + +## `otel.start(options)` + + + +* `options` {Object} + * `endpoint` {string} The OTLP collector endpoint URL. + **Default:** `'http://localhost:4318'`. + * `filter` {string|string\[]} Optional comma-separated string or array of + core module names to instrument (e.g. `['node:http']` or + `'node:http,node:undici'`). When omitted, all supported modules are + instrumented. + * `maxBufferSize` {number} Maximum number of spans buffered in memory before + triggering an immediate flush to the collector. Must be a positive integer. + **Default:** `100`. + * `flushInterval` {number} Interval in milliseconds between periodic flushes + of buffered spans to the collector. Must be a positive integer. + **Default:** `10000`. + +Enables the OpenTelemetry tracing subsystem. Spans are created for supported +core module operations and exported to the specified collector using the +OTLP/HTTP JSON protocol. + +If tracing is already active, calling `start()` will stop the current session +and start a new one with the provided options. + +## `otel.stop()` + + + +Disables the OpenTelemetry tracing subsystem. Any buffered spans are sent to +the collector before stopping. Because the export is asynchronous, delivery is +not confirmed before `stop()` returns. After calling `stop()`, no new spans are +created. + +Calling `stop()` when tracing is not active is a no-op. + +## `otel.active` + + + +* {boolean} + +Returns `true` if the OpenTelemetry tracing subsystem is currently active. + +## Instrumented operations + +When tracing is active, spans are automatically created for the following +operations: + +### HTTP server + +A span with kind `SERVER` is created for each incoming HTTP request. The span +starts when the request is received and ends when the response finishes. If the +client disconnects before the response completes, the span ends with an error +status. + +Server spans receive error status (`STATUS_ERROR`) for 5xx response codes. 4xx +responses are not treated as server errors per OpenTelemetry semantic +conventions. + +Attributes set on server spans: + +| Attribute | Description | Condition | +| --------------------------- | -------------------------------- | ----------------------------- | +| `http.request.method` | HTTP method (e.g. `GET`, `POST`) | Always | +| `url.path` | Request URL path (without query) | Always | +| `url.query` | Query string (without `?`) | When query string is present | +| `url.scheme` | `http` or `https` | Always | +| `server.address` | Host header value | When `Host` header is present | +| `network.protocol.version` | HTTP version (e.g. `1.1`) | Always | +| `http.response.status_code` | Response status code | When response finishes | +| `error.type` | HTTP status code as string | On 5xx responses | + +### HTTP client + +A span with kind `CLIENT` is created for each outgoing HTTP request made via +`node:http`. The span starts when the request is created and ends when the +response finishes or an error occurs. + +Client spans receive error status (`STATUS_ERROR`) for 4xx and 5xx response +codes. On connection errors, an `exception` event is added to the span with +`exception.type`, `exception.message`, and `exception.stacktrace` attributes. + +Attributes set on client spans: + +| Attribute | Description | Condition | +| --------------------------- | ------------------------- | ------------------------- | +| `http.request.method` | HTTP method | Always | +| `url.full` | Full request URL | Always | +| `server.address` | Target host | Always | +| `server.port` | Target port | When available | +| `http.response.status_code` | Response status code | When response is received | +| `network.protocol.version` | HTTP version | When response is received | +| `error.type` | Status code or error name | On 4xx/5xx or errors | + +### Undici/Fetch client + +A span with kind `CLIENT` is created for each outgoing request made via +`fetch()` or undici's `request()`. + +Error status and `exception` event behavior is the same as for HTTP client +spans above. + +Attributes set on undici/fetch client spans: + +| Attribute | Description | Condition | +| --------------------------- | ------------------------- | ------------------------- | +| `http.request.method` | HTTP method | Always | +| `url.full` | Full request URL | Always | +| `server.address` | Target origin | Always | +| `http.response.status_code` | Response status code | When response is received | +| `error.type` | Status code or error name | On 4xx/5xx or errors | + +## W3C Trace Context propagation + +The tracing subsystem automatically propagates [W3C Trace Context][] across HTTP +boundaries: + +* **Incoming requests**: The `traceparent` header is read from incoming HTTP + requests. Child spans created during request processing inherit the trace ID. +* **Outgoing requests**: The `traceparent` header is injected into outgoing HTTP + and undici/fetch requests, enabling distributed tracing across services. + +[OTLP/HTTP JSON]: https://opentelemetry.io/docs/specs/otlp/#otlphttp +[OpenTelemetry]: https://opentelemetry.io/ +[W3C Trace Context]: https://www.w3.org/TR/trace-context/ diff --git a/lib/internal/bootstrap/realm.js b/lib/internal/bootstrap/realm.js index f49f0814bbc687..2c7932997c8056 100644 --- a/lib/internal/bootstrap/realm.js +++ b/lib/internal/bootstrap/realm.js @@ -124,6 +124,7 @@ const legacyWrapperList = new SafeSet([ // beginning with "internal/". // Modules that can only be imported via the node: scheme. const schemelessBlockList = new SafeSet([ + 'otel', 'sea', 'sqlite', 'quic', @@ -131,7 +132,7 @@ const schemelessBlockList = new SafeSet([ 'test/reporters', ]); // Modules that will only be enabled at run time. -const experimentalModuleList = new SafeSet(['sqlite', 'quic']); +const experimentalModuleList = new SafeSet(['otel', 'sqlite', 'quic']); // Set up process.binding() and process._linkedBinding(). { diff --git a/lib/internal/otel/core.js b/lib/internal/otel/core.js new file mode 100644 index 00000000000000..0aca7fe407e98d --- /dev/null +++ b/lib/internal/otel/core.js @@ -0,0 +1,156 @@ +'use strict'; + +const { + ArrayIsArray, + SafeSet, + StringPrototypeSplit, + StringPrototypeTrim, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_ARG_VALUE, + }, +} = require('internal/errors'); +const { + validateInteger, + validateObject, + validateString, +} = require('internal/validators'); + +const { AsyncLocalStorage } = require('async_hooks'); +const { URL } = require('internal/url'); + +const kDefaultEndpoint = 'http://localhost:4318'; + +let endpoint = null; +let active = false; +let filter = null; // null = all modules enabled; SafeSet = only listed modules. +let spanStorage = null; +let subscriptions = null; +let collectorHost = null; // Normalized host (e.g. "localhost:4318") for HTTP client filtering. + +function getSpanStorage() { + spanStorage ??= new AsyncLocalStorage(); + return spanStorage; +} + +function isActive() { + return active; +} + +function getEndpoint() { + return endpoint; +} + +function getCollectorHost() { + return collectorHost; +} + +function isModuleEnabled(moduleName) { + if (filter == null) return true; + return filter.has(moduleName); +} + +function parseFilter(filter) { + if (filter == null) return null; + if (ArrayIsArray(filter)) { + const set = new SafeSet(); + for (let i = 0; i < filter.length; i++) { + const trimmed = StringPrototypeTrim(`${filter[i]}`); + if (trimmed) set.add(trimmed); + } + return set; + } + if (typeof filter === 'string') { + const parts = StringPrototypeSplit(filter, ','); + const set = new SafeSet(); + for (let i = 0; i < parts.length; i++) { + const trimmed = StringPrototypeTrim(parts[i]); + if (trimmed) set.add(trimmed); + } + return set; + } + throw new ERR_INVALID_ARG_TYPE('options.filter', + ['string', 'Array', 'null', 'undefined'], + filter); +} + +function start(options = { __proto__: null }) { + validateObject(options, 'options'); + + const endpointValue = options.endpoint ?? kDefaultEndpoint; + validateString(endpointValue, 'options.endpoint'); + if (!endpointValue) { + throw new ERR_INVALID_ARG_VALUE('options.endpoint', endpointValue, + 'must be a non-empty string'); + } + + let parsed; + try { + parsed = new URL(endpointValue); + } catch { + throw new ERR_INVALID_ARG_VALUE('options.endpoint', endpointValue, + 'must be a valid URL'); + } + + if (options.maxBufferSize !== undefined) { + validateInteger(options.maxBufferSize, 'options.maxBufferSize', 1); + } + if (options.flushInterval !== undefined) { + validateInteger(options.flushInterval, 'options.flushInterval', 1); + } + + const parsedFilter = parseFilter(options.filter); + + if (active) { + stop(); + } + + endpoint = endpointValue; + filter = parsedFilter; + active = true; + collectorHost = parsed.host; + + const { enableInstrumentations } = require('internal/otel/instrumentations'); + subscriptions = enableInstrumentations(); + + const { startFlusher } = require('internal/otel/flush'); + startFlusher(options); +} + +function stop() { + if (!active) return; + + const { disableInstrumentations } = require('internal/otel/instrumentations'); + if (subscriptions != null) { + disableInstrumentations(subscriptions); + subscriptions = null; + } + + const { stopFlusher, flush, resetCaches } = require('internal/otel/flush'); + flush(); + stopFlusher(); + resetCaches(); + + active = false; + endpoint = null; + filter = null; + collectorHost = null; + + if (spanStorage != null) { + spanStorage.disable(); + spanStorage = null; + } +} + +module.exports = { + start, + stop, + isActive, + getEndpoint, + getSpanStorage, + isModuleEnabled, + getCollectorHost, +}; diff --git a/lib/internal/otel/flush.js b/lib/internal/otel/flush.js new file mode 100644 index 00000000000000..7ab3efd1ae2356 --- /dev/null +++ b/lib/internal/otel/flush.js @@ -0,0 +1,304 @@ +'use strict'; + +const { + ArrayPrototypePush, + DateNow, + JSONStringify, + NumberIsInteger, + ObjectKeys, + String, + StringPrototypeEndsWith, +} = primordials; + +const { Buffer } = require('buffer'); +const http = require('http'); +const https = require('https'); +const { clearInterval, setInterval } = require('timers'); +const { URL } = require('internal/url'); +const { getEndpoint } = require('internal/otel/core'); + +const kDefaultMaxBufferSize = 100; +const kDefaultFlushIntervalMs = 10_000; +const kWarningThrottleMs = 30_000; + +let spanBuffer = []; +let flushTimer = null; +let maxBufferSize = kDefaultMaxBufferSize; + +let exportFailureCount = 0; +let lastExportWarningTime = 0; + +let cachedResource = null; +let cachedScope = null; + +function getResource() { + cachedResource ??= { + attributes: [ + { key: 'service.name', + value: { stringValue: process.env.OTEL_SERVICE_NAME || + `node-${process.pid}` } }, + { key: 'telemetry.sdk.name', + value: { stringValue: 'nodejs-core' } }, + { key: 'telemetry.sdk.language', + value: { stringValue: 'nodejs' } }, + { key: 'telemetry.sdk.version', + value: { stringValue: process.version } }, + { key: 'process.runtime.name', + value: { stringValue: 'nodejs' } }, + { key: 'process.runtime.version', + value: { stringValue: process.version } }, + { key: 'process.pid', + value: { intValue: String(process.pid) } }, + ], + }; + return cachedResource; +} + +function getScope() { + cachedScope ??= { + name: 'nodejs-core', + version: process.version, + }; + return cachedScope; +} + +function encodeAttributeValue(value) { + if (typeof value === 'string') { + return { stringValue: value }; + } + if (typeof value === 'number') { + if (NumberIsInteger(value)) { + return { intValue: String(value) }; + } + return { doubleValue: value }; + } + if (typeof value === 'boolean') { + return { boolValue: value }; + } + return { stringValue: String(value) }; +} + +function spanToOtlp(span) { + // TODO(bengl): A lot of objects are created in here for all the atributes. + // As a future optimization, we could hand-write the JSON encoding. + const rawAttrs = span.getAttributes(); + const attrKeys = ObjectKeys(rawAttrs); + const attributes = []; + for (let i = 0; i < attrKeys.length; i++) { + ArrayPrototypePush(attributes, { + key: attrKeys[i], + value: encodeAttributeValue(rawAttrs[attrKeys[i]]), + }); + } + + const rawEvents = span.getEvents(); + const events = []; + for (let i = 0; i < rawEvents.length; i++) { + const event = rawEvents[i]; + const eventAttrs = []; + const eventAttrKeys = ObjectKeys(event.attributes); + for (let j = 0; j < eventAttrKeys.length; j++) { + ArrayPrototypePush(eventAttrs, { + key: eventAttrKeys[j], + value: encodeAttributeValue(event.attributes[eventAttrKeys[j]]), + }); + } + const otlpEvent = { + name: event.name, + timeUnixNano: event.timeUnixNano, + }; + if (eventAttrs.length > 0) { + otlpEvent.attributes = eventAttrs; + } + ArrayPrototypePush(events, otlpEvent); + } + + const otlpSpan = { + traceId: span.traceId, + spanId: span.spanId, + name: span.name, + kind: span.kind, + startTimeUnixNano: span.startTimeUnixNano, + endTimeUnixNano: span.endTimeUnixNano, + }; + + if (attributes.length > 0) { + otlpSpan.attributes = attributes; + } + + const status = span.status; + if (status.code !== 0 || status.message) { + otlpSpan.status = status; + } + + if (span.parentSpanId) { + otlpSpan.parentSpanId = span.parentSpanId; + } + + if (events.length > 0) { + otlpSpan.events = events; + } + + return otlpSpan; +} + +function addSpan(span) { + ArrayPrototypePush(spanBuffer, span); + if (spanBuffer.length >= maxBufferSize) { + flush(); + } +} + +function flush() { + if (spanBuffer.length === 0) return; + if (getEndpoint() == null) return; + + const spans = spanBuffer; + spanBuffer = []; + + const otlpSpans = []; + for (let i = 0; i < spans.length; i++) { + try { + ArrayPrototypePush(otlpSpans, spanToOtlp(spans[i])); + } catch (err) { + exportFailureCount++; + emitExportWarningThrottled( + `Failed to serialize span "${spans[i].name}": ${err.message}`); + } + } + + if (otlpSpans.length === 0) return; + + let payload; + try { + payload = JSONStringify({ + resourceSpans: [{ + resource: getResource(), + scopeSpans: [{ + scope: getScope(), + spans: otlpSpans, + }], + }], + }); + } catch (err) { + exportFailureCount++; + emitExportWarningThrottled( + `Failed to serialize ${otlpSpans.length} spans: ${err.message}`); + return; + } + + sendToCollector(payload); +} + +function emitExportWarningThrottled(message) { + const now = DateNow(); + if (now - lastExportWarningTime >= kWarningThrottleMs) { + lastExportWarningTime = now; + const suffix = exportFailureCount > 1 ? + ` (${exportFailureCount} total failures)` : ''; + process.emitWarning( + `${message}${suffix}`, + 'OTelExportWarning', + ); + } +} + +function sendToCollector(body) { + const endpoint = getEndpoint(); + if (endpoint == null) return; + + try { + let urlStr = endpoint; + if (!StringPrototypeEndsWith(urlStr, '/v1/traces')) { + urlStr = StringPrototypeEndsWith(urlStr, '/') ? + urlStr + 'v1/traces' : + urlStr + '/v1/traces'; + } + + const parsed = new URL(urlStr); + + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + exportFailureCount++; + emitExportWarningThrottled( + `Unsupported protocol "${parsed.protocol}" in OTLP endpoint; ` + + 'only http: and https: are supported'); + return; + } + + const transport = parsed.protocol === 'https:' ? https : http; + + const req = transport.request({ + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname, + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + }, + }, (res) => { + // TODO(bengl): Once retry logic is added, parse the response body for + // ExportTraceServiceResponse.partial_success.rejected_spans. + res.resume(); + res.on('end', () => { + if (res.statusCode >= 400) { + exportFailureCount++; + emitExportWarningThrottled( + `OTLP collector responded with HTTP ${res.statusCode}`); + } + }); + res.on('error', (err) => { + exportFailureCount++; + emitExportWarningThrottled( + `OTLP export response stream error: ${err.message}`); + }); + }); + + req.on('error', (err) => { + exportFailureCount++; + emitExportWarningThrottled( + `Failed to export spans to ${endpoint}: ${err.message}`); + }); + + req.end(body); + } catch (err) { + exportFailureCount++; + emitExportWarningThrottled( + `Failed to export spans to ${endpoint}: ${err.message}`); + } +} + +function startFlusher(options) { + if (flushTimer != null) return; + maxBufferSize = options?.maxBufferSize ?? kDefaultMaxBufferSize; + const interval = options?.flushInterval ?? kDefaultFlushIntervalMs; + flushTimer = setInterval(flush, interval); + flushTimer.unref(); + + process.on('beforeExit', flush); +} + +function stopFlusher() { + if (flushTimer != null) { + clearInterval(flushTimer); + flushTimer = null; + } + process.removeListener('beforeExit', flush); +} + +function resetCaches() { + cachedResource = null; + cachedScope = null; + exportFailureCount = 0; + lastExportWarningTime = 0; + maxBufferSize = kDefaultMaxBufferSize; + spanBuffer = []; +} + +module.exports = { + addSpan, + flush, + startFlusher, + stopFlusher, + resetCaches, +}; diff --git a/lib/internal/otel/id.js b/lib/internal/otel/id.js new file mode 100644 index 00000000000000..7189595fa7fc52 --- /dev/null +++ b/lib/internal/otel/id.js @@ -0,0 +1,56 @@ +'use strict'; + +const { + Array, + NumberPrototypeToString, + StringPrototypePadStart, + Uint8Array, +} = primordials; + +const { randomFillSync } = require('internal/crypto/random'); + +// Pre-allocate a 4KB (page-aligned) buffer of random bytes. Refill when +// exhausted. This amortizes the cost of random number generation across +// many ID creations. Each trace ID uses 16 bytes, each span ID 8 bytes, +// so 4096 bytes provides ~170 spans before refill. +const kBufferSize = 4096; +const randomBuffer = new Uint8Array(kBufferSize); +let randomOffset = kBufferSize; // Start at end to trigger first fill + +// Hex lookup table for fast byte-to-hex conversion. +const hexTable = new Array(256); +for (let i = 0; i < 256; i++) { + hexTable[i] = StringPrototypePadStart( + NumberPrototypeToString(i, 16), 2, '0', + ); +} + +function ensureRandomBytes(needed) { + if (randomOffset + needed > kBufferSize) { + randomFillSync(randomBuffer); + randomOffset = 0; + } +} + +function generateTraceId() { + ensureRandomBytes(16); + let id = ''; + for (let i = 0; i < 16; i++) { + id += hexTable[randomBuffer[randomOffset++]]; + } + return id; +} + +function generateSpanId() { + ensureRandomBytes(8); + let id = ''; + for (let i = 0; i < 8; i++) { + id += hexTable[randomBuffer[randomOffset++]]; + } + return id; +} + +module.exports = { + generateTraceId, + generateSpanId, +}; diff --git a/lib/internal/otel/instrumentations.js b/lib/internal/otel/instrumentations.js new file mode 100644 index 00000000000000..23315713bc8b40 --- /dev/null +++ b/lib/internal/otel/instrumentations.js @@ -0,0 +1,240 @@ +'use strict'; + +const { + ArrayPrototypePush, + StringPrototypeIndexOf, + StringPrototypeSlice, +} = primordials; + +const dc = require('diagnostics_channel'); +const { + Span, + SPAN_KIND_SERVER, + SPAN_KIND_CLIENT, + STATUS_ERROR, + kSpan, +} = require('internal/otel/span'); +const { + isActive, + isModuleEnabled, + getSpanStorage, + getCollectorHost, +} = require('internal/otel/core'); + +function onHttpServerRequestStart({ request, response, socket, server }) { + if (!isActive() || !isModuleEnabled('node:http')) return; + + const traceparent = request.headers?.traceparent; + const parent = traceparent != null ? Span.extract(traceparent) : undefined; + + const method = request.method || 'GET'; + const span = new Span(method, SPAN_KIND_SERVER, { + parent, + }); + + span.setAttribute('http.request.method', method); + + const rawUrl = request.url || '/'; + const qIdx = StringPrototypeIndexOf(rawUrl, '?'); + if (qIdx === -1) { + span.setAttribute('url.path', rawUrl); + } else { + span.setAttribute('url.path', StringPrototypeSlice(rawUrl, 0, qIdx)); + span.setAttribute('url.query', StringPrototypeSlice(rawUrl, qIdx + 1)); + } + + span.setAttribute('url.scheme', socket?.encrypted ? 'https' : 'http'); + span.setAttribute('network.protocol.version', + request.httpVersion || '1.1'); + + const host = request.headers?.host; + if (host) { + span.setAttribute('server.address', host); + } + + request[kSpan] = span; + + const storage = getSpanStorage(); + storage.enterWith(span); + + request.on('close', () => { + if (span.endTimeUnixNano === undefined) { + span.setStatus(STATUS_ERROR, 'request closed before response'); + span.end(); + } + }); +} + +function onHttpServerResponseFinish({ request, response }) { + const span = request[kSpan]; + if (span == null) return; + + const statusCode = response.statusCode || 200; + span.setAttribute('http.response.status_code', statusCode); + + if (statusCode >= 500) { + span.setStatus(STATUS_ERROR, `HTTP ${statusCode}`); + span.setAttribute('error.type', `${statusCode}`); + } + + span.end(); +} + +function onHttpClientRequestCreated({ request }) { + if (!isActive() || !isModuleEnabled('node:http')) return; + + if (request.getHeader('host') === getCollectorHost()) return; + + const storage = getSpanStorage(); + const parent = storage.getStore(); + + const method = request.method || 'GET'; + const span = new Span( + method, + SPAN_KIND_CLIENT, + { parent }, + ); + + span.setAttribute('http.request.method', method); + span.setAttribute('server.address', request.host || ''); + + const port = request.socket?.remotePort || request.port; + if (port != null) { + span.setAttribute('server.port', port); + } + + const url = `${request.protocol}//${request.host}${request.path}`; + span.setAttribute('url.full', url); + + request[kSpan] = span; + + request.setHeader('traceparent', span.inject()); +} + +function onHttpClientResponseFinish({ request, response }) { + const span = request[kSpan]; + if (span == null) return; + + const statusCode = response.statusCode; + span.setAttribute('http.response.status_code', statusCode); + span.setAttribute('network.protocol.version', + response.httpVersion || '1.1'); + + if (statusCode >= 400) { + span.setStatus(STATUS_ERROR, `HTTP ${statusCode}`); + span.setAttribute('error.type', `${statusCode}`); + } + + span.end(); +} + +function onHttpClientRequestError({ request, error }) { + const span = request[kSpan]; + if (span == null) return; + + span.setAttribute('error.type', error?.name || 'Error'); + span.setStatus(STATUS_ERROR, error?.message || 'unknown error'); + span.addEvent('exception', { + 'exception.type': error?.name || 'Error', + 'exception.message': error?.message || '', + 'exception.stacktrace': error?.stack || '', + }); + span.end(); +} + +function onUndiciRequestCreate({ request }) { + if (!isActive()) return; + if (!isModuleEnabled('node:undici') && + !isModuleEnabled('node:fetch')) return; + + const storage = getSpanStorage(); + const parent = storage.getStore(); + + const method = request.method || 'GET'; + const span = new Span( + method, + SPAN_KIND_CLIENT, + { parent }, + ); + + span.setAttribute('http.request.method', method); + span.setAttribute('server.address', request.origin || ''); + + const url = `${request.origin}${request.path}`; + span.setAttribute('url.full', url); + + request[kSpan] = span; + + if (request.addHeader) { + request.addHeader('traceparent', span.inject()); + } +} + +function onUndiciRequestHeaders({ request, response }) { + const span = request[kSpan]; + if (span == null) return; + + const statusCode = response.statusCode; + span.setAttribute('http.response.status_code', statusCode); + + if (statusCode >= 400) { + span.setStatus(STATUS_ERROR, `HTTP ${statusCode}`); + span.setAttribute('error.type', `${statusCode}`); + } + + span.end(); +} + +function onUndiciRequestError({ request, error }) { + const span = request[kSpan]; + if (span == null) return; + + span.setAttribute('error.type', error?.name || 'Error'); + span.setStatus(STATUS_ERROR, error?.message || 'unknown error'); + span.addEvent('exception', { + 'exception.type': error?.name || 'Error', + 'exception.message': error?.message || '', + 'exception.stacktrace': error?.stack || '', + }); + span.end(); +} + +function enableInstrumentations() { + const subscriptions = []; + + function sub(channel, handler) { + dc.subscribe(channel, handler); + ArrayPrototypePush(subscriptions, [channel, handler]); + } + + if (isModuleEnabled('node:http')) { + sub('http.server.request.start', onHttpServerRequestStart); + sub('http.server.response.finish', onHttpServerResponseFinish); + sub('http.client.request.created', onHttpClientRequestCreated); + sub('http.client.response.finish', onHttpClientResponseFinish); + sub('http.client.request.error', onHttpClientRequestError); + } + + if (isModuleEnabled('node:undici') || isModuleEnabled('node:fetch')) { + sub('undici:request:create', onUndiciRequestCreate); + sub('undici:request:headers', onUndiciRequestHeaders); + sub('undici:request:error', onUndiciRequestError); + } + + return subscriptions; +} + +function disableInstrumentations(subscriptions) { + for (let i = 0; i < subscriptions.length; i++) { + try { + dc.unsubscribe(subscriptions[i][0], subscriptions[i][1]); + } catch { + // Best-effort cleanup; continue unsubscribing remaining channels. + } + } +} + +module.exports = { + enableInstrumentations, + disableInstrumentations, +}; diff --git a/lib/internal/otel/span.js b/lib/internal/otel/span.js new file mode 100644 index 00000000000000..fea16dd77c7de9 --- /dev/null +++ b/lib/internal/otel/span.js @@ -0,0 +1,191 @@ +'use strict'; + +const { + ArrayPrototypePush, + BigInt, + MathRound, + NumberParseInt, + NumberPrototypeToString, + ObjectAssign, + RegExpPrototypeExec, + StringPrototypePadStart, + StringPrototypeSplit, + Symbol, +} = primordials; + +const { addSpan } = require('internal/otel/flush'); +const { generateTraceId, generateSpanId } = require('internal/otel/id'); +const { now, getTimeOriginTimestamp } = require('internal/perf/utils'); + +const SPAN_KIND_INTERNAL = 1; +const SPAN_KIND_SERVER = 2; +const SPAN_KIND_CLIENT = 3; + +const STATUS_UNSET = 0; +const STATUS_ERROR = 2; + +const kSpan = Symbol('kOtelSpan'); + +const kHex32 = /^[0-9a-f]{32}$/; +const kHex16 = /^[0-9a-f]{16}$/; +const kHex2 = /^[0-9a-f]{2}$/; +const kAllZero32 = '00000000000000000000000000000000'; +const kAllZero16 = '0000000000000000'; + +// Compute the Unix epoch time origin once, in nanoseconds as a string. +// getTimeOriginTimestamp() returns milliseconds since Unix epoch. +// now() returns milliseconds since timeOrigin. +// We combine them for nanosecond-precision absolute timestamps. +let timeOriginNs; +function getTimeOriginNs() { + if (timeOriginNs === undefined) { + // getTimeOriginTimestamp() returns ms since Unix epoch. + // Multiply by 1e6 to get nanoseconds. + const originMs = getTimeOriginTimestamp(); + timeOriginNs = BigInt(MathRound(originMs * 1e6)); + } + return timeOriginNs; +} + +function hrTimeToNanos() { + const relativeMs = now(); + const ns = getTimeOriginNs() + BigInt(MathRound(relativeMs * 1e6)); + return `${ns}`; +} + +class Span { + traceId; + spanId; + parentSpanId; + name; + kind; + startTimeUnixNano; + endTimeUnixNano; + + #attributes; + #events; + #status; + #traceFlags; + + constructor(name, kind, options) { + const parent = options?.parent; + + this.name = name; + this.kind = kind; + this.spanId = generateSpanId(); + this.#attributes = { __proto__: null }; + this.#events = []; + this.#status = { code: STATUS_UNSET, message: '' }; + this.startTimeUnixNano = hrTimeToNanos(); + + if (parent != null) { + this.traceId = parent.traceId; + this.parentSpanId = parent.spanId; + this.#traceFlags = parent.traceFlags; + } else { + this.traceId = generateTraceId(); + this.parentSpanId = ''; + this.#traceFlags = 0x01; // Sampled by default. + } + } + + get traceFlags() { + return this.#traceFlags; + } + + setAttribute(key, value) { + this.#attributes[key] = value; + return this; + } + + setAttributes(attrs) { + if (attrs != null) { + ObjectAssign(this.#attributes, attrs); + } + return this; + } + + getAttributes() { + return this.#attributes; + } + + addEvent(name, attributes) { + ArrayPrototypePush(this.#events, { + name, + timeUnixNano: hrTimeToNanos(), + attributes: attributes || { __proto__: null }, + }); + return this; + } + + getEvents() { + return this.#events; + } + + get status() { + return this.#status; + } + + setStatus(code, message) { + this.#status = { code, message: message || '' }; + return this; + } + + end() { + if (this.endTimeUnixNano !== undefined) return; // Already ended. + this.endTimeUnixNano = hrTimeToNanos(); + + // Only export sampled spans. + if (this.#traceFlags & 0x01) { + addSpan(this); + } + } + + // Generate W3C traceparent header value. + // Format: {version}-{trace-id}-{span-id}-{trace-flags} + inject() { + const flags = StringPrototypePadStart( + NumberPrototypeToString(this.#traceFlags, 16), 2, '0'); + return `00-${this.traceId}-${this.spanId}-${flags}`; + } + + // Parse a W3C traceparent header into a "fake" remote parent span. + // Returns null if the header is invalid per the W3C Trace Context spec. + static extract(traceparentHeader) { + if (typeof traceparentHeader !== 'string') { + return null; + } + + const parts = StringPrototypeSplit(traceparentHeader, '-'); + if (parts.length !== 4) return null; + + // Only version 00 is defined; reject everything else. + if (parts[0] !== '00') return null; + const traceId = parts[1]; + const spanId = parts[2]; + const flags = parts[3]; + if (RegExpPrototypeExec(kHex32, traceId) === null) return null; + if (RegExpPrototypeExec(kHex16, spanId) === null) return null; + if (RegExpPrototypeExec(kHex2, flags) === null) return null; + + if (traceId === kAllZero32) return null; + if (spanId === kAllZero16) return null; + + return { + __proto__: null, + traceId, + spanId, + traceFlags: NumberParseInt(flags, 16), + }; + } +} + +module.exports = { + Span, + SPAN_KIND_INTERNAL, + SPAN_KIND_SERVER, + SPAN_KIND_CLIENT, + STATUS_UNSET, + STATUS_ERROR, + kSpan, +}; diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js index 0902536708bf1d..038e80c6d4e8e3 100644 --- a/lib/internal/process/pre_execution.js +++ b/lib/internal/process/pre_execution.js @@ -121,6 +121,7 @@ function prepareExecution(options) { setupEventsource(); setupCodeCoverage(); setupDebugEnv(); + setupOtel(); // Process initial diagnostic reporting configuration, if present. initializeReport(); @@ -387,6 +388,35 @@ function initializeConfigFileSupport() { } } +function setupOtel() { + const endpoint = process.env.NODE_OTEL_ENDPOINT; + const otelEnabled = process.env.NODE_OTEL; + + if (getOptionValue('--experimental-otel') || endpoint || otelEnabled) { + const { BuiltinModule } = require('internal/bootstrap/realm'); + BuiltinModule.allowRequireByUsers('otel'); + } + + // Auto-start tracing when activated via environment variables. + // NODE_OTEL_ENDPOINT sets a specific collector; NODE_OTEL=1 uses the + // default (http://localhost:4318). + if (endpoint || otelEnabled) { + try { + const { start } = require('internal/otel/core'); + const opts = { __proto__: null }; + if (endpoint) opts.endpoint = endpoint; + const envFilter = process.env.NODE_OTEL_FILTER; + if (envFilter) opts.filter = envFilter; + start(opts); + } catch (err) { + process.emitWarning( + `Failed to initialize OpenTelemetry tracing: ${err.message}`, + 'OTelWarning', + ); + } + } +} + function setupQuic() { if (!getOptionValue('--experimental-quic')) { return; diff --git a/lib/otel.js b/lib/otel.js new file mode 100644 index 00000000000000..d5012cbcb55a79 --- /dev/null +++ b/lib/otel.js @@ -0,0 +1,17 @@ +'use strict'; + +const { emitExperimentalWarning } = require('internal/util'); + +emitExperimentalWarning('node:otel'); + +const { + start, + stop, + isActive, +} = require('internal/otel/core'); + +module.exports = { + start, + stop, + get active() { return isActive(); }, +}; diff --git a/src/node_options.cc b/src/node_options.cc index 22fe0f61da8c69..88c506747fc510 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -592,6 +592,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { &EnvironmentOptions::experimental_sqlite, kAllowedInEnvvar, true); + AddOption("--experimental-otel", + "experimental built-in OpenTelemetry tracing", + &EnvironmentOptions::experimental_otel, + kAllowedInEnvvar); AddOption("--experimental-quic", #ifndef OPENSSL_NO_QUIC "experimental QUIC support", diff --git a/src/node_options.h b/src/node_options.h index 21f43946ea8a1e..afa2b1322b17b9 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -124,6 +124,7 @@ class EnvironmentOptions : public Options { bool enable_source_maps = false; bool experimental_addon_modules = false; bool experimental_eventsource = false; + bool experimental_otel = false; bool experimental_fetch = true; bool experimental_websocket = true; bool experimental_sqlite = true; diff --git a/test/parallel/test-otel-beforeexit-flush.js b/test/parallel/test-otel-beforeexit-flush.js new file mode 100644 index 00000000000000..fc68353d76e690 --- /dev/null +++ b/test/parallel/test-otel-beforeexit-flush.js @@ -0,0 +1,79 @@ +'use strict'; +// Flags: --experimental-otel + +require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const { describe, it } = require('node:test'); +const { spawn } = require('node:child_process'); + +// Test that buffered spans are flushed via the beforeExit handler +// when the process exits without calling otel.stop(). + +describe('node:otel beforeExit flush', () => { + it('flushes buffered spans on process beforeExit', async () => { + let resolveSpans; + const spansReceived = new Promise((r) => { resolveSpans = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + const data = JSON.parse(body); + const spans = data.resourceSpans[0].scopeSpans[0].spans; + res.writeHead(200); + res.end(); + resolveSpans(spans); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + const collectorPort = collector.address().port; + + // Spawn a child that starts otel, makes a request, and exits + // WITHOUT calling otel.stop(). The beforeExit handler should flush. + const script = ` +const http = require("http"); +const otel = require("node:otel"); +otel.start({ endpoint: "http://127.0.0.1:${collectorPort}" }); +const server = http.createServer((req, res) => { + res.writeHead(200); + res.end("ok"); +}); +server.listen(0, () => { + const port = server.address().port; + http.get("http://127.0.0.1:" + port + "/test", (res) => { + res.resume(); + res.on("end", () => { + server.close(); + }); + }); +}); +`; + + const child = spawn(process.execPath, [ + '--experimental-otel', '-e', script, + ], { stdio: 'pipe' }); + + const childExited = new Promise((resolve) => { + child.on('exit', (code) => resolve(code)); + }); + + const exitCode = await childExited; + assert.strictEqual(exitCode, 0); + + // Wait for the collector to receive spans. + const timeout = setTimeout(() => { + collector.close(); + assert.fail('Timed out waiting for spans from beforeExit flush'); + }, 10_000); + timeout.unref(); + + const spans = await spansReceived; + clearTimeout(timeout); + + collector.close(); + + assert.ok(spans.length > 0, 'Expected spans to be flushed on beforeExit'); + }); +}); diff --git a/test/parallel/test-otel-client-4xx.js b/test/parallel/test-otel-client-4xx.js new file mode 100644 index 00000000000000..62ca65eb0356ba --- /dev/null +++ b/test/parallel/test-otel-client-4xx.js @@ -0,0 +1,70 @@ +'use strict'; +// Flags: --experimental-otel + +require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const { describe, it } = require('node:test'); + +const otel = require('node:otel'); + +// Per OTel semantic conventions, client spans set STATUS_ERROR for >= 400, +// while server spans only set it for >= 500 (4xx is a client mistake). + +describe('node:otel HTTP 4xx status handling', () => { + it('sets error on client span but not server span for 404', async () => { + let resolveSpans; + const spansReceived = new Promise((r) => { resolveSpans = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + const data = JSON.parse(body); + const spans = data.resourceSpans[0].scopeSpans[0].spans; + res.writeHead(200); + res.end(); + resolveSpans(spans); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + + otel.start({ endpoint: `http://127.0.0.1:${collector.address().port}` }); + + const server = http.createServer((req, res) => { + res.writeHead(404); + res.end('not found'); + }); + await new Promise((r) => server.listen(0, r)); + + await new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${server.address().port}/missing`, (res) => { + res.resume(); + res.on('end', resolve); + }).on('error', reject); + }); + + otel.stop(); + + const spans = await spansReceived; + + collector.close(); + server.close(); + + // Find server span (kind SERVER = 1, OTLP wire = 2) and client span + // (kind CLIENT = 2, OTLP wire = 3). + const serverSpan = spans.find((s) => s.kind === 2); + const clientSpan = spans.find((s) => s.kind === 3); + + assert.ok(serverSpan, 'Expected a server span'); + assert.ok(clientSpan, 'Expected a client span'); + + // Server span: 404 should NOT have error status. + assert.strictEqual(serverSpan.status, undefined); + + // Client span: 404 should have error status. + assert.ok(clientSpan.status, 'Client span should have error status'); + assert.strictEqual(clientSpan.status.code, 2); // STATUS_ERROR + }); +}); diff --git a/test/parallel/test-otel-client-error.js b/test/parallel/test-otel-client-error.js new file mode 100644 index 00000000000000..5f85ac7e20df91 --- /dev/null +++ b/test/parallel/test-otel-client-error.js @@ -0,0 +1,83 @@ +'use strict'; +// Flags: --experimental-otel + +require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const { describe, it } = require('node:test'); + +const otel = require('node:otel'); + +describe('node:otel HTTP client error span', () => { + it('creates error span for HTTP client connection refused', async () => { + let resolvePayload; + const payloadReceived = new Promise((r) => { resolvePayload = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + res.writeHead(200); + res.end(); + resolvePayload(JSON.parse(body)); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + + otel.start({ endpoint: `http://127.0.0.1:${collector.address().port}` }); + + // Also need a server to trigger a flush (the error request won't + // reach a server, so make a successful request too). + const server = http.createServer((req, res) => { + res.writeHead(200); + res.end('ok'); + }); + + await new Promise((r) => server.listen(0, r)); + + // Make a request to a port that is not listening — should fail. + await new Promise((resolve) => { + http.get('http://127.0.0.1:1/fail', (res) => { + res.resume(); + res.on('end', resolve); + }).on('error', () => { + resolve(); + }); + }); + + // Make a successful request so we have something to flush. + await new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${server.address().port}/ok`, (res) => { + res.resume(); + res.on('end', resolve); + }).on('error', reject); + }); + + otel.stop(); + + const payload = await payloadReceived; + + collector.close(); + server.close(); + + const spans = payload.resourceSpans[0].scopeSpans[0].spans; + + const errorSpan = spans.find((s) => { + if (s.kind !== 3) return false; // CLIENT + const urlAttr = s.attributes?.find((a) => a.key === 'url.full'); + return urlAttr?.value?.stringValue?.includes('/fail'); + }); + + assert.ok(errorSpan, 'Expected an error client span for connection refused'); + + assert.ok(errorSpan.status, 'Error span should have status'); + assert.strictEqual(errorSpan.status.code, 2); // STATUS_ERROR + + assert.ok(errorSpan.events, 'Error span should have events'); + const exceptionEvent = errorSpan.events.find( + (e) => e.name === 'exception', + ); + assert.ok(exceptionEvent, 'Should have exception event'); + }); +}); diff --git a/test/parallel/test-otel-context-propagation.js b/test/parallel/test-otel-context-propagation.js new file mode 100644 index 00000000000000..dda371bafb5b8e --- /dev/null +++ b/test/parallel/test-otel-context-propagation.js @@ -0,0 +1,114 @@ +'use strict'; +// Flags: --experimental-otel + +require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const net = require('node:net'); +const { describe, it } = require('node:test'); + +const otel = require('node:otel'); + +// This test verifies W3C trace context propagation: +// An incoming request with a traceparent header causes child spans to +// share the same traceId, and outgoing requests carry the traceparent. + +describe('node:otel context propagation', () => { + it('propagates trace context across HTTP hops', async () => { + const incomingTraceId = 'abcdef0123456789abcdef0123456789'; + const incomingSpanId = '0123456789abcdef'; + const incomingTraceparent = + `00-${incomingTraceId}-${incomingSpanId}-01`; + + let outgoingTraceparent = null; + + // Backend server that records the outgoing traceparent. + const backend = http.createServer((req, res) => { + outgoingTraceparent = req.headers.traceparent || null; + res.writeHead(200); + res.end('backend-ok'); + }); + + let resolveSpans; + const spansReceived = new Promise((r) => { resolveSpans = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + const data = JSON.parse(body); + const spans = data.resourceSpans[0].scopeSpans[0].spans; + res.writeHead(200); + res.end(); + resolveSpans(spans); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + await new Promise((r) => backend.listen(0, r)); + + const collectorPort = collector.address().port; + const backendPort = backend.address().port; + + otel.start({ endpoint: `http://127.0.0.1:${collectorPort}` }); + + // Frontend server: receives request, makes outgoing call to backend. + const frontend = http.createServer((req, res) => { + http.get(`http://127.0.0.1:${backendPort}/backend`, (backendRes) => { + backendRes.resume(); + backendRes.on('end', () => { + res.writeHead(200); + res.end('frontend-ok'); + }); + }); + }); + + await new Promise((r) => frontend.listen(0, r)); + const frontendPort = frontend.address().port; + + // Use a raw TCP socket to send the initial request with a custom + // traceparent header. This bypasses the HTTP client instrumentation + // which would overwrite the header. + await new Promise((resolve) => { + const socket = net.connect(frontendPort, '127.0.0.1', () => { + socket.write( + `GET /frontend HTTP/1.1\r\n` + + `Host: 127.0.0.1:${frontendPort}\r\n` + + `traceparent: ${incomingTraceparent}\r\n` + + `Connection: close\r\n` + + `\r\n` + ); + socket.on('data', () => {}); + socket.on('end', resolve); + }); + }); + + otel.stop(); + + const spans = await spansReceived; + + collector.close(); + frontend.close(); + backend.close(); + + assert.ok(spans.length >= 1, `Expected at least 1 span, got: ${spans.length}`); + + const serverSpan = spans.find((s) => { + if (s.kind !== 2) return false; // SERVER + const pathAttr = s.attributes?.find((a) => a.key === 'url.path'); + return pathAttr?.value?.stringValue === '/frontend'; + }); + assert.ok(serverSpan, 'Expected a frontend server span'); + assert.strictEqual(serverSpan.traceId, incomingTraceId); + + assert.strictEqual(serverSpan.parentSpanId, incomingSpanId); + + const clientSpan = spans.find((s) => s.kind === 3); // CLIENT + assert.ok(clientSpan, 'Expected a client span for outgoing backend call'); + assert.strictEqual(clientSpan.traceId, incomingTraceId); + + assert.ok(outgoingTraceparent); + assert.ok(outgoingTraceparent.includes(incomingTraceId), + 'Outgoing traceparent should carry the original traceId'); + }); +}); diff --git a/test/parallel/test-otel-env.js b/test/parallel/test-otel-env.js new file mode 100644 index 00000000000000..da01b91d3e204e --- /dev/null +++ b/test/parallel/test-otel-env.js @@ -0,0 +1,138 @@ +'use strict'; +const common = require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const { describe, it } = require('node:test'); + +describe('node:otel environment variable configuration', () => { + it('auto-activates tracing when NODE_OTEL_ENDPOINT is set', async () => { + const { spawnPromisified } = common; + + const collector = http.createServer((req, res) => { + req.on('data', () => {}); + req.on('end', () => { + res.writeHead(200); + res.end(); + }); + }); + + await new Promise((resolve) => collector.listen(0, resolve)); + const collectorPort = collector.address().port; + + try { + const { code, stdout } = await spawnPromisified(process.execPath, [ + '-e', + ` + const otel = require('node:otel'); + console.log(otel.active); + `, + ], { + env: { + ...process.env, + NODE_OTEL_ENDPOINT: `http://127.0.0.1:${collectorPort}`, + }, + }); + + assert.strictEqual(code, 0); + assert.match(stdout.trim(), /true/); + } finally { + collector.close(); + } + }); + + it('NODE_OTEL_FILTER limits instrumented modules', async () => { + const { spawnPromisified } = common; + const { code, stdout } = await spawnPromisified(process.execPath, [ + '--expose-internals', + '-e', + ` + const otel = require('node:otel'); + const { isModuleEnabled } = require('internal/otel/core'); + console.log('http:' + isModuleEnabled('node:http')); + console.log('net:' + isModuleEnabled('node:net')); + console.log('dns:' + isModuleEnabled('node:dns')); + `, + ], { + env: { + ...process.env, + NODE_OTEL_ENDPOINT: 'http://127.0.0.1:1', + NODE_OTEL_FILTER: 'node:http,node:net', + }, + }); + + assert.strictEqual(code, 0); + const lines = stdout.trim().split('\n'); + assert.match(lines.find((l) => l.startsWith('http:')), /http:true/); + assert.match(lines.find((l) => l.startsWith('net:')), /net:true/); + assert.match(lines.find((l) => l.startsWith('dns:')), /dns:false/); + }); + + it('activates tracing with default endpoint when NODE_OTEL=1', async () => { + const { spawnPromisified } = common; + + const { code, stdout } = await spawnPromisified(process.execPath, [ + '--expose-internals', + '-e', + ` + const otel = require('node:otel'); + const { getEndpoint } = require('internal/otel/core'); + console.log('active:' + otel.active); + console.log('endpoint:' + getEndpoint()); + `, + ], { + env: { + ...process.env, + NODE_OTEL: '1', + }, + }); + + assert.strictEqual(code, 0); + const lines = stdout.trim().split('\n'); + assert.match(lines.find((l) => l.startsWith('active:')), /active:true/); + assert.match( + lines.find((l) => l.startsWith('endpoint:')), + /endpoint:http:\/\/localhost:4318/, + ); + }); + + it('NODE_OTEL_ENDPOINT overrides the default', async () => { + const { spawnPromisified } = common; + + const { code, stdout } = await spawnPromisified(process.execPath, [ + '--expose-internals', + '-e', + ` + const otel = require('node:otel'); + const { getEndpoint } = require('internal/otel/core'); + console.log('endpoint:' + getEndpoint()); + `, + ], { + env: { + ...process.env, + NODE_OTEL: '1', + NODE_OTEL_ENDPOINT: 'http://127.0.0.1:9999', + }, + }); + + assert.strictEqual(code, 0); + assert.match(stdout.trim(), /endpoint:http:\/\/127\.0\.0\.1:9999/); + }); + + it('emits warning and continues when env var endpoint is invalid', async () => { + const { spawnPromisified } = common; + + const { code, stderr } = await spawnPromisified(process.execPath, [ + '-e', + 'console.log("still running");', + ], { + env: { + ...process.env, + NODE_OTEL_ENDPOINT: 'not-a-valid-url', + }, + }); + + assert.strictEqual(code, 0); + assert.match(stderr, /OTelWarning/); + assert.match(stderr, /Failed to initialize OpenTelemetry tracing/); + }); +}); diff --git a/test/parallel/test-otel-exporter.js b/test/parallel/test-otel-exporter.js new file mode 100644 index 00000000000000..2659229fbc4aea --- /dev/null +++ b/test/parallel/test-otel-exporter.js @@ -0,0 +1,61 @@ +'use strict'; +const common = require('../common'); +const assert = require('node:assert'); +const { describe, it } = require('node:test'); + +// This test verifies exporter error handling and process lifecycle behavior. + +describe('node:otel exporter behavior', () => { + it('does not crash when collector is unreachable', async () => { + const { spawnPromisified } = common; + const { code, stdout } = await spawnPromisified(process.execPath, [ + '--experimental-otel', + '-e', + ` + const http = require('node:http'); + const otel = require('node:otel'); + + otel.start({ endpoint: 'http://127.0.0.1:1' }); + + const server = http.createServer((req, res) => { + res.writeHead(200); + res.end('ok'); + }); + + server.listen(0, () => { + http.get('http://127.0.0.1:' + server.address().port, (res) => { + res.resume(); + res.on('end', () => { + server.close(); + otel.stop(); + console.log('success'); + }); + }); + }); + `, + ]); + + assert.strictEqual(code, 0); + assert.match(stdout, /success/); + }); + + it('flush timer does not keep process alive', async () => { + const { spawnPromisified } = common; + const { code, stdout } = await spawnPromisified(process.execPath, [ + '--experimental-otel', + '-e', + ` + const otel = require('node:otel'); + otel.start({ endpoint: 'http://127.0.0.1:1' }); + // Don't call otel.stop() -- the process should still exit + // because the flush timer is unref'd. + console.log('exiting'); + `, + ], { + timeout: 10_000, + }); + + assert.strictEqual(code, 0); + assert.match(stdout, /exiting/); + }); +}); diff --git a/test/parallel/test-otel-filter.js b/test/parallel/test-otel-filter.js new file mode 100644 index 00000000000000..1fc2c8b1f77a62 --- /dev/null +++ b/test/parallel/test-otel-filter.js @@ -0,0 +1,59 @@ +'use strict'; +// Flags: --experimental-otel --expose-internals + +require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const { describe, it } = require('node:test'); + +const otel = require('node:otel'); +const { flush } = require('internal/otel/flush'); + +describe('node:otel filter', () => { + it('does not create spans for filtered-out modules', async () => { + let collectorHit = false; + const collector = http.createServer((req, res) => { + collectorHit = true; + req.resume(); + req.on('end', () => { + res.writeHead(200); + res.end(); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + + // Filter to a module that won't be exercised. + otel.start({ + endpoint: `http://127.0.0.1:${collector.address().port}`, + filter: ['node:dns'], + }); + + const server = http.createServer((req, res) => { + res.writeHead(200); + res.end('ok'); + }); + + await new Promise((r) => server.listen(0, r)); + + await new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${server.address().port}`, (res) => { + res.resume(); + res.on('end', resolve); + }).on('error', reject); + }); + + // Flush explicitly — if any HTTP spans were created despite the filter, + // they would be sent to the collector. + flush(); + + // Wait for any potential request to the collector. + await new Promise((r) => setTimeout(r, 100)); + + assert.strictEqual(collectorHit, false); + + otel.stop(); + server.close(); + collector.close(); + }); +}); diff --git a/test/parallel/test-otel-flush-coverage.js b/test/parallel/test-otel-flush-coverage.js new file mode 100644 index 00000000000000..23b54d8bfbcde8 --- /dev/null +++ b/test/parallel/test-otel-flush-coverage.js @@ -0,0 +1,128 @@ +'use strict'; +// Flags: --experimental-otel --expose-internals + +require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const { describe, it } = require('node:test'); + +const otel = require('node:otel'); +const { + Span, + SPAN_KIND_INTERNAL, +} = require('internal/otel/span'); +const { + addSpan, + flush, + resetCaches, +} = require('internal/otel/flush'); + +describe('flush.js coverage', () => { + it('flush is a no-op when buffer is empty', () => { + const dc = require('diagnostics_channel'); + let requestCreated = false; + const onRequest = () => { requestCreated = true; }; + dc.subscribe('http.client.request.created', onRequest); + + otel.start({ endpoint: 'http://127.0.0.1:1' }); + flush(); + + dc.unsubscribe('http.client.request.created', onRequest); + otel.stop(); + + assert.strictEqual(requestCreated, false); + }); + + it('flush handles spanToOtlp errors gracefully', async () => { + otel.start({ endpoint: 'http://127.0.0.1:1' }); + + const warningPromise = new Promise((resolve) => { + process.on('warning', function onWarning(w) { + if (w.name === 'OTelExportWarning') { + process.removeListener('warning', onWarning); + resolve(w); + } + }); + }); + + // Create a poisoned span-like object that will throw during serialization. + const badSpan = { + name: 'bad-span', + getAttributes() { throw new Error('serialize boom'); }, + }; + + addSpan(badSpan); + flush(); + + const warning = await warningPromise; + otel.stop(); + + assert.ok(warning.message.includes('serialize boom')); + }); + + it('flush handles JSONStringify errors gracefully', async () => { + otel.start({ endpoint: 'http://127.0.0.1:1' }); + + const warningPromise = new Promise((resolve) => { + process.on('warning', function onWarning(w) { + if (w.name === 'OTelExportWarning') { + process.removeListener('warning', onWarning); + resolve(w); + } + }); + }); + + // Create a fake span-like object that spanToOtlp can process, but + // whose output contains a BigInt which JSONStringify cannot handle. + const fakeSpan = { + name: 'bigint-span', + getAttributes() { return {}; }, + getEvents() { return []; }, + traceId: 'a'.repeat(32), + spanId: 'b'.repeat(16), + parentSpanId: null, + kind: 0, + startTimeUnixNano: 1n, + endTimeUnixNano: 2n, + status: { code: 0, message: '' }, + traceFlags: 0x01, + }; + + addSpan(fakeSpan); + flush(); + + const warning = await warningPromise; + otel.stop(); + + assert.ok(warning.message.includes('Failed to serialize')); + }); + + it('resetCaches clears the span buffer', async () => { + const http = require('node:http'); + let collectorHit = false; + const collector = http.createServer((req, res) => { + collectorHit = true; + req.resume(); + req.on('end', () => { res.writeHead(200); res.end(); }); + }); + await new Promise((r) => collector.listen(0, r)); + + otel.start({ endpoint: `http://127.0.0.1:${collector.address().port}` }); + + // Add a span so buffer is non-empty. + const span = new Span('test', SPAN_KIND_INTERNAL); + span.end(); + + resetCaches(); + + flush(); + + // Wait for any potential HTTP request to arrive. + await new Promise((r) => setTimeout(r, 100)); + + assert.strictEqual(collectorHit, false); + + otel.stop(); + collector.close(); + }); +}); diff --git a/test/parallel/test-otel-http-client.js b/test/parallel/test-otel-http-client.js new file mode 100644 index 00000000000000..40d9642745f198 --- /dev/null +++ b/test/parallel/test-otel-http-client.js @@ -0,0 +1,81 @@ +'use strict'; +// Flags: --experimental-otel + +require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const { describe, it } = require('node:test'); + +const otel = require('node:otel'); + +describe('node:otel HTTP client spans', () => { + it('creates client spans and injects traceparent', async () => { + let receivedTraceparent = null; + + // Target server records incoming traceparent header. + const target = http.createServer((req, res) => { + receivedTraceparent = req.headers.traceparent || null; + res.writeHead(200); + res.end('ok'); + }); + + let resolveSpans; + const spansReceived = new Promise((r) => { resolveSpans = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + const data = JSON.parse(body); + const spans = data.resourceSpans[0].scopeSpans[0].spans; + res.writeHead(200); + res.end(); + resolveSpans(spans); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + await new Promise((r) => target.listen(0, r)); + + const collectorPort = collector.address().port; + const targetPort = target.address().port; + + otel.start({ endpoint: `http://127.0.0.1:${collectorPort}` }); + + await new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${targetPort}/api/data`, (res) => { + res.resume(); + res.on('end', resolve); + }).on('error', reject); + }); + + otel.stop(); + + const spans = await spansReceived; + + collector.close(); + target.close(); + + assert.ok(spans.length >= 1, `Expected at least 1 span, got: ${spans.length}`); + + const clientSpan = spans.find((s) => s.kind === 3); // SPAN_KIND_CLIENT + assert.ok(clientSpan, `Expected a client span in: ${JSON.stringify(spans)}`); + + const attrs = {}; + for (const a of clientSpan.attributes) { + attrs[a.key] = a.value.stringValue || a.value.intValue || a.value.doubleValue; + } + assert.strictEqual(attrs['http.request.method'], 'GET'); + assert.ok(attrs['url.full'], 'Expected url.full attribute'); + assert.strictEqual(attrs['http.response.status_code'], '200'); + + assert.ok(receivedTraceparent, 'Expected non-empty traceparent'); + assert.match(receivedTraceparent, /^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$/); + + // The traceparent should reference the client span's IDs. + assert.ok(receivedTraceparent.includes(clientSpan.traceId), + 'traceparent should contain the client span traceId'); + assert.ok(receivedTraceparent.includes(clientSpan.spanId), + 'traceparent should contain the client span spanId'); + }); +}); diff --git a/test/parallel/test-otel-http-server.js b/test/parallel/test-otel-http-server.js new file mode 100644 index 00000000000000..6eed651f1ced1b --- /dev/null +++ b/test/parallel/test-otel-http-server.js @@ -0,0 +1,75 @@ +'use strict'; +// Flags: --experimental-otel + +require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const { describe, it } = require('node:test'); + +const otel = require('node:otel'); + +describe('node:otel HTTP server spans', () => { + it('creates server spans for incoming HTTP requests', async () => { + let resolveSpans; + const spansReceived = new Promise((r) => { resolveSpans = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + const data = JSON.parse(body); + const spans = data.resourceSpans[0].scopeSpans[0].spans; + res.writeHead(200); + res.end(); + resolveSpans(spans); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + const collectorPort = collector.address().port; + + otel.start({ endpoint: `http://127.0.0.1:${collectorPort}` }); + + const appServer = http.createServer((req, res) => { + res.writeHead(200); + res.end('ok'); + }); + + await new Promise((r) => appServer.listen(0, r)); + const appPort = appServer.address().port; + + await new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${appPort}/test-path`, (res) => { + res.resume(); + res.on('end', resolve); + }).on('error', reject); + }); + + otel.stop(); + + const spans = await spansReceived; + + collector.close(); + appServer.close(); + + assert.ok(spans.length >= 1, `Expected at least 1 span, got ${spans.length}`); + + const serverSpan = spans.find((s) => s.kind === 2); // SPAN_KIND_SERVER + assert.ok(serverSpan, 'Expected a server span'); + + assert.ok(serverSpan.traceId); + assert.strictEqual(serverSpan.traceId.length, 32); + assert.ok(serverSpan.spanId); + assert.strictEqual(serverSpan.spanId.length, 16); + assert.ok(serverSpan.startTimeUnixNano); + assert.ok(serverSpan.endTimeUnixNano); + + const attrs = {}; + for (const a of serverSpan.attributes) { + attrs[a.key] = a.value.stringValue || a.value.intValue || a.value.doubleValue; + } + assert.strictEqual(attrs['http.request.method'], 'GET'); + assert.strictEqual(attrs['url.path'], '/test-path'); + assert.strictEqual(attrs['http.response.status_code'], '200'); + }); +}); diff --git a/test/parallel/test-otel-id-refill.js b/test/parallel/test-otel-id-refill.js new file mode 100644 index 00000000000000..913caeb1632aa2 --- /dev/null +++ b/test/parallel/test-otel-id-refill.js @@ -0,0 +1,36 @@ +'use strict'; +// Flags: --experimental-otel --expose-internals + +require('../common'); +const assert = require('node:assert'); +const { describe, it } = require('node:test'); + +const { generateTraceId, generateSpanId } = require('internal/otel/id'); + +describe('otel ID generation buffer refill', () => { + it('generates valid IDs after exhausting the random buffer', () => { + // The random buffer is 4096 bytes. Each trace ID uses 16 bytes, so + // 257 trace IDs (257 * 16 = 4112) will force at least one refill. + const traceIds = new Set(); + for (let i = 0; i < 300; i++) { + const id = generateTraceId(); + assert.strictEqual(id.length, 32); + assert.match(id, /^[0-9a-f]{32}$/); + traceIds.add(id); + } + assert.strictEqual(traceIds.size, 300); + }); + + it('generates valid span IDs after exhausting the random buffer', () => { + // Each span ID uses 8 bytes, so 513 span IDs (513 * 8 = 4104) will + // force at least one refill. + const spanIds = new Set(); + for (let i = 0; i < 600; i++) { + const id = generateSpanId(); + assert.strictEqual(id.length, 16); + assert.match(id, /^[0-9a-f]{16}$/); + spanIds.add(id); + } + assert.strictEqual(spanIds.size, 600); + }); +}); diff --git a/test/parallel/test-otel-module-access.js b/test/parallel/test-otel-module-access.js new file mode 100644 index 00000000000000..d0c4e41f84aa6a --- /dev/null +++ b/test/parallel/test-otel-module-access.js @@ -0,0 +1,61 @@ +'use strict'; +const common = require('../common'); +const { spawnPromisified } = common; +const assert = require('node:assert'); +const { describe, it } = require('node:test'); + +describe('node:otel module access', () => { + it('cannot be accessed without the node: scheme', async () => { + const { code, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-otel', + '-e', + 'require("otel")', + ]); + assert.notStrictEqual(code, 0); + assert.match(stderr, /Cannot find module 'otel'/); + }); + + it('cannot be accessed without --experimental-otel flag', async () => { + const { code, stderr } = await spawnPromisified(process.execPath, [ + '-e', + 'require("node:otel")', + ]); + assert.notStrictEqual(code, 0); + assert.match(stderr, /No such built-in module: node:otel/); + }); + + it('can be accessed with --experimental-otel flag', async () => { + const { code, stdout } = await spawnPromisified(process.execPath, [ + '--experimental-otel', + '-e', + 'const otel = require("node:otel"); console.log(typeof otel.start)', + ]); + assert.strictEqual(code, 0); + assert.match(stdout, /function/); + }); + + it('can be accessed when NODE_OTEL_ENDPOINT is set', async () => { + const { code, stdout } = await spawnPromisified(process.execPath, [ + '-e', + 'const otel = require("node:otel"); console.log(typeof otel.start)', + ], { + env: { + ...process.env, + // Use a dummy endpoint that will fail silently. + NODE_OTEL_ENDPOINT: 'http://127.0.0.1:1', + }, + }); + assert.strictEqual(code, 0); + assert.match(stdout, /function/); + }); + + it('emits experimental warning', async () => { + const { code, stderr } = await spawnPromisified(process.execPath, [ + '--experimental-otel', + '-e', + 'require("node:otel")', + ]); + assert.strictEqual(code, 0); + assert.match(stderr, /ExperimentalWarning: node:otel/); + }); +}); diff --git a/test/parallel/test-otel-otlp-compliance.js b/test/parallel/test-otel-otlp-compliance.js new file mode 100644 index 00000000000000..1f967bca94e947 --- /dev/null +++ b/test/parallel/test-otel-otlp-compliance.js @@ -0,0 +1,413 @@ +'use strict'; +// Flags: --experimental-otel + +// This test verifies that the OTLP/HTTP JSON export payload is compliant +// with the OpenTelemetry specification (opentelemetry-proto). + +require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const { describe, it } = require('node:test'); + +const otel = require('node:otel'); + +describe('OTLP/JSON spec compliance', () => { + it('produces a spec-compliant ExportTraceServiceRequest', async () => { + let resolvePayload; + const payloadReceived = new Promise((r) => { resolvePayload = r; }); + let receivedContentType; + + const collector = http.createServer((req, res) => { + receivedContentType = req.headers['content-type']; + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + res.writeHead(200); + res.end(); + resolvePayload(JSON.parse(body)); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + + otel.start({ endpoint: `http://127.0.0.1:${collector.address().port}` }); + + const server = http.createServer((req, res) => { + res.writeHead(200); + res.end('ok'); + }); + + await new Promise((r) => server.listen(0, r)); + + await new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${server.address().port}/test`, (res) => { + res.resume(); + res.on('end', resolve); + }).on('error', reject); + }); + + otel.stop(); + + const payload = await payloadReceived; + + collector.close(); + server.close(); + + // === Content-Type === + assert.strictEqual(receivedContentType, 'application/json'); + + // === Top-level envelope === + assert.ok(Array.isArray(payload.resourceSpans), + 'resourceSpans must be an array'); + assert.strictEqual(payload.resourceSpans.length, 1); + + const resourceSpan = payload.resourceSpans[0]; + + // === Resource === + assert.ok(resourceSpan.resource, 'resource must be present'); + assert.ok(Array.isArray(resourceSpan.resource.attributes), + 'resource.attributes must be an array'); + + // Verify resource attributes are KeyValue format. + for (const attr of resourceSpan.resource.attributes) { + assert.ok(typeof attr.key === 'string', 'attribute key must be string'); + assert.ok(attr.value !== undefined, 'attribute value must be present'); + // Each value must have exactly one of the AnyValue fields. + const valueFields = Object.keys(attr.value); + assert.strictEqual(valueFields.length, 1, + `attribute value must have exactly one field, got: ${valueFields}`); + } + + // Verify required resource attributes. + const resourceAttrs = {}; + for (const a of resourceSpan.resource.attributes) { + resourceAttrs[a.key] = a.value; + } + assert.ok(resourceAttrs['service.name'], + 'resource must have service.name'); + assert.ok(resourceAttrs['service.name'].stringValue, + 'service.name must be a string value'); + + // === ScopeSpans === + assert.ok(Array.isArray(resourceSpan.scopeSpans), + 'scopeSpans must be an array'); + assert.strictEqual(resourceSpan.scopeSpans.length, 1); + + const scopeSpan = resourceSpan.scopeSpans[0]; + + // === InstrumentationScope === + assert.ok(scopeSpan.scope, 'scope must be present'); + assert.ok(typeof scopeSpan.scope.name === 'string', + 'scope.name must be a string'); + assert.ok(typeof scopeSpan.scope.version === 'string', + 'scope.version must be a string'); + + // === Spans === + assert.ok(Array.isArray(scopeSpan.spans), 'spans must be an array'); + assert.ok(scopeSpan.spans.length >= 1, 'must have at least 1 span'); + + for (const span of scopeSpan.spans) { + // --- traceId: 32-char lowercase hex string --- + assert.ok(typeof span.traceId === 'string', 'traceId must be a string'); + assert.strictEqual(span.traceId.length, 32); + assert.match(span.traceId, /^[0-9a-f]{32}$/, + 'traceId must be lowercase hex'); + // Must not be all zeros. + assert.notStrictEqual(span.traceId, '0'.repeat(32)); + + // --- spanId: 16-char lowercase hex string --- + assert.ok(typeof span.spanId === 'string', 'spanId must be a string'); + assert.strictEqual(span.spanId.length, 16); + assert.match(span.spanId, /^[0-9a-f]{16}$/, + 'spanId must be lowercase hex'); + assert.notStrictEqual(span.spanId, '0'.repeat(16)); + + // --- name: non-empty string --- + assert.ok(typeof span.name === 'string', 'name must be a string'); + assert.ok(span.name.length > 0, 'name must be non-empty'); + + // --- kind: integer 1-5 (OTLP SpanKind enum, no 0/UNSPECIFIED) --- + assert.ok(typeof span.kind === 'number', 'kind must be a number'); + assert.ok(Number.isInteger(span.kind), 'kind must be an integer'); + assert.ok(span.kind >= 1 && span.kind <= 5, + `kind must be 1-5, got: ${span.kind}`); + + // --- timestamps: decimal strings of nanoseconds --- + assert.ok(typeof span.startTimeUnixNano === 'string', + 'startTimeUnixNano must be a string'); + assert.match(span.startTimeUnixNano, /^\d+$/, + 'startTimeUnixNano must be a decimal string'); + assert.ok(typeof span.endTimeUnixNano === 'string', + 'endTimeUnixNano must be a string'); + assert.match(span.endTimeUnixNano, /^\d+$/, + 'endTimeUnixNano must be a decimal string'); + + // endTime >= startTime. + assert.ok(BigInt(span.endTimeUnixNano) >= BigInt(span.startTimeUnixNano), + 'endTimeUnixNano must be >= startTimeUnixNano'); + + // Timestamps should be plausible (after 2020, before 2100). + const startSec = Number(BigInt(span.startTimeUnixNano) / 1_000_000_000n); + assert.ok(startSec > 1577836800, 'timestamp too old'); // 2020-01-01 + assert.ok(startSec < 4102444800, 'timestamp too far in future'); // 2100-01-01 + + // --- parentSpanId: omitted for root, 16-char hex for child --- + if (span.parentSpanId !== undefined) { + assert.ok(typeof span.parentSpanId === 'string'); + assert.strictEqual(span.parentSpanId.length, 16); + assert.match(span.parentSpanId, /^[0-9a-f]{16}$/); + } + + // --- attributes: array of KeyValue (or omitted if empty) --- + if (span.attributes !== undefined) { + assert.ok(Array.isArray(span.attributes)); + for (const attr of span.attributes) { + assert.ok(typeof attr.key === 'string'); + assert.ok(attr.value !== undefined); + + // Verify AnyValue has exactly one field. + const fields = Object.keys(attr.value); + assert.strictEqual(fields.length, 1); + + const field = fields[0]; + assert.ok( + ['stringValue', 'boolValue', 'intValue', + 'doubleValue', 'arrayValue', 'kvlistValue', + 'bytesValue'].includes(field), + `unexpected AnyValue field: ${field}`, + ); + + // intValue must be a decimal string (int64 JSON encoding). + if (field === 'intValue') { + assert.ok(typeof attr.value.intValue === 'string', + 'intValue must be a string (int64 JSON encoding)'); + assert.match(attr.value.intValue, /^-?\d+$/); + } + } + } + + // --- status: omitted when unset, or {code, message} --- + if (span.status !== undefined) { + assert.ok(typeof span.status === 'object'); + assert.ok(typeof span.status.code === 'number'); + assert.ok(Number.isInteger(span.status.code)); + assert.ok(span.status.code >= 0 && span.status.code <= 2, + `status.code must be 0-2, got: ${span.status.code}`); + // If message is present, it must be a string. + if (span.status.message !== undefined) { + assert.ok(typeof span.status.message === 'string'); + } + } + + // --- events: omitted if empty, or array of Event --- + if (span.events !== undefined) { + assert.ok(Array.isArray(span.events)); + for (const event of span.events) { + assert.ok(typeof event.name === 'string'); + assert.ok(event.name.length > 0, 'event name must be non-empty'); + assert.ok(typeof event.timeUnixNano === 'string'); + assert.match(event.timeUnixNano, /^\d+$/); + if (event.attributes !== undefined) { + assert.ok(Array.isArray(event.attributes)); + } + } + } + + // --- No snake_case field names --- + for (const key of Object.keys(span)) { + assert.ok(!key.includes('_') || key === 'startTimeUnixNano' || + key === 'endTimeUnixNano' || key === 'timeUnixNano', + `unexpected snake_case-style field: ${key}`); + } + } + }); + + it('omits status when unset (200 OK response)', async () => { + let resolvePayload; + const payloadReceived = new Promise((r) => { resolvePayload = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + res.writeHead(200); + res.end(); + resolvePayload(JSON.parse(body)); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + + otel.start({ endpoint: `http://127.0.0.1:${collector.address().port}` }); + + const server = http.createServer((req, res) => { + res.writeHead(200); + res.end('ok'); + }); + + await new Promise((r) => server.listen(0, r)); + + await new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${server.address().port}/ok`, (res) => { + res.resume(); + res.on('end', resolve); + }).on('error', reject); + }); + + otel.stop(); + + const payload = await payloadReceived; + + collector.close(); + server.close(); + + const spans = payload.resourceSpans[0].scopeSpans[0].spans; + // For a 200 OK server span, status should be omitted (STATUS_UNSET). + const serverSpan = spans.find((s) => s.kind === 2); + assert.ok(serverSpan); + assert.strictEqual(serverSpan.status, undefined); + }); + + it('includes status with code 2 for error responses', async () => { + let resolvePayload; + const payloadReceived = new Promise((r) => { resolvePayload = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + res.writeHead(200); + res.end(); + resolvePayload(JSON.parse(body)); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + + otel.start({ endpoint: `http://127.0.0.1:${collector.address().port}` }); + + const server = http.createServer((req, res) => { + res.writeHead(500); + res.end('error'); + }); + + await new Promise((r) => server.listen(0, r)); + + await new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${server.address().port}/fail`, (res) => { + res.resume(); + res.on('end', resolve); + }).on('error', reject); + }); + + otel.stop(); + + const payload = await payloadReceived; + + collector.close(); + server.close(); + + const spans = payload.resourceSpans[0].scopeSpans[0].spans; + const serverSpan = spans.find((s) => s.kind === 2); + assert.ok(serverSpan); + assert.ok(serverSpan.status, 'status must be present for error'); + assert.strictEqual(serverSpan.status.code, 2); // STATUS_CODE_ERROR + assert.ok(typeof serverSpan.status.message === 'string'); + }); + + it('omits empty attributes array', async () => { + let resolvePayload; + const payloadReceived = new Promise((r) => { resolvePayload = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + res.writeHead(200); + res.end(); + resolvePayload(JSON.parse(body)); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + + otel.start({ endpoint: `http://127.0.0.1:${collector.address().port}` }); + + const server = http.createServer((req, res) => { + res.writeHead(200); + res.end('ok'); + }); + + await new Promise((r) => server.listen(0, r)); + + await new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${server.address().port}/test`, (res) => { + res.resume(); + res.on('end', resolve); + }).on('error', reject); + }); + + otel.stop(); + + const payload = await payloadReceived; + + collector.close(); + server.close(); + + const spans = payload.resourceSpans[0].scopeSpans[0].spans; + for (const span of spans) { + // If attributes is present, it must be non-empty. + if (span.attributes !== undefined) { + assert.ok(span.attributes.length > 0, + 'attributes must not be an empty array'); + } + // Events should not be present unless there are actual events. + if (span.events !== undefined) { + assert.ok(span.events.length > 0, + 'events must not be an empty array'); + } + } + }); + + it('posts to /v1/traces endpoint', async () => { + let receivedPath; + let resolveRequest; + const requestReceived = new Promise((r) => { resolveRequest = r; }); + + const collector = http.createServer((req, res) => { + receivedPath = req.url; + req.on('data', () => {}); + req.on('end', () => { + res.writeHead(200); + res.end(); + resolveRequest(); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + + otel.start({ endpoint: `http://127.0.0.1:${collector.address().port}` }); + + const server = http.createServer((req, res) => { + res.writeHead(200); + res.end('ok'); + }); + + await new Promise((r) => server.listen(0, r)); + + await new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${server.address().port}/x`, (res) => { + res.resume(); + res.on('end', resolve); + }).on('error', reject); + }); + + otel.stop(); + await requestReceived; + + collector.close(); + server.close(); + + assert.strictEqual(receivedPath, '/v1/traces'); + }); +}); diff --git a/test/parallel/test-otel-restart.js b/test/parallel/test-otel-restart.js new file mode 100644 index 00000000000000..30a8ce6bc450a1 --- /dev/null +++ b/test/parallel/test-otel-restart.js @@ -0,0 +1,71 @@ +'use strict'; +// Flags: --experimental-otel + +require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const { describe, it } = require('node:test'); + +const otel = require('node:otel'); + +describe('node:otel restart behavior', () => { + it('start() while active performs clean restart', async () => { + let resolvePayload; + let payloadReceived = new Promise((r) => { resolvePayload = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + res.writeHead(200); + res.end(); + resolvePayload(JSON.parse(body)); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + const collectorPort = collector.address().port; + + otel.start({ endpoint: `http://127.0.0.1:${collectorPort}` }); + + const server = http.createServer((req, res) => { + res.writeHead(200); + res.end('ok'); + }); + + await new Promise((r) => server.listen(0, r)); + + await new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${server.address().port}/first`, (res) => { + res.resume(); + res.on('end', resolve); + }).on('error', reject); + }); + + // Call start() again WITHOUT calling stop() — should implicitly stop. + payloadReceived = new Promise((r) => { resolvePayload = r; }); + otel.start({ endpoint: `http://127.0.0.1:${collectorPort}` }); + + await new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${server.address().port}/second`, (res) => { + res.resume(); + res.on('end', resolve); + }).on('error', reject); + }); + + otel.stop(); + + const payload = await payloadReceived; + + collector.close(); + server.close(); + + const spans = payload.resourceSpans[0].scopeSpans[0].spans; + assert.ok(spans.length >= 1, 'Expected spans from second session'); + + for (const span of spans) { + assert.match(span.traceId, /^[0-9a-f]{32}$/); + assert.match(span.spanId, /^[0-9a-f]{16}$/); + } + }); +}); diff --git a/test/parallel/test-otel-self-trace.js b/test/parallel/test-otel-self-trace.js new file mode 100644 index 00000000000000..4e84884ac2c63e --- /dev/null +++ b/test/parallel/test-otel-self-trace.js @@ -0,0 +1,97 @@ +'use strict'; +// Flags: --experimental-otel --expose-internals + +require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const { describe, it } = require('node:test'); + +const otel = require('node:otel'); +const { flush } = require('internal/otel/flush'); + +describe('node:otel self-trace prevention', () => { + it('does not create spans for export requests to the collector', async () => { + const allSpans = []; + let batchCount = 0; + let resolveFirstBatch; + const firstBatch = new Promise((r) => { resolveFirstBatch = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + const data = JSON.parse(body); + const spans = data.resourceSpans[0].scopeSpans[0].spans; + for (const s of spans) allSpans.push(s); + res.writeHead(200); + res.end(); + batchCount++; + if (batchCount === 1) resolveFirstBatch(); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + const collectorPort = collector.address().port; + + otel.start({ endpoint: `http://127.0.0.1:${collectorPort}` }); + + const server = http.createServer((req, res) => { + res.writeHead(200); + res.end('ok'); + }); + + await new Promise((r) => server.listen(0, r)); + + await new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${server.address().port}/test`, (res) => { + res.resume(); + res.on('end', resolve); + }).on('error', reject); + }); + + // Flush while instrumentation is still active. This sends an HTTP + // request to the collector, which fires http.client.request.created. + // The self-trace check (getCollectorHost()) should prevent creating + // a span for this export request. + flush(); + + await firstBatch; + + // Allow time for any inadvertent export-request spans to be buffered. + await new Promise((r) => setTimeout(r, 100)); + + // Flush again: if a span was created for the export request above, + // it would now be in the buffer and sent to the collector. + flush(); + await new Promise((r) => setTimeout(r, 100)); + + otel.stop(); + + collector.close(); + server.close(); + + for (const span of allSpans) { + if (span.attributes) { + const urlAttr = span.attributes.find((a) => a.key === 'url.full'); + if (urlAttr) { + assert.ok( + !urlAttr.value.stringValue.includes(`:${collectorPort}`), + `Span should not target collector: ${urlAttr.value.stringValue}`, + ); + } + } + } + + // Should have exactly the user request spans (server + client), + // not any spans for the export requests. + const clientSpans = allSpans.filter((s) => s.kind === 3); // CLIENT + for (const cs of clientSpans) { + const urlAttr = cs.attributes?.find((a) => a.key === 'url.full'); + assert.ok(urlAttr, 'Client span should have url.full'); + assert.ok( + urlAttr.value.stringValue.includes('/test'), + `Client span should be for /test, got: ${urlAttr.value.stringValue}`, + ); + } + }); +}); diff --git a/test/parallel/test-otel-server-close.js b/test/parallel/test-otel-server-close.js new file mode 100644 index 00000000000000..48e155a563a5e5 --- /dev/null +++ b/test/parallel/test-otel-server-close.js @@ -0,0 +1,83 @@ +'use strict'; +// Flags: --experimental-otel + +require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const net = require('node:net'); +const { describe, it } = require('node:test'); + +const otel = require('node:otel'); + +describe('node:otel server request close handling', () => { + it('ends span with error when client disconnects before response', async () => { + let resolvePayload; + const payloadReceived = new Promise((r) => { resolvePayload = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + res.writeHead(200); + res.end(); + resolvePayload(JSON.parse(body)); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + + otel.start({ endpoint: `http://127.0.0.1:${collector.address().port}` }); + + // Create a server that delays its response. + const server = http.createServer((req, res) => { + // Don't respond — the client will disconnect first. + req.on('close', () => { + // After the client disconnects, make a normal request to trigger flush. + http.get(`http://127.0.0.1:${server.address().port}/flush`, (r) => { + r.resume(); + r.on('end', () => otel.stop()); + }); + }); + }); + + // Override handler for the /flush path. + const origHandler = server.listeners('request')[0]; + server.removeAllListeners('request'); + server.on('request', (req, res) => { + if (req.url === '/flush') { + res.writeHead(200); + res.end('ok'); + return; + } + origHandler(req, res); + }); + + await new Promise((r) => server.listen(0, r)); + const serverPort = server.address().port; + + // Use a raw TCP socket to connect and send a partial HTTP request, + // then destroy the connection before the server responds. + const socket = net.connect(serverPort, '127.0.0.1', () => { + socket.write('GET /disconnect HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n'); + // Destroy immediately — server never gets to respond. + setTimeout(() => socket.destroy(), 50); + }); + + const payload = await payloadReceived; + + collector.close(); + server.close(); + + const spans = payload.resourceSpans[0].scopeSpans[0].spans; + + const disconnectSpan = spans.find((s) => { + if (s.kind !== 2) return false; // SERVER + const pathAttr = s.attributes?.find((a) => a.key === 'url.path'); + return pathAttr?.value?.stringValue === '/disconnect'; + }); + + assert.ok(disconnectSpan, 'Expected a server span for /disconnect'); + assert.ok(disconnectSpan.status, 'Span should have error status'); + assert.strictEqual(disconnectSpan.status.code, 2); // STATUS_ERROR + }); +}); diff --git a/test/parallel/test-otel-span-coverage.js b/test/parallel/test-otel-span-coverage.js new file mode 100644 index 00000000000000..080e6545a29628 --- /dev/null +++ b/test/parallel/test-otel-span-coverage.js @@ -0,0 +1,110 @@ +'use strict'; +// Flags: --experimental-otel --expose-internals + +require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const { describe, it } = require('node:test'); + +const otel = require('node:otel'); +const { + Span, + SPAN_KIND_INTERNAL, + STATUS_UNSET, +} = require('internal/otel/span'); + +describe('Span internals coverage', () => { + it('unsampled span does not call addSpan', () => { + // Create a parent with traceFlags=0x00 (not sampled). + const parent = { + __proto__: null, + traceId: 'a'.repeat(32), + spanId: 'b'.repeat(16), + traceFlags: 0x00, + }; + + const span = new Span('unsampled', SPAN_KIND_INTERNAL, { parent }); + assert.strictEqual(span.traceFlags, 0x00); + + // end() should not throw even though addSpan won't buffer it. + span.end(); + assert.ok(span.endTimeUnixNano !== undefined); + }); + + it('setAttributes with null is a no-op', () => { + const span = new Span('test', SPAN_KIND_INTERNAL); + span.setAttribute('key', 'value'); + span.setAttributes(null); + span.setAttributes(undefined); + + const attrs = span.getAttributes(); + assert.strictEqual(attrs.key, 'value'); + assert.strictEqual(Object.keys(attrs).length, 1); + }); + + it('addEvent without attributes uses empty object', () => { + const span = new Span('test', SPAN_KIND_INTERNAL); + span.addEvent('my-event'); + + const events = span.getEvents(); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].name, 'my-event'); + assert.ok(events[0].attributes); + }); + + it('status defaults to UNSET', () => { + const span = new Span('test', SPAN_KIND_INTERNAL); + assert.strictEqual(span.status.code, STATUS_UNSET); + assert.strictEqual(span.status.message, ''); + }); + + it('second end() call is a no-op', () => { + otel.start({ endpoint: 'http://127.0.0.1:1' }); + + const span = new Span('test', SPAN_KIND_INTERNAL); + assert.strictEqual(span.endTimeUnixNano, undefined); + + span.end(); + const firstEndTime = span.endTimeUnixNano; + assert.ok(firstEndTime !== undefined); + + span.end(); + assert.strictEqual(span.endTimeUnixNano, firstEndTime); + + otel.stop(); + }); + + it('does not export duplicate spans when end() called twice', async () => { + let resolvePayload; + const payloadReceived = new Promise((r) => { resolvePayload = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + res.writeHead(200); + res.end(); + resolvePayload(JSON.parse(body)); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + + otel.start({ endpoint: `http://127.0.0.1:${collector.address().port}` }); + + const span = new Span('double-end', SPAN_KIND_INTERNAL); + span.end(); + span.end(); + + otel.stop(); + + const payload = await payloadReceived; + + collector.close(); + + const spans = payload.resourceSpans[0].scopeSpans[0].spans; + + const matches = spans.filter((s) => s.name === 'double-end'); + assert.strictEqual(matches.length, 1); + }); +}); diff --git a/test/parallel/test-otel-start-stop.js b/test/parallel/test-otel-start-stop.js new file mode 100644 index 00000000000000..fda770129497b7 --- /dev/null +++ b/test/parallel/test-otel-start-stop.js @@ -0,0 +1,121 @@ +'use strict'; +// Flags: --experimental-otel --expose-internals + +require('../common'); +const assert = require('node:assert'); +const { describe, it } = require('node:test'); + +const otel = require('node:otel'); +const { getEndpoint } = require('internal/otel/core'); + +describe('node:otel start/stop API', () => { + it('start requires an options object', () => { + assert.throws(() => otel.start('bad'), { + code: 'ERR_INVALID_ARG_TYPE', + }); + }); + + it('start uses default endpoint when omitted', () => { + otel.start(); + assert.strictEqual(otel.active, true); + assert.strictEqual(getEndpoint(), 'http://localhost:4318'); + otel.stop(); + }); + + it('start uses default endpoint with empty options', () => { + otel.start({}); + assert.strictEqual(otel.active, true); + assert.strictEqual(getEndpoint(), 'http://localhost:4318'); + otel.stop(); + }); + + it('start rejects non-string endpoint', () => { + assert.throws(() => otel.start({ endpoint: 42 }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + }); + + it('start rejects empty endpoint', () => { + assert.throws(() => otel.start({ endpoint: '' }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + }); + + it('active reflects the tracing state', () => { + assert.strictEqual(otel.active, false); + otel.start({ endpoint: 'http://127.0.0.1:1' }); + assert.strictEqual(otel.active, true); + otel.stop(); + assert.strictEqual(otel.active, false); + }); + + it('stop is a no-op when not active', () => { + assert.strictEqual(otel.active, false); + otel.stop(); // Should not throw. + assert.strictEqual(otel.active, false); + }); + + it('start rejects an invalid URL endpoint', () => { + assert.throws(() => otel.start({ endpoint: 'not-a-valid-url' }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + }); + + it('start rejects an invalid filter type', () => { + assert.throws( + () => otel.start({ endpoint: 'http://127.0.0.1:1', filter: 42 }), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + }); + + it('start accepts maxBufferSize and flushInterval', () => { + otel.start({ + endpoint: 'http://127.0.0.1:1', + maxBufferSize: 50, + flushInterval: 5000, + }); + assert.strictEqual(otel.active, true); + otel.stop(); + }); + + it('start rejects non-integer maxBufferSize', () => { + assert.throws( + () => otel.start({ endpoint: 'http://127.0.0.1:1', maxBufferSize: 1.5 }), + { code: 'ERR_OUT_OF_RANGE' }, + ); + }); + + it('start rejects non-positive maxBufferSize', () => { + assert.throws( + () => otel.start({ endpoint: 'http://127.0.0.1:1', maxBufferSize: 0 }), + { code: 'ERR_OUT_OF_RANGE' }, + ); + }); + + it('start rejects non-integer flushInterval', () => { + assert.throws( + () => otel.start({ + endpoint: 'http://127.0.0.1:1', + flushInterval: 'fast', + }), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + }); + + it('start rejects non-positive flushInterval', () => { + assert.throws( + () => otel.start({ endpoint: 'http://127.0.0.1:1', flushInterval: -1 }), + { code: 'ERR_OUT_OF_RANGE' }, + ); + }); + + it('start can be called after stop', () => { + otel.start({ endpoint: 'http://127.0.0.1:1' }); + assert.strictEqual(otel.active, true); + otel.stop(); + otel.start({ endpoint: 'http://127.0.0.1:2' }); + assert.strictEqual(otel.active, true); + otel.stop(); + assert.strictEqual(otel.active, false); + }); +}); diff --git a/test/parallel/test-otel-traceparent-validation.js b/test/parallel/test-otel-traceparent-validation.js new file mode 100644 index 00000000000000..fdd005f7189967 --- /dev/null +++ b/test/parallel/test-otel-traceparent-validation.js @@ -0,0 +1,129 @@ +'use strict'; +// Flags: --experimental-otel + +require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const net = require('node:net'); +const { describe, it } = require('node:test'); + +const otel = require('node:otel'); + +describe('node:otel traceparent validation', () => { + it('rejects invalid traceparent headers (non-hex chars)', async () => { + let resolveSpans; + const spansReceived = new Promise((r) => { resolveSpans = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + const data = JSON.parse(body); + const spans = data.resourceSpans[0].scopeSpans[0].spans; + res.writeHead(200); + res.end(); + resolveSpans(spans); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + + otel.start({ endpoint: `http://127.0.0.1:${collector.address().port}` }); + + const server = http.createServer((req, res) => { + res.writeHead(200); + res.end('ok'); + }); + + await new Promise((r) => server.listen(0, r)); + const serverPort = server.address().port; + + // Send a request with an invalid traceparent (non-hex chars). + await new Promise((resolve) => { + const socket = net.connect(serverPort, '127.0.0.1', () => { + socket.write( + `GET /test HTTP/1.1\r\n` + + `Host: 127.0.0.1:${serverPort}\r\n` + + `traceparent: 00-ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ-ZZZZZZZZZZZZZZZZ-01\r\n` + + `Connection: close\r\n` + + `\r\n` + ); + socket.on('data', () => {}); + socket.on('end', resolve); + }); + }); + + otel.stop(); + + const spans = await spansReceived; + + collector.close(); + server.close(); + + const serverSpan = spans.find((s) => s.kind === 2); + assert.ok(serverSpan, 'Expected a server span'); + assert.match(serverSpan.traceId, /^[0-9a-f]{32}$/); + assert.notStrictEqual(serverSpan.traceId, + 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz'); + // No parentSpanId since the invalid traceparent was rejected. + assert.ok(!serverSpan.parentSpanId, + 'Should not have parentSpanId from invalid traceparent'); + }); + + it('rejects all-zero traceId in traceparent', async () => { + let resolveSpans; + const spansReceived = new Promise((r) => { resolveSpans = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + const data = JSON.parse(body); + const spans = data.resourceSpans[0].scopeSpans[0].spans; + res.writeHead(200); + res.end(); + resolveSpans(spans); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + + otel.start({ endpoint: `http://127.0.0.1:${collector.address().port}` }); + + const server = http.createServer((req, res) => { + res.writeHead(200); + res.end('ok'); + }); + + await new Promise((r) => server.listen(0, r)); + const serverPort = server.address().port; + + // All-zero traceId is invalid per W3C spec. + await new Promise((resolve) => { + const socket = net.connect(serverPort, '127.0.0.1', () => { + socket.write( + `GET /test HTTP/1.1\r\n` + + `Host: 127.0.0.1:${serverPort}\r\n` + + `traceparent: 00-00000000000000000000000000000000-0123456789abcdef-01\r\n` + + `Connection: close\r\n` + + `\r\n` + ); + socket.on('data', () => {}); + socket.on('end', resolve); + }); + }); + + otel.stop(); + + const spans = await spansReceived; + + collector.close(); + server.close(); + + const serverSpan = spans.find((s) => s.kind === 2); + assert.ok(serverSpan); + assert.notStrictEqual(serverSpan.traceId, + '00000000000000000000000000000000'); + assert.ok(!serverSpan.parentSpanId); + }); +}); diff --git a/test/parallel/test-otel-undici.js b/test/parallel/test-otel-undici.js new file mode 100644 index 00000000000000..598c3a57d61d76 --- /dev/null +++ b/test/parallel/test-otel-undici.js @@ -0,0 +1,69 @@ +'use strict'; +// Flags: --experimental-otel + +require('../common'); +const assert = require('node:assert'); +const http = require('node:http'); +const { describe, it } = require('node:test'); + +const otel = require('node:otel'); + +describe('node:otel undici spans', () => { + it('creates client spans for fetch requests', async () => { + const target = http.createServer((req, res) => { + res.writeHead(200); + res.end('ok'); + }); + + let resolveSpans; + const spansReceived = new Promise((r) => { resolveSpans = r; }); + + const collector = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + const data = JSON.parse(body); + const spans = data.resourceSpans[0].scopeSpans[0].spans; + res.writeHead(200); + res.end(); + resolveSpans(spans); + }); + }); + + await new Promise((r) => collector.listen(0, r)); + await new Promise((r) => target.listen(0, r)); + + const collectorPort = collector.address().port; + const targetPort = target.address().port; + + otel.start({ + endpoint: `http://127.0.0.1:${collectorPort}`, + filter: ['node:undici', 'node:fetch'], + }); + + try { + const res = await fetch(`http://127.0.0.1:${targetPort}/fetch-test`); + await res.text(); + } catch { + // Ignore fetch errors. + } + + otel.stop(); + + const spans = await spansReceived; + + collector.close(); + target.close(); + + assert.ok(spans.length > 0, 'Expected at least one span from fetch'); + const clientSpan = spans.find((s) => s.kind === 3); // CLIENT + assert.ok(clientSpan, 'Expected a CLIENT span from fetch'); + + const attrs = {}; + for (const a of clientSpan.attributes) { + attrs[a.key] = a.value.stringValue || a.value.intValue; + } + assert.strictEqual(attrs['http.request.method'], 'GET'); + assert.ok(attrs['url.full'], 'Expected url.full attribute'); + }); +});