UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

573 lines (454 loc) 16.5 kB
'use strict' const uniq = require('../../../../datadog-core/src/utils/src/uniq') const analyticsSampler = require('../../analytics_sampler') const FORMAT_HTTP_HEADERS = 'http_headers' const log = require('../../log') const tags = require('../../../../../ext/tags') const types = require('../../../../../ext/types') const kinds = require('../../../../../ext/kinds') const { ERROR_MESSAGE } = require('../../constants') const TracingPlugin = require('../tracing') const { storage } = require('../../../../datadog-core') const legacyStorage = storage('legacy') const urlFilter = require('./urlfilter') const { createInferredProxySpan, finishInferredProxySpan } = require('./inferred_proxy') const { extractURL, obfuscateQs, calculateHttpEndpoint } = require('./url') const WEB = types.WEB const SERVER = kinds.SERVER const RESOURCE_NAME = tags.RESOURCE_NAME const SPAN_TYPE = tags.SPAN_TYPE const SPAN_KIND = tags.SPAN_KIND const ERROR = tags.ERROR const HTTP_METHOD = tags.HTTP_METHOD const HTTP_URL = tags.HTTP_URL const HTTP_STATUS_CODE = tags.HTTP_STATUS_CODE const HTTP_ROUTE = tags.HTTP_ROUTE const HTTP_ENDPOINT = tags.HTTP_ENDPOINT const HTTP_REQUEST_HEADERS = tags.HTTP_REQUEST_HEADERS const HTTP_RESPONSE_HEADERS = tags.HTTP_RESPONSE_HEADERS const HTTP_USERAGENT = tags.HTTP_USERAGENT const HTTP_CLIENT_IP = tags.HTTP_CLIENT_IP const MANUAL_DROP = tags.MANUAL_DROP const contexts = new WeakMap() // TODO: change this to no longer rely on creating a dummy plugin to be able to access startSpan function createWebPlugin (tracer, config = {}) { const plugin = new TracingPlugin(tracer, tracer._config) plugin.component = 'web' plugin.config = config return plugin } function startSpanHelper (tracer, name, options, traceCtx, config = {}) { if (!web.plugin) { web.plugin = createWebPlugin(tracer, config) } return web.plugin.startSpan(name, { ...options, tracer, config }, traceCtx) } const web = { TYPE: WEB, /** @type {TracingPlugin | null} */ plugin: null, // Ensure the configuration has the correct structure and defaults. normalizeConfig (config) { const headers = getHeadersToRecord(config) const validateStatus = getStatusValidator(config) const hooks = getHooks(config) const filter = urlFilter.getFilter(config) const middleware = getMiddlewareSetting(config) const queryStringObfuscation = getQsObfuscator(config) const extractIp = config.clientIpEnabled ? require('./ip_extractor').extractIp : undefined return { ...config, headers, validateStatus, hooks, filter, middleware, queryStringObfuscation, extractIp, } }, setFramework (req, name, config) { const context = this.patch(req) const span = context.span if (!span) return span.context()._name = `${name}.request` span.context().setTag('component', name) span._integrationName = name web.setConfig(req, config) }, setConfig (req, config) { const context = contexts.get(req) const span = context.span context.config = config if (!config.filter(req.url)) { span.setTag(MANUAL_DROP, true) span.context()._trace.isRecording = false } if (config.service) { web.plugin.setServiceName(span, config.service) } analyticsSampler.sample(span, config.measured, true) }, startSpan (tracer, config, req, res, name, traceCtx) { const context = this.patch(req) let span if (context.span) { context.span.context()._name = name span = context.span } else { span = web.startServerlessSpanWithInferredProxy(tracer, config, name, req, traceCtx) } context.tracer = tracer context.span = span context.res = res this.setConfig(req, config) addRequestTags(context, this.TYPE) return span }, // Add a route segment that will be used for the resource name. enterRoute (req, path) { if (typeof path === 'string') { contexts.get(req).paths.push(path) } }, setRoute (req, path) { const context = contexts.get(req) if (!context) return context.paths = [path] }, // Remove the current route segment. exitRoute (req) { contexts.get(req).paths.pop() }, // Register a callback to run before res.end() is called. beforeEnd (req, callback) { contexts.get(req).beforeEnd.push(callback) }, // Prepare the request for instrumentation. patch (req) { let context = contexts.get(req) if (context) return context context = req.stream && contexts.get(req.stream) if (context) { contexts.set(req, context) return context } context = { req, span: null, paths: [], middleware: [], beforeEnd: [], config: {}, } contexts.set(req, context) return context }, // Return the request root span. root (req) { const context = contexts.get(req) return context ? context.span : null }, // Return the active span. active (req) { const context = contexts.get(req) if (!context) return null if (context.middleware.length === 0) return context.span || null return context.middleware.at(-1) }, startServerlessSpanWithInferredProxy (tracer, config, name, req, traceCtx) { const headers = req.headers const reqCtx = contexts.get(req) const store = legacyStorage.getStore() const pubsubSpan = store?.span?._name === 'pubsub.push.receive' ? store.span : null let childOf = pubsubSpan || tracer.extract(FORMAT_HTTP_HEADERS, headers) // we may have headers signaling a router proxy span should be created (such as for AWS API Gateway) if (tracer._config?.inferredProxyServicesEnabled) { const proxySpan = createInferredProxySpan(headers, childOf, tracer, reqCtx, traceCtx, config, startSpanHelper) if (proxySpan) { childOf = proxySpan } } return startSpanHelper(tracer, name, { childOf }, traceCtx, config) }, // Validate a request's status code and then add error tags if necessary addStatusError (req, statusCode) { const context = contexts.get(req) const { span, inferredProxySpan, error } = context const spanContext = span.context() const spanHasExistingError = spanContext.getTag('error') || spanContext.getTag(ERROR_MESSAGE) const inferredSpanContext = inferredProxySpan?.context() const inferredSpanHasExistingError = inferredSpanContext?.getTag('error') || inferredSpanContext?.getTag(ERROR_MESSAGE) const isValidStatusCode = context.config.validateStatus(statusCode) if (!spanHasExistingError && !isValidStatusCode) { span.setTag(ERROR, error || true) } if (inferredProxySpan && !inferredSpanHasExistingError && !isValidStatusCode) { inferredProxySpan.setTag(ERROR, error || true) } }, // Add an error to the request addError (req, error) { if (error instanceof Error) { const context = contexts.get(req) if (context) { context.error = error } } }, finishMiddleware (context) { if (context.finished) return let span while ((span = context.middleware.pop())) { span.finish() } }, finishSpan (context, spanType) { const { req, res } = context if (context.finished && !req.stream) return // `addRequestTags` is idempotent: in the normal HTTP path it ran during // `web.startSpan`. Serverless callers (e.g. Azure Functions) skip // `web.startSpan` and rely on this call to do the request-side work. addRequestTags(context, spanType) // Configured-header tagging runs at finish time. Framework plugins // (connect, express, ...) install their own config via `setFramework` // after `web.startSpan` has already locked the http-plugin config in; // tagging earlier would use the http-plugin's `headers` list and drop // the framework's. addRequestHeaders(context) addResponseTags(context) context.config.hooks.request(context.span, req, res) addResourceTag(context) context.span.finish() context.finished = true }, finishAll (context, spanType) { for (const beforeEnd of context.beforeEnd) { beforeEnd() } web.finishMiddleware(context) web.finishSpan(context, spanType) finishInferredProxySpan(context) }, wrapWriteHead (context) { const { req, res } = context const writeHead = res.writeHead return function (statusCode, statusMessage, headers) { // CORS preflight tagging only matters for OPTIONS requests. Skip the // getHeaders() spread + isOriginAllowed work entirely for the common // GET / POST / etc. case. Node's http module passes `req.method` // through unchanged, so all standard methods are uppercase; the // `toLowerCase` fallback covers any non-standard caller. if (req.method === 'OPTIONS' || req.method.toLowerCase() === 'options') { headers = typeof statusMessage === 'string' ? headers : statusMessage headers = { ...res.getHeaders(), ...headers } if (isOriginAllowed(req, headers)) { addAllowHeaders(req, res, headers) } } return writeHead.apply(this, arguments) } }, getContext (req) { return contexts.get(req) }, setRouteOrEndpointTag (req) { const context = contexts.get(req) if (!context) return applyRouteOrEndpointTag(context) }, } function addAllowHeaders (req, res, headers) { const allowHeaders = splitHeader(headers['access-control-allow-headers']) const requestHeaders = splitHeader(req.headers['access-control-request-headers']) const contextHeaders = [ 'x-datadog-origin', 'x-datadog-parent-id', 'x-datadog-sampled', // Deprecated, but still accept it in case it's sent. 'x-datadog-sampling-priority', 'x-datadog-trace-id', 'x-datadog-tags', ] for (const header of contextHeaders) { if (requestHeaders.includes(header)) { allowHeaders.push(header) } } if (allowHeaders.length > 0) { res.setHeader('access-control-allow-headers', uniq(allowHeaders).join(',')) } } function isOriginAllowed (req, headers) { const origin = req.headers.origin const allowOrigin = headers['access-control-allow-origin'] return origin && (allowOrigin === '*' || allowOrigin === origin) } function splitHeader (str) { return typeof str === 'string' ? str.split(/\s*,\s*/) : [] } function addRequestTags (context, spanType) { const { req, span, inferredProxySpan, config } = context const spanContext = span.context() // Idempotency guard. `addRequestTags` runs in `web.startSpan` for the // normal HTTP path and again in `web.finishSpan`; without this guard the // second call would re-extract the URL, re-obfuscate the query string, // and re-publish five `tagsUpdateCh` events with the same values. The // serverless path skips `startSpan` and lands here first, in which case // HTTP_URL is unset and the work runs normally. if (spanContext.hasTag(HTTP_URL)) return const url = extractURL(req) const type = spanType ?? WEB span.addTags({ [HTTP_URL]: obfuscateQs(config, url), [HTTP_METHOD]: req.method, [SPAN_KIND]: SERVER, [SPAN_TYPE]: type, [HTTP_USERAGENT]: req.headers['user-agent'], }) // if client ip has already been set by appsec, no need to run it again if (config.extractIp && !spanContext.hasTag(HTTP_CLIENT_IP)) { const clientIp = config.extractIp(config, req) if (clientIp) { span.setTag(HTTP_CLIENT_IP, clientIp) inferredProxySpan?.setTag(HTTP_CLIENT_IP, clientIp) } } // Datadog scan/test markers, tagged unconditionally so the API endpoint // reducer can keep scan/test traffic out of the API inventory. const endpointScan = req.headers['x-datadog-endpoint-scan'] if (endpointScan !== undefined) { span.setTag(`${HTTP_REQUEST_HEADERS}.x-datadog-endpoint-scan`, endpointScan) } const securityTest = req.headers['x-datadog-security-test'] if (securityTest !== undefined) { span.setTag(`${HTTP_REQUEST_HEADERS}.x-datadog-security-test`, securityTest) } } function addResponseTags (context) { const { req, res, inferredProxySpan, span } = context applyRouteOrEndpointTag(context) span.addTags({ [HTTP_STATUS_CODE]: res.statusCode, }) inferredProxySpan?.addTags({ [HTTP_STATUS_CODE]: res.statusCode, }) addResponseHeaders(context) web.addStatusError(req, res.statusCode) } function applyRouteOrEndpointTag (context) { const { paths, span, config } = context if (!span) return const spanContext = span.context() // AppSec calls `web.setRouteOrEndpointTag` from a pre-finish hook so the // route/endpoint tags are available for API Security sampling, and the // normal finish-time path runs this again. Either tag being present // means the work has already been done; paths are stable between the // two calls, so the second pass has nothing to add. if (spanContext.hasTag(HTTP_ROUTE) || spanContext.hasTag(HTTP_ENDPOINT)) return // Skip the `Array.prototype.join` builtin in the empty / single-segment // cases; `paths[0]` covers both (`undefined` is falsy for the empty case). const route = paths.length > 1 ? paths.join('') : paths[0] if (route) { // Use http.route from trusted framework instrumentation. span.setTag(HTTP_ROUTE, route) return } if (!config.resourceRenamingEnabled) return // Route is unavailable, compute http.endpoint once. const url = spanContext.getTag(HTTP_URL) const endpoint = url ? calculateHttpEndpoint(url) : '/' span.setTag(HTTP_ENDPOINT, endpoint) } function addResourceTag (context) { const { req, span } = context const spanContext = span.context() if (spanContext.getTag(RESOURCE_NAME)) return const resource = [req.method, spanContext.getTag(HTTP_ROUTE)] .filter(Boolean) .join(' ') span.setTag(RESOURCE_NAME, resource) } function addRequestHeaders (context) { const { req, config, span, inferredProxySpan } = context for (const [key, tag] of config.headers) { const reqHeader = req.headers[key] if (reqHeader) { const tagName = tag || `${HTTP_REQUEST_HEADERS}.${key}` span.setTag(tagName, reqHeader) inferredProxySpan?.setTag(tagName, reqHeader) } } } function addResponseHeaders (context) { const { res, config, span, inferredProxySpan } = context for (const [key, tag] of config.headers) { const resHeader = res.getHeader(key) if (resHeader) { const tagName = tag || `${HTTP_RESPONSE_HEADERS}.${key}` span.setTag(tagName, resHeader) inferredProxySpan?.setTag(tagName, resHeader) } } } function getHeadersToRecord (config) { if (Array.isArray(config.headers)) { try { return config.headers .map(h => h.split(':')) .map(([key, tag]) => [key.toLowerCase(), tag]) } catch (err) { log.error('Web plugin error getting headers', err) } } else if (config.hasOwnProperty('headers')) { log.error('Expected `headers` to be an array of strings.') } return [] } function isNot500ErrorCode (code) { return code < 500 } function getStatusValidator (config) { if (typeof config.validateStatus === 'function') { return config.validateStatus } else if (config.hasOwnProperty('validateStatus')) { log.error('Expected `validateStatus` to be a function.') } return isNot500ErrorCode } const noop = () => {} function getHooks (config) { const request = config.hooks?.request ?? noop return { request } } function getMiddlewareSetting (config) { if (config && typeof config.middleware === 'boolean') { return config.middleware } else if (config && config.hasOwnProperty('middleware')) { log.error('Expected `middleware` to be a boolean.') } return true } function getQsObfuscator (config) { const obfuscator = config.queryStringObfuscation if (typeof obfuscator === 'boolean') { return obfuscator } if (typeof obfuscator === 'string') { if (obfuscator === '') return false // disable obfuscator if (obfuscator === '.*') return true // optimize full redact try { return new RegExp(obfuscator, 'gi') } catch (err) { log.error('Web plugin error getting qs obfuscator', err) } } if (config.hasOwnProperty('queryStringObfuscation')) { log.error('Expected `queryStringObfuscation` to be a regex string or boolean.') } return true } module.exports = web