Skip to content

Commit

Permalink
Add initial support for Code Origin for Spans (Fastify only)
Browse files Browse the repository at this point in the history
To enable, set `DD_CODE_ORIGIN_FOR_SPANS_ENABLED=true`.

By default span origin is only set for spans whos callstack contains
user land stack frames.

To support span origin on entry spans (e.g. the span automatically
created when an incoming HTTP request is being instrumented), one would
need to add this to the router instrumentation for each web framework.

This commit includes support for span origin on Fastify entry spans
only.
  • Loading branch information
watson committed Oct 9, 2024
1 parent 2d175d3 commit 5c53936
Show file tree
Hide file tree
Showing 14 changed files with 452 additions and 26 deletions.
21 changes: 20 additions & 1 deletion packages/datadog-instrumentations/src/fastify.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

const shimmer = require('../../datadog-shimmer')
const { addHook, channel, AsyncResource } = require('./helpers/instrument')
const { entryTag } = require('../../dd-trace/src/code_origin')

const errorChannel = channel('apm:fastify:middleware:error')
const handleChannel = channel('apm:fastify:request:handle')
const codeOriginEnabledChannel = channel('datadog:code-origin-enabled')

const kCodeOriginForSpansTagsSym = Symbol('kCodeOriginForSpansTagsSym')

const parsingResources = new WeakMap()

Expand All @@ -27,6 +31,11 @@ function wrapFastify (fastify, hasParsingEvents) {
app.addHook('preHandler', preValidation)
}

// Hack to check if the Code Origin for Spans feature is enabled
if (codeOriginEnabledChannel.hasSubscribers) {
app.addHook('onRoute', onRoute)
}

app.addHook = wrapAddHook(app.addHook)

return app
Expand Down Expand Up @@ -86,8 +95,9 @@ function onRequest (request, reply, done) {

const req = getReq(request)
const res = getRes(reply)
const tags = getCodeOriginForSpansTags(request)

handleChannel.publish({ req, res })
handleChannel.publish({ req, res, tags })

return done()
}
Expand Down Expand Up @@ -142,6 +152,10 @@ function getRes (reply) {
return reply && (reply.raw || reply.res || reply)
}

function getCodeOriginForSpansTags (request) {
return request?.routeOptions?.config?.[kCodeOriginForSpansTagsSym]
}

function publishError (error, req) {
if (error) {
errorChannel.publish({ error, req })
Expand All @@ -150,6 +164,11 @@ function publishError (error, req) {
return error
}

function onRoute (routeOptions) {
if (!routeOptions.config) routeOptions.config = {}
routeOptions.config[kCodeOriginForSpansTagsSym] = entryTag(onRoute)
}

addHook({ name: 'fastify', versions: ['>=3'] }, fastify => {
const wrapped = shimmer.wrapFunction(fastify, fastify => wrapFastify(fastify, true))

Expand Down
2 changes: 1 addition & 1 deletion packages/datadog-instrumentations/src/mocha/common.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const { addHook, channel } = require('../helpers/instrument')
const shimmer = require('../../../datadog-shimmer')
const { getCallSites } = require('../../../dd-trace/src/plugins/util/test')
const { getCallSites } = require('../../../dd-trace/src/plugins/util/stacktrace')
const { testToStartLine } = require('./utils')

const parameterizedTestCh = channel('ci:mocha:test:parameterize')
Expand Down
7 changes: 6 additions & 1 deletion packages/datadog-plugin-fastify/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ class FastifyPlugin extends RouterPlugin {
constructor (...args) {
super(...args)

this.addSub('apm:fastify:request:handle', ({ req }) => {
this.addSub('apm:fastify:request:handle', ({ req, tags }) => {
this.setFramework(req, 'fastify', this.config)
this.setSpanTags(req, tags)
})

if (this._tracerConfig.codeOriginForSpansEnabled) {
this.addSub('datadog:code-origin-enabled')
}
}
}

Expand Down
190 changes: 188 additions & 2 deletions packages/datadog-plugin-fastify/test/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ describe('Plugin', () => {

describe('fastify', () => {
withVersions('fastify', 'fastify', (version, _, specificVersion) => {
if (NODE_MAJOR <= 18 && semver.satisfies(specificVersion, '>=5')) return

beforeEach(() => {
tracer = require('../../dd-trace')
})
Expand All @@ -26,8 +28,6 @@ describe('Plugin', () => {

withExports('fastify', version, ['default', 'fastify'], '>=3', getExport => {
describe('without configuration', () => {
if (NODE_MAJOR <= 18 && semver.satisfies(specificVersion, '>=5')) return

before(() => {
return agent.load(['fastify', 'find-my-way', 'http'], [{}, {}, { client: false }])
})
Expand Down Expand Up @@ -542,7 +542,193 @@ describe('Plugin', () => {
})
}
})

describe('with tracer config codeOriginForSpansEnabled', () => {
if (semver.satisfies(specificVersion, '<4')) return // TODO: Why doesn't it work on older versions?

before(() => {
return agent.load(
['fastify', 'find-my-way', 'http'],
[{}, {}, { client: false }],
{ codeOriginForSpansEnabled: true }
)
})

after(() => {
return agent.close({ ritmReset: false })
})

beforeEach(() => {
fastify = getExport()
app = fastify()

if (semver.intersects(version, '>=3')) {
return app.register(require('../../../versions/middie').get())
}
})

it('should add code_origin tag on entry spans when feature is enabled', done => {
let routeRegisterLine

// Wrap in a named function to have at least one frame with a function name
function wrapperFunction () {
routeRegisterLine = getNextLineNumber()
app.get('/user', function userHandler (request, reply) {
reply.send()
})
}

const callWrapperLine = getNextLineNumber()
wrapperFunction()

app.listen(() => {
const port = app.server.address().port

agent
.use(traces => {
const spans = traces[0]
const tags = spans[0].meta

expect(tags).to.have.property('_dd.code_origin.type', 'entry')

expect(tags).to.have.property('_dd.code_origin.frames.0.file', __filename)
expect(tags).to.have.property('_dd.code_origin.frames.0.line', routeRegisterLine)
expect(tags).to.have.property('_dd.code_origin.frames.0.method', 'wrapperFunction')
expect(tags).to.not.have.property('_dd.code_origin.frames.0.type')

expect(tags).to.have.property('_dd.code_origin.frames.1.file', __filename)
expect(tags).to.have.property('_dd.code_origin.frames.1.line', callWrapperLine)
expect(tags).to.not.have.property('_dd.code_origin.frames.1.method')
expect(tags).to.have.property('_dd.code_origin.frames.1.type', 'Context')

expect(tags).to.not.have.property('_dd.code_origin.frames.2.file')
})
.then(done)
.catch(done)

axios
.get(`http://localhost:${port}/user`)
.catch(done)
})
})

it('should point to where actual route handler is configured, not the prefix', done => {
let routeRegisterLine

app.register(function v1Handler (app, opts, done) {
routeRegisterLine = getNextLineNumber()
app.get('/user', function userHandler (request, reply) {
reply.send()
})
done()
}, { prefix: '/v1' })

app.listen(() => {
const port = app.server.address().port

agent
.use(traces => {
const spans = traces[0]
const tags = spans[0].meta

expect(tags).to.have.property('_dd.code_origin.type', 'entry')

expect(tags).to.have.property('_dd.code_origin.frames.0.file', __filename)
expect(tags).to.have.property('_dd.code_origin.frames.0.line', routeRegisterLine)
expect(tags).to.have.property('_dd.code_origin.frames.0.method', 'v1Handler')
expect(tags).to.not.have.property('_dd.code_origin.frames.0.type')

expect(tags).to.not.have.property('_dd.code_origin.frames.1.file')
})
.then(done)
.catch(done)

axios
.get(`http://localhost:${port}/v1/user`)
.catch(done)
})
})

it('should point to route handler even if passed through a middleware', function testCase (done) {
app.use(function middleware (req, res, next) {
next()
})

const routeRegisterLine = getNextLineNumber()
app.get('/user', function userHandler (request, reply) {
reply.send()
})

app.listen({ host, port: 0 }, () => {
const port = app.server.address().port

agent
.use(traces => {
const spans = traces[0]
const tags = spans[0].meta

expect(tags).to.have.property('_dd.code_origin.type', 'entry')

expect(tags).to.have.property('_dd.code_origin.frames.0.file', __filename)
expect(tags).to.have.property('_dd.code_origin.frames.0.line', routeRegisterLine)
expect(tags).to.have.property('_dd.code_origin.frames.0.method', 'testCase')
expect(tags).to.have.property('_dd.code_origin.frames.0.type', 'Context')

expect(tags).to.not.have.property('_dd.code_origin.frames.1.file')
})
.then(done)
.catch(done)

axios
.get(`http://localhost:${port}/user`)
.catch(done)
})
})

// TODO: In Fastify, the route is resolved before the middleware is called, so we actually can get the line
// number of where the route handler is defined. However, this might not be the right choice and it might be
// better to point to the middleware.
it.skip('should point to middleware if middleware responds early', function testCase (done) {
const middlewareRegisterLine = getNextLineNumber()
app.use(function middleware (req, res, next) {
res.end()
})

app.get('/user', function userHandler (request, reply) {
reply.send()
})

app.listen({ host, port: 0 }, () => {
const port = app.server.address().port

agent
.use(traces => {
const spans = traces[0]
const tags = spans[0].meta

expect(tags).to.have.property('_dd.code_origin.type', 'entry')

expect(tags).to.have.property('_dd.code_origin.frames.0.file', __filename)
expect(tags).to.have.property('_dd.code_origin.frames.0.line', middlewareRegisterLine)
expect(tags).to.have.property('_dd.code_origin.frames.0.method', 'testCase')
expect(tags).to.have.property('_dd.code_origin.frames.0.type', 'Context')

expect(tags).to.not.have.property('_dd.code_origin.frames.1.file')
})
.then(done)
.catch(done)

axios
.get(`http://localhost:${port}/user`)
.catch(done)
})
})
})
})
})
})
})

function getNextLineNumber () {
return String(Number(new Error().stack.split('\n')[2].match(/:(\d+):/)[1]) + 1)
}
4 changes: 4 additions & 0 deletions packages/datadog-plugin-web/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ class WebPlugin extends Plugin {
setFramework (req, name, config) {
web.setFramework(req, name, config)
}

setSpanTags (req, tags) {
web.setSpanTags(req, tags)
}
}

module.exports = WebPlugin
37 changes: 37 additions & 0 deletions packages/dd-trace/src/code_origin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use strict'

const { getUserLandFrames } = require('./plugins/util/stacktrace')

const limit = Number(process.env._DD_CODE_ORIGIN_MAX_USER_FRAMES) || 8

module.exports = {
entryTag,
exitTag
}

function entryTag (topOfStackFunc) {
return tag('entry', topOfStackFunc)
}

function exitTag (topOfStackFunc) {
return tag('exit', topOfStackFunc)
}

function tag (type, topOfStackFunc) {
const frames = getUserLandFrames(topOfStackFunc, limit)
const tags = {
'_dd.code_origin.type': type
}
for (let i = 0; i < frames.length; i++) {
const frame = frames[i]
tags[`_dd.code_origin.frames.${i}.file`] = frame.file
tags[`_dd.code_origin.frames.${i}.line`] = String(frame.line)
if (frame.method) {
tags[`_dd.code_origin.frames.${i}.method`] = frame.method
}
if (frame.type) {
tags[`_dd.code_origin.frames.${i}.type`] = frame.type
}
}
return tags
}
4 changes: 4 additions & 0 deletions packages/dd-trace/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ class Config {
this._setValue(defaults, 'appsec.wafTimeout', 5e3) // µs
this._setValue(defaults, 'clientIpEnabled', false)
this._setValue(defaults, 'clientIpHeader', null)
this._setValue(defaults, 'codeOriginForSpansEnabled', false)
this._setValue(defaults, 'dbmPropagationMode', 'disabled')
this._setValue(defaults, 'dogstatsd.hostname', '127.0.0.1')
this._setValue(defaults, 'dogstatsd.port', '8125')
Expand Down Expand Up @@ -571,6 +572,7 @@ class Config {
DD_APPSEC_RASP_ENABLED,
DD_APPSEC_TRACE_RATE_LIMIT,
DD_APPSEC_WAF_TIMEOUT,
DD_CODE_ORIGIN_FOR_SPANS_ENABLED,
DD_DATA_STREAMS_ENABLED,
DD_DBM_PROPAGATION_MODE,
DD_DOGSTATSD_HOSTNAME,
Expand Down Expand Up @@ -701,6 +703,7 @@ class Config {
this._envUnprocessed['appsec.wafTimeout'] = DD_APPSEC_WAF_TIMEOUT
this._setBoolean(env, 'clientIpEnabled', DD_TRACE_CLIENT_IP_ENABLED)
this._setString(env, 'clientIpHeader', DD_TRACE_CLIENT_IP_HEADER)
this._setBoolean(env, 'codeOriginForSpansEnabled', DD_CODE_ORIGIN_FOR_SPANS_ENABLED)
this._setString(env, 'dbmPropagationMode', DD_DBM_PROPAGATION_MODE)
this._setString(env, 'dogstatsd.hostname', DD_DOGSTATSD_HOSTNAME)
this._setString(env, 'dogstatsd.port', DD_DOGSTATSD_PORT)
Expand Down Expand Up @@ -867,6 +870,7 @@ class Config {
this._optsUnprocessed['appsec.wafTimeout'] = options.appsec.wafTimeout
this._setBoolean(opts, 'clientIpEnabled', options.clientIpEnabled)
this._setString(opts, 'clientIpHeader', options.clientIpHeader)
this._setBoolean(opts, 'codeOriginForSpansEnabled', options.codeOriginForSpansEnabled)
this._setString(opts, 'dbmPropagationMode', options.dbmPropagationMode)
if (options.dogstatsd) {
this._setString(opts, 'dogstatsd.hostname', options.dogstatsd.hostname)
Expand Down
Loading

0 comments on commit 5c53936

Please sign in to comment.