Skip to content
Open
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
62 changes: 54 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,50 @@ OpenTelemetry SDKs export them, so a collector sees the threshold and flags
too. The span flags also record whether each span's parent arrived from
another process.

## Working with the OpenTelemetry SDK

microtel can run next to the official OpenTelemetry SDK for Node.js. When the
SDK has registered a context manager that supports `attach()`, as
`@opentelemetry/context-async-hooks` 2.11.0 and later do, the two share one
context. Spans the SDK starts while microtel traces a request become children of
microtel's server span, and the client spans microtel creates become children
of whatever SDK span is current. The same holds when the SDK's HTTP
instrumentation traces inbound requests and microtel traces the outbound ones.
This needs no configuration and no dependency on `@opentelemetry/api`. Without
such a context manager, microtel keeps its own context, and the two trace
separately.

While microtel traces a request, `trace.getActiveSpan()` returns its server
span. Attributes, events, status, exceptions, and name changes made through it
land on that span until the span ends, following the SDK's rules for status
and exceptions. As in the SDK, a span whose trace isn't sampled records none
of them, and its `isRecording()` returns false. A span keeps at most 128 events,
the SDK's default limit.
Calls to `end()` are ignored, since microtel ends its own spans, and links are
dropped. Requests made while the SDK suppresses tracing, such as its own
exports, are not traced.

Each side propagates trace context the way it normally does. microtel forwards
the `baggage` of requests it received itself, so baggage that the SDK extracted
or set is not added to the requests microtel traces.

Only one of them should trace each kind of request, or the request gets two
spans. Turn off the SDK's `http` and `undici` instrumentations, for example
through `getNodeAutoInstrumentations()`:

```js
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': { enabled: false },
'@opentelemetry/instrumentation-undici': { enabled: false },
});
```

Or turn off microtel's with the `exclude` option. The two can also split the
work: the SDK's HTTP instrumentation can trace only inbound requests, with
`disableOutgoingRequestInstrumentation: true`, while microtel excludes
`node:http/server`. Each exports its own spans, so give both the same service
name.

## Benchmarks

The benchmarks use the experimental `node:bench` runner, which is also what
Expand All @@ -310,14 +354,16 @@ batches of spans, with each flush awaited through the end of its export.

## Current limitations

The tracing subsystem is independent of the OpenTelemetry JavaScript packages. It
is not interoperable with `@opentelemetry/api`, and spans created by one are not
visible to the other. Running both in the same process produces duplicate trace
and span IDs. The built-in instrumentation also overwrites the `traceparent`
(and, when present, `tracestate`) header on outgoing HTTP requests, and
replaces the `baggage` header when the span carries one, discarding whatever a
userland propagator may have set. Users should run either this package or a
userland OpenTelemetry SDK, not both.
microtel does not implement the OpenTelemetry API itself. Spans created through
`@opentelemetry/api` are recorded only when the OpenTelemetry SDK is running
too, and then by the SDK. See
[Working with the OpenTelemetry SDK](#working-with-the-opentelemetry-sdk) for
how the two share context. The built-in instrumentation overwrites the
`traceparent` (and, when present, `tracestate`) header on outgoing HTTP
requests, and replaces the `baggage` header when the span carries one,
discarding whatever a userland propagator may have set. The SDK's framework
instrumentations, such as its Express instrumentation, do not yet add routes to
microtel's server spans.

`init()` activates tracing in the thread that calls it. Worker threads
(`node:worker_threads`) are not traced unless they call `init()` themselves.
Expand Down
264 changes: 264 additions & 0 deletions lib/context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
'use strict';

// Tracks the current span. When the OpenTelemetry SDK runs in the same
// process, the current span lives in the SDK's context store instead of
// microtel's own, so spans from either side parent spans from the other.
// The OpenTelemetry API registers its parts on a global and names its
// context keys with Symbol.for, so this needs no dependency on it.

const { AsyncLocalStorage } = require('node:async_hooks');

const { STATUS_ERROR, STATUS_UNSET } = require('./span');

const kOtelApi = Symbol.for('opentelemetry.js.api.1');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

question for the OTEL team, how likely is this to change?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is very unlikely to change in @opentelemetry/api@^1.0.0.
Future major versions may do something else, but there's nothing in the pipeline yet.

const kSpanKey = Symbol.for('OpenTelemetry Context Key SPAN');
const kSuppressTracingKey = Symbol.for(
'OpenTelemetry SDK Context Key SUPPRESS_TRACING',
);

const kTraceId = /^(?!0{32})[0-9a-f]{32}$/;
const kSpanId = /^(?!0{16})[0-9a-f]{16}$/;

// Returned by activeParent() when the SDK has asked for no tracing, as its
// exporters do for their own requests.
const kSuppressed = Symbol('kSuppressed');

// microtel's span rides along in the context next to its bridge. The SDK's
// child contexts keep it, so a client span under an SDK span still inherits
// the baggage microtel received.
const kMicrotelSpan = Symbol('kMicrotelSpan');

const storage = new AsyncLocalStorage();

// The handlers run in diagnostics_channel subscribers, before the code they
// trace, so they cannot wrap it in the SDK's with(). Sharing needs a context
// manager that can also take a context imperatively, through attach().
function sdkContextManager() {
const manager = globalThis[kOtelApi]?.context;
return typeof manager?.attach === 'function' ? manager : undefined;
}

// The parent for a new client span: a microtel span, the span context of an
// SDK span, or undefined for none.
function activeParent() {
const manager = sdkContextManager();
if (manager === undefined) return storage.getStore();

const context = manager.active();
if (context.getValue(kSuppressTracingKey) === true) return kSuppressed;

const span = context.getValue(kSpanKey);
if (span === undefined) return undefined;
if (span instanceof BridgeSpan) return span.span;

const { traceId, spanId, traceFlags, traceState, isRemote } =
span.spanContext();
if (!kTraceId.test(traceId) || !kSpanId.test(spanId)) return undefined;
return {
traceId,
spanId,
traceFlags,
isRemote: isRemote === true,
tracestate: traceState?.serialize() || undefined,
baggage: context.getValue(kMicrotelSpan)?.baggage,
};
}

// Makes a span current for the rest of the async chain it was created in,
// including the request handler that runs next.
function enterSpan(span) {
const manager = sdkContextManager();
if (manager === undefined) {
storage.enterWith(span);
return;
}
// TODO(bengl): The SDK's framework instrumentations, such as Express,
// report the matched route through an RPC metadata object they look up in
// this context. Adding one here, and reading its route when the response
// finishes, would give server spans http.route and a route in their name.
manager.attach(
new Context(
new Map([
[kSpanKey, new BridgeSpan(span)],
[kMicrotelSpan, span],
]),
),
);
}

// A minimal OpenTelemetry Context. Each server request starts from an empty
// one, as the SDK's own HTTP instrumentation does, so nothing from the code
// that started the server leaks into requests.
class Context {
#values;

constructor(values) {
this.#values = values;
}

getValue(key) {
return this.#values?.get(key);
}

setValue(key, value) {
return new Context(new Map(this.#values).set(key, value));
}

deleteValue(key) {
const values = new Map(this.#values);
values.delete(key);
return new Context(values);
}
}

// The OpenTelemetry Span interface over a microtel span, for the SDK and for
// code that calls trace.getActiveSpan(). Writes land on the microtel span
// while it is recording, the way the SDK's own spans take them. microtel ends
// its own spans, so end() does nothing, and links are dropped because
// microtel spans have none.
class BridgeSpan {
#spanContext;

constructor(span) {
this.span = span;
}

spanContext() {
const { span } = this;
this.#spanContext ??= {
traceId: span.traceId,
spanId: span.spanId,
traceFlags: span.traceFlags,
traceState: span.tracestate ? new TraceState(span.tracestate) : undefined,
isRemote: false,
};
return this.#spanContext;
}

// As in the SDK, a span whose trace is not sampled records nothing.
isRecording() {
return this.span.endTime === undefined && this.span.isSampled;
}

setAttribute(key, value) {
if (this.isRecording()) this.span.setAttribute(key, value);
return this;
}

setAttributes(attributes) {
if (this.isRecording()) {
for (const key of Object.keys(attributes))
this.span.setAttribute(key, attributes[key]);
}
return this;
}

// The second argument is either attributes or a time. microtel times
// events itself, so a time is ignored.
addEvent(name, attributesOrTime) {
if (!this.isRecording()) return this;
const isAttributes =
typeof attributesOrTime === 'object' &&
attributesOrTime !== null &&
!Array.isArray(attributesOrTime) &&
!(attributesOrTime instanceof Date);
this.span.addEvent(
name,
isAttributes ? { __proto__: null, ...attributesOrTime } : undefined,
);
return this;
}

addLink() {
return this;
}

addLinks() {
return this;
}

// As the SDK does, an unset status is ignored, and only an error keeps a
// message. The span itself keeps an ok status final.
setStatus({ code, message }) {
if (this.isRecording() && code !== STATUS_UNSET) {
const isError = code === STATUS_ERROR && typeof message === 'string';
this.span.setStatus(code, isError ? message : '');
}
return this;
}

updateName(name) {
if (this.isRecording()) this.span.name = name;
return this;
}

// Maps an exception the way the SDK does. An error's code wins over its
// name as the type, and nothing is recorded without a type or a message.
recordException(exception) {
if (!this.isRecording()) return;
const attributes = { __proto__: null };
if (typeof exception === 'string') {
attributes['exception.message'] = exception;
} else if (exception) {
const type = exception.code ? String(exception.code) : exception.name;
if (type) attributes['exception.type'] = type;
if (exception.message)
attributes['exception.message'] = exception.message;
if (exception.stack) attributes['exception.stacktrace'] = exception.stack;
}
if (attributes['exception.type'] || attributes['exception.message'])
this.span.addEvent('exception', attributes);
}

end() {}
}

// OpenTelemetry's TraceState interface over a tracestate header. It hands the
// header back unchanged, and a change makes a new one with the changed key
// first, as the W3C spec asks.
class TraceState {
#header;

constructor(header) {
this.#header = header;
}

#members() {
const members = [];
for (const member of this.#header.split(',')) {
const eq = member.indexOf('=');
if (eq > 0)
members.push([member.slice(0, eq).trim(), member.slice(eq + 1).trim()]);
}
return members;
}

get(key) {
return this.#members().find(([k]) => k === key)?.[1];
}

set(key, value) {
const rest = this.#members().filter(([k]) => k !== key);
return TraceState.#of([[key, value], ...rest]);
}

unset(key) {
return TraceState.#of(this.#members().filter(([k]) => k !== key));
}

serialize() {
return this.#header;
}

static #of(members) {
return new TraceState(members.map(([k, v]) => `${k}=${v}`).join(','));
}
}

module.exports = {
BridgeSpan,
TraceState,
activeParent,
enterSpan,
kSuppressed,
};
8 changes: 0 additions & 8 deletions lib/core.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use strict';

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

const { configureSampler } = require('./sampler');
Expand All @@ -22,7 +21,6 @@ let endpoint = null;
let active = false;
let filter = null; // null = all modules enabled; Set = only listed modules.
let exclude = null; // null = nothing excluded; Set = listed modules disabled.
let spanStorage = null;
let collectorHost = null; // Normalized host (e.g. "localhost:4318") for self-trace suppression.
let exportUrl = null; // The collector's full export URL, fixed after start.
let serviceName = kDefaultServiceName;
Expand All @@ -32,11 +30,6 @@ 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();
return spanStorage;
}

// A filter or exclude entry matches the module itself; a broader entry
// also matches its subscopes, so 'node:http' covers node:http/client and
// node:http/server.
Expand Down Expand Up @@ -259,7 +252,6 @@ module.exports = {
start,
isActive: () => active,
getEndpoint: () => endpoint,
getSpanStorage,
isModuleEnabled,
getCollectorHost: () => collectorHost,
getExportUrl: () => exportUrl,
Expand Down
Loading
Loading