lib/instrumentation/modules/undici.js (173 lines of code) (raw):

/* * Copyright Elasticsearch B.V. and other contributors where applicable. * Licensed under the BSD 2-Clause License; you may not use this file except in * compliance with the BSD 2-Clause License. */ 'use strict'; // Instrument the undici module. // // This uses undici's diagnostics_channel support for instrumentation. // https://github.com/nodejs/undici/blob/main/docs/api/DiagnosticsChannel.md // Undici is also used for Node >=v18.0.0's `fetch()` implementation, via // an esbuild bundle. This instrumentation is enabled if either `global.fetch` // is present or `require('undici')`. // // Limitations: // - Currently this isn't subscribing to 'undici:client:...' messages. // With typical undici usage a connection will only be initiated for a // request. However, if a user manually does `client.connect(...)` then it is // possible for this instrumentation to miss a connection error from // 'undici:client:connectError'. It would eventually be nice to heuristically // add 'connect' spans as children of request spans. // - This doesn't instrument HTTP CONNECT, as exposed by `undici.connect(...)`. // I don't think the current undici diagnostics_channel messages provide a // way to watch the completion of the CONNECT request. // - This hasn't been tested with `undici.upgrade()`. // // Some notes on if/when we want to collect some HTTP client metrics: // - The time between 'undici:client:connected' and 'undici:client:sendHeaders' // could be a measure of client-side latency. I'm not sure if client-side // queueing of requests would show a time gap here. // - The time between 'undici:client:sendHeaders' and 'undici:client:bodySent' // might be interesting for large bodies, or perhaps for streaming requests. // - The time between 'undici:client:bodySent' and 'undici:request:headers' // could be a measure of response TTFB latency. let diagch = null; try { diagch = require('diagnostics_channel'); } catch (_importErr) { // pass } const semver = require('semver'); // Search an undici@5 request.headers string for a 'traceparent' header. const headersStrHasTraceparentRe = /^traceparent:/im; let isInstrumented = false; let spanFromReq = null; let chans = null; // Get the content-length from undici response headers. // `headers` is an Array of buffers: [k, v, k, v, ...]. // If the header is not present, or has an invalid value, this returns null. function contentLengthFromResponseHeaders(headers) { const name = 'content-length'; for (let i = 0; i < headers.length; i += 2) { const k = headers[i]; if (k.length === name.length && k.toString().toLowerCase() === name) { const v = Number(headers[i + 1]); if (!isNaN(v)) { return v; } else { return null; } } } return null; } function uninstrumentUndici() { if (!isInstrumented) { return; } isInstrumented = false; spanFromReq = null; chans.forEach(({ chan, onMessage }) => { chan.unsubscribe(onMessage); }); chans = null; } /** * Setup instrumentation for undici. The instrumentation is based entirely on * diagnostics_channel usage, so no reference to the loaded undici module is * required. */ function instrumentUndici(agent) { if (isInstrumented) { return; } isInstrumented = true; const ins = agent._instrumentation; spanFromReq = new WeakMap(); // Keep ref to avoid https://github.com/nodejs/node/issues/42170 bug and for // unsubscribing. chans = []; function diagchSub(name, onMessage) { const chan = diagch.channel(name); chan.subscribe(onMessage); chans.push({ name, chan, onMessage, }); } diagchSub('undici:request:create', ({ request }) => { // We do not handle instrumenting HTTP CONNECT. See limitation notes above. if (request.method === 'CONNECT') { return; } const url = new URL(request.origin); const span = ins.createSpan( `${request.method} ${url.host}`, 'external', 'http', request.method, { exitSpan: true }, ); // W3C trace-context propagation. // If the span is null (e.g. hit `transactionMaxSpans`, unsampled // transaction), then fallback to the current run context's span or // transaction, if any. const parentRunContext = ins.currRunContext(); const propSpan = span || parentRunContext.currSpan() || parentRunContext.currTransaction(); if (propSpan) { // Guard against adding a duplicate 'traceparent' header, because that // breaks ES. https://github.com/elastic/apm-agent-nodejs/issues/3964 // Dev Note: This cheats a little and assumes the header names to add // will include 'traceparent'. let alreadyHasTp = false; if (Array.isArray(request.headers)) { // undici@6 for (let i = 0; i < request.headers.length; i += 2) { if (request.headers[i].toLowerCase() === 'traceparent') { alreadyHasTp = true; break; } } } else if (typeof request.headers === 'string') { // undici@5 alreadyHasTp = headersStrHasTraceparentRe.test(request.headers); } if (!alreadyHasTp) { propSpan.propagateTraceContextHeaders( request, function (req, name, value) { if (typeof request.addHeader === 'function') { req.addHeader(name, value); } else if (Array.isArray(request.headers)) { // undici@6.11.0 accidentally, briefly removed `request.addHeader()`. req.headers.push(name, value); } }, ); } } if (span) { spanFromReq.set(request, span); // Set some initial HTTP context, in case the request errors out before a response. span.setHttpContext({ method: request.method, url: request.origin + request.path, }); const destContext = { address: url.hostname, }; const port = Number(url.port) || (url.protocol === 'https:' && 443) || (url.protocol === 'http:' && 80); if (port) { destContext.port = port; } span._setDestinationContext(destContext); } }); diagchSub('undici:request:headers', ({ request, response }) => { const span = spanFromReq.get(request); if (span !== undefined) { // We are currently *not* capturing response headers, even though the // intake API does allow it, because none of the other `setHttpContext` // uses currently do. const httpContext = { method: request.method, status_code: response.statusCode, url: request.origin + request.path, }; const cLen = contentLengthFromResponseHeaders(response.headers); if (cLen !== null) { httpContext.response = { encoded_body_size: cLen }; } span.setHttpContext(httpContext); span._setOutcomeFromHttpStatusCode(response.statusCode); } }); diagchSub('undici:request:trailers', ({ request }) => { const span = spanFromReq.get(request); if (span !== undefined) { span.end(); spanFromReq.delete(request); } }); diagchSub('undici:request:error', ({ request, error }) => { const span = spanFromReq.get(request); const errOpts = {}; if (span !== undefined) { errOpts.parent = span; // Cases where we won't have an undici parent span: // - We've hit transactionMaxSpans. // - The undici HTTP span was suppressed because it is a child of an // exit span (e.g. when used as the transport for the Elasticsearch // client). // It might be debatable whether we want to capture the error in the // latter case. This could be revisited later. } agent.captureError(error, errOpts); if (span !== undefined) { span.end(); spanFromReq.delete(request); } }); } function shimUndici(undici, agent, { version, enabled }) { if (!enabled) { return undici; } if (semver.lt(version, '4.7.1')) { // Undici added its diagnostics_channel messages in v4.7.0. In v4.7.1 the // `request.origin` property, that we need, was added. agent.logger.debug( 'cannot instrument undici: undici version %s is not supported', version, ); return undici; } if (!diagch) { agent.logger.debug( 'cannot instrument undici: there is no "diagnostics_channel" module', process.version, ); return undici; } instrumentUndici(agent); return undici; } module.exports = shimUndici; module.exports.instrumentUndici = instrumentUndici; module.exports.uninstrumentUndici = uninstrumentUndici;