Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 46 additions & 0 deletions lib/core.js

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: Here is the opentelemetry-js PR that added support for an async function to get the headers for an export: open-telemetry/opentelemetry-js#5994
It links to two issues. The main use case is for an endpoint that uses OAuth2 and needs to periodically refresh its auth token.

Just an FYI, in case you want to consider this now, but I suspect this could be added separately, if at all.
I don't have a good sense where to draw limits on configuring exporters. There is a long string of issues to OTel JS for this. mTLS support. Tweaking timeouts. keepAlive settings. Etc.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll leave a TODO here, since this will likely come up.

Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const { AsyncLocalStorage } = require('node:async_hooks');
const { validateHeaderName, validateHeaderValue } = require('node:http');

const { configureSampler } = require('./sampler');
const {
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand All @@ -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');
Expand All @@ -168,4 +213,5 @@ module.exports = {
getServiceName: () => serviceName,
getSdkName: () => sdkName,
getSdkVersion: () => sdkVersion,
getExportHeaders: () => exportHeaders,
};
2 changes: 2 additions & 0 deletions lib/flush.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const { clearTimeout, setImmediate, setTimeout } = require('node:timers');

const {
getEndpoint,
getExportHeaders,
getExportUrl,
getServiceName,
getSdkName,
Expand Down Expand Up @@ -256,6 +257,7 @@ function sendToCollector(body) {
method: 'POST',
agent: kExportAgents[parsed.protocol],
headers: {
...getExportHeaders(),
'content-type': 'application/json',
'content-length': encoded.length,
},
Expand Down
10 changes: 9 additions & 1 deletion test/fixtures/traced-request-child.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions test/test-exporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const {
fixture,
httpGet,
startHTTPServer,
startOTelCollector,
spawnMicrotel,
} = require('./common/otel');

Expand Down Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions test/test-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() => {
Expand Down
Loading