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
48 changes: 41 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +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. |
| `filter` | string | all modules | Comma-separated list of modules to instrument. Supported values: `node:http`, `node:http/client`, `node:http/server`, and `node:undici`. `node:fetch` is an alias for `node:undici`: both cover all undici requests, including `fetch()`, and either one enables them. |
| `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. |
| `flushInterval` | number | `10000` | Milliseconds between periodic flushes. Must be a positive integer. |
Expand Down Expand Up @@ -93,6 +93,32 @@ Attributes set on server spans:
| `http.response.status_code` | Response status code | When response finishes |
| `error.type` | HTTP status code as string | On 5xx responses |

### HTTP/2 server

A span with kind `SERVER` is created for each incoming HTTP/2 request
stream, whether over cleartext (`http2.createServer`) or TLS
(`http2.createSecureServer`). The span starts when the request headers are
received and ends when the stream closes, so its duration covers the full
response transfer. Incoming `traceparent`/`tracestate` headers are parsed
with the same semantics as for HTTP/1.1 servers.

If a stream is aborted before the response completes, the span ends with an
error status. Server spans receive error status (`STATUS_ERROR`) for 5xx
response codes; 4xx responses are not server errors.

Attributes set on HTTP/2 server spans:

| Attribute | Description | Condition |
| --------------------------- | -------------------------------- | ----------------------------- |
| `http.request.method` | HTTP method (e.g. `GET`, `POST`) | Always |
| `url.path` | Request URL path (without query) | Always |
| `url.query` | Query string (without `?`) | When query string is present |
| `url.scheme` | `http` or `https` | Always |
| `server.address` | `:authority` pseudo-header value | When present |
| `network.protocol.version` | `2.0` | Always |
| `http.response.status_code` | Response status code | When the response is sent |
| `error.type` | HTTP status code as string | On 5xx responses |

### HTTP client

A span with kind `CLIENT` is created for each outgoing HTTP request made via
Expand Down Expand Up @@ -149,14 +175,15 @@ scheme default (`80` for `http:`, `443` for `https:`) when the origin omits it.
Tracing automatically propagates [W3C Trace Context](https://www.w3.org/TR/trace-context/) across HTTP boundaries:

* **Incoming requests**: The `traceparent` header is read from incoming HTTP
requests, and child spans created during request processing inherit the trace
ID. `traceparent` values using a version other than `00` are rejected, and a
fresh trace is started instead. The `tracestate` header, when present, is
attached to the span and forwarded verbatim to outgoing requests. It is never
parsed or modified.
and HTTP/2 requests, and child spans created during request processing
inherit the trace ID. `traceparent` values using a version other than `00` are
rejected, and a fresh trace is started instead. The `tracestate` header, when
present, is attached to the span and forwarded verbatim to outgoing requests.
It is never parsed or modified.
* **Outgoing requests**: The `traceparent` header is injected into outgoing
HTTP and undici/fetch requests, together with the stored `tracestate` when
one is present, enabling distributed tracing across services.
one is present, enabling distributed tracing across services. Outgoing
`node:http2` requests receive no injection; see the limitations below.

## Benchmarks

Expand Down Expand Up @@ -185,6 +212,13 @@ package or a userland OpenTelemetry SDK, not both.
`init()` activates tracing in the thread that calls it. Worker threads
(`node:worker_threads`) are not traced unless they call `init()` themselves.

Outgoing requests made through `node:http2` are not traced, on either the
span or the propagation side. Node's HTTP/2 client diagnostics channels
publish a detached snapshot of the request headers after they have already
been serialized for the wire, so there is no seam at which the
instrumentation could inject a `traceparent` or remove a stale `tracestate`.
Inbound HTTP/2 context is fully parsed; only the client side is affected.

Buffered spans are exported periodically, when the buffer fills, and when the
event loop is about to drain. Spans that are still buffered when `process.exit()`
is called explicitly are lost, because explicit exits do not run the exit-time
Expand Down
115 changes: 115 additions & 0 deletions lib/instrumentations.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,111 @@ function onClientRequestError({ request, error }) {
failClientSpan(span, error);
}

// HTTP/2 server spans. Only the server channels are instrumented: Node's
// http2 client channels publish a detached snapshot of the request headers
// after they have already been serialized for the wire, so there is no seam
// at which an outgoing traceparent could be injected.

function onHttp2ServerStreamStart({ stream, headers }) {
// Exports travel over HTTP/1.1, so an export request can never arrive as
// an HTTP/2 stream and the self-trace suppression the http handlers need
// does not apply here.
const traceparent = headers?.traceparent;
const parent =
traceparent != null ? Span.fromTraceparent(traceparent) : undefined;
// Per the W3C Trace Context spec, tracestate is only meaningful
// alongside a valid traceparent. It is discarded when the traceparent
// is absent or rejected, same as for HTTP/1.1 requests.
const tracestate = parent != null ? headers?.tracestate : undefined;

const method = headers?.[':method'] || 'GET';
const rawUrl = headers?.[':path'] || '/';
const qIdx = rawUrl.indexOf('?');
const path = qIdx === -1 ? rawUrl : rawUrl.slice(0, qIdx);

const span = new Span(method, SPAN_KIND_SERVER, {
parent,
// The tracestate is forwarded verbatim; it is never parsed.
tracestate,
});

span.setAttribute('http.request.method', method);
span.setAttribute('url.path', path);
if (qIdx !== -1) span.setAttribute('url.query', rawUrl.slice(qIdx + 1));

// The :scheme pseudo-header carries the request scheme; requests that
// omit it fall back to the session's encryption state, like the
// socket check for HTTP/1.1.
span.setAttribute(
'url.scheme',
headers?.[':scheme'] || (stream?.session?.encrypted ? 'https' : 'http'),
);
span.setAttribute('network.protocol.version', '2.0');

const authority = headers?.[':authority'];
if (authority) span.setAttribute('server.address', authority);

// The span is stashed on the stream so the finish and close handlers do
// not need a second AsyncLocalStorage lookup.
stream[kSpan] = span;

// The span deliberately stays in the AsyncLocalStorage after the stream
// ends, so work started while handling it still links to the server
// span as its parent.
spanStorage.enterWith(span);
}

// Fires when the response headers are submitted, so the status code is
// known before the body transfers; the span itself ends when the stream
// closes, keeping the duration over the full transfer.
// stream.respond() accepts headers as either an object or a flat array of
// key/value pairs, and the channel publishes whichever form was used.
function getH2Header(headers, name) {
if (Array.isArray(headers)) return headers[headers.indexOf(name) + 1];
return headers?.[name];
}

function onHttp2ServerStreamFinish({ stream, headers }) {
const span = stream[kSpan];
if (span == null) return;

const statusCode = Number(getH2Header(headers, ':status'));
if (!Number.isFinite(statusCode)) return;

span.setAttribute('http.response.status_code', statusCode);

if (statusCode >= 500) {
span.setStatus(STATUS_ERROR, `HTTP ${statusCode}`);
span.setAttribute('error.type', `${statusCode}`);
}
}

function onHttp2ServerStreamClose({ stream }) {
const span = stream[kSpan];
if (span == null) return;

// 'close' without a completed response means the stream was terminated
// before the response completed. The server stream is not ended through
// the usual stream API on aborts, so writableEnded cannot distinguish
// them; the aborted flag marks resets, and headersSent is false whenever
// the server never responded at all.
if (span.endTime === undefined && (stream.aborted || !stream.headersSent)) {
span.setStatus(STATUS_ERROR, 'request closed before response');
}

span.end();
}

function onHttp2ServerStreamError({ stream, error }) {
const span = stream[kSpan];
if (span == null) return;

if (span.endTime === undefined) {
span.setStatus(STATUS_ERROR, error?.message || 'stream error');
span.end();
}
}

// Undici's addHeader appends rather than replaces; update an existing
// header in place so a caller-supplied value is overridden, not
// duplicated.
Expand Down Expand Up @@ -293,6 +398,16 @@ function enableInstrumentations() {
dc.subscribe('http.client.request.error', onClientRequestError);
}

// A filter of node:http2 covers node:http2/server through the prefix
// match in isModuleEnabled, the same way node:http covers its client and
// server subscopes.
if (isModuleEnabled('node:http2/server')) {
dc.subscribe('http2.server.stream.start', onHttp2ServerStreamStart);

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.

should we be adding spans for http/2 server push?

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 think so, but in a separate PR.

dc.subscribe('http2.server.stream.finish', onHttp2ServerStreamFinish);
dc.subscribe('http2.server.stream.close', onHttp2ServerStreamClose);
dc.subscribe('http2.server.stream.error', onHttp2ServerStreamError);
}

// node:fetch is an alias for node:undici: both enable the same
// channels, which cover every undici request, fetch() included.
if (isModuleEnabled('node:undici') || isModuleEnabled('node:fetch')) {
Expand Down
Loading
Loading