UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

361 lines (286 loc) 11.5 kB
'use strict' const shimmer = require('../../datadog-shimmer') const { addHook, channel } = require('./helpers/instrument') const errorChannel = channel('apm:fastify:middleware:error') const handleChannel = channel('apm:fastify:request:handle') const routeAddedChannel = channel('apm:fastify:route:added') const bodyParserReadCh = channel('datadog:fastify:body-parser:finish') const queryParamsReadCh = channel('datadog:fastify:query-params:finish') const cookieParserReadCh = channel('datadog:fastify-cookie:read:finish') const responsePayloadReadCh = channel('datadog:fastify:response:finish') const pathParamsReadCh = channel('datadog:fastify:path-params:finish') const finishSetHeaderCh = channel('datadog:fastify:set-header:finish') // context management channels const preParsingCh = channel('datadog:fastify:pre-parsing:start') const preValidationCh = channel('datadog:fastify:pre-validation:start') const callbackFinishCh = channel('datadog:fastify:callback:execute') const parsingContexts = new WeakMap() const cookiesPublished = new WeakSet() const bodyPublished = new WeakSet() function wrapFastify (fastify, hasParsingEvents) { if (typeof fastify !== 'function') return fastify return function fastifyWithTrace (...args) { const app = fastify.apply(this, args) if (!app || typeof app.addHook !== 'function') return app app.addHook('onRoute', onRoute) app.addHook('onRequest', onRequest) app.addHook('preHandler', preHandler) if (hasParsingEvents) { app.addHook('preParsing', preParsing) app.addHook('preValidation', preValidation) } else { app.addHook('onRequest', preParsing) app.addHook('preHandler', preValidation) } app.addHook = wrapAddHook(app.addHook) return app } } function wrapAddHook (addHook) { return shimmer.wrapFunction(addHook, addHook => function addHookWithTrace (name, fn) { fn = arguments[arguments.length - 1] if (typeof fn !== 'function') return addHook.apply(this, arguments) arguments[arguments.length - 1] = shimmer.wrapFunction(fn, fn => function wrappedHook () { // Fast path: every fastify request invokes each addHook'd handler, so the wrap // runs in the user's hot path. The only side effects this wrapper carries are // the three channels below; when none of them have a subscriber (the default // plugin config, and the steady state once appsec / cookie subscribers detach), // the wrap has nothing to do, and a `fn.apply(this, arguments)` forward keeps // V8's CallApplyArguments fast path intact. // // The previous shape mutated `arguments[arguments.length - 1]` to swap `done`. // That mutation materialises the magical arguments object and disables V8 // inlining of the enclosing function. The slow path below builds a fresh args // array instead so the hot fast path keeps a clean forward. if (errorChannel.hasSubscribers || cookieParserReadCh.hasSubscribers || callbackFinishCh.hasSubscribers) { return invokeHookWithContext(name, fn, this, arguments) } return fn.apply(this, arguments) }) return addHook.apply(this, arguments) }) } /** * Slow path of {@link wrapAddHook}; entered only when at least one wrap-fed * channel has a subscriber. Allocates the per-request context, rewraps `done`, * and forwards to the user-supplied hook. * * @param {string} name Lifecycle phase the hook was registered against. * @param {Function} fn User-supplied hook. * @param {unknown} thisArg `this` Fastify passes to the hook. * @param {ArrayLike<unknown>} args Fastify's positional args; the dispatcher always * places `done` as the trailing positional (see fastify/lib/hooks.js hookIterator, * onSendHookRunner, preParsingHookRunner, onRequestAbortHookRunner). */ function invokeHookWithContext (name, fn, thisArg, args) { const request = args[0] const reply = args[1] const req = getReq(request) const ctx = { req } try { const lastArg = args[args.length - 1] if (typeof lastArg === 'function') { // Copy the args so we can swap the trailing `done` without touching the // caller's magical arguments object. Fastify hook arities are 2 to 4 // across lifecycle phases, but `done` is always last. const callArgs = [...args] callArgs[callArgs.length - 1] = wrapHookDone(ctx, request, reply, req, name, lastArg) return fn.apply(thisArg, callArgs) } const promise = fn.apply(thisArg, args) if (promise && typeof promise.catch === 'function') { return promise.catch(error => { ctx.error = error return publishError(ctx) }) } return promise } catch (error) { ctx.error = error throw publishError(ctx) } } /** * Per-request closure invoked when fastify resolves the user hook's `done`. * Captures `ctx` plus the dispatcher-level fields needed to publish on the * cookie / callback channels. The closure cannot be hoisted: fastify invokes * `done` with a single `(err)` arg, so request / reply / req / name / doneCallback * must close over rather than ride the call signature. * * @param {{ req: unknown, [key: string]: unknown }} ctx * @param {{ cookies?: Record<string, unknown>, [key: string]: unknown }} request * @param {object} reply * @param {unknown} req * @param {string} name * @param {Function} doneCallback */ function wrapHookDone (ctx, request, reply, req, name, doneCallback) { return function wrappedDone (error) { ctx.error = error publishError(ctx) const hasCookies = request.cookies && Object.keys(request.cookies).length > 0 if (cookieParserReadCh.hasSubscribers && hasCookies && !cookiesPublished.has(req)) { ctx.res = getRes(reply) ctx.abortController = new AbortController() ctx.cookies = request.cookies cookieParserReadCh.publish(ctx) cookiesPublished.add(req) if (ctx.abortController.signal.aborted) return } if (name === 'onRequest' || name === 'preParsing') { parsingContexts.set(req, ctx) if (callbackFinishCh.hasSubscribers) { const self = this const allArgs = arguments return callbackFinishCh.runStores(ctx, () => doneCallback.apply(self, allArgs)) } } return doneCallback.apply(this, arguments) } } function onRequest (request, reply, done) { if (typeof done !== 'function') return const req = getReq(request) const res = getRes(reply) const routeConfig = getRouteConfig(request) const ctx = { req, res, routeConfig } handleChannel.publish(ctx) return done() } function preHandler (request, reply, done) { if (typeof done !== 'function') return if (!reply || typeof reply.send !== 'function') return done() const req = getReq(request) const res = getRes(reply) const ctx = { req, res } const hasBody = request.body && Object.keys(request.body).length > 0 // For multipart/form-data, the body is not available until after preValidation hook if (bodyParserReadCh.hasSubscribers && hasBody && !bodyPublished.has(req)) { ctx.abortController = new AbortController() ctx.body = request.body bodyParserReadCh.publish(ctx) bodyPublished.add(req) if (ctx.abortController.signal.aborted) return } reply.send = wrapSend(reply.send, req) done() } function preValidation (request, reply, done) { const req = getReq(request) const res = getRes(reply) const ctx = parsingContexts.get(req) ctx.res = res if (!ctx) return processInContext(request, ctx, done, req) preValidationCh.runStores(ctx, processInContext, undefined, request, ctx, done, req) } /** * @param {{ query?: object, body?: object, params?: object, [key: string]: unknown }} request * @param {{ res?: object, abortController?: AbortController, [key: string]: unknown }} ctx * @param {Function} done * @param {unknown} req */ function processInContext (request, ctx, done, req) { let abortController if (queryParamsReadCh.hasSubscribers && request.query) { abortController ??= new AbortController() ctx.abortController = abortController ctx.query = request.query queryParamsReadCh.publish(ctx) if (abortController.signal.aborted) return } // Analyze body before schema validation if (bodyParserReadCh.hasSubscribers && request.body && !bodyPublished.has(req)) { abortController ??= new AbortController() ctx.abortController = abortController ctx.body = request.body bodyParserReadCh.publish(ctx) bodyPublished.add(req) if (abortController.signal.aborted) return } if (pathParamsReadCh.hasSubscribers && request.params) { abortController ??= new AbortController() ctx.abortController = abortController ctx.params = request.params pathParamsReadCh.publish(ctx) if (abortController.signal.aborted) return } done() } function preParsing (request, reply, payload, done) { if (typeof done !== 'function') { done = payload } const req = getReq(request) const ctx = { req } parsingContexts.set(req, ctx) preParsingCh.runStores(ctx, () => done()) } function wrapSend (send, req) { return function sendWithTrace (payload) { const ctx = { req } if (payload instanceof Error) { ctx.error = payload errorChannel.publish(ctx) } else if (canPublishResponsePayload(payload)) { const res = getRes(this) ctx.res = res ctx.body = payload responsePayloadReadCh.publish(ctx) } return send.apply(this, arguments) } } function getReq (request) { return request && (request.raw || request.req || request) } function getRes (reply) { return reply && (reply.raw || reply.res || reply) } function getRouteConfig (request) { return request?.routeOptions?.config } function publishError (ctx) { if (ctx.error) { errorChannel.publish(ctx) } return ctx.error } function onRoute (routeOptions) { const ctx = { routeOptions, onRoute } routeAddedChannel.publish(ctx) } // send() payload types: https://fastify.dev/docs/latest/Reference/Reply/#senddata function canPublishResponsePayload (payload) { return responsePayloadReadCh.hasSubscribers && payload && typeof payload === 'object' && typeof payload.pipe !== 'function' && // Node streams typeof payload.body?.pipe !== 'function' && // Response with body stream !Buffer.isBuffer(payload) && // Buffer !(payload instanceof ArrayBuffer) && // ArrayBuffer !ArrayBuffer.isView(payload) // TypedArray } addHook({ name: 'fastify', versions: ['>=3'] }, (fastify) => { const wrapped = shimmer.wrapFunction(fastify, fastify => wrapFastify(fastify, true)) wrapped.fastify = wrapped wrapped.default = wrapped return wrapped }) addHook({ name: 'fastify', versions: ['2'] }, (fastify) => { return shimmer.wrapFunction(fastify, fastify => wrapFastify(fastify, true)) }) addHook({ name: 'fastify', versions: ['1'] }, (fastify) => { return shimmer.wrapFunction(fastify, fastify => wrapFastify(fastify, false)) }) function wrapReplyHeader (Reply) { shimmer.wrap(Reply.prototype, 'header', header => function (key, value) { const result = header.apply(this, arguments) if (finishSetHeaderCh.hasSubscribers && key && value) { const ctx = { name: key, value, res: getRes(this) } finishSetHeaderCh.publish(ctx) } return result }) return Reply } addHook({ name: 'fastify', file: 'lib/reply.js', versions: ['>=1'] }, wrapReplyHeader)