Skip to content

Commit

Permalink
Add support for Fastify entry spans for Code Origin for Spans (#4449)
Browse files Browse the repository at this point in the history
This commit does two things:

- It lays the groundwork for an upcoming feature called "Code Origin for
  Spans".
- 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 authored and bengl committed Oct 16, 2024
1 parent f3dc7d3 commit 7bd337b
Show file tree
Hide file tree
Showing 13 changed files with 509 additions and 37 deletions.
38 changes: 38 additions & 0 deletions packages/datadog-code-origin/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use strict'

const { getUserLandFrames } = require('../dd-trace/src/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)
tags[`_dd.code_origin.frames.${i}.column`] = String(frame.column)
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
}
13 changes: 12 additions & 1 deletion packages/datadog-instrumentations/src/fastify.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { addHook, channel, AsyncResource } = 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 parsingResources = new WeakMap()

Expand All @@ -16,6 +17,7 @@ function wrapFastify (fastify, hasParsingEvents) {

if (!app || typeof app.addHook !== 'function') return app

app.addHook('onRoute', onRoute)
app.addHook('onRequest', onRequest)
app.addHook('preHandler', preHandler)

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

const req = getReq(request)
const res = getRes(reply)
const routeConfig = getRouteConfig(request)

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

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

function getRouteConfig (request) {
return request?.routeOptions?.config
}

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

function onRoute (routeOptions) {
routeAddedChannel.publish({ routeOptions, 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
31 changes: 31 additions & 0 deletions packages/datadog-plugin-fastify/src/code_origin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use strict'

const { entryTag } = require('../../datadog-code-origin')
const Plugin = require('../../dd-trace/src/plugins/plugin')
const web = require('../../dd-trace/src/plugins/util/web')

const kCodeOriginForSpansTagsSym = Symbol('datadog.codeOriginForSpansTags')

class FastifyCodeOriginForSpansPlugin extends Plugin {
static get id () {
return 'fastify'
}

constructor (...args) {
super(...args)

this.addSub('apm:fastify:request:handle', ({ req, routeConfig }) => {
const tags = routeConfig?.[kCodeOriginForSpansTagsSym]
if (!tags) return
const context = web.getContext(req)
context.span?.addTags(tags)
})

this.addSub('apm:fastify:route:added', ({ routeOptions, onRoute }) => {
if (!routeOptions.config) routeOptions.config = {}
routeOptions.config[kCodeOriginForSpansTagsSym] = entryTag(onRoute)
})
}
}

module.exports = FastifyCodeOriginForSpansPlugin
22 changes: 10 additions & 12 deletions packages/datadog-plugin-fastify/src/index.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
'use strict'

const RouterPlugin = require('../../datadog-plugin-router/src')
const FastifyTracingPlugin = require('./tracing')
const FastifyCodeOriginForSpansPlugin = require('./code_origin')
const CompositePlugin = require('../../dd-trace/src/plugins/composite')

class FastifyPlugin extends RouterPlugin {
static get id () {
return 'fastify'
}

constructor (...args) {
super(...args)

this.addSub('apm:fastify:request:handle', ({ req }) => {
this.setFramework(req, 'fastify', this.config)
})
class FastifyPlugin extends CompositePlugin {
static get id () { return 'fastify' }
static get plugins () {
return {
tracing: FastifyTracingPlugin,
codeOriginForSpans: FastifyCodeOriginForSpansPlugin
}
}
}

Expand Down
19 changes: 19 additions & 0 deletions packages/datadog-plugin-fastify/src/tracing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use strict'

const RouterPlugin = require('../../datadog-plugin-router/src')

class FastifyTracingPlugin extends RouterPlugin {
static get id () {
return 'fastify'
}

constructor (...args) {
super(...args)

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

module.exports = FastifyTracingPlugin
216 changes: 216 additions & 0 deletions packages/datadog-plugin-fastify/test/code_origin.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
'use strict'

const axios = require('axios')
const semver = require('semver')
const agent = require('../../dd-trace/test/plugins/agent')
const { NODE_MAJOR } = require('../../../version')

const host = 'localhost'

describe('Plugin', () => {
let fastify
let app

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

afterEach(() => {
app.close()
})

withExports('fastify', version, ['default', 'fastify'], '>=3', getExport => {
describe('with tracer config codeOriginForSpans.enabled: true', () => {
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 }],
{ codeOriginForSpans: { enabled: 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.column').to.match(/^\d+$/)
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.have.property('_dd.code_origin.frames.1.column').to.match(/^\d+$/)
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.column').to.match(/^\d+$/)
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.column').to.match(/^\d+$/)
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.column').to.match(/^\d+$/)
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)
}
Loading

0 comments on commit 7bd337b

Please sign in to comment.