diff --git a/README.md b/README.md index d12f6cd..1c2b174 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ It's intended to be included in Node.js core as a dependency, and wired up in th It seeks to enable: * Instrumentation of inbound and outbound HTTP transactions, following [OTel Semconv](https://opentelemetry.io/docs/specs/semconv/general/trace/). -* Tracing data egress via OTLP/JSON. (JSON-only to avoid bringing in a gRPC dependency.) +* Tracing data egress via OTLP/HTTP JSON or OTLP/gRPC, without runtime dependencies. * Distributed tracing via W3C Traceparent, including forwarding baggage (but not modifying it). Anything else, including the following, should be considered initially out of scope: @@ -48,7 +48,8 @@ request it happened on carries on untraced or partly traced. | Option | Type | Default | Description | | ---------------- | ------ | ---------------------------- | ----------- | -| `endpoint` | string | `http://localhost:4318` | Base URL of an OTLP/HTTP collector. Only `http:` and `https:` URLs are accepted. `/v1/traces` is appended to the endpoint's existing path. An endpoint already ending with `/v1/traces` is used as is. | +| `protocol` | string | `json` | Export protocol: `json` or `grpc`. | +| `endpoint` | string | protocol dependent | Collector URL. Defaults to `http://localhost:4318` for `json`, or `http://localhost:4317` for `grpc`. Only `http:` and `https:` URLs are accepted. For HTTP/JSON, `/v1/traces` is appended to the existing path unless already present. For gRPC, the path is replaced with `/opentelemetry.proto.collector.trace.v1.TraceService/Export`. | | `serviceName` | string | `unknown_service:node` | Service name in exported resource attributes. The default follows the OpenTelemetry [semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/trace/) for low-cardinality service names. | | `sdkName` | string | `microtel` | `telemetry.sdk.name` resource attribute, and the name of the instrumentation scope on exported spans. | | `sdkVersion` | string | this package's version | `telemetry.sdk.version` resource attribute, and the scope version. | @@ -68,6 +69,12 @@ per-process random `service.instance.id` UUID, `host.name` from can replace any of microtel's values except `service.name` and the `telemetry.sdk.*` keys. +To enable gRPC export, set `protocol` to `grpc`: + +```js +otel.init({ protocol: 'grpc' }); +``` + ### Exported functions * `init(options)` activates tracing. Returns nothing. Throws on invalid options. diff --git a/benchmark/helpers/otlp.js b/benchmark/helpers/otlp.js new file mode 100644 index 0000000..1636d5f --- /dev/null +++ b/benchmark/helpers/otlp.js @@ -0,0 +1,56 @@ +'use strict'; + +const { OtlpEncoder } = require('../../lib/otlp'); +const { Span, SPAN_KIND_CLIENT } = require('../../lib/span'); +const { version } = require('../../package.json'); + +// Prebuild ended span models so benchmarks measure encoding/export rather +// than instrumentation, ID generation, or span construction. +function createInput(batchSize, exceptions) { + const spans = Array.from({ length: batchSize }, (_, i) => { + const span = new Span('GET', SPAN_KIND_CLIENT); + span.setAttribute('http.request.method', 'GET'); + span.setAttribute('url.full', `https://example.com/items/${i}`); + span.setAttribute('server.address', 'example.com'); + span.setAttribute('server.port', 443); + span.setAttribute('network.protocol.version', '1.1'); + span.setAttribute('http.response.status_code', exceptions ? 503 : 200); + if (exceptions) { + span.setStatus(2, 'HTTP 503'); + span.setAttribute('error.type', '503'); + span.addEvent('exception', { + 'exception.type': 'Error', + 'exception.message': 'Unavailable 日本語 💥', + 'exception.stacktrace': + 'Error: unavailable\n' + + ' at handler (/app/server.js:42:10)\n'.repeat(20), + }); + } + // Do not call end(): that would enqueue the span for automatic export. + span.endTime = span.startTime + 10; + return span; + }); + const resource = { + attributes: Object.entries({ + 'service.name': 'benchmark', + 'telemetry.sdk.name': 'microtel', + 'telemetry.sdk.language': 'nodejs', + 'telemetry.sdk.version': version, + 'process.runtime.name': 'nodejs', + 'process.runtime.version': process.version, + 'process.pid': process.pid, + }).map(([key, value]) => ({ + key, + value: + typeof value === 'number' + ? { intValue: String(value) } + : { stringValue: value }, + })), + }; + return { + spans, + encoder: new OtlpEncoder(resource, { name: 'microtel', version }), + }; +} + +module.exports = { createInput }; diff --git a/benchmark/otlp-encoding.bench.js b/benchmark/otlp-encoding.bench.js new file mode 100644 index 0000000..afdfca0 --- /dev/null +++ b/benchmark/otlp-encoding.bench.js @@ -0,0 +1,32 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { bench, suite } = require('node:bench'); +const { createInput } = require('./helpers/otlp'); + +suite('OTLP encoding', () => { + for (const batchSize of [1, 100]) { + for (const exceptions of [false, true]) { + const { encoder, spans } = createInput(batchSize, exceptions); + const iterations = 100000 / batchSize; + const reference = encoder.encode(spans); + const expected = reference.length + reference[reference.length - 1]; + bench( + `OTLP encode / batch=${batchSize} / ${exceptions ? 'exception' : 'HTTP'}`, + { samples: 30, warmup: 20, params: { batchSize, exceptions } }, + (b) => { + let checksum = 0; + b.start(); + for (let i = 0; i < iterations; i++) { + const buffer = encoder.encode(spans); + checksum += buffer.length + buffer[buffer.length - 1]; + } + // One operation is a complete batch, including its sizing pass, + // allocation, protobuf encoding and gRPC message prefix. + b.end(iterations); + assert.equal(checksum, iterations * expected); + }, + ); + } + } +}); diff --git a/benchmark/otlp-export.bench.js b/benchmark/otlp-export.bench.js new file mode 100644 index 0000000..a80fe65 --- /dev/null +++ b/benchmark/otlp-export.bench.js @@ -0,0 +1,71 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const http2 = require('node:http2'); +const { before, after, bench, suite } = require('node:bench'); +const { GrpcExporter } = require('../lib/grpc'); +const { createInput } = require('./helpers/otlp'); + +const kSuccessResponse = Buffer.from([0, 0, 0, 0, 0]); +const kBatchesPerSample = 100; + +suite('OTLP export', () => { + let server; + let exporter; + let received = 0; + + before(async () => { + // An in-process collector consumes the request and acknowledges it. + // It does not decode protobuf or depend on an external Collector. + server = http2.createServer(); + server.on('stream', (stream) => { + stream.on('error', () => {}); + stream.resume(); + stream.on('end', () => { + received++; + stream.respond( + { ':status': 200, 'content-type': 'application/grpc+proto' }, + { waitForTrailers: true }, + ); + stream.on('wantTrailers', () => + stream.sendTrailers({ 'grpc-status': '0' }), + ); + stream.end(kSuccessResponse); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + exporter = new GrpcExporter( + new URL(`http://127.0.0.1:${server.address().port}`), + 10000, + ); + }); + + after(async () => { + exporter?.close(); + if (server) await new Promise((resolve) => server.close(resolve)); + }); + + for (const batchSize of [1, 100]) { + for (const exceptions of [false, true]) { + const { encoder, spans } = createInput(batchSize, exceptions); + bench( + `OTLP export / batch=${batchSize} / ${exceptions ? 'exception' : 'HTTP'}`, + { samples: 30, warmup: 20, params: { batchSize, exceptions } }, + async (b) => { + const before = received; + let result; + b.start(); + for (let i = 0; i < kBatchesPerSample; i++) { + result = await exporter.send(encoder.encode(spans)); + } + // One operation includes encoding and the complete local gRPC + // round trip, through response decoding and final status handling. + b.end(kBatchesPerSample); + assert.equal(received - before, kBatchesPerSample); + assert.equal(result.rejectedSpans, 0n); + assert.equal(result.errorMessage, ''); + }, + ); + } + } +}); diff --git a/lib/core.js b/lib/core.js index de1f576..a30736e 100644 --- a/lib/core.js +++ b/lib/core.js @@ -18,6 +18,7 @@ const kDefaultEndpoint = 'http://localhost:4318'; const kDefaultServiceName = 'unknown_service:node'; const kDefaultSdkName = 'microtel'; +let protocol = 'json'; let endpoint = null; let active = false; let filter = null; // null = all modules enabled; Set = only listed modules. @@ -173,7 +174,19 @@ function start(options = { __proto__: null }) { validateObject(options, 'options'); - const endpointValue = optionalString(options, 'endpoint', kDefaultEndpoint); + const protocolValue = optionalString(options, 'protocol', 'json'); + if (protocolValue !== 'json' && protocolValue !== 'grpc') { + throw invalidArgValueError( + 'options.protocol', + protocolValue, + 'must be json or grpc', + ); + } + const endpointValue = optionalString( + options, + 'endpoint', + protocolValue === 'grpc' ? 'http://localhost:4317' : kDefaultEndpoint, + ); let parsed; try { @@ -197,12 +210,15 @@ function start(options = { __proto__: null }) { ); } - // /v1/traces is appended to the endpoint's path, unless the path already - // ends with it. The resolved URL is fixed at activation: it drives both - // the exports and the self-trace suppression. - let pathname = parsed.pathname.replace(/\/+$/, ''); - if (!pathname.endsWith('/v1/traces')) pathname += '/v1/traces'; - parsed.pathname = pathname; + // Resolve once for both the transport and self-trace suppression. + if (protocolValue === 'grpc') { + // gRPC method paths are absolute; endpoint paths are not HTTP prefixes. + parsed.pathname = require('./grpc').kTraceExportPath; + } else { + let pathname = parsed.pathname.replace(/\/+$/, ''); + if (!pathname.endsWith('/v1/traces')) pathname += '/v1/traces'; + parsed.pathname = pathname; + } parsed.search = ''; if (options.maxBufferSize !== undefined) @@ -234,6 +250,7 @@ function start(options = { __proto__: null }) { options.resourceAttributes, ); + protocol = protocolValue; endpoint = endpointValue; filter = parsedFilter; exclude = parsedExclude; @@ -259,6 +276,7 @@ module.exports = { start, isActive: () => active, getEndpoint: () => endpoint, + getProtocol: () => protocol, getSpanStorage, isModuleEnabled, getCollectorHost: () => collectorHost, diff --git a/lib/flush.js b/lib/flush.js index c4ca388..3e3233d 100644 --- a/lib/flush.js +++ b/lib/flush.js @@ -12,6 +12,7 @@ const { guard } = require('./guard'); const { getEndpoint, getExportHeaders, + getProtocol, getExportUrl, getInstanceId, getResourceAttributes, @@ -53,6 +54,8 @@ const inFlightExports = new Set(); let cachedResource = null; let cachedScope = null; +let otlpEncoder = null; +let grpcExporter = null; // A user value replaces microtel's default for the same key, so each key // appears once. User keys microtel does not set are appended. @@ -247,6 +250,43 @@ function exportBuffered() { const spans = spanBuffer; spanBuffer = []; + if (getProtocol() === 'grpc') { + try { + const { OtlpEncoder } = require('./otlp'); + const { GrpcExporter } = require('./grpc'); + otlpEncoder ??= new OtlpEncoder(getResource(), getScope()); + grpcExporter ??= new GrpcExporter( + getExportUrl(), + exportTimeoutMs, + getExportHeaders(), + ); + const body = otlpEncoder.encode(spans, (err, span) => { + warnExportFailure( + `Failed to serialize span "${span.name}": ${err.message}`, + ); + }); + if (!body) return settleAll(pending); + const exportDone = grpcExporter.send(body).then( + (result) => { + if (result.rejectedSpans !== 0n || result.errorMessage) { + warnExportFailure( + `OTLP collector rejected ${result.rejectedSpans} spans: ${result.errorMessage}`, + ); + } + }, + (err) => warnExportFailure(`Failed to export spans: ${err.message}`), + ); + inFlightExports.add(exportDone); + exportDone.then(() => inFlightExports.delete(exportDone)); + pending.push(exportDone); + } catch (err) { + warnExportFailure( + `Failed to serialize ${spans.length} spans: ${err.message}`, + ); + } + return settleAll(pending); + } + const otlpSpans = []; for (let i = 0; i < spans.length; i++) { try { @@ -474,6 +514,9 @@ function resetCaches() { flushScheduled = false; flushIntervalMs = kDefaultFlushIntervalMs; exportTimeoutMs = kDefaultExportTimeoutMs; + grpcExporter?.close(); + grpcExporter = null; + otlpEncoder = null; cachedResource = null; cachedScope = null; exportFailureCount = 0; diff --git a/lib/grpc.js b/lib/grpc.js new file mode 100644 index 0000000..a1b7997 --- /dev/null +++ b/lib/grpc.js @@ -0,0 +1,243 @@ +'use strict'; + +const { Buffer } = require('node:buffer'); +const http2 = require('node:http2'); +const { decodeResponse } = require('./otlp'); +const { kMaxMessageSize } = require('./protobuf'); + +const kTraceExportPath = + '/opentelemetry.proto.collector.trace.v1.TraceService/Export'; +const kMaxResponseSize = 64 * 1024; +const kInitialBackoffMs = 1000; +const kMaxBackoffMs = 30000; + +// A single active unary RPC; queued and active payloads share a byte budget. +// Failures are reported to the flusher, which owns warning/drop policy. +class GrpcExporter { + session = null; + sessions = new Set(); + queue = []; + active = null; + queuedBytes = 0; + closed = false; + backoffMs = kInitialBackoffMs; + nextExportAt = 0; + resumeTimer = null; + + constructor(url, timeout, headers) { + this.url = url; + this.timeout = timeout; + this.headers = headers; + } + + send(body) { + if (this.closed) + return Promise.reject(new Error('OTLP gRPC exporter closed')); + if ( + this.queue.length + (this.active ? 1 : 0) >= 100 || + this.queuedBytes + body.length > kMaxMessageSize + 5 + ) { + return Promise.reject( + new Error('OTLP gRPC export queue exceeds size limit'), + ); + } + return new Promise((resolve, reject) => { + const item = { + body, + resolve, + reject, + expires: Date.now() + this.timeout, + }; + item.timer = setTimeout(() => { + this.finish( + item, + new Error( + `OTLP gRPC export exceeded the ${this.timeout}ms export timeout`, + ), + ); + }, this.timeout); + this.queuedBytes += body.length; + this.queue.push(item); + this.pump(); + }); + } + + getSession() { + if (this.session && !this.session.closed && !this.session.destroyed) + return this.session; + const session = http2.connect(this.url.origin, { + settings: { maxHeaderListSize: 8192 }, + maxHeaderListPairs: 128, + }); + this.session = session; + this.sessions.add(session); + session.on('error', (err) => { + if (this.session === session) this.session = null; + if (this.active?.session === session) this.finish(this.active, err); + session.destroy(); + }); + session.on('goaway', () => { + if (this.session === session) this.session = null; + session.close(); + }); + session.on('close', () => { + this.sessions.delete(session); + if (this.session === session) this.session = null; + if (this.active?.session === session) + this.finish(this.active, new Error('OTLP gRPC session closed')); + }); + return session; + } + + pump() { + if (this.active || this.closed) return; + if (this.queue.length === 0) { + clearTimeout(this.resumeTimer); + this.resumeTimer = null; + return; + } + const delay = this.nextExportAt - Date.now(); + if (delay > 0) { + this.resumeTimer ??= setTimeout(() => { + this.resumeTimer = null; + this.pump(); + }, delay); + this.resumeTimer.unref(); + return; + } + const item = this.queue.shift(); + if (!item) return; + this.active = item; + try { + // A previous RPC can finish before delayed timeout callbacks run. + // Do not start a stream for a batch whose deadline has already passed. + if (item.expires <= Date.now()) { + throw new Error( + `OTLP gRPC export exceeded the ${this.timeout}ms export timeout`, + ); + } + const session = this.getSession(); + item.session = session; + session.ref(); + const stream = session.request({ + ...this.headers, + ':method': 'POST', + ':path': + this.url.pathname === '/' ? kTraceExportPath : this.url.pathname, + 'content-type': 'application/grpc+proto', + te: 'trailers', + 'grpc-timeout': `${Math.min(99999999, Math.max(1, item.expires - Date.now()))}m`, + }); + item.stream = stream; + let headers; + let status; + let message; + let length = 0; + const chunks = []; + stream.on('response', (value) => { + headers = value; + status = value['grpc-status']; + message = value['grpc-message']; + }); + stream.on('trailers', (value) => { + status = value['grpc-status']; + message = value['grpc-message']; + }); + stream.on('data', (chunk) => { + length += chunk.length; + if (length > kMaxResponseSize + 5) { + this.finish(item, new Error('OTLP gRPC response exceeds size limit')); + } else chunks.push(chunk); + }); + stream.on('error', (err) => this.finish(item, err)); + stream.on('close', () => { + if (!item.done) + this.finish( + item, + new Error('OTLP gRPC stream closed before response completed'), + ); + }); + stream.on('end', () => { + if (item.done) return; + try { + if (headers?.[':status'] !== 200) + throw new Error( + `OTLP gRPC collector responded with HTTP ${headers?.[':status']}`, + ); + if ( + !/^application\/grpc(?:\+proto)?(?:;|$)/i.test( + headers['content-type'] || '', + ) + ) { + throw new Error('Invalid OTLP gRPC response content-type'); + } + if (!/^(?:[0-9]|1[0-6])$/.test(status ?? '')) + throw new Error('Missing or invalid OTLP grpc-status'); + if (status !== '0') { + if (status === '14') { + // UNAVAILABLE is the OTLP overload signal. Drop this batch, + // but throttle new work as well as work already queued. + this.nextExportAt = Date.now() + this.backoffMs; + this.backoffMs = Math.min(this.backoffMs * 2, kMaxBackoffMs); + } + let decoded = message || ''; + try { + decoded = decodeURIComponent(decoded); + } catch { + /* Preserve invalid escaped diagnostics. */ + } + throw new Error(`OTLP gRPC status ${status}: ${decoded}`); + } + const frame = Buffer.concat(chunks, length); + if (length < 5 || frame.readUInt32BE(1) !== length - 5) + throw new Error('Invalid OTLP gRPC response framing'); + if (frame[0] !== 0) + throw new Error('Unsupported OTLP gRPC response compression'); + this.finish(item, null, decodeResponse(frame.subarray(5))); + } catch (err) { + this.finish(item, err); + } + }); + stream.end(item.body); + } catch (err) { + this.finish(item, err); + } + } + + finish(item, error, result) { + if (item.done) return; + item.done = true; + clearTimeout(item.timer); + this.queuedBytes -= item.body.length; + if (this.active === item) { + this.active = null; + if (error) { + item.stream?.close(http2.constants.NGHTTP2_CANCEL); + if (item.session?.connecting) item.session.destroy(); + } + item.session?.unref(); + } else { + const index = this.queue.indexOf(item); + if (index !== -1) this.queue.splice(index, 1); + } + if (error) item.reject(error); + else { + this.backoffMs = kInitialBackoffMs; + this.nextExportAt = 0; + item.resolve(result); + } + this.pump(); + } + + close() { + this.closed = true; + clearTimeout(this.resumeTimer); + this.resumeTimer = null; + const error = new Error('OTLP gRPC exporter closed'); + if (this.active) this.finish(this.active, error); + for (const item of [...this.queue]) this.finish(item, error); + for (const session of this.sessions) session.destroy(); + } +} + +module.exports = { GrpcExporter, kTraceExportPath }; diff --git a/lib/instrumentations.js b/lib/instrumentations.js index c08bf9f..c049ad8 100644 --- a/lib/instrumentations.js +++ b/lib/instrumentations.js @@ -419,9 +419,13 @@ function onClientRequestError({ request, error }) { // at which an outgoing traceparent could be injected. function onHttp2ServerStreamStart({ stream, headers }) { - // Exports travel over HTTP/1.1, so an export request can never arrive as - // an HTTP/2 stream and the self-trace suppression the http handlers need - // does not apply here. + // gRPC exports can reach an in-process HTTP/2 collector. Match both + // authority and method path so unrelated requests remain traced. + if ( + headers?.[':authority'] === getCollectorHost() && + headers?.[':path'] === getExportUrl().pathname + ) + return; const traceparent = headers?.traceparent; const parent = traceparent != null ? Span.fromTraceparent(traceparent) : undefined; diff --git a/lib/otlp.js b/lib/otlp.js new file mode 100644 index 0000000..615ee62 --- /dev/null +++ b/lib/otlp.js @@ -0,0 +1,301 @@ +'use strict'; + +const { Buffer } = require('node:buffer'); +const { + measureMessage, + measureString, + measureInteger, + writeHeader, + writeInteger, + writeString, + writeBytes, + Reader, + kMaxMessageSize, +} = require('./protobuf'); +const kMaxTimestampMs = 18_446_744_073_709; +const unixNano = (time) => BigInt(Math.round(time)) * 1_000_000n; + +function measureAnyValue(value) { + switch (typeof value) { + case 'boolean': + return 2; + case 'number': + return Number.isInteger(value) ? measureInteger(value) : 9; + default: + return measureString(String(value)); + } +} + +function writeAnyValue(buffer, offset, value) { + switch (typeof value) { + case 'boolean': + return writeInteger(buffer, offset, 16, value ? 1 : 0); + case 'number': + if (Number.isInteger(value)) + return writeInteger(buffer, offset, 24, value); + buffer[offset++] = 33; // AnyValue.double_value, fixed64 + buffer.writeDoubleLE(value, offset); + return offset + 8; + default: + return writeString(buffer, offset, 10, String(value)); + } +} + +function measureAttributes(attrs) { + let size = 0; + for (const key in attrs) { + if (!Object.hasOwn(attrs, key) || attrs[key] === undefined) continue; + const pairSize = + measureString(key) + measureMessage(measureAnyValue(attrs[key])); + size += measureMessage(pairSize); + } + return size; +} + +function writeAttributes(buffer, offset, tag, attrs) { + for (const key in attrs) { + if (!Object.hasOwn(attrs, key) || attrs[key] === undefined) continue; + const value = attrs[key]; + const valueSize = measureAnyValue(value); + offset = writeHeader( + buffer, + offset, + tag, + measureString(key) + measureMessage(valueSize), + ); + offset = writeString(buffer, offset, 10, key); + offset = writeHeader(buffer, offset, 18, valueSize); + offset = writeAnyValue(buffer, offset, value); + } + return offset; +} + +function validateTime(time) { + // Largest whole millisecond that fits in an unsigned nanosecond fixed64. + if ( + !Number.isFinite(time) || + Math.round(time) < 0 || + Math.round(time) > kMaxTimestampMs + ) { + throw new RangeError('Invalid OTLP timestamp'); + } +} + +function measureEvent(value) { + validateTime(value.time); + return 9 + measureString(value.name) + measureAttributes(value.attributes); +} +function writeEvent(buffer, offset, value) { + offset = writeHeader(buffer, offset, 90, measureEvent(value)); // Span.events + buffer[offset++] = 9; // Event.time_unix_nano + buffer.writeBigUInt64LE(unixNano(value.time), offset); + offset += 8; + offset = writeString(buffer, offset, 18, value.name); + return writeAttributes(buffer, offset, 26, value.attributes); +} + +function measureStatus(value) { + return ( + (value.message ? measureString(value.message) : 0) + + (value.code ? measureInteger(value.code) : 0) + ); +} +function writeStatus(buffer, offset, value) { + offset = writeHeader(buffer, offset, 122, measureStatus(value)); // Span.status + if (value.message) offset = writeString(buffer, offset, 18, value.message); + if (value.code) offset = writeInteger(buffer, offset, 24, value.code); + return offset; +} + +function validateId(value, length, name) { + if ( + typeof value !== 'string' || + value.length !== length * 2 || + !/^[0-9a-f]+$/i.test(value) + ) { + throw new TypeError(`Invalid OTLP ${name}`); + } +} +function writeId(buffer, offset, tag, value, length) { + offset = writeHeader(buffer, offset, tag, length); + return offset + buffer.write(value, offset, length, 'hex'); +} + +function measureSpan(value) { + validateTime(value.startTime); + validateTime(value.endTime); + validateId(value.traceId, 16, 'traceId'); + validateId(value.spanId, 8, 'spanId'); + // trace_id (18), span_id (10), start/end fixed64 timestamps (18). + let size = 46 + measureString(value.name) + measureInteger(value.kind); + if (value.parentSpanId) { + validateId(value.parentSpanId, 8, 'parentSpanId'); + size += 10; + } + if (value.tracestate) size += measureString(value.tracestate); + size += measureAttributes(value.getAttributes()); + for (const event of value.getEvents()) + size += measureMessage(measureEvent(event)); + if (value.status.code || value.status.message) + size += measureMessage(measureStatus(value.status)); + if (value.traceFlags) size += 6; // Two-byte tag for field 16 plus fixed32. + return size; +} + +function writeSpan(buffer, offset, value) { + offset = writeHeader(buffer, offset, 18, measureSpan(value)); // ScopeSpans.spans + offset = writeId(buffer, offset, 10, value.traceId, 16); + offset = writeId(buffer, offset, 18, value.spanId, 8); + if (value.tracestate) + offset = writeString(buffer, offset, 26, value.tracestate); + if (value.parentSpanId) + offset = writeId(buffer, offset, 34, value.parentSpanId, 8); + offset = writeString(buffer, offset, 42, value.name); + offset = writeInteger(buffer, offset, 48, value.kind); + buffer[offset++] = 57; // Span.start_time_unix_nano + buffer.writeBigUInt64LE(unixNano(value.startTime), offset); + offset += 8; + buffer[offset++] = 65; // Span.end_time_unix_nano + buffer.writeBigUInt64LE(unixNano(value.endTime), offset); + offset += 8; + offset = writeAttributes(buffer, offset, 74, value.getAttributes()); + for (const event of value.getEvents()) + offset = writeEvent(buffer, offset, event); + if (value.status.code || value.status.message) + offset = writeStatus(buffer, offset, value.status); + if (value.traceFlags) { + buffer[offset++] = 133; + buffer[offset++] = 1; // Span.flags: (16 << 3) | 5 + buffer.writeUInt32LE(value.traceFlags, offset); + offset += 4; + } + return offset; +} + +function measureResourceValue(value) { + if (value.intValue !== undefined) + return measureInteger(BigInt(value.intValue)); + if (value.doubleValue !== undefined) return 9; + if (value.boolValue !== undefined) return 2; + return measureString(value.stringValue); +} +function measureResource(resource) { + let size = 0; + for (const attr of resource.attributes) { + size += measureMessage( + measureString(attr.key) + + measureMessage(measureResourceValue(attr.value)), + ); + } + return size; +} +function writeResource(buffer, resource) { + let offset = 0; + for (const attr of resource.attributes) { + const value = attr.value; + const valueSize = measureResourceValue(value); + offset = writeHeader( + buffer, + offset, + 10, + measureString(attr.key) + measureMessage(valueSize), + ); + offset = writeString(buffer, offset, 10, attr.key); + offset = writeHeader(buffer, offset, 18, valueSize); + if (value.intValue !== undefined) { + offset = writeInteger(buffer, offset, 24, BigInt(value.intValue)); + } else if (value.doubleValue !== undefined) { + buffer[offset++] = 33; + buffer.writeDoubleLE(value.doubleValue, offset); + offset += 8; + } else if (value.boolValue !== undefined) { + offset = writeInteger(buffer, offset, 16, value.boolValue ? 1 : 0); + } else { + offset = writeString(buffer, offset, 10, value.stringValue); + } + } + return offset; +} + +function allocate(size) { + if (size > kMaxMessageSize) + throw new RangeError('OTLP message exceeds size limit'); + return Buffer.allocUnsafe(size); +} + +class OtlpEncoder { + constructor(resource, scope) { + this.resource = allocate(measureResource(resource)); + writeResource(this.resource, resource); + this.scope = allocate( + measureString(scope.name) + measureString(scope.version), + ); + let offset = writeString(this.scope, 0, 10, scope.name); + writeString(this.scope, offset, 18, scope.version); + this.resourceFieldSize = measureMessage(this.resource.length); + this.scopeFieldSize = measureMessage(this.scope.length); + } + + byteLength(spans) { + let size = this.scopeFieldSize; + for (const span of spans) size += measureMessage(measureSpan(span)); + return measureMessage(this.resourceFieldSize + measureMessage(size)); + } + + encode(spans, onError) { + let scopeSpansSize = this.scopeFieldSize; + // Allocate a filtered list only on the error path. Valid batches need + // neither a message tree, a lengths array, a Set, nor encoding closures. + let accepted; + for (let i = 0; i < spans.length; i++) { + try { + scopeSpansSize += measureMessage(measureSpan(spans[i])); + if (accepted) accepted.push(spans[i]); + } catch (err) { + if (!onError) throw err; + accepted ??= spans.slice(0, i); + onError(err, spans[i]); + } + } + if (accepted?.length === 0) return null; + const resourceSpansSize = + this.resourceFieldSize + measureMessage(scopeSpansSize); + const size = measureMessage(resourceSpansSize); + if (size > kMaxMessageSize) + throw new RangeError('OTLP message exceeds size limit'); + const buffer = Buffer.allocUnsafe(5 + size); + buffer[0] = 0; + buffer.writeUInt32BE(size, 1); + let offset = writeHeader(buffer, 5, 10, resourceSpansSize); + offset = writeBytes(buffer, offset, 10, this.resource); + offset = writeHeader(buffer, offset, 18, scopeSpansSize); + offset = writeBytes(buffer, offset, 10, this.scope); + for (const span of accepted ?? spans) + offset = writeSpan(buffer, offset, span); + if (offset !== buffer.length) throw new Error('OTLP encoded size changed'); + return buffer; + } +} +function decodeResponse(buffer) { + const result = { rejectedSpans: 0n, errorMessage: '' }; + const reader = new Reader(buffer); + reader.fields((field, wire) => { + if (field !== 1 || wire !== 2) return false; + const partial = new Reader(reader.bytes()); + partial.fields((f, type) => { + if (f === 1 && type === 0) { + result.rejectedSpans = BigInt.asIntN(64, partial.varint()); + return true; + } + if (f === 2 && type === 2) { + result.errorMessage = partial.bytes().toString('utf8'); + return true; + } + return false; + }); + return true; + }); + return result; +} + +module.exports = { OtlpEncoder, measureSpan, decodeResponse }; diff --git a/lib/protobuf.js b/lib/protobuf.js new file mode 100644 index 0000000..ceaf223 --- /dev/null +++ b/lib/protobuf.js @@ -0,0 +1,189 @@ +'use strict'; + +const { Buffer } = require('node:buffer'); +const kMaxMessageSize = 16 * 1024 * 1024; + +// Precompute thresholds once while retaining their seven-bit sizing math. +const kNumberVarint1ByteLimit = 2 ** 7; +const kNumberVarint2ByteLimit = 2 ** 14; +const kNumberVarint3ByteLimit = 2 ** 21; +const kNumberVarint4ByteLimit = 2 ** 28; +const kNumberVarint5ByteLimit = 2 ** 35; +const kNumberVarint6ByteLimit = 2 ** 42; +const kNumberVarint7ByteLimit = 2 ** 49; +const kNumberVarint8ByteLimit = 2 ** 56; +const kNumberVarint9ByteLimit = 2 ** 63; + +const kBigIntVarint1ByteLimit = 1n << 7n; +const kBigIntVarint2ByteLimit = 1n << 14n; +const kBigIntVarint3ByteLimit = 1n << 21n; +const kBigIntVarint4ByteLimit = 1n << 28n; +const kBigIntVarint5ByteLimit = 1n << 35n; +const kBigIntVarint6ByteLimit = 1n << 42n; +const kBigIntVarint7ByteLimit = 1n << 49n; +const kBigIntVarint8ByteLimit = 1n << 56n; +const kBigIntVarint9ByteLimit = 1n << 63n; + +// Sizing uses Number for unsigned lengths/tags and BigInt for full int64s. +// Writing splits wide values into scalar words, without a LongBits object. +function measureVarint(value) { + if (typeof value === 'number') { + if (value < kNumberVarint1ByteLimit) return 1; + if (value < kNumberVarint2ByteLimit) return 2; + if (value < kNumberVarint3ByteLimit) return 3; + if (value < kNumberVarint4ByteLimit) return 4; + if (value < kNumberVarint5ByteLimit) return 5; + if (value < kNumberVarint6ByteLimit) return 6; + if (value < kNumberVarint7ByteLimit) return 7; + if (value < kNumberVarint8ByteLimit) return 8; + if (value < kNumberVarint9ByteLimit) return 9; + } else { + if (value < kBigIntVarint1ByteLimit) return 1; + if (value < kBigIntVarint2ByteLimit) return 2; + if (value < kBigIntVarint3ByteLimit) return 3; + if (value < kBigIntVarint4ByteLimit) return 4; + if (value < kBigIntVarint5ByteLimit) return 5; + if (value < kBigIntVarint6ByteLimit) return 6; + if (value < kBigIntVarint7ByteLimit) return 7; + if (value < kBigIntVarint8ByteLimit) return 8; + if (value < kBigIntVarint9ByteLimit) return 9; + } + return 10; +} + +function integerValue(value) { + if (typeof value === 'number') { + if (!Number.isSafeInteger(value)) + throw new RangeError('OTLP integer must be a safe integer'); + if (value >= 0) return value; + } + const n = BigInt(value); + if (n < -(1n << 63n) || n >= 1n << 63n) + throw new RangeError('OTLP integer exceeds int64 range'); + return BigInt.asUintN(64, n); +} + +// All length-delimited and integer fields in the supported OTLP messages +// have one-byte tags. Span.flags (field 16) is handled explicitly as fixed32. +const measureMessage = (size) => 1 + measureVarint(size) + size; +const measureString = (value) => measureMessage(Buffer.byteLength(value)); +const measureInteger = (value) => 1 + measureVarint(integerValue(value)); + +function writeWords(buffer, offset, lo, hi) { + while (hi) { + buffer[offset++] = (lo & 127) | 128; + lo = ((lo >>> 7) | (hi << 25)) >>> 0; + hi >>>= 7; + } + while (lo > 127) { + buffer[offset++] = (lo & 127) | 128; + lo >>>= 7; + } + buffer[offset++] = lo; + return offset; +} + +function writeVarint(buffer, offset, value) { + if (typeof value === 'number') { + while (value >= 128) { + buffer[offset++] = (value % 128) | 128; + value = Math.floor(value / 128); + } + buffer[offset++] = value; + return offset; + } + return writeWords( + buffer, + offset, + Number(value & 0xffffffffn), + Number(value >> 32n), + ); +} + +function writeHeader(buffer, offset, tag, length) { + buffer[offset++] = tag; + return writeVarint(buffer, offset, length); +} + +function writeInteger(buffer, offset, tag, value) { + buffer[offset++] = tag; + if (typeof value === 'number' && value < 0) { + if (!Number.isSafeInteger(value)) + throw new RangeError('OTLP integer must be a safe integer'); + const magnitude = -value; + let lo = magnitude >>> 0; + let hi = Math.floor(magnitude / 4294967296) >>> 0; + lo = (~lo + 1) >>> 0; + hi = (~hi + (lo === 0 ? 1 : 0)) >>> 0; + return writeWords(buffer, offset, lo, hi); + } + return writeVarint(buffer, offset, integerValue(value)); +} + +function writeString(buffer, offset, tag, value) { + offset = writeHeader(buffer, offset, tag, Buffer.byteLength(value)); + return offset + buffer.write(value, offset, undefined, 'utf8'); +} + +function writeBytes(buffer, offset, tag, value) { + offset = writeHeader(buffer, offset, tag, value.length); + buffer.set(value, offset); + return offset + value.length; +} + +class Reader { + offset = 0; + constructor(buffer) { + this.buffer = buffer; + } + fail() { + throw new Error('Malformed OTLP protobuf response'); + } + varint() { + let value = 0n; + for (let i = 0; i < 10; i++) { + if (this.offset >= this.buffer.length) this.fail(); + const byte = this.buffer[this.offset++]; + if (i === 9 && byte > 1) this.fail(); + value |= BigInt(byte & 127) << BigInt(i * 7); + if (byte < 128) return value; + } + this.fail(); + } + bytes() { + const size = this.varint(); + if (size > BigInt(this.buffer.length - this.offset)) this.fail(); + const start = this.offset; + this.offset += Number(size); + return this.buffer.subarray(start, this.offset); + } + fields(visit) { + while (this.offset < this.buffer.length) { + const tag = this.varint(); + const field = Number(tag >> 3n); + const wire = Number(tag & 7n); + if (field < 1 || field > 0x1fffffff) this.fail(); + if (visit(field, wire)) continue; + if (wire === 0) this.varint(); + else if (wire === 2) this.bytes(); + else if (wire === 1 || wire === 5) { + this.offset += wire === 1 ? 8 : 4; + if (this.offset > this.buffer.length) this.fail(); + } else this.fail(); + } + } +} + +module.exports = { + measureVarint, + measureMessage, + measureString, + measureInteger, + writeVarint, + writeHeader, + writeInteger, + writeString, + writeBytes, + Reader, + kMaxMessageSize, +}; diff --git a/package-lock.json b/package-lock.json index 59dbb61..1d26026 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "devDependencies": { "@opentelemetry/semantic-conventions": "1.43.0", "esbuild": "0.28.2", - "prettier": "3.9.10" + "prettier": "3.9.10", + "protobufjs": "8.8.1" }, "engines": { "node": ">=26.9.0" @@ -511,6 +512,13 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/prettier": { "version": "3.9.10", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", @@ -526,6 +534,19 @@ "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" } + }, + "node_modules/protobufjs": { + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.1.tgz", + "integrity": "sha512-d9e9179bjXSJdpJ3+R9fP8hzXsnKLyxTjHLG1dUzje8a27pPyXCe6PrcRtgx2rjoM3IZzHd2vAs+6J/BsO1k7Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } } } } diff --git a/package.json b/package.json index ad5debd..5cb5d49 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@opentelemetry/semantic-conventions": "1.43.0", "esbuild": "0.28.2", - "prettier": "3.9.10" + "prettier": "3.9.10", + "protobufjs": "8.8.1" } } diff --git a/test/common/otlp.js b/test/common/otlp.js new file mode 100644 index 0000000..86ebd4d --- /dev/null +++ b/test/common/otlp.js @@ -0,0 +1,63 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const protobuf = require('protobufjs'); + +// OpenTelemetry Proto v1.9.0, pinned to its immutable commit. Schemas are +// downloaded only for tests and cached in the system temporary directory. +const revision = 'a8951735f7801e8adfaec5c0ace9262771cfec6e'; +const cache = path.join(os.tmpdir(), 'microtel-otlp', revision); +const files = [ + 'common/v1/common.proto', + 'resource/v1/resource.proto', + 'trace/v1/trace.proto', + 'collector/trace/v1/trace_service.proto', +]; + +async function loadOtlp() { + await Promise.all( + files.map(async (name) => { + const relative = `opentelemetry/proto/${name}`; + const filename = path.join(cache, relative); + try { + await fs.access(filename); + return; + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + try { + const response = await fetch( + `https://raw.githubusercontent.com/open-telemetry/opentelemetry-proto/${revision}/${relative}`, + { signal: AbortSignal.timeout(10000) }, + ); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const source = await response.text(); + await fs.mkdir(path.dirname(filename), { recursive: true }); + const temporary = `${filename}.${process.pid}.tmp`; + await fs.writeFile(temporary, source); + await fs.rename(temporary, filename); + } catch (err) { + throw new Error( + `Cannot download OTLP test schema ${relative}; the first test run needs access to raw.githubusercontent.com`, + { cause: err }, + ); + } + }), + ); + const root = new protobuf.Root(); + root.resolvePath = (_origin, target) => path.join(cache, target); + root.loadSync('opentelemetry/proto/collector/trace/v1/trace_service.proto'); + return { + Request: root.lookupType( + 'opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest', + ), + Response: root.lookupType( + 'opentelemetry.proto.collector.trace.v1.ExportTraceServiceResponse', + ), + Span: root.lookupType('opentelemetry.proto.trace.v1.Span'), + }; +} + +module.exports = { loadOtlp }; diff --git a/test/fixtures/grpc-selftrace-child.js b/test/fixtures/grpc-selftrace-child.js new file mode 100644 index 0000000..1f8833e --- /dev/null +++ b/test/fixtures/grpc-selftrace-child.js @@ -0,0 +1,38 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const http2 = require('node:http2'); +const otel = require(process.env.MICROTEL_PATH); +const { Span } = require(process.env.MICROTEL_PATH + '/lib/span'); +let exportsReceived = 0; +const server = http2.createServer(); +server.on('stream', (stream) => { + stream.resume(); + stream.on('end', () => { + exportsReceived++; + stream.respond( + { ':status': 200, 'content-type': 'application/grpc' }, + { waitForTrailers: true }, + ); + stream.on('wantTrailers', () => + stream.sendTrailers({ 'grpc-status': '0' }), + ); + stream.end(Buffer.from([0, 0, 0, 0, 0])); + // Keep the collector alive beyond the next periodic flush. An export + // span would otherwise cause a loop of requests to this same server. + setTimeout(() => { + assert.equal(exportsReceived, 1); + server.close(); + stream.session?.close(); + }, 100); + }); +}); +server.listen(0, '127.0.0.1', () => { + otel.init({ + protocol: 'grpc', + endpoint: `http://127.0.0.1:${server.address().port}`, + flushInterval: 10, + }); + new Span('seed', 1).end(); + otel.flush(); +}); diff --git a/test/test-beforeexit-flush.js b/test/test-beforeexit-flush.js index 3a3fa22..ca49d98 100644 --- a/test/test-beforeexit-flush.js +++ b/test/test-beforeexit-flush.js @@ -20,7 +20,10 @@ describe('otel beforeExit flush', () => { // The child makes a traced request and exits without flushing. const { code } = await spawnMicrotel([ fixture('beforeexit-flush-child.js'), - JSON.stringify({ endpoint: `http://127.0.0.1:${collector.port}` }), + JSON.stringify({ + protocol: 'json', + endpoint: `http://127.0.0.1:${collector.port}`, + }), ]); assert.strictEqual(code, 0); diff --git a/test/test-grpc-exporter.js b/test/test-grpc-exporter.js new file mode 100644 index 0000000..a5cef8a --- /dev/null +++ b/test/test-grpc-exporter.js @@ -0,0 +1,167 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const http2 = require('node:http2'); +const fs = require('node:fs'); +const { test } = require('node:test'); +const { fixture, spawnMicrotel } = require('./common/otel'); + +async function run( + t, + { + explicitFlush = false, + secure = false, + status = '0', + partial = false, + stall = false, + options = {}, + } = {}, +) { + const server = secure + ? http2.createSecureServer({ + key: fs.readFileSync(fixture('https-key.pem')), + cert: fs.readFileSync(fixture('https-cert.pem')), + }) + : http2.createServer(); + const payloads = []; + const requests = []; + const sessions = new Set(); + server.on('session', (s) => { + sessions.add(s); + s.on('close', () => sessions.delete(s)); + }); + t.after(() => { + for (const s of sessions) s.destroy(); + server.close(); + }); + server.on('stream', (stream, headers) => { + requests.push(headers); + const chunks = []; + stream.on('error', () => {}); + stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('end', () => { + const body = Buffer.concat(chunks); + assert.equal(body.readUInt32BE(1), body.length - 5); + assert.equal(body[0], 0); + payloads.push(body.subarray(5)); + assert.equal( + headers[':path'], + '/opentelemetry.proto.collector.trace.v1.TraceService/Export', + ); + if (stall) return; + stream.respond( + { ':status': 200, 'content-type': 'application/grpc' }, + { waitForTrailers: true }, + ); + stream.on('wantTrailers', () => + stream.sendTrailers({ 'grpc-status': status }), + ); + stream.end( + partial + ? Buffer.from([0, 0, 0, 0, 4, 10, 2, 8, 1]) + : Buffer.from([0, 0, 0, 0, 0]), + ); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const result = await spawnMicrotel( + [ + ...(explicitFlush + ? [ + '-e', + ` + const otel = require(process.env.MICROTEL_PATH); + otel.init(JSON.parse(process.argv[1])); + const { Span } = require(process.env.MICROTEL_PATH + '/lib/span'); + const { addSpan } = require(process.env.MICROTEL_PATH + '/lib/flush'); + const span = new Span('test', 1); + span.endTime = span.startTime + 1; + addSpan(span); + otel.flush().then(() => process.exit(0)); + `, + ] + : [fixture('beforeexit-flush-child.js')]), + JSON.stringify({ + protocol: 'grpc', + endpoint: `${secure ? 'https' : 'http'}://localhost:${server.address().port}`, + ...options, + }), + ], + secure ? { NODE_EXTRA_CA_CERTS: fixture('https-cert.pem') } : {}, + { timeout: 5000 }, + ); + return { ...result, payloads, requests }; +} + +for (const secure of [false, true]) { + test(`public init exports traced spans on beforeExit over ${secure ? 'TLS' : 'cleartext'} gRPC and exits`, async (t) => { + const result = await run(t, { secure }); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.equal(result.payloads.length, 1); + assert.ok(result.payloads[0].length > 0); + // These tests cover transport and lifecycle. Exact wire bytes are + // checked independently by the protobufjs encoding tests. + }); +} +test('gRPC export reports partial rejection', async (t) => { + const result = await run(t, { partial: true }); + assert.equal(result.code, 0); + assert.match(result.stderr, /OTelExportWarning.*rejected 1 spans/); +}); +test('gRPC export reports RPC failure', async (t) => { + const result = await run(t, { status: '14' }); + assert.equal(result.code, 0); + assert.match(result.stderr, /OTelExportWarning.*status 14/); +}); +test('gRPC deadline allows process exit with an unresponsive collector', async (t) => { + const result = await run(t, { stall: true, options: { exportTimeout: 100 } }); + assert.equal(result.code, 0); + assert.match(result.stderr, /OTelExportWarning.*timeout/); +}); +test('protocol validation occurs before activation', async () => { + const result = await spawnMicrotel( + [ + '-e', + ` + const assert = require('node:assert/strict'); + const otel = require(process.env.MICROTEL_PATH); + assert.throws(() => otel.init({ protocol: 'invalid' }), { code: 'ERR_INVALID_ARG_VALUE' }); + assert.throws(() => otel.init({ protocol: 'http/json' }), { code: 'ERR_INVALID_ARG_VALUE' }); + assert.throws(() => otel.init({ protocol: 1 }), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.equal(otel.isActive(), false); + otel.init({ protocol: 'grpc' }); + assert.equal(require(process.env.MICROTEL_PATH + '/lib/core').getExportUrl().href, + 'http://localhost:4317/opentelemetry.proto.collector.trace.v1.TraceService/Export'); + `, + ], + {}, + { timeout: 5000 }, + ); + assert.equal(result.code, 0, result.stderr); +}); + +test('an in-process gRPC collector is excluded from tracing', async () => { + const result = await spawnMicrotel( + [fixture('grpc-selftrace-child.js')], + {}, + { timeout: 5000 }, + ); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stderr, ''); +}); + +test('gRPC forwards configured export headers', async (t) => { + const result = await run(t, { + options: { headers: { authorization: 'Bearer test' } }, + }); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.requests[0].authorization, 'Bearer test'); +}); + +test('explicit flush waits for gRPC exports before resolving', async (t) => { + const result = await run(t, { explicitFlush: true }); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.equal(result.payloads.length, 1); +}); diff --git a/test/test-grpc.js b/test/test-grpc.js new file mode 100644 index 0000000..a0a6336 --- /dev/null +++ b/test/test-grpc.js @@ -0,0 +1,284 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const http2 = require('node:http2'); +const { test } = require('node:test'); + +async function collector(t, handler, options) { + const server = options + ? http2.createSecureServer(options) + : http2.createServer(); + server.on('stream', (stream, headers) => { + stream.on('error', () => {}); + stream.resume(); + stream.on('end', () => handler(stream, headers)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + return `${options ? 'https' : 'http'}://localhost:${server.address().port}`; +} +function respond(stream, body = Buffer.from([0, 0, 0, 0, 0]), status = '0') { + stream.respond( + { ':status': 200, 'content-type': 'application/grpc' }, + { waitForTrailers: true }, + ); + stream.on('wantTrailers', () => + stream.sendTrailers({ 'grpc-status': status }), + ); + // Deliberately split inside the gRPC prefix. + stream.write(body.subarray(0, 2)); + stream.end(body.subarray(2)); +} +function exporter(t, url, timeout = 500) { + const { GrpcExporter } = require('../lib/grpc'); + const client = new GrpcExporter(new URL(url), timeout); + t.after(() => client.close()); + return client; +} +const empty = Buffer.from([0, 0, 0, 0, 0]); + +test('unary gRPC uses the OTLP path, required headers and reuses its session', async (t) => { + let calls = 0; + const url = await collector(t, (stream, headers) => { + calls++; + assert.equal( + headers[':path'], + '/opentelemetry.proto.collector.trace.v1.TraceService/Export', + ); + assert.equal(headers[':method'], 'POST'); + assert.equal(headers.te, 'trailers'); + assert.equal(headers['content-type'], 'application/grpc+proto'); + assert.match(headers['grpc-timeout'], /^\d+m$/); + respond(stream); + }); + const client = exporter(t, url); + const first = await client.send(empty); + assert.equal(first.rejectedSpans, 0n); + await client.send(empty); + assert.equal(calls, 2); +}); + +for (const [name, handler, pattern] of [ + [ + 'trailers-only error', + (s) => + s.respond( + { + ':status': 200, + 'content-type': 'application/grpc', + 'grpc-status': '7', + 'grpc-message': 'access%20denied', + }, + { endStream: true }, + ), + /7.*access denied/, + ], + [ + 'missing status', + (s) => { + s.respond({ ':status': 200, 'content-type': 'application/grpc' }); + s.end(empty); + }, + /status/, + ], + [ + 'HTTP error', + (s) => { + s.respond({ ':status': 503 }); + s.end(); + }, + /503/, + ], + [ + 'wrong content type', + (s) => { + s.respond({ ':status': 200, 'content-type': 'text/html' }); + s.end(); + }, + /content.type/, + ], + ['compression', (s) => respond(s, Buffer.from([1, 0, 0, 0, 0])), /compress/], + [ + 'truncated message', + (s) => respond(s, Buffer.from([0, 0, 0, 0, 1])), + /framing/, + ], + [ + 'multiple messages', + (s) => respond(s, Buffer.concat([empty, empty])), + /framing/, + ], + ['oversized response', (s) => respond(s, Buffer.alloc(65542)), /size/], + [ + 'stream reset', + (s) => s.close(http2.constants.NGHTTP2_INTERNAL_ERROR), + /stream|closed/, + ], + ['deadline', () => {}, /timeout/], +]) { + test(`gRPC rejects ${name}`, async (t) => { + const url = await collector(t, handler); + await assert.rejects(exporter(t, url, 100).send(empty), pattern); + }); +} + +test('gRPC decodes partial success', async (t) => { + const url = await collector(t, (s) => + respond(s, Buffer.from([0, 0, 0, 0, 9, 10, 7, 8, 2, 18, 3, 98, 97, 100])), + ); + assert.deepEqual(await exporter(t, url).send(empty), { + rejectedSpans: 2n, + errorMessage: 'bad', + }); +}); + +test('queued exports share an absolute deadline and bounded byte budget', async (t) => { + const url = await collector(t, () => {}); + const client = exporter(t, url, 100); + const first = assert.rejects(client.send(empty), /timeout/); + const queued = assert.rejects(client.send(empty), /timeout/); + await assert.rejects(client.send(Buffer.alloc(16 * 1024 * 1024)), /queue/); + await Promise.all([first, queued]); +}); + +test('gRPC drops expired queued batches before opening a stream', async (t) => { + let now = 1000; + t.mock.method(Date, 'now', () => now); + let calls = 0; + const url = await collector(t, (stream) => { + calls++; + // Advance past both deadlines before completing the active RPC, + // without giving the queued batch's real timer a chance to run. + if (calls === 1) now += 1000; + respond(stream); + }); + const client = exporter(t, url, 500); + const active = client.send(empty); + const expired = assert.rejects(client.send(empty), /export timeout/); + await Promise.all([active, expired]); + assert.equal(calls, 1, 'the expired batch must never reach the collector'); + + // Discarding expired work must leave the exporter able to send new work. + await client.send(empty); + assert.equal(calls, 2); +}); + +test('gRPC backs off after UNAVAILABLE, caps the delay, and resets on success', async (t) => { + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1000 }); + let available = false; + const url = await collector(t, (stream) => + respond(stream, empty, available ? '0' : '14'), + ); + const client = exporter(t, url, 1000000); + const active = client.send(empty); + const request = t.mock.method(client.session, 'request'); + const queued = client.send(empty); + queued.catch(() => {}); + await assert.rejects(active, /status 14/); + let requests = 0; + + // These are new batches, not retries of the rejected batch. Observe real + // HTTP/2 stream creation while advancing a fake clock, without sleeps. + for (const delay of [1000, 2000, 4000, 8000, 16000, 30000, 30000]) { + const pending = requests === 0 ? queued : client.send(empty); + pending.catch(() => {}); + assert.equal(request.mock.callCount(), requests); + t.mock.timers.tick(delay - 1); + assert.equal(request.mock.callCount(), requests); + t.mock.timers.tick(1); + await assert.rejects(pending, /status 14/); + assert.equal(request.mock.callCount(), ++requests); + } + + available = true; + const recovered = client.send(empty); + t.mock.timers.tick(30000); + await recovered; + assert.equal(request.mock.callCount(), ++requests); + + available = false; + await assert.rejects(client.send(empty), /status 14/); + assert.equal(request.mock.callCount(), ++requests); + available = true; + const next = client.send(empty); + t.mock.timers.tick(999); + assert.equal(request.mock.callCount(), requests); + t.mock.timers.tick(1); + await next; + assert.equal(request.mock.callCount(), ++requests); +}); + +test('gRPC keeps deadlines and cancellation effective during overload backoff', async (t) => { + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1000 }); + const url = await collector(t, (stream) => respond(stream, empty, '14')); + const client = exporter(t, url, 100); + await assert.rejects(client.send(empty), /status 14/); + const request = t.mock.method(client.session, 'request'); + + const expired = assert.rejects(client.send(empty), /export timeout/); + t.mock.timers.tick(100); + await expired; + assert.equal(request.mock.callCount(), 0); + + const cancelled = assert.rejects(client.send(empty), /closed/); + client.close(); + await cancelled; + t.mock.timers.tick(30000); + assert.equal(request.mock.callCount(), 0); +}); + +test('gRPC reports connection failure without an unhandled error', async (t) => { + await assert.rejects( + exporter(t, 'http://127.0.0.1:1').send(empty), + /connect|closed|ECONNREFUSED/, + ); +}); + +module.exports = { collector, respond }; + +test('gRPC replaces a session after GOAWAY', async (t) => { + let calls = 0; + const url = await collector(t, (s) => { + calls++; + if (calls === 1) s.session.goaway(); + respond(s); + }); + const client = exporter(t, url); + await client.send(empty); + await client.send(empty); + assert.equal(calls, 2); +}); + +test('gRPC reconnects after a session is destroyed', async (t) => { + let calls = 0; + const url = await collector(t, (s) => { + calls++; + if (calls === 1) s.session.destroy(); + else respond(s); + }); + const client = exporter(t, url); + await assert.rejects(client.send(empty), /status|session|stream|HTTP/); + await client.send(empty); + assert.equal(calls, 2); +}); + +test('closing the exporter settles active and queued work', async (t) => { + const url = await collector(t, () => {}); + const client = exporter(t, url); + const active = assert.rejects(client.send(empty), /closed/); + const queued = assert.rejects(client.send(empty), /closed/); + client.close(); + await Promise.all([active, queued]); + await assert.rejects(client.send(empty), /closed/); +}); + +test('the gRPC queue bounds the number of pending exports as well as bytes', async (t) => { + const url = await collector(t, () => {}); + const client = exporter(t, url); + const pending = Array.from({ length: 100 }, () => + assert.rejects(client.send(empty), /closed/), + ); + await assert.rejects(client.send(empty), /queue/); + client.close(); + await Promise.all(pending); +}); diff --git a/test/test-otlp-protobuf.js b/test/test-otlp-protobuf.js new file mode 100644 index 0000000..fa579bc --- /dev/null +++ b/test/test-otlp-protobuf.js @@ -0,0 +1,320 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { before, test } = require('node:test'); +const { loadOtlp } = require('./common/otlp'); +let Request, Response, Span; +before(async () => { + ({ Request, Response, Span } = await loadOtlp()); +}); + +// Literal OTLP model, independent of the encoder's schema traversal. +function referenceSpan() { + return { + traceId: Buffer.from('ab'.repeat(16), 'hex'), + spanId: Buffer.from('cd'.repeat(8), 'hex'), + parentSpanId: Buffer.from('ef'.repeat(8), 'hex'), + traceState: 'vendor=value', + name: 'GET /日本語', + kind: 2, + flags: 1, + startTimeUnixNano: '1700000000000000000', + endTimeUnixNano: '1700000000001000000', + status: { code: 2, message: 'failed' }, + attributes: [ + { key: 'empty', value: { stringValue: '' } }, + { key: 'zero', value: { intValue: '0' } }, + { key: 'no', value: { boolValue: false } }, + { key: 'negative', value: { intValue: '-1' } }, + { key: 'fraction', value: { doubleValue: 1.5 } }, + ], + events: [ + { + name: 'exception', + timeUnixNano: '1700000000000000000', + attributes: [{ key: 'message', value: { stringValue: '💥' } }], + }, + ], + }; +} + +function referenceRequest( + spans, + resource = { attributes: [] }, + scope = { name: 'test', version: '0' }, +) { + return Buffer.from( + Request.encode( + Request.fromObject({ + resourceSpans: [{ resource, scopeSpans: [{ scope, spans }] }], + }), + ).finish(), + ); +} + +const makeSpan = () => ({ + traceId: 'ab'.repeat(16), + spanId: 'cd'.repeat(8), + parentSpanId: 'ef'.repeat(8), + name: 'GET /日本語', + kind: 2, + startTime: 1700000000000, + endTime: 1700000000001, + traceFlags: 1, + tracestate: 'vendor=value', + status: { code: 2, message: 'failed' }, + getAttributes: () => ({ + empty: '', + zero: 0, + no: false, + negative: -1, + fraction: 1.5, + unused: undefined, + }), + getEvents: () => [ + { name: 'exception', time: 1700000000000, attributes: { message: '💥' } }, + ], +}); + +test('direct OTLP encoder matches protobufjs using the official OTLP schemas', () => { + const { OtlpEncoder } = require('../lib/otlp'); + const encoder = new OtlpEncoder( + { attributes: [{ key: 'service.name', value: { stringValue: 'test' } }] }, + { name: 'microtel', version: '0' }, + ); + const frame = encoder.encode([makeSpan(), makeSpan()]); + assert.equal(frame[0], 0); + assert.equal(frame.readUInt32BE(1), frame.length - 5); + assert.deepEqual( + frame.subarray(5), + referenceRequest( + [referenceSpan(), referenceSpan()], + { attributes: [{ key: 'service.name', value: { stringValue: 'test' } }] }, + { name: 'microtel', version: '0' }, + ), + ); +}); + +test('export response decoder accepts partial success and rejects malformed protobuf', () => { + const { decodeResponse } = require('../lib/otlp'); + const bytes = Response.encode( + Response.fromObject({ + partialSuccess: { + rejectedSpans: '9007199254740993', + errorMessage: 'rejected', + }, + }), + ).finish(); + assert.deepEqual(decodeResponse(bytes), { + rejectedSpans: 9007199254740993n, + errorMessage: 'rejected', + }); + assert.deepEqual(decodeResponse(Buffer.alloc(0)), { + rejectedSpans: 0n, + errorMessage: '', + }); + for (const malformed of [[10, 20], [8, 128], [0], [15], [10, 2, 8, 128]]) { + assert.throws(() => decodeResponse(Buffer.from(malformed)), /protobuf/); + } +}); + +test('encoder validates IDs, integer range and maximum payload before allocating', () => { + const { OtlpEncoder } = require('../lib/otlp'); + const encoder = new OtlpEncoder( + { attributes: [] }, + { name: 'test', version: '0' }, + ); + assert.throws( + () => encoder.encode([{ ...makeSpan(), traceId: 'bad' }]), + /traceId/, + ); + assert.throws( + () => + encoder.encode([ + { ...makeSpan(), getAttributes: () => ({ bad: Number.MAX_VALUE }) }, + ]), + /integer/, + ); + assert.throws( + () => + encoder.encode([{ ...makeSpan(), name: 'x'.repeat(16 * 1024 * 1024) }]), + /size/, + ); +}); + +test('encoder can discard a malformed span without losing the rest of the batch', () => { + const { OtlpEncoder } = require('../lib/otlp'); + const encoder = new OtlpEncoder( + { attributes: [] }, + { name: 'test', version: '0' }, + ); + const errors = []; + const frame = encoder.encode( + [{ ...makeSpan(), traceId: 'bad' }, makeSpan()], + (err) => errors.push(err), + ); + assert.equal(errors.length, 1); + assert.deepEqual(frame.subarray(5), referenceRequest([referenceSpan()])); +}); + +test('response decoder skips unknown fields and validates all wire boundaries', () => { + const { decodeResponse } = require('../lib/otlp'); + // Unknown varint, fixed64, bytes, fixed32 fields before a known partial success. + const unknown = Buffer.from([ + 16, 1, 25, 0, 0, 0, 0, 0, 0, 0, 0, 34, 1, 99, 45, 0, 0, 0, 0, + ]); + assert.deepEqual( + decodeResponse(Buffer.concat([unknown, Buffer.from([10, 2, 8, 3])])), + { rejectedSpans: 3n, errorMessage: '' }, + ); + for (const invalid of [ + [17, 0], + [21, 0], + [8, ...Array(10).fill(128)], + [10, 1, 0], + ]) { + assert.throws(() => decodeResponse(Buffer.from(invalid)), /protobuf/); + } +}); + +test('OTLP sizes are computed explicitly before one exact-sized buffer allocation', (t) => { + const { OtlpEncoder, measureSpan } = require('../lib/otlp'); + assert.equal(typeof measureSpan, 'function'); + const span = makeSpan(); + assert.equal( + measureSpan(span), + Span.encode(Span.fromObject(referenceSpan())).finish().length, + ); + const encoder = new OtlpEncoder( + { attributes: [] }, + { name: 'test', version: '0' }, + ); + const expectedPayload = referenceRequest([referenceSpan(), referenceSpan()]); + const allocations = []; + for (const method of [ + 'alloc', + 'allocUnsafe', + 'allocUnsafeSlow', + 'from', + 'concat', + ]) { + const original = Buffer[method]; + t.mock.method(Buffer, method, (...args) => { + allocations.push({ method, size: args[0] }); + const result = Reflect.apply(original, Buffer, args); + // Make every unwritten byte visible to the independent byte-for-byte + // comparison, rather than relying on zero-filled memory. + if (method === 'allocUnsafe') result.fill(0xa5); + return result; + }); + } + const size = encoder.byteLength([span, span]); + assert.equal(size, expectedPayload.length); + assert.deepEqual(allocations, [], 'sizing must not allocate any buffers'); + const buffer = encoder.encode([span, span]); + t.mock.restoreAll(); + assert.equal(buffer.length, size + 5); + assert.deepEqual(allocations, [{ method: 'allocUnsafe', size: size + 5 }]); + assert.equal(buffer[0], 0); + assert.equal(buffer.readUInt32BE(1), expectedPayload.length); + assert.deepEqual( + buffer.subarray(5), + expectedPayload, + 'every output byte must match the independent protobufjs encoding', + ); +}); + +test('explicit sizing covers length and integer varint boundaries', () => { + const { OtlpEncoder } = require('../lib/otlp'); + const encoder = new OtlpEncoder( + { attributes: [] }, + { name: 'test', version: '0' }, + ); + for (const length of [1, 127, 128, 16383, 16384]) { + const attrs = { + false: false, + zero: 0, + empty: '', + min: Number.MIN_SAFE_INTEGER, + max: Number.MAX_SAFE_INTEGER, + small: 127, + larger: 128, + }; + const span = { + ...makeSpan(), + name: 'x'.repeat(length), + getAttributes: () => attrs, + }; + const frame = encoder.encode([span]); + assert.equal(frame.length, encoder.byteLength([span]) + 5); + const expected = referenceSpan(); + expected.name = 'x'.repeat(length); + expected.attributes = [ + { key: 'false', value: { boolValue: false } }, + { key: 'zero', value: { intValue: '0' } }, + { key: 'empty', value: { stringValue: '' } }, + { key: 'min', value: { intValue: '-9007199254740991' } }, + { key: 'max', value: { intValue: '9007199254740991' } }, + { key: 'small', value: { intValue: '127' } }, + { key: 'larger', value: { intValue: '128' } }, + ]; + assert.deepEqual(frame.subarray(5), referenceRequest([expected])); + } +}); + +test('oversized requests are rejected without allocating an output buffer', (t) => { + const { OtlpEncoder } = require('../lib/otlp'); + const encoder = new OtlpEncoder( + { attributes: [] }, + { name: 'test', version: '0' }, + ); + const span = { ...makeSpan(), name: 'x'.repeat(16 * 1024 * 1024) }; + const allocations = []; + for (const method of [ + 'alloc', + 'allocUnsafe', + 'allocUnsafeSlow', + 'from', + 'concat', + ]) { + const original = Buffer[method]; + t.mock.method(Buffer, method, (...args) => { + allocations.push(method); + return Reflect.apply(original, Buffer, args); + }); + } + assert.throws(() => encoder.encode([span]), /size/); + t.mock.restoreAll(); + assert.deepEqual(allocations, []); +}); + +test('sizing rejects invalid timestamps before writing and isolates malformed spans', () => { + const { OtlpEncoder } = require('../lib/otlp'); + const encoder = new OtlpEncoder( + { attributes: [] }, + { name: 'test', version: '0' }, + ); + const errors = []; + const buffer = encoder.encode( + [{ ...makeSpan(), startTime: NaN }, makeSpan()], + (err) => errors.push(err), + ); + assert.equal(errors.length, 1); + assert.deepEqual(buffer.subarray(5), referenceRequest([referenceSpan()])); +}); + +test('resource attributes preserve boolean and double values', () => { + const { OtlpEncoder } = require('../lib/otlp'); + const resource = { + attributes: [ + { key: 'enabled', value: { boolValue: true } }, + { key: 'ratio', value: { doubleValue: 1.5 } }, + { key: 'large', value: { doubleValue: 1e30 } }, + ], + }; + const scope = { name: 'test', version: '0' }; + assert.deepEqual( + new OtlpEncoder(resource, scope).encode([makeSpan()]).subarray(5), + referenceRequest([referenceSpan()], resource, scope), + ); +}); diff --git a/test/test-protobuf.js b/test/test-protobuf.js new file mode 100644 index 0000000..a4763fa --- /dev/null +++ b/test/test-protobuf.js @@ -0,0 +1,116 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const protobuf = require('protobufjs'); +const { + measureInteger, + measureVarint, + writeInteger, + writeVarint, +} = require('../lib/protobuf'); + +test('negative Number integers write without constructing BigInts', (t) => { + const values = [-1, -128, -129, -(2 ** 32), Number.MIN_SAFE_INTEGER]; + const expected = values.map((value) => + protobuf.Writer.create().uint32(8).int64(String(value)).finish(), + ); + const buffer = Buffer.alloc(16); + const bigint = t.mock.method(globalThis, 'BigInt'); + for (let i = 0; i < values.length; i++) { + const end = writeInteger(buffer, 0, 8, values[i]); + assert.deepEqual(buffer.subarray(0, end), expected[i]); + } + assert.equal(bigint.mock.callCount(), 0); +}); + +test('64-bit varints match protobufjs at seven-bit and word boundaries', () => { + const values = new Set([0n, (1n << 64n) - 1n]); + for (const bits of [7, 14, 21, 28, 32, 35, 42, 49, 53, 56, 63]) { + for (const delta of [-1n, 0n, 1n]) values.add((1n << BigInt(bits)) + delta); + } + for (const value of values) { + const buffer = Buffer.alloc(16, 0xa5); + const end = writeVarint(buffer, 2, value); + assert.deepEqual( + buffer.subarray(2, end), + protobuf.Writer.create().uint64(String(value)).finish(), + ); + assert.equal(end - 2, measureVarint(value)); + assert.equal(buffer[1], 0xa5); + assert.equal(buffer[end], 0xa5); + } +}); + +test('signed integers match protobufjs for limits and deterministic random values', () => { + const values = [ + 0, + -0, + 127, + 128, + 2 ** 32 - 1, + 2 ** 32, + -(2 ** 32), + Number.MAX_SAFE_INTEGER, + Number.MIN_SAFE_INTEGER, + (1n << 63n) - 1n, + -(1n << 63n), + ]; + let seed = 0x74c3; + function word() { + seed ^= seed << 13; + seed ^= seed >>> 17; + seed ^= seed << 5; + return seed >>> 0; + } + for (let i = 0; i < 500; i++) { + const n = (word() & 0x1fffff) * 2 ** 32 + word(); + values.push( + n, + -n, + BigInt.asIntN(64, (BigInt(word()) << 32n) | BigInt(word())), + ); + } + for (const value of values) { + const buffer = Buffer.alloc(16, 0xa5); + const end = writeInteger(buffer, 2, 8, value); + assert.deepEqual( + buffer.subarray(2, end), + protobuf.Writer.create().uint32(8).int64(String(value)).finish(), + ); + assert.equal(end - 2, measureInteger(value)); + assert.equal(buffer[1], 0xa5); + assert.equal(buffer[end], 0xa5); + } + for (const value of [ + 1.5, + NaN, + Infinity, + Number.MAX_SAFE_INTEGER + 1, + Number.MIN_SAFE_INTEGER - 1, + 1n << 63n, + -(1n << 63n) - 1n, + ]) { + assert.throws( + () => writeInteger(Buffer.alloc(16), 0, 8, value), + RangeError, + ); + } +}); + +test('varint sizes match protobufjs across all ten byte lengths', () => { + const values = [0n, (1n << 64n) - 1n]; + for (let bits = 7; bits < 64; bits += 7) { + const limit = 1n << BigInt(bits); + values.push(limit - 1n, limit, limit + 1n); + } + for (const value of values) { + const expected = protobuf.Writer.create() + .uint64(String(value)) + .finish().length; + assert.equal(measureVarint(value), expected); + if (value <= BigInt(Number.MAX_SAFE_INTEGER)) { + assert.equal(measureVarint(Number(value)), expected); + } + } +});