Skip to content

Commit 4a70eaf

Browse files
authored
feat: add HTTP/2 server instrumentation (#14)
Signed-off-by: Bryan English <bryan@bryanenglish.com>
1 parent 53e87a3 commit 4a70eaf

3 files changed

Lines changed: 451 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ started. Invalid options throw.
4747
| `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. |
4848
| `sdkName` | string | `microtel` | `telemetry.sdk.name` resource attribute, and the name of the instrumentation scope on exported spans. |
4949
| `sdkVersion` | string | this package's version | `telemetry.sdk.version` resource attribute, and the scope version. |
50-
| `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. |
50+
| `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. |
5151
| `exclude` | string | none | Comma-separated list of modules to exclude from instrumentation. Same values as `filter`; takes precedence over it. |
5252
| `maxBufferSize` | number | `100` | Number of spans buffered before an immediate flush is triggered. Must be a positive integer. |
5353
| `flushInterval` | number | `10000` | Milliseconds between periodic flushes. Must be a positive integer. |
@@ -93,6 +93,32 @@ Attributes set on server spans:
9393
| `http.response.status_code` | Response status code | When response finishes |
9494
| `error.type` | HTTP status code as string | On 5xx responses |
9595

96+
### HTTP/2 server
97+
98+
A span with kind `SERVER` is created for each incoming HTTP/2 request
99+
stream, whether over cleartext (`http2.createServer`) or TLS
100+
(`http2.createSecureServer`). The span starts when the request headers are
101+
received and ends when the stream closes, so its duration covers the full
102+
response transfer. Incoming `traceparent`/`tracestate` headers are parsed
103+
with the same semantics as for HTTP/1.1 servers.
104+
105+
If a stream is aborted before the response completes, the span ends with an
106+
error status. Server spans receive error status (`STATUS_ERROR`) for 5xx
107+
response codes; 4xx responses are not server errors.
108+
109+
Attributes set on HTTP/2 server spans:
110+
111+
| Attribute | Description | Condition |
112+
| --------------------------- | -------------------------------- | ----------------------------- |
113+
| `http.request.method` | HTTP method (e.g. `GET`, `POST`) | Always |
114+
| `url.path` | Request URL path (without query) | Always |
115+
| `url.query` | Query string (without `?`) | When query string is present |
116+
| `url.scheme` | `http` or `https` | Always |
117+
| `server.address` | `:authority` pseudo-header value | When present |
118+
| `network.protocol.version` | `2.0` | Always |
119+
| `http.response.status_code` | Response status code | When the response is sent |
120+
| `error.type` | HTTP status code as string | On 5xx responses |
121+
96122
### HTTP client
97123

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

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

161188
## Benchmarks
162189

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

215+
Outgoing requests made through `node:http2` are not traced, on either the
216+
span or the propagation side. Node's HTTP/2 client diagnostics channels
217+
publish a detached snapshot of the request headers after they have already
218+
been serialized for the wire, so there is no seam at which the
219+
instrumentation could inject a `traceparent` or remove a stale `tracestate`.
220+
Inbound HTTP/2 context is fully parsed; only the client side is affected.
221+
188222
Buffered spans are exported periodically, when the buffer fills, and when the
189223
event loop is about to drain. Spans that are still buffered when `process.exit()`
190224
is called explicitly are lost, because explicit exits do not run the exit-time

‎lib/instrumentations.js‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,111 @@ function onClientRequestError({ request, error }) {
211211
failClientSpan(span, error);
212212
}
213213

214+
// HTTP/2 server spans. Only the server channels are instrumented: Node's
215+
// http2 client channels publish a detached snapshot of the request headers
216+
// after they have already been serialized for the wire, so there is no seam
217+
// at which an outgoing traceparent could be injected.
218+
219+
function onHttp2ServerStreamStart({ stream, headers }) {
220+
// Exports travel over HTTP/1.1, so an export request can never arrive as
221+
// an HTTP/2 stream and the self-trace suppression the http handlers need
222+
// does not apply here.
223+
const traceparent = headers?.traceparent;
224+
const parent =
225+
traceparent != null ? Span.fromTraceparent(traceparent) : undefined;
226+
// Per the W3C Trace Context spec, tracestate is only meaningful
227+
// alongside a valid traceparent. It is discarded when the traceparent
228+
// is absent or rejected, same as for HTTP/1.1 requests.
229+
const tracestate = parent != null ? headers?.tracestate : undefined;
230+
231+
const method = headers?.[':method'] || 'GET';
232+
const rawUrl = headers?.[':path'] || '/';
233+
const qIdx = rawUrl.indexOf('?');
234+
const path = qIdx === -1 ? rawUrl : rawUrl.slice(0, qIdx);
235+
236+
const span = new Span(method, SPAN_KIND_SERVER, {
237+
parent,
238+
// The tracestate is forwarded verbatim; it is never parsed.
239+
tracestate,
240+
});
241+
242+
span.setAttribute('http.request.method', method);
243+
span.setAttribute('url.path', path);
244+
if (qIdx !== -1) span.setAttribute('url.query', rawUrl.slice(qIdx + 1));
245+
246+
// The :scheme pseudo-header carries the request scheme; requests that
247+
// omit it fall back to the session's encryption state, like the
248+
// socket check for HTTP/1.1.
249+
span.setAttribute(
250+
'url.scheme',
251+
headers?.[':scheme'] || (stream?.session?.encrypted ? 'https' : 'http'),
252+
);
253+
span.setAttribute('network.protocol.version', '2.0');
254+
255+
const authority = headers?.[':authority'];
256+
if (authority) span.setAttribute('server.address', authority);
257+
258+
// The span is stashed on the stream so the finish and close handlers do
259+
// not need a second AsyncLocalStorage lookup.
260+
stream[kSpan] = span;
261+
262+
// The span deliberately stays in the AsyncLocalStorage after the stream
263+
// ends, so work started while handling it still links to the server
264+
// span as its parent.
265+
spanStorage.enterWith(span);
266+
}
267+
268+
// Fires when the response headers are submitted, so the status code is
269+
// known before the body transfers; the span itself ends when the stream
270+
// closes, keeping the duration over the full transfer.
271+
// stream.respond() accepts headers as either an object or a flat array of
272+
// key/value pairs, and the channel publishes whichever form was used.
273+
function getH2Header(headers, name) {
274+
if (Array.isArray(headers)) return headers[headers.indexOf(name) + 1];
275+
return headers?.[name];
276+
}
277+
278+
function onHttp2ServerStreamFinish({ stream, headers }) {
279+
const span = stream[kSpan];
280+
if (span == null) return;
281+
282+
const statusCode = Number(getH2Header(headers, ':status'));
283+
if (!Number.isFinite(statusCode)) return;
284+
285+
span.setAttribute('http.response.status_code', statusCode);
286+
287+
if (statusCode >= 500) {
288+
span.setStatus(STATUS_ERROR, `HTTP ${statusCode}`);
289+
span.setAttribute('error.type', `${statusCode}`);
290+
}
291+
}
292+
293+
function onHttp2ServerStreamClose({ stream }) {
294+
const span = stream[kSpan];
295+
if (span == null) return;
296+
297+
// 'close' without a completed response means the stream was terminated
298+
// before the response completed. The server stream is not ended through
299+
// the usual stream API on aborts, so writableEnded cannot distinguish
300+
// them; the aborted flag marks resets, and headersSent is false whenever
301+
// the server never responded at all.
302+
if (span.endTime === undefined && (stream.aborted || !stream.headersSent)) {
303+
span.setStatus(STATUS_ERROR, 'request closed before response');
304+
}
305+
306+
span.end();
307+
}
308+
309+
function onHttp2ServerStreamError({ stream, error }) {
310+
const span = stream[kSpan];
311+
if (span == null) return;
312+
313+
if (span.endTime === undefined) {
314+
span.setStatus(STATUS_ERROR, error?.message || 'stream error');
315+
span.end();
316+
}
317+
}
318+
214319
// Undici's addHeader appends rather than replaces; update an existing
215320
// header in place so a caller-supplied value is overridden, not
216321
// duplicated.
@@ -293,6 +398,16 @@ function enableInstrumentations() {
293398
dc.subscribe('http.client.request.error', onClientRequestError);
294399
}
295400

401+
// A filter of node:http2 covers node:http2/server through the prefix
402+
// match in isModuleEnabled, the same way node:http covers its client and
403+
// server subscopes.
404+
if (isModuleEnabled('node:http2/server')) {
405+
dc.subscribe('http2.server.stream.start', onHttp2ServerStreamStart);
406+
dc.subscribe('http2.server.stream.finish', onHttp2ServerStreamFinish);
407+
dc.subscribe('http2.server.stream.close', onHttp2ServerStreamClose);
408+
dc.subscribe('http2.server.stream.error', onHttp2ServerStreamError);
409+
}
410+
296411
// node:fetch is an alias for node:undici: both enable the same
297412
// channels, which cover every undici request, fetch() included.
298413
if (isModuleEnabled('node:undici') || isModuleEnabled('node:fetch')) {

0 commit comments

Comments
 (0)