diff --git a/README.md b/README.md index f7457b9..77925fc 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ started. Invalid options throw. | `flushInterval` | number | `10000` | Milliseconds between periodic flushes. Must be a positive integer. | | `exportTimeout` | number | `10000` | Milliseconds to wait for a collector response before an export is failed. Must be a positive integer. | | `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. | ### Exported functions diff --git a/lib/core.js b/lib/core.js index 55db3e4..bebca4a 100644 --- a/lib/core.js +++ b/lib/core.js @@ -1,6 +1,7 @@ 'use strict'; const { AsyncLocalStorage } = require('node:async_hooks'); +const { validateHeaderName, validateHeaderValue } = require('node:http'); const { configureSampler } = require('./sampler'); const { @@ -26,6 +27,7 @@ let exportUrl = null; // The collector's full export URL, fixed after start. let serviceName = kDefaultServiceName; let sdkName = kDefaultSdkName; let sdkVersion = null; +let exportHeaders = null; // Copied at init, sent with every export request. function getSpanStorage() { spanStorage ??= new AsyncLocalStorage(); @@ -63,6 +65,46 @@ function parseFilter(name, filter) { throw invalidArgTypeError(name, ['string', 'null', 'undefined'], filter); } +// Header names the exporter itself sets or depends on, refused at any +// letter case. content-type and content-length are set on each export, +// host is matched against the collector's for self-trace suppression, and +// transfer-encoding, trailer, and connection belong to the framing and the +// keep-alive agent. Node refuses to send a trailer next to content-length. +const kReservedHeaders = new Set([ + 'content-type', + 'content-length', + 'host', + 'transfer-encoding', + 'trailer', + 'connection', +]); + +// Validates and copies the configured export headers, so a caller changing +// its object after init() has no effect. +// TODO(bengl): Accept a function for headers, called before each export and +// allowed to return a promise, so credentials that expire, such as OAuth2 +// access tokens, can be refreshed while the process runs. +function parseHeaders(headers) { + if (headers === undefined) return null; + validateObject(headers, 'options.headers'); + const copy = { __proto__: null }; + for (const name of Object.keys(headers)) { + const value = headers[name]; + validateHeaderName(name); + if (kReservedHeaders.has(name.toLowerCase())) { + throw invalidArgValueError( + `options.headers['${name}']`, + value, + 'is a header microtel sets itself', + ); + } + validateString(value, `options.headers['${name}']`); + validateHeaderValue(name, value); + copy[name] = value; + } + return copy; +} + function optionalString(options, name, fallback) { const value = options[name]; if (value === undefined) return fallback; @@ -124,6 +166,8 @@ function start(options = { __proto__: null }) { if (options.sampleRatio !== undefined) validateNumber(options.sampleRatio, 'options.sampleRatio', 0, 1); + const parsedHeaders = parseHeaders(options.headers); + const serviceNameValue = optionalString( options, 'serviceName', @@ -148,6 +192,7 @@ function start(options = { __proto__: null }) { serviceName = serviceNameValue; sdkName = sdkNameValue; sdkVersion = sdkVersionValue; + exportHeaders = parsedHeaders; configureSampler(options.sampleRatio ?? 1); const { enableInstrumentations } = require('./instrumentations'); @@ -168,4 +213,5 @@ module.exports = { getServiceName: () => serviceName, getSdkName: () => sdkName, getSdkVersion: () => sdkVersion, + getExportHeaders: () => exportHeaders, }; diff --git a/lib/flush.js b/lib/flush.js index 46e84c4..b9122ae 100644 --- a/lib/flush.js +++ b/lib/flush.js @@ -7,6 +7,7 @@ const { clearTimeout, setImmediate, setTimeout } = require('node:timers'); const { getEndpoint, + getExportHeaders, getExportUrl, getServiceName, getSdkName, @@ -256,6 +257,7 @@ function sendToCollector(body) { method: 'POST', agent: kExportAgents[parsed.protocol], headers: { + ...getExportHeaders(), 'content-type': 'application/json', 'content-length': encoded.length, }, diff --git a/test/fixtures/traced-request-child.js b/test/fixtures/traced-request-child.js index a2ec893..54d9d52 100644 --- a/test/fixtures/traced-request-child.js +++ b/test/fixtures/traced-request-child.js @@ -7,7 +7,15 @@ const { init, flush } = require(process.env.MICROTEL_PATH); const http = require('node:http'); -init(JSON.parse(process.argv[2])); +const options = JSON.parse(process.argv[2]); +init(options); + +// The headers were copied at init(), so mutations afterwards must not +// reach the export. +if (options.headers) { + options.headers['x-api-key'] = 'mutated'; + options.headers['x-extra'] = 'added'; +} const server = http.createServer((req, res) => { res.writeHead(200); diff --git a/test/test-exporter.js b/test/test-exporter.js index fec14ed..9f9a2ef 100644 --- a/test/test-exporter.js +++ b/test/test-exporter.js @@ -8,6 +8,7 @@ const { fixture, httpGet, startHTTPServer, + startOTelCollector, spawnMicrotel, } = require('./common/otel'); @@ -123,6 +124,32 @@ describe('otel exporter behavior', () => { } }); + it('sends the configured headers to the collector', async () => { + // The child mutates its headers object after init(), so the collector + // seeing the init-time values proves both the delivery and the copy. + const collector = await startOTelCollector(); + + const { code, stdout } = await spawnMicrotel([ + fixture('traced-request-child.js'), + JSON.stringify({ + endpoint: `http://127.0.0.1:${collector.port}`, + headers: { 'x-api-key': 'secret', 'x-tenant': 'bengl' }, + }), + ]); + + assert.strictEqual(code, 0); + assert.match(stdout, /done/); + + await collector.next(); + const { headers } = collector.requests[0]; + assert.strictEqual(headers['x-api-key'], 'secret'); + assert.strictEqual(headers['x-tenant'], 'bengl'); + assert.strictEqual(headers['x-extra'], undefined); + assert.strictEqual(headers['content-type'], 'application/json'); + + await collector.close(); + }); + it('flush timer does not keep process alive', async () => { // No spans are buffered, so no flush timer exists: the child must exit // as soon as its script ends. diff --git a/test/test-init.js b/test/test-init.js index 769cf80..86892d3 100644 --- a/test/test-init.js +++ b/test/test-init.js @@ -96,6 +96,42 @@ describe('otel init validation', () => { ); }); + it('throws on invalid headers', () => { + const initHeaders = (headers) => + microtel.init({ endpoint: 'http://127.0.0.1:1', headers }); + + assert.throws(() => initHeaders('x-api-key=secret'), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => initHeaders(['x-api-key']), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => initHeaders({ 'bad name': 'secret' }), { + code: 'ERR_INVALID_HTTP_TOKEN', + }); + assert.throws(() => initHeaders({ 'x-api-key': 'a\nb' }), { + code: 'ERR_INVALID_CHAR', + }); + assert.throws(() => initHeaders({ 'x-api-key': 42 }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + + // Names microtel sets or depends on itself are refused at any letter + // case. + for (const name of [ + 'content-type', + 'Content-Length', + 'HOST', + 'Transfer-Encoding', + 'Trailer', + 'connection', + ]) { + assert.throws(() => initHeaders({ [name]: 'x' }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + } + }); + it('throws on an invalid filter type', () => { assert.throws( () => {