From a7db61b2e20f2473e9c4cc04fa8cd4cc9d753402 Mon Sep 17 00:00:00 2001 From: Bryan English Date: Tue, 3 Nov 2026 13:25:35 -0500 Subject: [PATCH] feat: add a resourceAttributes option and two default attributes Previously, the resource microtel exports held only the service name, the SDK, and the process, and there was no way to add attributes such as service.version, which backends group and filter by. Now init() takes resourceAttributes, an object of string, boolean, or finite number values, copied at init. A user value replaces microtel's value for its key. service.name and the telemetry.sdk.* keys are refused, since microtel sets them and the serviceName, sdkName, and sdkVersion options change them. Empty keys are refused too. Integers past the safe range now export as doubleValue, for span attributes as well. As intValue, which OTLP writes as a decimal string, a number like 1e21 came out as "1e+21". The resource also gains two defaults: service.instance.id, a random UUID made once per process, and host.name from os.hostname(). Both can be replaced through resourceAttributes. Signed-off-by: Bryan English --- README.md | 8 ++ lib/core.js | 55 ++++++++++ lib/flush.js | 57 ++++++++--- test/common/otel.js | 5 +- test/fixtures/resource-attrs-child.js | 35 +++++++ test/test-init.js | 142 ++++++++++++++++++++++++++ 6 files changed, 286 insertions(+), 16 deletions(-) create mode 100644 test/fixtures/resource-attrs-child.js diff --git a/README.md b/README.md index 77925fc..d20ca10 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ started. Invalid options throw. | `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. | +| `resourceAttributes` | object | none | Additional attributes in the exported resource, replacing microtel's values for the same keys. Values must be strings, booleans, or finite numbers. `service.name`, `telemetry.sdk.name`, and `telemetry.sdk.version` are refused: use `serviceName`, `sdkName`, and `sdkVersion` instead. Other `telemetry.sdk.*` keys are refused because microtel sets them. The object is copied at init, so changing it afterwards has no effect. | | `filter` | string | all modules | Comma-separated list of modules to instrument. Supported values: `node:http`, `node:http/client`, `node:http/server`, `node:http2`, `node:http2/server`, and `node:undici`. `node:http` covers `node:http/client` and `node:http/server`; `node:http2` covers `node:http2/server`. `node:fetch` is an alias for `node:undici`: both cover all undici requests, including `fetch()`, and either one enables them. | | `exclude` | string | none | Comma-separated list of modules to exclude from instrumentation. Same values as `filter`; takes precedence over it. | | `maxBufferSize` | number | `100` | Number of spans buffered before an immediate flush is triggered. Must be a positive integer. | @@ -55,6 +56,13 @@ started. Invalid options throw. | `sampleRatio` | number | `1` | Probability, from 0 to 1, that a trace this process starts is sampled. Requests that arrive with a `traceparent` keep its decision instead. See [Sampling](#sampling). | | `headers` | object | none | Header names mapped to string values, sent with every export request, such as an API key a hosted backend requires. `content-type`, `content-length`, `host`, `transfer-encoding`, `trailer`, and `connection` are refused at any letter case, since microtel manages them itself. The object is copied at init. | +Every exported span shares one resource. It carries the service identity, a +per-process random `service.instance.id` UUID, `host.name` from +`os.hostname()`, the SDK identity, and the process attributes +(`process.runtime.*` and `process.pid`). `resourceAttributes` adds to it, and +can replace any of microtel's values except `service.name` and the +`telemetry.sdk.*` keys. + ### Exported functions * `init(options)` activates tracing. Returns nothing. Throws on invalid options. diff --git a/lib/core.js b/lib/core.js index bebca4a..de1f576 100644 --- a/lib/core.js +++ b/lib/core.js @@ -1,5 +1,6 @@ 'use strict'; +const { randomUUID } = require('node:crypto'); const { AsyncLocalStorage } = require('node:async_hooks'); const { validateHeaderName, validateHeaderValue } = require('node:http'); @@ -28,6 +29,8 @@ let serviceName = kDefaultServiceName; let sdkName = kDefaultSdkName; let sdkVersion = null; let exportHeaders = null; // Copied at init, sent with every export request. +let resourceAttributes = null; // A copy of options.resourceAttributes, made at init. +let instanceId = null; // Random per-process identity, made at init. function getSpanStorage() { spanStorage ??= new AsyncLocalStorage(); @@ -105,6 +108,51 @@ function parseHeaders(headers) { return copy; } +// Resource keys that other options set. They are refused here, so each key +// has one place to be set. +const kOptionResourceKeys = { + __proto__: null, + 'service.name': 'options.serviceName', + 'telemetry.sdk.name': 'options.sdkName', + 'telemetry.sdk.version': 'options.sdkVersion', +}; + +// Validates and copies options.resourceAttributes, so changes to the +// caller's object after init have no effect. +function validateResourceAttributes(attrs) { + if (attrs === undefined) return null; + validateObject(attrs, 'options.resourceAttributes'); + const copy = { __proto__: null }; + for (const key of Object.keys(attrs)) { + const name = `options.resourceAttributes['${key}']`; + const value = attrs[key]; + if (key === '') + throw invalidArgValueError( + 'options.resourceAttributes', + attrs, + 'must not have an empty key', + ); + const option = kOptionResourceKeys[key]; + if (option !== undefined) + throw invalidArgValueError(name, value, `is set through ${option}`); + if (key.startsWith('telemetry.sdk.')) + throw invalidArgValueError(name, value, 'is set by microtel'); + switch (typeof value) { + case 'string': + case 'boolean': + break; + case 'number': + if (!Number.isFinite(value)) + throw invalidArgValueError(name, value, 'must be a finite number'); + break; + default: + throw invalidArgTypeError(name, ['string', 'boolean', 'number'], value); + } + copy[key] = value; + } + return copy; +} + function optionalString(options, name, fallback) { const value = options[name]; if (value === undefined) return fallback; @@ -182,6 +230,9 @@ function start(options = { __proto__: null }) { const parsedFilter = parseFilter('options.filter', options.filter); const parsedExclude = parseFilter('options.exclude', options.exclude); + const parsedResourceAttributes = validateResourceAttributes( + options.resourceAttributes, + ); endpoint = endpointValue; filter = parsedFilter; @@ -193,6 +244,8 @@ function start(options = { __proto__: null }) { sdkName = sdkNameValue; sdkVersion = sdkVersionValue; exportHeaders = parsedHeaders; + resourceAttributes = parsedResourceAttributes; + instanceId = randomUUID(); configureSampler(options.sampleRatio ?? 1); const { enableInstrumentations } = require('./instrumentations'); @@ -214,4 +267,6 @@ module.exports = { getSdkName: () => sdkName, getSdkVersion: () => sdkVersion, getExportHeaders: () => exportHeaders, + getResourceAttributes: () => resourceAttributes, + getInstanceId: () => instanceId, }; diff --git a/lib/flush.js b/lib/flush.js index b9122ae..4ef0b60 100644 --- a/lib/flush.js +++ b/lib/flush.js @@ -3,12 +3,15 @@ const { Buffer } = require('node:buffer'); const http = require('node:http'); const https = require('node:https'); +const { hostname } = require('node:os'); const { clearTimeout, setImmediate, setTimeout } = require('node:timers'); const { getEndpoint, getExportHeaders, getExportUrl, + getInstanceId, + getResourceAttributes, getServiceName, getSdkName, getSdkVersion, @@ -44,21 +47,42 @@ let lastExportWarningTime = 0; let cachedResource = null; let cachedScope = null; -function getResource() { - cachedResource ??= { - attributes: [ - { key: 'service.name', value: { stringValue: getServiceName() } }, - { key: 'telemetry.sdk.name', value: { stringValue: getSdkName() } }, - { key: 'telemetry.sdk.language', value: { stringValue: 'nodejs' } }, - { key: 'telemetry.sdk.version', value: { stringValue: getSdkVersion() } }, - { key: 'process.runtime.name', value: { stringValue: 'nodejs' } }, - { - key: 'process.runtime.version', - value: { stringValue: process.version }, - }, - { key: 'process.pid', value: { intValue: String(process.pid) } }, - ], +// A user value replaces microtel's default for the same key, so each key +// appears once. User keys microtel does not set are appended. +function buildResource() { + const values = { + __proto__: null, + 'service.name': getServiceName(), + 'service.instance.id': getInstanceId(), + 'host.name': hostname(), + 'telemetry.sdk.name': getSdkName(), + 'telemetry.sdk.language': 'nodejs', + 'telemetry.sdk.version': getSdkVersion(), + 'process.runtime.name': 'nodejs', + 'process.runtime.version': process.version, + 'process.pid': process.pid, }; + const userValues = getResourceAttributes(); + const keys = Object.keys(values); + if (userValues != null) { + for (const key of Object.keys(userValues)) { + values[key] = userValues[key]; + // Keys microtel also sets are already in the list. + if (!keys.includes(key)) keys.push(key); + } + } + const attributes = []; + for (let i = 0; i < keys.length; i++) { + attributes.push({ + key: keys[i], + value: encodeAttributeValue(values[keys[i]]), + }); + } + return { attributes }; +} + +function getResource() { + cachedResource ??= buildResource(); return cachedResource; } @@ -74,8 +98,11 @@ function encodeAttributeValue(value) { switch (typeof value) { case 'string': return { stringValue: value }; + // OTLP writes an int64 as a decimal string. Integers past the safe range + // print in exponent form or lose their exact value, so they export as + // doubles. case 'number': - return Number.isInteger(value) + return Number.isSafeInteger(value) ? { intValue: String(value) } : { doubleValue: value }; case 'boolean': diff --git a/test/common/otel.js b/test/common/otel.js index 950b050..b511eae 100644 --- a/test/common/otel.js +++ b/test/common/otel.js @@ -177,7 +177,10 @@ function spanAttrs(span) { const attrs = {}; for (const a of span.attributes ?? []) { attrs[a.key] = - a.value.stringValue || a.value.intValue || a.value.doubleValue; + a.value.stringValue ?? + a.value.intValue ?? + a.value.doubleValue ?? + a.value.boolValue; } return attrs; } diff --git a/test/fixtures/resource-attrs-child.js b/test/fixtures/resource-attrs-child.js new file mode 100644 index 0000000..82560cc --- /dev/null +++ b/test/fixtures/resource-attrs-child.js @@ -0,0 +1,35 @@ +'use strict'; + +// Spawned with JSON init options as argv[2]: activates tracing, mutates +// the options object to prove init copied the resource attributes, serves +// two traced requests from its own HTTP server with a flush after each, +// and exits. + +const { init, flush } = require(process.env.MICROTEL_PATH); +const http = require('node:http'); + +const options = JSON.parse(process.argv[2]); +init(options); +options.resourceAttributes ??= {}; +options.resourceAttributes['service.version'] = 'mutated'; + +const server = http.createServer((req, res) => { + res.writeHead(200); + res.end('ok'); +}); +server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + for (const path of ['/one', '/two']) { + await new Promise((resolve) => { + http.get(`http://127.0.0.1:${port}${path}`, (res) => { + res.resume(); + res.on('end', resolve); + }); + }); + flush(); + } + server.close(() => { + http.globalAgent.destroy(); + console.log('done'); + }); +}); diff --git a/test/test-init.js b/test/test-init.js index 86892d3..a26e21f 100644 --- a/test/test-init.js +++ b/test/test-init.js @@ -2,6 +2,7 @@ const assert = require('node:assert'); const { spawn } = require('node:child_process'); +const os = require('node:os'); const { describe, it } = require('node:test'); const { MICROTEL_PATH, @@ -141,6 +142,57 @@ describe('otel init validation', () => { ); }); + it('throws on an invalid resourceAttributes', () => { + const initWith = (resourceAttributes) => () => + microtel.init({ endpoint: 'http://127.0.0.1:1', resourceAttributes }); + + assert.throws(initWith('service.version=1.2.3'), { + code: 'ERR_INVALID_ARG_TYPE', + }); + for (const [attributes, code, text] of [ + [ + { 'service.version': null }, + 'ERR_INVALID_ARG_TYPE', + "options.resourceAttributes['service.version']", + ], + [{ ratio: NaN }, 'ERR_INVALID_ARG_VALUE', 'must be a finite number'], + [{ ratio: Infinity }, 'ERR_INVALID_ARG_VALUE', 'must be a finite number'], + [ + { ratio: -Infinity }, + 'ERR_INVALID_ARG_VALUE', + 'must be a finite number', + ], + [{ '': 'x' }, 'ERR_INVALID_ARG_VALUE', 'must not have an empty key'], + // Keys that other options set have one place to be set. + [ + { 'service.name': 'x' }, + 'ERR_INVALID_ARG_VALUE', + 'is set through options.serviceName', + ], + [ + { 'telemetry.sdk.name': 'x' }, + 'ERR_INVALID_ARG_VALUE', + 'is set through options.sdkName', + ], + [ + { 'telemetry.sdk.version': 'x' }, + 'ERR_INVALID_ARG_VALUE', + 'is set through options.sdkVersion', + ], + [ + { 'telemetry.sdk.language': 'x' }, + 'ERR_INVALID_ARG_VALUE', + 'is set by microtel', + ], + ]) { + assert.throws(initWith(attributes), (err) => { + assert.strictEqual(err.code, code); + assert.ok(err.message.includes(text), err.message); + return true; + }); + } + }); + it('throws on an unsupported endpoint protocol', () => { assert.throws( () => { @@ -462,6 +514,96 @@ describe('otel init activation', () => { assert.ok(spans.length > 0, 'Expected at least one span'); }); + it('exports the default service.instance.id and host.name, stable across exports', async () => { + const collector = await startOTelCollector(); + + const { code, stdout } = await spawnMicrotel([ + fixture('resource-attrs-child.js'), + JSON.stringify({ endpoint: `http://127.0.0.1:${collector.port}` }), + ]); + + assert.strictEqual(code, 0); + assert.match(stdout, /done/); + + const first = spanAttrs((await collector.next()).resourceSpans[0].resource); + const second = spanAttrs( + (await collector.next()).resourceSpans[0].resource, + ); + await collector.close(); + + const uuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + assert.match(first['service.instance.id'], uuid); + assert.strictEqual( + second['service.instance.id'], + first['service.instance.id'], + ); + assert.strictEqual(first['host.name'], os.hostname()); + assert.strictEqual(second['host.name'], os.hostname()); + }); + + it('exports user resource attributes, replacing microtel defaults', async () => { + const collector = await startOTelCollector(); + + const { code, stdout } = await spawnMicrotel([ + fixture('resource-attrs-child.js'), + JSON.stringify({ + endpoint: `http://127.0.0.1:${collector.port}`, + resourceAttributes: { + 'service.version': '1.2.3', + 'deployment.environment.name': 'production', + 'feature.flag': true, + 'feature.other': false, + replicas: 3, + zero: 0, + ratio: 1.5, + big: 1e21, + unsafe: 2 ** 53, + 'host.name': 'my-host', + 'process.pid': 'not-a-pid', + }, + }), + ]); + + assert.strictEqual(code, 0); + assert.match(stdout, /done/); + + const payload = await collector.next(); + await collector.close(); + + const resource = payload.resourceSpans[0].resource; + // The fixture changes service.version after init(), and the export + // keeps what init() saw. + assert.partialDeepStrictEqual(spanAttrs(resource), { + 'service.version': '1.2.3', + 'deployment.environment.name': 'production', + 'feature.flag': true, + replicas: '3', + ratio: 1.5, + 'host.name': 'my-host', + 'process.pid': 'not-a-pid', + }); + + // Values are encoded the way span attributes are. Integers past the + // safe range export as doubles, since an int64 string could not hold + // them exactly. + const find = (key) => resource.attributes.find((a) => a.key === key); + for (const [key, value] of [ + ['replicas', { intValue: '3' }], + ['zero', { intValue: '0' }], + ['ratio', { doubleValue: 1.5 }], + ['big', { doubleValue: 1e21 }], + ['unsafe', { doubleValue: 2 ** 53 }], + ['feature.flag', { boolValue: true }], + ['feature.other', { boolValue: false }], + ]) + assert.deepStrictEqual(find(key).value, value, key); + + // User values replace the defaults, so no key appears twice. + const keys = resource.attributes.map((a) => a.key); + assert.strictEqual(new Set(keys).size, keys.length); + }); + it('appends /v1/traces to the endpoint path', async () => { const collector = await startOTelCollector();