Skip to content

Commit

Permalink
Add initial support for Code Origin for Spans
Browse files Browse the repository at this point in the history
This commit does two things:

1. It lays the groundwork for a new feature called "Code Origin for
   Spans".
2. To showcase this feature, it adds limited support for just Fastify
   entry-spans.

To enable, set `DD_CODE_ORIGIN_FOR_SPANS_ENABLED=true`.
  • Loading branch information
watson committed Oct 9, 2024
1 parent 5eea208 commit 4d48155
Show file tree
Hide file tree
Showing 13 changed files with 449 additions and 27 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
192 changes: 189 additions & 3 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,14 +28,12 @@ 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 }])
})

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

beforeEach(() => {
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, wipe: true })
})

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 @@ -572,6 +573,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 @@ -702,6 +704,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 @@ -868,6 +871,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 4d48155

Please sign in to comment.