signalfx-tracing
Version:
Provides auto-instrumentation for JavaScript libraries and frameworks
474 lines (383 loc) • 13.1 kB
JavaScript
'use strict'
const pathToRegexp = require('path-to-regexp')
const xregexp = require('xregexp')
const analyticsSampler = require('../../analytics_sampler')
const FORMAT_HTTP_HEADERS = require('opentracing').FORMAT_HTTP_HEADERS
const log = require('../../log')
const tags = require('../../../ext/tags')
const types = require('../../../ext/types')
const kinds = require('../../../ext/kinds')
const urlFilter = require('./urlfilter')
const platform = require('../../platform')
const SpanContext = require('../../opentracing/span_context')
const idToHex = require('../../utils').idToHex
const HTTP = types.HTTP
const SERVER = kinds.SERVER
const RESOURCE_NAME = tags.RESOURCE_NAME
const SERVICE_NAME = tags.SERVICE_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_REQUEST_HEADERS = tags.HTTP_REQUEST_HEADERS
const HTTP_RESPONSE_HEADERS = tags.HTTP_RESPONSE_HEADERS
const web = {
// Ensure the configuration has the correct structure and defaults.
normalizeConfig (config) {
config = config.server || config
const headers = getHeadersToRecord(config)
const validateStatus = getStatusValidator(config)
const hooks = getHooks(config)
const filter = urlFilter.getFilter(config)
const expandRouteParameters = getExpandRouteParameters(config)
const synthesizeRequestingContext = getSynthesizeRequestingContext(config)
return Object.assign({}, config, {
headers,
validateStatus,
hooks,
filter,
expandRouteParameters,
synthesizeRequestingContext
})
},
// Start a span and activate a scope for a request.
instrument (tracer, config, req, res, name, callback) {
this.patch(req)
const span = startSpan(tracer, config, req, res, name)
// TODO: replace this with a REFERENCE_NOOP after we split http/express/etc
if (!config.filter(req.url)) {
span.context()._sampling.drop = true
}
if (config.service) {
span.setTag(SERVICE_NAME, config.service)
}
analyticsSampler.sample(span, config.analytics, true)
wrapEnd(req)
wrapEvents(req)
if (config.enableServerTiming) {
if (!res._sfx_serverTimingAdded) {
res.setHeader('Server-Timing', traceParentHeader(span.context()))
res.setHeader('Access-Control-Expose-Headers', 'Server-Timing')
Object.defineProperty(res, '_sfx_serverTimingAdded', { value: true })
}
}
return callback && tracer.scope().activate(span, () => callback(span))
},
// Reactivate the request scope in case it was changed by a middleware.
reactivate (req, fn) {
return reactivate(req, fn)
},
// Add a route segment that will be used for the resource name.
enterRoute (req, path) {
req._datadog.paths.push(path)
},
// Remove the current route segment.
exitRoute (req) {
req._datadog.paths.pop()
},
// Start a new middleware span and activate a new scope with the span.
wrapMiddleware (req, middleware, name, fn) {
if (!this.active(req)) return fn()
const tracer = req._datadog.tracer
const childOf = this.active(req)
const span = tracer.startSpan(name, { childOf })
span.addTags({
[RESOURCE_NAME]: middleware._name || middleware.name || '<anonymous>'
})
analyticsSampler.sample(span, req._datadog.config.analytics)
req._datadog.middleware.push(span)
return tracer.scope().activate(span, fn)
},
// Finish the active middleware span.
finish (req, error) {
if (!this.active(req)) return
const span = req._datadog.middleware.pop()
if (span) {
if (error) {
span.addTags({
'sfx.error.kind': error.name,
'sfx.error.message': error.message,
'sfx.error.stack': error.stack
})
}
span.finish()
}
},
// Register a callback to run before res.end() is called.
beforeEnd (req, callback) {
req._datadog.beforeEnd.push(callback)
},
// Prepare the request for instrumentation.
patch (req) {
if (req._datadog) return
Object.defineProperty(req, '_datadog', {
value: {
span: null,
paths: [],
middleware: [],
beforeEnd: [],
childOfRequestingContext: false
}
})
},
// Return the request root span.
root (req) {
return req._datadog ? req._datadog.span : null
},
// Return the active span.
active (req) {
if (!req._datadog) return null
if (req._datadog.middleware.length === 0) return req._datadog.span || null
return req._datadog.middleware.slice(-1)[0]
}
}
function startSpan (tracer, config, req, res, name) {
req._datadog.config = config
if (req._datadog.span) {
req._datadog.span.context()._name = name
return req._datadog.span
}
let childOf = tracer.extract(FORMAT_HTTP_HEADERS, req.headers)
if (!childOf) {
childOf = synthesizedSpanContext(req)
} else {
req._datadog.childOfRequestingContext = true
}
const span = tracer.startSpan(name, { childOf })
req._datadog.tracer = tracer
req._datadog.span = span
req._datadog.res = res
return span
}
function finish (req, res) {
if (req._datadog.finished) return
addRequestTags(req)
addResponseTags(req)
req._datadog.config.hooks.request(req._datadog.span, req, res)
addResourceTag(req)
revertSynthesizedContext(req)
req._datadog.span.finish()
req._datadog.finished = true
}
function finishMiddleware (req, res) {
if (req._datadog.finished) return
let span
while ((span = req._datadog.middleware.pop())) {
span.finish()
}
}
function wrapEnd (req) {
const scope = req._datadog.tracer.scope()
const res = req._datadog.res
const end = res.end
if (end === req._datadog.end) return
let _end = req._datadog.end = res.end = function () {
req._datadog.beforeEnd.forEach(beforeEnd => beforeEnd())
finishMiddleware(req, res)
const returnValue = end.apply(this, arguments)
finish(req, res)
return returnValue
}
Object.defineProperty(res, 'end', {
configurable: true,
get () {
return _end
},
set (value) {
_end = scope.bind(value, req._datadog.span)
}
})
}
function wrapEvents (req) {
const scope = req._datadog.tracer.scope()
const res = req._datadog.res
const on = res.on
if (on === req._datadog.on) return
req._datadog.on = scope.bind(res, req._datadog.span).on
}
function reactivate (req, fn) {
return req._datadog.tracer.scope().activate(req._datadog.span, fn)
}
function addRequestTags (req) {
const protocol = req.connection.encrypted ? 'https' : 'http'
const url = `${protocol}://${req.headers['host']}${req.originalUrl || req.url}`
const span = req._datadog.span
span.addTags({
[HTTP_URL]: url.split('?')[0],
[HTTP_METHOD]: req.method,
[SPAN_KIND]: SERVER,
[SPAN_TYPE]: HTTP
})
addHeaders(req)
}
function addResponseTags (req) {
const span = req._datadog.span
const res = req._datadog.res
if (req._datadog.paths.length > 0) {
span.setTag(HTTP_ROUTE, req._datadog.paths.join(''))
}
span.addTags({
[HTTP_STATUS_CODE]: res.statusCode
})
addStatusError(req)
}
function addResourceTag (req) {
const span = req._datadog.span
const tags = span.context()._tags
if (tags['resource.name']) return
const path = expandRouteParameters(tags[HTTP_ROUTE], req)
const resource = [].concat(path)
.filter(val => val)
.join(' ')
if (!resource) {
const componentName = tags.component ? tags.component : 'handle'
span.setTag(RESOURCE_NAME, `${componentName}.request`)
} else {
span.setTag(RESOURCE_NAME, resource)
}
}
// Allows :routeParameters to be expanded by their request path value
function expandRouteParameters (httpRoute, req) {
let expandedPath = httpRoute // default w/o expansion
const expansionRules = req._datadog.config.expandRouteParameters[httpRoute]
if (expansionRules === undefined) {
return expandedPath
}
const keys = []
const re = pathToRegexp(httpRoute, keys)
// Account for routing-reduced paths
const path = req.originalUrl.substring(0, req.originalUrl.indexOf(req.path) + req.path.length)
const matches = re.exec(path)
if (matches === null) {
return expandedPath
}
const hits = matches.slice(1, keys.length + 1)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
if (expansionRules[key.name] === true) {
const replacePattern = `:${key.name}`
const patternIndex = expandedPath.indexOf(replacePattern)
// get substrings before and after :key.name
const before = expandedPath.substring(0, patternIndex)
let after = expandedPath.substring(patternIndex + replacePattern.length)
// remove immediate capture group from after substring
let capGroupMatches
try {
capGroupMatches = xregexp.matchRecursive(after, '\\(', '\\)')
} catch (err) { // will throw if unbalanced parens in data (nothing we can do)
capGroupMatches = []
}
if (capGroupMatches.length >= 1) {
// replace stripped outer parens from recursive match and remove from after substring
const replacedGroup = `(${capGroupMatches[0]})`
const replacedGroupIndex = after.indexOf(replacedGroup)
after = after.substring(replacedGroupIndex + replacedGroup.length)
}
// recreate expanded path with truncated substring
// set expandedPath to be replaced :key.name w/ value
expandedPath = before + hits[i] + after
}
}
return expandedPath
}
// Creates a new span context and sets its ids as `req.sfx.traceId`
// and `req.sfx.spanId` for user access.
function synthesizedSpanContext (req) {
const traceId = platform.id()
const spanContext = new SpanContext({ traceId, spanId: traceId })
Object.defineProperty(spanContext, 'isSynthesized', { value: true })
const resId = idToHex(traceId)
Object.defineProperty(req, 'sfx', { value: { traceId: resId, spanId: resId } })
return spanContext
}
// Will remove the synthesized parent for any request without
// `synthesizeRequestingContext` configured. Since the router
// instrumentation only determines paths by the end of the
// lifecycle, this must affect all spans that aren't the actual
// child of propagated context.
function revertSynthesizedContext (req) {
if (req._datadog.childOfRequestingContext) {
return
}
const span = req._datadog.span
const tags = span.context()._tags
const path = tags[HTTP_ROUTE]
const synthesize = req._datadog.config.synthesizeRequestingContext[path]
if (synthesize) {
return
}
// "revert" synthesized context
span.context()._parentId = null
}
function addHeaders (req) {
const span = req._datadog.span
req._datadog.config.headers.forEach(key => {
const reqHeader = req.headers[key]
const resHeader = req._datadog.res.getHeader(key)
if (reqHeader) {
span.setTag(`${HTTP_REQUEST_HEADERS}.${key}`, reqHeader)
}
if (resHeader) {
span.setTag(`${HTTP_RESPONSE_HEADERS}.${key}`, resHeader)
}
})
}
function addStatusError (req) {
if (!req._datadog.config.validateStatus(req._datadog.res.statusCode)) {
req._datadog.span.setTag(ERROR, true)
}
}
function getHeadersToRecord (config) {
if (Array.isArray(config.headers)) {
try {
return config.headers.map(key => key.toLowerCase())
} catch (err) {
log.error(err)
}
} else if (config.hasOwnProperty('headers')) {
log.error('Expected `headers` to be an array of strings.')
}
return []
}
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 code => code < 500
}
function getHooks (config) {
const noop = () => {}
const request = (config.hooks && config.hooks.request) || noop
return { request }
}
function getExpandRouteParameters (config) {
if (typeof config.expandRouteParameters === 'object') {
return config.expandRouteParameters
} else if (config.hasOwnProperty('expandRouteParameters')) {
log.error('Expected `expandRouteParameters` to be an object of paths to expansion rules')
}
return {}
}
function getSynthesizeRequestingContext (config) {
if (typeof config.synthesizeRequestingContext === 'object') {
return config.synthesizeRequestingContext
} else if (config.hasOwnProperty('synthesizeRequestingContext')) {
log.error('Expected `synthesizeRequestingContext` to be an object of paths to booleans')
}
return {}
}
function padTo128 (hexId) {
const padded = '0000000000000000' + hexId
return padded.slice(-32)
}
function traceParentHeader (spanContext) {
// https://www.w3.org/TR/server-timing/
// https://www.w3.org/TR/trace-context/#traceparent-header
return 'traceparent;desc="00-' + padTo128(idToHex(spanContext._traceId)) + '-' + idToHex(spanContext._spanId) + '-01"'
}
module.exports = web