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();