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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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.
Expand Down
55 changes: 55 additions & 0 deletions lib/core.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';

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

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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');
Expand All @@ -214,4 +267,6 @@ module.exports = {
getSdkName: () => sdkName,
getSdkVersion: () => sdkVersion,
getExportHeaders: () => exportHeaders,
getResourceAttributes: () => resourceAttributes,
getInstanceId: () => instanceId,
};
57 changes: 42 additions & 15 deletions lib/flush.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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':
Expand Down
5 changes: 4 additions & 1 deletion test/common/otel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
35 changes: 35 additions & 0 deletions test/fixtures/resource-attrs-child.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading