diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index d216a0fa5fe..4ee5836448e 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -317,6 +317,14 @@ jobs: suffix: plugins-${{ github.job }} - uses: codecov/codecov-action@v5 + dd-trace-api: + runs-on: ubuntu-latest + env: + PLUGINS: dd-trace-api + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/plugins/test + dns: runs-on: ubuntu-latest env: diff --git a/packages/datadog-plugin-dd-trace-api/src/index.js b/packages/datadog-plugin-dd-trace-api/src/index.js new file mode 100644 index 00000000000..0e7c40764b1 --- /dev/null +++ b/packages/datadog-plugin-dd-trace-api/src/index.js @@ -0,0 +1,120 @@ +'use strict' + +const Plugin = require('../../dd-trace/src/plugins/plugin') +const telemetryMetrics = require('../../dd-trace/src/telemetry/metrics') +const apiMetrics = telemetryMetrics.manager.namespace('tracers') + +// api ==> here +const objectMap = new WeakMap() + +const injectionEnabledTag = + `injection_enabled:${process.env.DD_INJECTION_ENABLED ? 'yes' : 'no'}` + +module.exports = class DdTraceApiPlugin extends Plugin { + static get id () { + return 'dd-trace-api' + } + + constructor (...args) { + super(...args) + + const tracer = this._tracer + + this.addSub('datadog-api:v1:tracerinit', ({ proxy }) => { + const proxyVal = proxy() + objectMap.set(proxyVal, tracer) + objectMap.set(proxyVal.appsec, tracer.appsec) + objectMap.set(proxyVal.dogstatsd, tracer.dogstatsd) + }) + + const handleEvent = (name) => { + const counter = apiMetrics.count('dd_trace_api.called', [ + `name:${name.replaceAll(':', '.')}`, + 'api_version:v1', + injectionEnabledTag + ]) + + // For v1, APIs are 1:1 with their internal equivalents, so we can just + // call the internal method directly. That's what we do here unless we + // want to override. As the API evolves, this may change. + this.addSub(`datadog-api:v1:${name}`, ({ self, args, ret, proxy, revProxy }) => { + counter.inc() + + if (name.includes(':')) { + name = name.split(':').pop() + } + + if (objectMap.has(self)) { + self = objectMap.get(self) + } + + for (let i = 0; i < args.length; i++) { + if (objectMap.has(args[i])) { + args[i] = objectMap.get(args[i]) + } + if (typeof args[i] === 'function') { + const orig = args[i] + args[i] = (...fnArgs) => { + for (let j = 0; j < fnArgs.length; j++) { + if (revProxy && revProxy[j]) { + const proxyVal = revProxy[j]() + objectMap.set(proxyVal, fnArgs[j]) + fnArgs[j] = proxyVal + } + } + // TODO do we need to apply(this, ...) here? + return orig(...fnArgs) + } + } + } + + try { + ret.value = self[name](...args) + if (proxy) { + const proxyVal = proxy() + objectMap.set(proxyVal, ret.value) + ret.value = proxyVal + } else if (ret.value && typeof ret.value === 'object') { + throw new TypeError(`Objects need proxies when returned via API (${name})`) + } + } catch (e) { + ret.error = e + } + }) + } + + // handleEvent('configure') + handleEvent('startSpan') + handleEvent('wrap') + handleEvent('trace') + handleEvent('inject') + handleEvent('extract') + handleEvent('getRumData') + handleEvent('profilerStarted') + handleEvent('context:toTraceId') + handleEvent('context:toSpanId') + handleEvent('context:toTraceparent') + handleEvent('span:context') + handleEvent('span:setTag') + handleEvent('span:addTags') + handleEvent('span:finish') + handleEvent('span:addLink') + handleEvent('scope') + handleEvent('scope:activate') + handleEvent('scope:active') + handleEvent('scope:bind') + handleEvent('appsec:blockRequest') + handleEvent('appsec:isUserBlocked') + handleEvent('appsec:setUser') + handleEvent('appsec:trackCustomEvent') + handleEvent('appsec:trackUserLoginFailureEvent') + handleEvent('appsec:trackUserLoginSuccessEvent') + handleEvent('dogstatsd:decrement') + handleEvent('dogstatsd:distribution') + handleEvent('dogstatsd:flush') + handleEvent('dogstatsd:gauge') + handleEvent('dogstatsd:histogram') + handleEvent('dogstatsd:increment') + handleEvent('use') + } +} diff --git a/packages/datadog-plugin-dd-trace-api/test/index.spec.js b/packages/datadog-plugin-dd-trace-api/test/index.spec.js new file mode 100644 index 00000000000..55523177d9e --- /dev/null +++ b/packages/datadog-plugin-dd-trace-api/test/index.spec.js @@ -0,0 +1,289 @@ +'use strict' + +const dc = require('dc-polyfill') + +const agent = require('../../dd-trace/test/plugins/agent') +const assert = require('assert') + +const SELF = Symbol('self') + +describe('Plugin', () => { + describe('dd-trace-api', () => { + let dummyTracer + let tracer + + const allChannels = new Set() + const testedChannels = new Set() + + before(async () => { + sinon.spy(dc, 'channel') + + await agent.load('dd-trace-api') + + tracer = require('../../dd-trace') + + sinon.spy(tracer) + sinon.spy(tracer.appsec) + sinon.spy(tracer.dogstatsd) + + for (let i = 0; i < dc.channel.callCount; i++) { + const call = dc.channel.getCall(i) + const channel = call.args[0] + if (channel.startsWith('datadog-api:v1:') && !channel.endsWith('tracerinit')) { + allChannels.add(channel) + } + } + + dummyTracer = { + appsec: {}, + dogstatsd: {} + } + const payload = { + proxy: () => dummyTracer, + args: [] + } + dc.channel('datadog-api:v1:tracerinit').publish(payload) + }) + + after(() => agent.close({ ritmReset: false })) + + describe('scope', () => { + let dummyScope + let scope + + it('should call underlying api', () => { + dummyScope = {} + testChannel({ + name: 'scope', + fn: tracer.scope, + ret: dummyScope + }) + }) + + describe('scope:active', () => { + it('should call underlying api', () => { + scope = tracer.scope() + sinon.spy(scope, 'active') + testChannel({ + name: 'scope:active', + fn: scope.active, + self: dummyScope, + ret: null + }) + scope.active.restore() + }) + }) + + describe('scope:activate', () => { + it('should call underlying api', () => { + scope = tracer.scope() + sinon.spy(scope, 'activate') + testChannel({ + name: 'scope:activate', + fn: scope.activate, + self: dummyScope + }) + scope.activate.restore() + }) + }) + + describe('scope:bind', () => { + it('should call underlying api', () => { + scope = tracer.scope() + sinon.spy(scope, 'bind') + testChannel({ + name: 'scope:bind', + fn: scope.bind, + self: dummyScope + }) + scope.bind.restore() + }) + }) + }) + + describe('startSpan', () => { + let dummySpan + let dummySpanContext + let span + let spanContext + + it('should call underlying api', () => { + dummySpan = {} + testChannel({ + name: 'startSpan', + fn: tracer.startSpan, + ret: dummySpan + }) + span = tracer.startSpan.getCall(0).returnValue + sinon.spy(span) + }) + + describe('span:context', () => { + const traceId = '1234567890abcdef' + const spanId = 'abcdef1234567890' + const traceparent = `00-${traceId}-${spanId}-01` + + it('should call underlying api', () => { + dummySpanContext = {} + testChannel({ + name: 'span:context', + fn: span.context, + self: dummySpan, + ret: dummySpanContext + }) + spanContext = span.context.getCall(0).returnValue + sinon.stub(spanContext, 'toTraceId').callsFake(() => traceId) + sinon.stub(spanContext, 'toSpanId').callsFake(() => spanId) + sinon.stub(spanContext, 'toTraceparent').callsFake(() => traceparent) + }) + + describe('context:toTraceId', () => { + it('should call underlying api', () => { + testChannel({ + name: 'context:toTraceId', + fn: spanContext.toTraceId, + self: dummySpanContext, + ret: traceId + }) + }) + }) + + describe('context:toSpanId', () => { + it('should call underlying api', () => { + testChannel({ + name: 'context:toSpanId', + fn: spanContext.toSpanId, + self: dummySpanContext, + ret: spanId + }) + }) + }) + + describe('context:toTraceparent', () => { + it('should call underlying api', () => { + testChannel({ + name: 'context:toTraceparent', + fn: spanContext.toTraceparent, + self: dummySpanContext, + ret: traceparent + }) + }) + }) + }) + + describe('span:setTag', () => { + it('should call underlying api', () => { + testChannel({ + name: 'span:setTag', + fn: span.setTag, + self: dummySpan, + ret: dummySpan + }) + }) + }) + + describe('span:addTags', () => { + it('should call underlying api', () => { + testChannel({ + name: 'span:addTags', + fn: span.addTags, + self: dummySpan, + ret: dummySpan + }) + }) + }) + + describe('span:finish', () => { + it('should call underlying api', () => { + testChannel({ + name: 'span:finish', + fn: span.finish, + self: dummySpan + }) + }) + }) + + describe('span:addLink', () => { + it('should call underlying api', () => { + testChannel({ + name: 'span:addLink', + fn: span.addLink, + self: dummySpan, + ret: dummySpan, + args: [dummySpanContext] + }) + }) + }) + }) + + describeMethod('inject') + describeMethod('extract', null) + describeMethod('getRumData', '') + describeMethod('trace') + describeMethod('wrap') + describeMethod('use', SELF) + describeMethod('profilerStarted', Promise.resolve(false)) + + describeSubsystem('appsec', 'blockRequest', false) + describeSubsystem('appsec', 'isUserBlocked', false) + describeSubsystem('appsec', 'setUser') + describeSubsystem('appsec', 'trackCustomEvent') + describeSubsystem('appsec', 'trackUserLoginFailureEvent') + describeSubsystem('appsec', 'trackUserLoginSuccessEvent') + describeSubsystem('dogstatsd', 'decrement') + describeSubsystem('dogstatsd', 'distribution') + describeSubsystem('dogstatsd', 'flush') + describeSubsystem('dogstatsd', 'gauge') + describeSubsystem('dogstatsd', 'histogram') + describeSubsystem('dogstatsd', 'increment') + + after('dd-trace-api all events tested', () => { + assert.deepStrictEqual([...allChannels].sort(), [...testedChannels].sort()) + }) + + function describeMethod (name, ret) { + describe(name, () => { + it('should call underlying api', () => { + if (ret === SELF) { + ret = dummyTracer + } + testChannel({ name, fn: tracer[name], ret }) + }) + }) + } + + function describeSubsystem (name, command, ret) { + describe(`${name}:${command}`, () => { + it('should call underlying api', () => { + const options = { + name: `${name}:${command}`, + fn: tracer[name][command], + self: tracer[name] + } + if (typeof ret !== 'undefined') { + options.ret = ret + } + testChannel(options) + }) + }) + } + + function testChannel ({ name, fn, self = dummyTracer, ret = undefined, args = [] }) { + testedChannels.add('datadog-api:v1:' + name) + const ch = dc.channel('datadog-api:v1:' + name) + const payload = { + self, + args, + ret: {}, + proxy: ret && typeof ret === 'object' ? () => ret : undefined, + revProxy: [] + } + ch.publish(payload) + if (payload.ret.error) { + throw payload.ret.error + } + expect(payload.ret.value).to.equal(ret) + expect(fn).to.have.been.calledOnceWithExactly(...args) + } + }) +}) diff --git a/packages/dd-trace/src/plugin_manager.js b/packages/dd-trace/src/plugin_manager.js index 2e6c9be9460..c1b326dc767 100644 --- a/packages/dd-trace/src/plugin_manager.js +++ b/packages/dd-trace/src/plugin_manager.js @@ -31,6 +31,9 @@ loadChannel.subscribe(({ name }) => { // Globals maybeEnable(require('../../datadog-plugin-fetch/src')) +// Always enabled +maybeEnable(require('../../datadog-plugin-dd-trace-api/src')) + function maybeEnable (Plugin) { if (!Plugin || typeof Plugin !== 'function') return if (!pluginClasses[Plugin.id]) { diff --git a/packages/dd-trace/src/plugins/index.js b/packages/dd-trace/src/plugins/index.js index 3e77226a119..0104417b2fc 100644 --- a/packages/dd-trace/src/plugins/index.js +++ b/packages/dd-trace/src/plugins/index.js @@ -34,6 +34,7 @@ module.exports = { get couchbase () { return require('../../../datadog-plugin-couchbase/src') }, get cypress () { return require('../../../datadog-plugin-cypress/src') }, get dns () { return require('../../../datadog-plugin-dns/src') }, + get 'dd-trace-api' () { return require('../../../datadog-plugin-dd-trace-api/src') }, get elasticsearch () { return require('../../../datadog-plugin-elasticsearch/src') }, get express () { return require('../../../datadog-plugin-express/src') }, get fastify () { return require('../../../datadog-plugin-fastify/src') },