dd-trace
Version:
Datadog APM tracing client for JavaScript
315 lines (261 loc) • 8.88 kB
JavaScript
/* eslint-disable no-fallthrough */
const url = require('url')
const { errorMonitor } = require('events')
const { channel, addHook } = require('../helpers/instrument')
const shimmer = require('../../../datadog-shimmer')
const log = require('../../../dd-trace/src/log')
const startChannel = channel('apm:http:client:request:start')
const finishChannel = channel('apm:http:client:request:finish')
const endChannel = channel('apm:http:client:request:end')
const asyncStartChannel = channel('apm:http:client:request:asyncStart')
const errorChannel = channel('apm:http:client:request:error')
const responseFinishChannel = channel('apm:http:client:response:finish')
addHook({ name: 'http' }, hookFn)
addHook({ name: 'https' }, hookFn)
function hookFn (http) {
patch(http, 'request')
patch(http, 'get')
return http
}
function combineOptions (inputURL, inputOptions) {
return inputOptions !== null && typeof inputOptions === 'object'
? Object.assign(inputURL || {}, inputOptions)
: inputURL
}
function normalizeHeaders (options) {
options.headers ??= {}
}
function normalizeCallback (inputOptions, callback, inputURL) {
return typeof inputOptions === 'function' ? [inputOptions, inputURL || {}] : [callback, inputOptions]
}
/**
* Wires the downstream response so we can observe when the customer consumes
* the body and when the stream finishes
*
* @param {object} ctx - Instrumentation context
* @param {import('http').IncomingMessage} res - The downstream response object.
* @returns {{ finalizeIfNeeded: () => void }|null} Cleanup helper used for drain.
*/
function setupResponseInstrumentation (ctx, res) {
const shouldInstrumentFinish = responseFinishChannel.hasSubscribers
if (!shouldInstrumentFinish) {
return null
}
let bodyConsumed = false
let finishCalled = false
let originalRead = null
let dataListenerAdded = false
let dataReadStarted = false
const { shouldCollectBody } = ctx
const bodyChunks = shouldCollectBody ? [] : null
const collectChunk = chunk => {
if (!shouldCollectBody || !chunk) return
if (typeof chunk === 'string') {
bodyChunks.push(chunk)
} else if (Buffer.isBuffer(chunk)) {
bodyChunks.push(chunk)
} else {
// Handle Uint8Array or other array-like types
bodyChunks.push(Buffer.from(chunk))
}
}
// Listen for body consumption
const onNewListener = (eventName) => {
if (eventName === 'data' || eventName === 'readable') {
bodyConsumed = true
// For 'data' events, add our own listener to collect chunks
if (eventName === 'data' && !dataListenerAdded && !dataReadStarted) {
dataListenerAdded = true
res.on('data', collectChunk)
}
// For 'readable' events, wrap the read() method
if (eventName === 'readable' && !originalRead && !dataListenerAdded && typeof res.read === 'function') {
originalRead = res.read
res.read = function () {
const chunk = originalRead.apply(this, arguments)
if (!dataListenerAdded) {
dataReadStarted = true
collectChunk(chunk)
}
return chunk
}
}
}
}
res.on('newListener', onNewListener)
// Cleanup function to restore original behavior
const cleanup = () => {
res.off('newListener', onNewListener)
res.off('data', collectChunk)
if (originalRead) {
res.read = originalRead
originalRead = null
}
}
const notifyFinish = () => {
if (finishCalled) return
finishCalled = true
// Combine collected chunks into a single body
let body = null
if (bodyChunks?.length) {
const firstChunk = bodyChunks[0]
body = typeof firstChunk === 'string'
? bodyChunks.join('')
: Buffer.concat(bodyChunks)
}
responseFinishChannel.publish({ ctx, res, body })
cleanup()
}
res.once('end', notifyFinish)
res.once('close', notifyFinish)
return {
finalizeIfNeeded () {
if (!bodyConsumed) {
// Body not consumed, resume to complete the response
notifyFinish()
}
},
}
}
function patch (http, methodName) {
shimmer.wrap(http, methodName, instrumentRequest)
function instrumentRequest (request) {
return function () {
if (!startChannel.hasSubscribers) {
return request.apply(this, arguments)
}
let args
try {
args = normalizeArgs.apply(null, arguments)
} catch (e) {
log.error('Error normalising http req arguments', e)
return request.apply(this, arguments)
}
const abortController = new AbortController()
const ctx = { args, http, abortController }
return startChannel.runStores(ctx, () => {
let finished = false
let callback = args.callback
if (callback) {
callback = shimmer.wrapFunction(args.callback, cb => function () {
return asyncStartChannel.runStores(ctx, () => {
return cb.apply(this, arguments)
})
})
}
const options = args.options
const finish = () => {
if (!finished) {
finished = true
finishChannel.publish(ctx)
}
}
try {
const req = request.call(this, options, callback)
const emit = req.emit
const setTimeout = req.setTimeout
ctx.req = req
// tracked to accurately discern custom request socket timeout
let customRequestTimeout = false
req.setTimeout = function () {
customRequestTimeout = true
return setTimeout.apply(this, arguments)
}
req.emit = function (eventName, arg) {
switch (eventName) {
case 'response': {
const res = arg
ctx.res = res
res.once('end', finish)
res.once(errorMonitor, finish)
const instrumentation = setupResponseInstrumentation(ctx, res)
if (!instrumentation) {
break
}
const result = emit.apply(this, arguments)
instrumentation.finalizeIfNeeded()
return result
}
case 'connect':
case 'upgrade':
ctx.res = arg
finish()
break
case 'error':
case 'timeout':
ctx.error = arg
ctx.customRequestTimeout = customRequestTimeout
errorChannel.publish(ctx)
case 'abort': // deprecated and replaced by `close` in node 17
case 'close':
finish()
}
return emit.apply(this, arguments)
}
if (abortController.signal.aborted) {
req.destroy(abortController.signal.reason || new Error('Aborted'))
}
return req
} catch (e) {
ctx.error = e
errorChannel.publish(ctx)
// if the initial request failed, ctx.req will be unset, we must close the span here
// fix for: https://github.com/DataDog/dd-trace-js/issues/5016
if (!ctx.req) {
finish()
}
throw e
} finally {
endChannel.publish(ctx)
}
})
}
}
function normalizeArgs (inputURL, inputOptions, cb) {
const originalUrl = inputURL
inputURL = normalizeOptions(inputURL)
const [callback, inputOptionsNormalized] = normalizeCallback(inputOptions, cb, inputURL)
const options = combineOptions(inputURL, inputOptionsNormalized)
normalizeHeaders(options)
const uri = url.format(options)
return { uri, options, callback, originalUrl }
}
function normalizeOptions (inputURL) {
if (typeof inputURL === 'string') {
try {
return urlToOptions(new url.URL(inputURL))
} catch {
// eslint-disable-next-line n/no-deprecated-api
return url.parse(inputURL)
}
} else if (inputURL instanceof url.URL) {
return urlToOptions(inputURL)
} else {
return inputURL
}
}
function urlToOptions (url) {
const agent = url.agent || http.globalAgent
const options = {
protocol: url.protocol || agent.protocol,
hostname: typeof url.hostname === 'string' && url.hostname.startsWith('[')
? url.hostname.slice(1, -1)
: url.hostname ||
url.host ||
'localhost',
hash: url.hash,
search: url.search,
pathname: url.pathname,
path: `${url.pathname || ''}${url.search || ''}`,
href: url.href,
}
if (url.port !== '') {
options.port = Number(url.port)
}
if (url.username || url.password) {
options.auth = `${url.username}:${url.password}`
}
return options
}
}