dd-trace
Version:
Datadog APM tracing client for JavaScript
333 lines (265 loc) • 9.15 kB
JavaScript
'use strict'
const HttpClientPlugin = require('../../datadog-plugin-http/src/client')
const { storage } = require('../../datadog-core')
const tags = require('../../../ext/tags')
const formats = require('../../../ext/formats')
const HTTP_HEADERS = formats.HTTP_HEADERS
const log = require('../../dd-trace/src/log')
const { CLIENT_PORT_KEY } = require('../../dd-trace/src/constants')
const {
HTTP_STATUS_CODE,
HTTP_REQUEST_HEADERS,
HTTP_RESPONSE_HEADERS,
} = tags
// WeakMap to store span context for native undici request objects
const requestContexts = new WeakMap()
class UndiciPlugin extends HttpClientPlugin {
static id = 'undici'
static prefix = 'tracing:apm:undici:fetch'
constructor (...args) {
super(...args)
// Subscribe to native undici diagnostic channels for undici >= 4.7.0
// These channels fire for ALL undici requests (fetch, request, stream, etc.)
this.addSub('undici:request:create', this.#onNativeRequestCreate.bind(this))
this.addSub('undici:request:bodySent', this.#onNativeRequestBodySent.bind(this))
this.addSub('undici:request:headers', this.#onNativeRequestHeaders.bind(this))
this.addSub('undici:request:trailers', this.#onNativeRequestTrailers.bind(this))
this.addSub('undici:request:error', this.#onNativeRequestError.bind(this))
}
// ===========================================
// Native undici diagnostic channel handlers
// These fire for undici >= 4.7.0 for ALL request types (fetch, request, stream, etc.)
// ===========================================
#onNativeRequestCreate ({ request }) {
if (!request) return
const store = storage('legacy').getStore()
const { origin = '', path = '/' } = request
const method = request.method?.toUpperCase() ?? 'GET'
// Parse origin to extract protocol, hostname, port
let protocol = 'http:'
let hostname = 'localhost'
let port = ''
try {
const url = new URL(origin)
protocol = url.protocol
hostname = url.hostname
port = url.port
} catch {
// If origin is not a valid URL, use defaults
}
const host = port ? `${hostname}:${port}` : hostname
const pathname = path.split(/[?#]/)[0]
const uri = `${protocol}//${host}${pathname}`
const allowed = this.config.filter(uri)
const childOf = store && allowed ? store.span : null
const span = this.startSpan(this.operationName(), {
childOf,
meta: {
'span.kind': 'client',
'http.method': method,
'http.url': uri,
'out.host': hostname,
},
metrics: {
[CLIENT_PORT_KEY]: port ? Number.parseInt(port, 10) : undefined,
},
service: this.serviceName({ pluginConfig: this.config, sessionDetails: { host: hostname, port } }),
resource: method,
type: 'http',
}, false)
// Disable recording if not allowed
if (!allowed) {
span._spanContext._trace.record = false
}
// Capture request headers if configured
if (request.headers && this.config.headers) {
addConfiguredHeaders(span, request.headers, this.config.headers, HTTP_REQUEST_HEADERS)
}
// Inject trace headers if propagation is allowed
if (this.config.propagationFilter(uri)) {
const headers = {}
this.tracer.inject(span, HTTP_HEADERS, headers)
// Use addHeader if available (undici provides this on the request object)
if (typeof request.addHeader === 'function') {
for (const [name, value] of Object.entries(headers)) {
request.addHeader(name, value)
}
}
}
// Store span context for request for later retrieval
requestContexts.set(request, {
span,
store,
uri,
method,
})
// Enter the span context
storage('legacy').enterWith({ ...store, span })
}
#onNativeRequestBodySent ({ request }) {
const ctx = requestContexts.get(request)
if (!ctx || ctx.method !== 'CONNECT') return
const { span, store } = ctx
this.config.hooks.request(span, null, null)
span.finish()
requestContexts.delete(request)
if (store) {
storage('legacy').enterWith(store)
}
}
#onNativeRequestHeaders ({ request, response }) {
const ctx = requestContexts.get(request)
if (!ctx) return
const { span } = ctx
const statusCode = response?.statusCode
if (statusCode) {
span.setTag(HTTP_STATUS_CODE, statusCode)
if (!this.config.validateStatus(statusCode)) {
span.setTag('error', 1)
}
}
// Add response headers if configured
if (response?.headers && this.config.headers) {
addConfiguredHeaders(span, response.headers, this.config.headers, HTTP_RESPONSE_HEADERS)
}
}
#onNativeRequestTrailers ({ request }) {
const ctx = requestContexts.get(request)
if (!ctx) return
const { span, store } = ctx
// Call the request hook if configured
this.config.hooks.request(span, null, null)
// Finish the span
span.finish()
// Clean up
requestContexts.delete(request)
// Restore parent store
if (store) {
storage('legacy').enterWith(store)
}
}
#onNativeRequestError ({ request, error }) {
const ctx = requestContexts.get(request)
if (!ctx) return
const { span, store } = ctx
// Don't record AbortError as an error - it's user-initiated cancellation
if (error && error.name !== 'AbortError') {
span.setTag('error', error)
}
// Call the request hook if configured
this.config.hooks.request(span, null, null)
// Finish the span
span.finish()
// Clean up
requestContexts.delete(request)
// Restore parent store
if (store) {
storage('legacy').enterWith(store)
}
}
// ===========================================
// Fetch-based tracing channel handlers
// These handle fetch() for undici < 4.7.0 (before native DC was added)
// ===========================================
bindStart (ctx) {
const req = ctx.req
const options = new URL(req.url)
options.headers = Object.fromEntries(req.headers.entries())
options.method = req.method
ctx.args = { options }
const store = super.bindStart(ctx)
// Inject trace headers back into the request
for (const name of Object.keys(options.headers)) {
if (!req.headers.has(name)) {
req.headers.set(name, options.headers[name])
}
}
return store
}
error (ctx) {
// Don't record AbortError as an error - it's user-initiated cancellation
if (!ctx.error || ctx.error.name !== 'AbortError') {
return super.error(ctx)
}
}
asyncEnd (ctx) {
ctx.res = ctx.result
return this.finish(ctx)
}
configure (config) {
return super.configure(normalizeConfig(config))
}
}
// Add configured headers to span with appropriate tags
function addConfiguredHeaders (span, rawHeaders, configuredHeaders, headerType) {
const headers = normalizeHeaders(rawHeaders)
for (const [key, tag] of configuredHeaders) {
const value = headers[key]
if (value) {
span.setTag(tag || `${headerType}.${key}`, value)
}
}
}
// Normalize headers to an object, handling different undici formats:
// - Array format: alternating key-value pairs (undici >= 6.0.0)
// - String format: HTTP header lines like "key: value\r\n" (undici 5.x)
// - Object format: already a headers object
function normalizeHeaders (headers) {
if (!headers) return {}
// String format (undici 5.x): "key: value\r\nkey2: value2\r\n"
if (typeof headers === 'string') {
const result = {}
const lines = headers.split('\r\n')
for (const line of lines) {
if (!line) continue
const colonIndex = line.indexOf(':')
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).toLowerCase().trim()
const value = line.slice(colonIndex + 1).trim()
result[key] = value
}
}
return result
}
// Array format (undici >= 6.0.0): alternating key-value pairs
if (Array.isArray(headers)) {
const result = {}
for (let i = 0; i < headers.length; i += 2) {
const key = headers[i]
if (typeof key === 'string') {
result[key.toLowerCase()] = headers[i + 1]
} else if (Buffer.isBuffer(key)) {
result[key.toString().toLowerCase()] = headers[i + 1]?.toString?.() || headers[i + 1]
}
}
return result
}
// Object format: use as-is
return headers
}
function normalizeConfig (config) {
const validateStatus = getStatusValidator(config)
const hooks = getHooks(config)
return {
...config,
validateStatus,
hooks,
}
}
function getStatusValidator (config) {
if (typeof config.validateStatus === 'function') {
return config.validateStatus
} else if (Object.hasOwn(config, 'validateStatus')) {
log.error('Expected `validateStatus` to be a function.')
}
return defaultValidateStatus
}
function defaultValidateStatus (code) {
return code < 400 || code >= 500
}
function getHooks (config) {
const request = config.hooks?.request ?? noop
return { request }
}
function noop () {}
module.exports = UndiciPlugin