diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 75e751ab1a1..f080443f35e 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -30,7 +30,7 @@ jobs: injection-image-publish: runs-on: ubuntu-latest - needs: ['publish'] + needs: ['dev_release'] steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8e7755544d3..600cdd34312 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -29,6 +29,7 @@ package: stage: deploy variables: PRODUCT_NAME: auto_inject-node + PACKAGE_FILTER: js # product name is "node" but package name ends "js" deploy_to_reliability_env: stage: deploy diff --git a/docs/test.ts b/docs/test.ts index ec12ed40ed5..b667616a73e 100644 --- a/docs/test.ts +++ b/docs/test.ts @@ -107,7 +107,10 @@ tracer.init({ obfuscatorKeyRegex: '.*', obfuscatorValueRegex: '.*', blockedTemplateHtml: './blocked.html', - blockedTemplateJson: './blocked.json' + blockedTemplateJson: './blocked.json', + eventTracking: { + mode: 'safe' + } } }); diff --git a/index.d.ts b/index.d.ts index a0f65bf6366..43bda7352bc 100644 --- a/index.d.ts +++ b/index.d.ts @@ -556,6 +556,19 @@ export declare interface TracerOptions { * Specifies a path to a custom blocking template json file. */ blockedTemplateJson?: string, + + /** + * Controls the automated user event tracking configuration + */ + eventTracking?: { + /** + * Controls the automated user event tracking mode. Possible values are disabled, safe and extended. + * On safe mode, any detected Personally Identifiable Information (PII) about the user will be redacted from the event. + * On extended mode, no redaction will take place. + * @default 'safe' + */ + mode?: 'safe' | 'extended' | 'disabled' + } }; /** @@ -1450,6 +1463,20 @@ declare namespace plugins { }; } + /** + * This plugin automatically instruments the + * [openai](https://platform.openai.com/docs/api-reference?lang=node.js) module. + * + * Note that for logs to work you'll need to set the `DD_API_KEY` environment variable. + * You'll also need to adjust any firewall settings to allow the tracer to communicate + * with `http-intake.logs.datadoghq.com`. + * + * Note that for metrics to work you'll need to enable + * [DogStatsD](https://docs.datadoghq.com/developers/dogstatsd/?tab=hostagent#setup) + * in the agent. + */ + interface openai extends Instrumentation {} + /** * This plugin automatically instruments the * [opensearch](https://github.com/opensearch-project/opensearch-js) module. diff --git a/integration-tests/ci-visibility/test-api-manual/run-fake-test-framework.js b/integration-tests/ci-visibility/test-api-manual/run-fake-test-framework.js new file mode 100644 index 00000000000..0916ddbd7dc --- /dev/null +++ b/integration-tests/ci-visibility/test-api-manual/run-fake-test-framework.js @@ -0,0 +1,27 @@ +'use strict' + +/* eslint-disable */ + +function runTests () { + const promises = global.tests.map(async (test) => { + let testStatus = 'pass' + let testError = null + global.beforeEachHooks.forEach(beforeEach => { + beforeEach(test.description) + }) + try { + await test.fn() + console.log(`✓ ${test.description}`) + } catch (e) { + testError = e + testStatus = 'fail' + console.log(`x ${test.description}: ${e}`) + } + global.afterEachHooks.forEach(afterEach => { + afterEach(testStatus, testError) + }) + }) + return Promise.all(promises) +} + +runTests() diff --git a/integration-tests/ci-visibility/test-api-manual/setup-fake-test-framework.js b/integration-tests/ci-visibility/test-api-manual/setup-fake-test-framework.js new file mode 100644 index 00000000000..af7b3ab16b3 --- /dev/null +++ b/integration-tests/ci-visibility/test-api-manual/setup-fake-test-framework.js @@ -0,0 +1,33 @@ +'use strict' + +global.tests = [] +global.beforeEachHooks = [] +global.afterEachHooks = [] + +function describe (description, cb) { + cb() +} + +function test (description, fn) { + global.tests.push({ description, fn }) +} + +function beforeEach (fn) { + global.beforeEachHooks.push(fn) +} + +function afterEach (fn) { + global.afterEachHooks.push(fn) +} + +global.describe = describe +global.test = test +global.beforeEach = beforeEach +global.afterEach = afterEach +global.assert = { + equal: (a, b) => { + if (a !== b) { + throw new Error(`${a} is not equal to ${b}`) + } + } +} diff --git a/integration-tests/ci-visibility/test-api-manual/test.fake.js b/integration-tests/ci-visibility/test-api-manual/test.fake.js new file mode 100644 index 00000000000..bc1f17972b7 --- /dev/null +++ b/integration-tests/ci-visibility/test-api-manual/test.fake.js @@ -0,0 +1,48 @@ +/* eslint-disable */ +const { channel } = require('diagnostics_channel') +const tracer = require('dd-trace') + +const testStartCh = channel('dd-trace:ci:manual:test:start') +const testFinishCh = channel('dd-trace:ci:manual:test:finish') +const testAddTagsCh = channel('dd-trace:ci:manual:test:addTags') +const testSuite = __filename + +describe('can run tests', () => { + beforeEach((testName) => { + testStartCh.publish({ testName, testSuite }) + }) + afterEach((status, error) => { + testFinishCh.publish({ status, error }) + }) + test('first test will pass', () => { + testAddTagsCh.publish({ 'test.custom.tag': 'custom.value' }) + assert.equal(1, 1) + }) + test('second test will fail', () => { + assert.equal(1, 2) + }) + test('async test will pass', () => { + return new Promise((resolve) => { + setTimeout(() => { + assert.equal(1, 1) + resolve() + }, 10) + }) + }) + test('integration test', () => { + // Just for testing purposes, so we don't create a custom span + if (!process.env.DD_CIVISIBILITY_MANUAL_API_ENABLED) { + return Promise.resolve() + } + const testSpan = tracer.scope().active() + const childSpan = tracer.startSpan('custom.span', { + childOf: testSpan + }) + return new Promise((resolve) => { + setTimeout(() => { + childSpan.finish() + resolve() + }, 10) + }) + }) +}) diff --git a/integration-tests/helpers.js b/integration-tests/helpers.js index 08caa4e7dae..acbb2630f26 100644 --- a/integration-tests/helpers.js +++ b/integration-tests/helpers.js @@ -27,6 +27,7 @@ class FakeAgent extends EventEmitter { async start () { const app = express() app.use(bodyParser.raw({ limit: Infinity, type: 'application/msgpack' })) + app.use(bodyParser.json({ limit: Infinity, type: 'application/json' })) app.put('/v0.4/traces', (req, res) => { if (req.body.length === 0) return res.status(200).send() res.status(200).send({ rate_by_service: { 'service:,env:': 1 } }) @@ -43,6 +44,13 @@ class FakeAgent extends EventEmitter { files: req.files }) }) + app.post('/telemetry/proxy/api/v2/apmtelemetry', (req, res) => { + res.status(200).send() + this.emit('telemetry', { + headers: req.headers, + payload: req.body + }) + }) return new Promise((resolve, reject) => { const timeoutObj = setTimeout(() => { @@ -103,6 +111,48 @@ class FakeAgent extends EventEmitter { return resultPromise } + + assertTelemetryReceived (fn, timeout, requestType, expectedMessageCount = 1) { + timeout = timeout || 5000 + let resultResolve + let resultReject + let msgCount = 0 + const errors = [] + + const timeoutObj = setTimeout(() => { + resultReject([...errors, new Error('timeout')]) + }, timeout) + + const resultPromise = new Promise((resolve, reject) => { + resultResolve = () => { + clearTimeout(timeoutObj) + resolve() + } + resultReject = (e) => { + clearTimeout(timeoutObj) + reject(e) + } + }) + + const messageHandler = msg => { + if (msg.payload.request_type !== requestType) return + msgCount += 1 + try { + fn(msg) + if (msgCount === expectedMessageCount) { + resultResolve() + } + } catch (e) { + errors.push(e) + } + if (msgCount === expectedMessageCount) { + this.removeListener('telemetry', messageHandler) + } + } + this.on('telemetry', messageHandler) + + return resultPromise + } } function spawnProc (filename, options = {}) { diff --git a/integration-tests/opentelemetry.spec.js b/integration-tests/opentelemetry.spec.js index 0b7d01ceba3..57750934fc1 100644 --- a/integration-tests/opentelemetry.spec.js +++ b/integration-tests/opentelemetry.spec.js @@ -6,9 +6,13 @@ const { join } = require('path') const { assert } = require('chai') const { satisfies } = require('semver') -function check (agent, proc, timeout, onMessage = () => { }) { +function check (agent, proc, timeout, onMessage = () => { }, isMetrics) { + const messageReceiver = isMetrics + ? agent.assertTelemetryReceived(onMessage, timeout, 'generate-metrics') + : agent.assertMessageReceived(onMessage, timeout) + return Promise.all([ - agent.assertMessageReceived(onMessage, timeout), + messageReceiver, new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error('Process timed out')) @@ -38,6 +42,11 @@ function eachEqual (spans, expected, fn) { return spans.every((span, i) => fn(span) === expected[i]) } +function nearNow (ts, now = Date.now(), range = 1000) { + const delta = Math.abs(now - ts) + return delta < range && delta >= 0 +} + describe('opentelemetry', () => { let agent let proc @@ -84,6 +93,49 @@ describe('opentelemetry', () => { }) }) + it('should capture telemetry', () => { + proc = fork(join(cwd, 'opentelemetry/basic.js'), { + cwd, + env: { + DD_TRACE_AGENT_PORT: agent.port, + DD_TRACE_OTEL_ENABLED: 1, + DD_TELEMETRY_HEARTBEAT_INTERVAL: 1, + TIMEOUT: 1500 + } + }) + + return check(agent, proc, timeout, ({ payload }) => { + assert.strictEqual(payload.request_type, 'generate-metrics') + + const metrics = payload.payload + assert.strictEqual(metrics.namespace, 'tracers') + + const spanCreated = metrics.series.find(({ metric }) => metric === 'span_created') + const spanFinished = metrics.series.find(({ metric }) => metric === 'span_finished') + + // Validate common fields between start and finish + for (const series of [spanCreated, spanFinished]) { + assert.ok(series) + + assert.strictEqual(series.points.length, 1) + assert.strictEqual(series.points[0].length, 2) + + const [ts, value] = series.points[0] + assert.ok(nearNow(ts, Date.now() / 1e3)) + assert.strictEqual(value, 1) + + assert.strictEqual(series.type, 'count') + assert.strictEqual(series.common, true) + assert.deepStrictEqual(series.tags, [ + 'integration_name:otel', + 'otel_enabled:true', + 'lib_language:nodejs', + `version:${process.version}` + ]) + } + }, true) + }) + it('should work within existing datadog-traced http request', async () => { proc = fork(join(cwd, 'opentelemetry/server.js'), { cwd, diff --git a/integration-tests/opentelemetry/basic.js b/integration-tests/opentelemetry/basic.js index 024c4395e3d..f3397103084 100644 --- a/integration-tests/opentelemetry/basic.js +++ b/integration-tests/opentelemetry/basic.js @@ -1,5 +1,7 @@ 'use strict' +const TIMEOUT = Number(process.env.TIMEOUT || 0) + const tracer = require('dd-trace').init() const { TracerProvider } = tracer @@ -16,5 +18,8 @@ const otelTracer = ot.trace.getTracer( otelTracer.startActiveSpan('otel-sub', otelSpan => { setImmediate(() => { otelSpan.end() + + // Allow the process to be held open to gather telemetry metrics + setTimeout(() => {}, TIMEOUT) }) }) diff --git a/integration-tests/test-api-manual.spec.js b/integration-tests/test-api-manual.spec.js new file mode 100644 index 00000000000..7873a300ac3 --- /dev/null +++ b/integration-tests/test-api-manual.spec.js @@ -0,0 +1,103 @@ +'use strict' + +const { exec } = require('child_process') + +const getPort = require('get-port') +const { assert } = require('chai') + +const { + createSandbox, + getCiVisAgentlessConfig +} = require('./helpers') +const { FakeCiVisIntake } = require('./ci-visibility-intake') +const webAppServer = require('./ci-visibility/web-app-server') +const { + TEST_STATUS +} = require('../packages/dd-trace/src/plugins/util/test') + +describe('test-api-manual', () => { + let sandbox, cwd, receiver, childProcess, webAppPort + before(async () => { + sandbox = await createSandbox([], true) + cwd = sandbox.folder + webAppPort = await getPort() + webAppServer.listen(webAppPort) + }) + + after(async () => { + await sandbox.remove() + await new Promise(resolve => webAppServer.close(resolve)) + }) + + beforeEach(async function () { + const port = await getPort() + receiver = await new FakeCiVisIntake(port).start() + }) + + afterEach(async () => { + childProcess.kill() + await receiver.stop() + }) + + it('can use the manual api', (done) => { + const receiverPromise = receiver.gatherPayloadsMaxTimeout(({ url }) => url === '/api/v2/citestcycle', payloads => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const testEvents = events.filter(event => event.type === 'test') + assert.includeMembers(testEvents.map(test => test.content.resource), [ + 'ci-visibility/test-api-manual/test.fake.js.second test will fail', + 'ci-visibility/test-api-manual/test.fake.js.first test will pass', + 'ci-visibility/test-api-manual/test.fake.js.async test will pass', + 'ci-visibility/test-api-manual/test.fake.js.integration test' + ]) + + assert.includeMembers(testEvents.map(test => test.content.meta[TEST_STATUS]), [ + 'pass', + 'pass', + 'pass', + 'fail' + ]) + + const passedTest = testEvents.find( + test => test.content.resource === 'ci-visibility/test-api-manual/test.fake.js.first test will pass' + ) + assert.propertyVal(passedTest.content.meta, 'test.custom.tag', 'custom.value') + + const customSpan = events.find(event => event.type === 'span') + assert.propertyVal(customSpan.content, 'resource', 'custom.span') + }).catch(done) + + childProcess = exec( + 'node --require ./ci-visibility/test-api-manual/setup-fake-test-framework.js ' + + '--require ./ci-visibility/test-api-manual/test.fake.js ./ci-visibility/test-api-manual/run-fake-test-framework', + { + cwd, + env: { ...getCiVisAgentlessConfig(receiver.port), DD_CIVISIBILITY_MANUAL_API_ENABLED: '1' }, + stdio: 'pipe' + } + ) + childProcess.on('exit', () => { + receiverPromise.then(() => done()) + }) + }) + + it('does not report test spans if DD_CIVISIBILITY_MANUAL_API_ENABLED is not set', (done) => { + receiver.assertPayloadReceived(() => { + const error = new Error('should not report spans') + done(error) + }, ({ url }) => url === '/api/v2/citestcycle').catch(() => {}) + + childProcess = exec( + 'node --require ./ci-visibility/test-api-manual/setup-fake-test-framework.js ' + + '--require ./ci-visibility/test-api-manual/test.fake.js ./ci-visibility/test-api-manual/run-fake-test-framework', + { + cwd, + env: getCiVisAgentlessConfig(receiver.port), + stdio: 'pipe' + } + ) + childProcess.on('exit', () => { + done() + }) + }) +}) diff --git a/package.json b/package.json index 0040c4b4365..a7f223eb9c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dd-trace", - "version": "3.24.0", + "version": "3.25.0", "description": "Datadog APM tracing client for JavaScript", "main": "index.js", "typings": "index.d.ts", diff --git a/packages/.eslintrc.json b/packages/.eslintrc.json index 8b1dd5f8fe2..9ae4e0ef309 100644 --- a/packages/.eslintrc.json +++ b/packages/.eslintrc.json @@ -11,7 +11,8 @@ "proxyquire": true, "withNamingSchema": true, "withVersions": true, - "withExports": true + "withExports": true, + "withPeerService": true }, "rules": { "no-unused-expressions": 0, diff --git a/packages/datadog-instrumentations/src/express.js b/packages/datadog-instrumentations/src/express.js index a0ea360d7ba..f16c79f3d40 100644 --- a/packages/datadog-instrumentations/src/express.js +++ b/packages/datadog-instrumentations/src/express.js @@ -57,3 +57,26 @@ addHook({ }) }) }) + +const processParamsStartCh = channel('datadog:express:process_params:start') +const wrapProcessParamsMethod = (requestPositionInArguments) => { + return (original) => { + return function () { + if (processParamsStartCh.hasSubscribers) { + processParamsStartCh.publish({ req: arguments[requestPositionInArguments] }) + } + + return original.apply(this, arguments) + } + } +} + +addHook({ name: 'express', versions: ['>=4.0.0 <4.3.0'] }, express => { + shimmer.wrap(express.Router, 'process_params', wrapProcessParamsMethod(1)) + return express +}) + +addHook({ name: 'express', versions: ['>=4.3.0'] }, express => { + shimmer.wrap(express.Router, 'process_params', wrapProcessParamsMethod(2)) + return express +}) diff --git a/packages/datadog-instrumentations/src/helpers/hooks.js b/packages/datadog-instrumentations/src/helpers/hooks.js index a717b484a0f..1d068b24fee 100644 --- a/packages/datadog-instrumentations/src/helpers/hooks.js +++ b/packages/datadog-instrumentations/src/helpers/hooks.js @@ -69,7 +69,10 @@ module.exports = { 'net': () => require('../net'), 'next': () => require('../next'), 'oracledb': () => require('../oracledb'), + 'openai': () => require('../openai'), 'paperplane': () => require('../paperplane'), + 'passport-http': () => require('../passport-http'), + 'passport-local': () => require('../passport-local'), 'pg': () => require('../pg'), 'pino': () => require('../pino'), 'pino-pretty': () => require('../pino'), diff --git a/packages/datadog-instrumentations/src/openai.js b/packages/datadog-instrumentations/src/openai.js new file mode 100644 index 00000000000..132ebec5c9b --- /dev/null +++ b/packages/datadog-instrumentations/src/openai.js @@ -0,0 +1,50 @@ +'use strict' + +const { + channel, + addHook +} = require('./helpers/instrument') +const shimmer = require('../../datadog-shimmer') + +const startCh = channel('apm:openai:request:start') +const finishCh = channel('apm:openai:request:finish') +const errorCh = channel('apm:openai:request:error') + +addHook({ name: 'openai', file: 'dist/api.js', versions: ['>=3.0.0'] }, exports => { + const methodNames = Object.getOwnPropertyNames(exports.OpenAIApi.prototype) + methodNames.shift() // remove leading 'constructor' method + + for (const methodName of methodNames) { + shimmer.wrap(exports.OpenAIApi.prototype, methodName, fn => function () { + if (!startCh.hasSubscribers) { + return fn.apply(this, arguments) + } + + startCh.publish({ + methodName, + args: arguments, + basePath: this.basePath, + apiKey: this.configuration.apiKey + }) + + return fn.apply(this, arguments) + .then((response) => { + finishCh.publish({ + headers: response.headers, + body: response.data, + path: response.request.path, + method: response.request.method + }) + + return response + }) + .catch((err) => { + errorCh.publish({ err }) + + throw err + }) + }) + } + + return exports +}) diff --git a/packages/datadog-instrumentations/src/passport-http.js b/packages/datadog-instrumentations/src/passport-http.js new file mode 100644 index 00000000000..3ffc369a395 --- /dev/null +++ b/packages/datadog-instrumentations/src/passport-http.js @@ -0,0 +1,22 @@ +'use strict' + +const shimmer = require('../../datadog-shimmer') +const { addHook } = require('./helpers/instrument') +const { wrapVerify } = require('./passport-utils') + +addHook({ + name: 'passport-http', + file: 'lib/passport-http/strategies/basic.js', + versions: ['>=0.3.0'] +}, BasicStrategy => { + return shimmer.wrap(BasicStrategy, function () { + const type = 'http' + + if (typeof arguments[0] === 'function') { + arguments[0] = wrapVerify(arguments[0], false, type) + } else { + arguments[1] = wrapVerify(arguments[1], (arguments[0] && arguments[0].passReqToCallback), type) + } + return BasicStrategy.apply(this, arguments) + }) +}) diff --git a/packages/datadog-instrumentations/src/passport-local.js b/packages/datadog-instrumentations/src/passport-local.js new file mode 100644 index 00000000000..d0c48c56ccb --- /dev/null +++ b/packages/datadog-instrumentations/src/passport-local.js @@ -0,0 +1,22 @@ +'use strict' + +const shimmer = require('../../datadog-shimmer') +const { addHook } = require('./helpers/instrument') +const { wrapVerify } = require('./passport-utils') + +addHook({ + name: 'passport-local', + file: 'lib/strategy.js', + versions: ['>=1.0.0'] +}, Strategy => { + return shimmer.wrap(Strategy, function () { + const type = 'local' + + if (typeof arguments[0] === 'function') { + arguments[0] = wrapVerify(arguments[0], false, type) + } else { + arguments[1] = wrapVerify(arguments[1], (arguments[0] && arguments[0].passReqToCallback), type) + } + return Strategy.apply(this, arguments) + }) +}) diff --git a/packages/datadog-instrumentations/src/passport-utils.js b/packages/datadog-instrumentations/src/passport-utils.js new file mode 100644 index 00000000000..5af55ca94c0 --- /dev/null +++ b/packages/datadog-instrumentations/src/passport-utils.js @@ -0,0 +1,36 @@ +'use strict' + +const shimmer = require('../../datadog-shimmer') +const { channel } = require('./helpers/instrument') + +const passportVerifyChannel = channel('datadog:passport:verify:finish') + +function wrapVerifiedAndPublish (username, password, verified, type) { + if (!passportVerifyChannel.hasSubscribers) { + return verified + } + + return shimmer.wrap(verified, function (err, user, info) { + const credentials = { type, username } + passportVerifyChannel.publish({ credentials, user }) + return verified.apply(this, arguments) + }) +} + +function wrapVerify (verify, passReq, type) { + if (passReq) { + return function (req, username, password, verified) { + arguments[3] = wrapVerifiedAndPublish(username, password, verified, type) + return verify.apply(this, arguments) + } + } else { + return function (username, password, verified) { + arguments[2] = wrapVerifiedAndPublish(username, password, verified, type) + return verify.apply(this, arguments) + } + } +} + +module.exports = { + wrapVerify +} diff --git a/packages/datadog-instrumentations/src/pg.js b/packages/datadog-instrumentations/src/pg.js index 543dc0a2362..a94012b2c0d 100644 --- a/packages/datadog-instrumentations/src/pg.js +++ b/packages/datadog-instrumentations/src/pg.js @@ -31,10 +31,23 @@ function wrapQuery (query) { const asyncResource = new AsyncResource('bound-anonymous-fn') const processId = this.processID - const pgQuery = arguments[0] && typeof arguments[0] === 'object' ? arguments[0] : { text: arguments[0] } - - // shallow clone the existing query to swap out .text field - let newQuery = { ...pgQuery } + const pgQuery = arguments[0] && typeof arguments[0] === 'object' + ? arguments[0] + : { text: arguments[0] } + + // The query objects passed in can be pretty complex. They can be instances of EventEmitter. + // For this reason we can't make a shallow clone of the object. + // Some libraries, such as sql-template-tags, can provide a getter .text property. + // For this reason we can't replace the .text property. + // Instead, we create a new object, and set the original query as the prototype. + // This allows any existing methods to still work and lets us easily provide a new query. + let newQuery = { + __ddInjectableQuery: '', + get text () { + return this.__ddInjectableQuery || Object.getPrototypeOf(this).text + } + } + Object.setPrototypeOf(newQuery, pgQuery) return asyncResource.runInAsyncScope(() => { startCh.publish({ diff --git a/packages/datadog-instrumentations/test/passport-http.spec.js b/packages/datadog-instrumentations/test/passport-http.spec.js new file mode 100644 index 00000000000..e772a6680b3 --- /dev/null +++ b/packages/datadog-instrumentations/test/passport-http.spec.js @@ -0,0 +1,143 @@ +'use strict' + +const agent = require('../../dd-trace/test/plugins/agent') +const getPort = require('get-port') +const axios = require('axios') +const dc = require('../../diagnostics_channel') + +withVersions('passport-http', 'passport-http', version => { + describe('passport-http instrumentation', () => { + const passportVerifyChannel = dc.channel('datadog:passport:verify:finish') + let port, server, subscriberStub + + before(() => { + return agent.load(['express', 'passport', 'passport-http'], { client: false }) + }) + before((done) => { + const express = require('../../../versions/express').get() + const passport = require('../../../versions/passport').get() + const BasicStrategy = require(`../../../versions/passport-http@${version}`).get().BasicStrategy + const app = express() + + passport.use(new BasicStrategy((username, password, done) => { + const users = [{ + _id: 1, + username: 'test', + password: '1234', + email: 'testuser@ddog.com' + }] + + const user = users.find(user => (user.username === username) && (user.password === password)) + + if (!user) { + return done(null, false) + } else { + return done(null, user) + } + } + )) + + app.use(passport.initialize()) + app.use(express.json()) + + app.get('/', + passport.authenticate('basic', { + successRedirect: '/grant', + failureRedirect: '/deny', + passReqToCallback: false, + session: false + }) + ) + + app.post('/req', + passport.authenticate('basic', { + successRedirect: '/grant', + failureRedirect: '/deny', + passReqToCallback: true, + session: false + }) + ) + + app.get('/grant', (req, res) => { + res.send('Granted') + }) + + app.get('/deny', (req, res) => { + res.send('Denied') + }) + + passportVerifyChannel.subscribe(function ({ credentials, user, err, info }) { + subscriberStub(arguments[0]) + }) + + getPort().then(newPort => { + port = newPort + server = app.listen(port, () => { + done() + }) + }) + }) + beforeEach(() => { + subscriberStub = sinon.stub() + }) + + after(() => { + server.close() + return agent.close({ ritmReset: false }) + }) + + it('should call subscriber with proper arguments on success', async () => { + const res = await axios.get(`http://localhost:${port}/`, { + headers: { + // test:1234 + 'Authorization': 'Basic dGVzdDoxMjM0' + } + }) + + expect(res.status).to.equal(200) + expect(res.data).to.equal('Granted') + expect(subscriberStub).to.be.calledOnceWithExactly( + { + credentials: { type: 'http', username: 'test' }, + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' } + } + ) + }) + + it('should call subscriber with proper arguments on success with passReqToCallback set to true', async () => { + const res = await axios.get(`http://localhost:${port}/`, { + headers: { + // test:1234 + 'Authorization': 'Basic dGVzdDoxMjM0' + } + }) + + expect(res.status).to.equal(200) + expect(res.data).to.equal('Granted') + expect(subscriberStub).to.be.calledOnceWithExactly( + { + credentials: { type: 'http', username: 'test' }, + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' } + } + ) + }) + + it('should call subscriber with proper arguments on failure', async () => { + const res = await axios.get(`http://localhost:${port}/`, { + headers: { + // test:1 + 'Authorization': 'Basic dGVzdDox' + } + }) + + expect(res.status).to.equal(200) + expect(res.data).to.equal('Denied') + expect(subscriberStub).to.be.calledOnceWithExactly( + { + credentials: { type: 'http', username: 'test' }, + user: false + } + ) + }) + }) +}) diff --git a/packages/datadog-instrumentations/test/passport-local.spec.js b/packages/datadog-instrumentations/test/passport-local.spec.js new file mode 100644 index 00000000000..78c9521ab20 --- /dev/null +++ b/packages/datadog-instrumentations/test/passport-local.spec.js @@ -0,0 +1,129 @@ +'use strict' + +const agent = require('../../dd-trace/test/plugins/agent') +const getPort = require('get-port') +const axios = require('axios') +const dc = require('../../diagnostics_channel') + +withVersions('passport-local', 'passport-local', version => { + describe('passport-local instrumentation', () => { + const passportVerifyChannel = dc.channel('datadog:passport:verify:finish') + let port, server, subscriberStub + + before(() => { + return agent.load(['express', 'passport', 'passport-local'], { client: false }) + }) + before((done) => { + const express = require('../../../versions/express').get() + const passport = require(`../../../versions/passport`).get() + const LocalStrategy = require(`../../../versions/passport-local@${version}`).get().Strategy + const app = express() + + passport.use(new LocalStrategy({ usernameField: 'username', passwordField: 'password' }, + (username, password, done) => { + const users = [{ + _id: 1, + username: 'test', + password: '1234', + email: 'testuser@ddog.com' + }] + + const user = users.find(user => (user.username === username) && (user.password === password)) + + if (!user) { + return done(null, false) + } else { + return done(null, user) + } + } + )) + + app.use(passport.initialize()) + app.use(express.json()) + + app.post('/', + passport.authenticate('local', { + successRedirect: '/grant', + failureRedirect: '/deny', + passReqToCallback: false, + session: false + }) + ) + + app.post('/req', + passport.authenticate('local', { + successRedirect: '/grant', + failureRedirect: '/deny', + passReqToCallback: true, + session: false + }) + ) + + app.get('/grant', (req, res) => { + res.send('Granted') + }) + + app.get('/deny', (req, res) => { + res.send('Denied') + }) + + passportVerifyChannel.subscribe(function ({ credentials, user, err, info }) { + subscriberStub(arguments[0]) + }) + + getPort().then(newPort => { + port = newPort + server = app.listen(port, () => { + done() + }) + }) + }) + beforeEach(() => { + subscriberStub = sinon.stub() + }) + + after(() => { + server.close() + return agent.close({ ritmReset: false }) + }) + + it('should call subscriber with proper arguments on success', async () => { + const res = await axios.post(`http://localhost:${port}/`, { username: 'test', password: '1234' }) + + expect(res.status).to.equal(200) + expect(res.data).to.equal('Granted') + expect(subscriberStub).to.be.calledOnceWithExactly( + { + credentials: { type: 'local', username: 'test' }, + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' } + } + ) + }) + + it('should call subscriber with proper arguments on success with passReqToCallback set to true', async () => { + const res = await axios.post(`http://localhost:${port}/req`, { username: 'test', password: '1234' }) + + expect(res.status).to.equal(200) + expect(res.data).to.equal('Granted') + expect(subscriberStub).to.be.calledOnceWithExactly( + { + credentials: { type: 'local', username: 'test' }, + user: { _id: 1, username: 'test', password: '1234', email: 'testuser@ddog.com' } + } + ) + }) + + it('should call subscriber with proper arguments on failure', async () => { + const res = await axios.post(`http://localhost:${port}/`, { username: 'test', password: '1' }) + + expect(res.status).to.equal(200) + expect(res.data).to.equal('Denied') + expect(subscriberStub).to.be.calledOnceWithExactly( + { + credentials: { type: 'local', username: 'test' }, + user: false + } + ) + }) + }) +}) diff --git a/packages/datadog-instrumentations/test/passport-utils.spec.js b/packages/datadog-instrumentations/test/passport-utils.spec.js new file mode 100644 index 00000000000..3cf6a64a60a --- /dev/null +++ b/packages/datadog-instrumentations/test/passport-utils.spec.js @@ -0,0 +1,36 @@ +'use strict' + +const proxyquire = require('proxyquire') +const { channel } = require('../src/helpers/instrument') + +const passportVerifyChannel = channel('datadog:passport:verify:finish') + +describe('passport-utils', () => { + const shimmer = { + wrap: sinon.stub() + } + + let passportUtils + + beforeEach(() => { + passportUtils = proxyquire('../src/passport-utils', { + '../../datadog-shimmer': shimmer + }) + }) + + it('should not call wrap when there is no subscribers', () => { + const wrap = passportUtils.wrapVerify(() => {}, false, 'type') + + wrap() + expect(shimmer.wrap).not.to.have.been.called + }) + + it('should call wrap when there is subscribers', () => { + const wrap = passportUtils.wrapVerify(() => {}, false, 'type') + + passportVerifyChannel.subscribe(() => {}) + + wrap() + expect(shimmer.wrap).to.have.been.called + }) +}) diff --git a/packages/datadog-plugin-amqp10/test/index.spec.js b/packages/datadog-plugin-amqp10/test/index.spec.js index 9652274434e..f25f8154efe 100644 --- a/packages/datadog-plugin-amqp10/test/index.spec.js +++ b/packages/datadog-plugin-amqp10/test/index.spec.js @@ -60,6 +60,13 @@ describe('Plugin', () => { }) describe('when sending messages', () => { + withPeerService( + () => tracer, + () => sender.send({ key: 'value' }), + 'localhost', + 'out.host' + ) + it('should do automatic instrumentation', done => { agent .use(traces => { diff --git a/packages/datadog-plugin-amqplib/test/index.spec.js b/packages/datadog-plugin-amqplib/test/index.spec.js index db6799939ed..fea5d4c43cd 100644 --- a/packages/datadog-plugin-amqplib/test/index.spec.js +++ b/packages/datadog-plugin-amqplib/test/index.spec.js @@ -53,6 +53,13 @@ describe('Plugin', () => { }) describe('when sending commands', () => { + withPeerService( + () => tracer, + () => channel.assertQueue('test', {}, () => {}), + 'localhost', + 'out.host' + ) + it('should do automatic instrumentation for immediate commands', done => { agent .use(traces => { @@ -124,6 +131,13 @@ describe('Plugin', () => { }) describe('when publishing messages', () => { + withPeerService( + () => tracer, + () => channel.assertQueue('test', {}, () => {}), + 'localhost', + 'out.host' + ) + it('should do automatic instrumentation', done => { agent .use(traces => { diff --git a/packages/datadog-plugin-cassandra-driver/src/index.js b/packages/datadog-plugin-cassandra-driver/src/index.js index d67c3e46fba..b6c6aa6b29b 100644 --- a/packages/datadog-plugin-cassandra-driver/src/index.js +++ b/packages/datadog-plugin-cassandra-driver/src/index.js @@ -12,8 +12,8 @@ class CassandraDriverPlugin extends DatabasePlugin { query = combine(query) } - this.startSpan('cassandra.query', { - service: this.config.service, + this.startSpan(this.operationName(), { + service: this.serviceName(this.config, this.system), resource: trim(query, 5000), type: 'cassandra', kind: 'client', diff --git a/packages/datadog-plugin-cassandra-driver/test/index.spec.js b/packages/datadog-plugin-cassandra-driver/test/index.spec.js index c96fb64f626..c768122bbc4 100644 --- a/packages/datadog-plugin-cassandra-driver/test/index.spec.js +++ b/packages/datadog-plugin-cassandra-driver/test/index.spec.js @@ -3,6 +3,7 @@ const semver = require('semver') const agent = require('../../dd-trace/test/plugins/agent') const { ERROR_TYPE, ERROR_MESSAGE, ERROR_STACK } = require('../../dd-trace/src/constants') +const namingSchema = require('./naming') describe('Plugin', () => { let cassandra @@ -46,7 +47,8 @@ describe('Plugin', () => { const query = 'SELECT now() FROM local;' agent .use(traces => { - expect(traces[0][0]).to.have.property('service', 'test-cassandra') + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0]).to.have.property('resource', query) expect(traces[0][0]).to.have.property('type', 'cassandra') expect(traces[0][0].meta).to.have.property('db.type', 'cassandra') @@ -142,6 +144,12 @@ describe('Plugin', () => { }) }) }) + + withNamingSchema( + done => client.execute('SELECT now() FROM local;', err => err && done(err)), + () => namingSchema.outbound.opName, + () => namingSchema.outbound.serviceName + ) }) describe('with configuration', () => { @@ -181,6 +189,12 @@ describe('Plugin', () => { client.execute('SELECT now() FROM local;', err => err && done(err)) }) + + withNamingSchema( + done => client.execute('SELECT now() FROM local;', err => err && done(err)), + () => namingSchema.outbound.opName, + () => 'custom' + ) }) // Promise support added in 3.2.0 @@ -219,7 +233,8 @@ describe('Plugin', () => { agent .use(traces => { - expect(traces[0][0]).to.have.property('service', 'test-cassandra') + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0]).to.have.property('resource', query) expect(traces[0][0]).to.have.property('type', 'cassandra') expect(traces[0][0].meta).to.have.property('db.type', 'cassandra') diff --git a/packages/datadog-plugin-cassandra-driver/test/naming.js b/packages/datadog-plugin-cassandra-driver/test/naming.js new file mode 100644 index 00000000000..b67253ca771 --- /dev/null +++ b/packages/datadog-plugin-cassandra-driver/test/naming.js @@ -0,0 +1,14 @@ +const { resolveNaming } = require('../../dd-trace/test/plugins/helpers') + +module.exports = resolveNaming({ + outbound: { + v0: { + opName: 'cassandra.query', + serviceName: 'test-cassandra' + }, + v1: { + opName: 'cassandra.query', + serviceName: 'test' + } + } +}) diff --git a/packages/datadog-plugin-dns/src/lookup.js b/packages/datadog-plugin-dns/src/lookup.js index fd5bfc4f57b..3db2610caf0 100644 --- a/packages/datadog-plugin-dns/src/lookup.js +++ b/packages/datadog-plugin-dns/src/lookup.js @@ -33,7 +33,7 @@ class DNSLookupPlugin extends ClientPlugin { span.setTag('dns.address', result) } - span.finish() + super.finish() } } diff --git a/packages/datadog-plugin-elasticsearch/src/index.js b/packages/datadog-plugin-elasticsearch/src/index.js index fdf9a5516d1..14708d83e70 100644 --- a/packages/datadog-plugin-elasticsearch/src/index.js +++ b/packages/datadog-plugin-elasticsearch/src/index.js @@ -8,8 +8,8 @@ class ElasticsearchPlugin extends DatabasePlugin { start ({ params }) { const body = getBody(params.body || params.bulkBody) - this.startSpan(`${this.system}.query`, { - service: this.config.service, + this.startSpan(this.operationName(), { + service: this.serviceName(this.config), resource: `${params.method} ${quantizePath(params.path)}`, type: 'elasticsearch', kind: 'client', diff --git a/packages/datadog-plugin-elasticsearch/test/index.spec.js b/packages/datadog-plugin-elasticsearch/test/index.spec.js index 1bd2fbd69d7..052eb756a5b 100644 --- a/packages/datadog-plugin-elasticsearch/test/index.spec.js +++ b/packages/datadog-plugin-elasticsearch/test/index.spec.js @@ -3,6 +3,7 @@ const { ERROR_MESSAGE, ERROR_STACK, ERROR_TYPE } = require('../../dd-trace/src/constants') const agent = require('../../dd-trace/test/plugins/agent') const { breakThen, unbreakThen } = require('../../dd-trace/test/plugins/helpers') +const namingSchema = require('./naming') describe('Plugin', () => { let elasticsearch @@ -57,6 +58,8 @@ describe('Plugin', () => { it('should set the correct tags', done => { agent .use(traces => { + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0].meta).to.have.property('component', 'elasticsearch') expect(traces[0][0].meta).to.have.property('db.type', 'elasticsearch') expect(traces[0][0].meta).to.have.property('span.kind', 'client') @@ -90,6 +93,8 @@ describe('Plugin', () => { it('should set the correct tags on msearch', done => { agent .use(traces => { + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0].meta).to.have.property('component', 'elasticsearch') expect(traces[0][0].meta).to.have.property('db.type', 'elasticsearch') expect(traces[0][0].meta).to.have.property('span.kind', 'client') @@ -143,7 +148,8 @@ describe('Plugin', () => { it('should do automatic instrumentation', done => { agent .use(traces => { - expect(traces[0][0]).to.have.property('service', 'test-elasticsearch') + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0]).to.have.property('resource', 'HEAD /') expect(traces[0][0]).to.have.property('type', 'elasticsearch') }) @@ -206,7 +212,8 @@ describe('Plugin', () => { it('should do automatic instrumentation', done => { agent .use(traces => { - expect(traces[0][0]).to.have.property('service', 'test-elasticsearch') + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0]).to.have.property('resource', 'HEAD /') expect(traces[0][0]).to.have.property('type', 'elasticsearch') }) @@ -276,6 +283,15 @@ describe('Plugin', () => { client.ping().catch(done) }) + + withNamingSchema( + () => client.search( + { index: 'logstash-2000.01.01', body: {} }, + hasCallbackSupport ? () => {} : undefined + ), + () => namingSchema.outbound.opName, + () => namingSchema.outbound.serviceName + ) }) }) @@ -284,7 +300,7 @@ describe('Plugin', () => { before(() => { return agent.load('elasticsearch', { - service: 'test', + service: 'custom', hooks: { query: (span, params) => { span.addTags({ 'elasticsearch.params': 'foo', 'elasticsearch.method': params.method }) } } @@ -316,7 +332,8 @@ describe('Plugin', () => { agent .use(traces => { - expect(traces[0][0]).to.have.property('service', 'test') + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', 'custom') expect(traces[0][0].meta).to.have.property('component', 'elasticsearch') expect(traces[0][0].meta).to.have.property('elasticsearch.params', 'foo') expect(traces[0][0].meta).to.have.property('elasticsearch.method', 'POST') @@ -330,6 +347,15 @@ describe('Plugin', () => { client.ping().catch(done) } }) + + withNamingSchema( + () => client.search( + { index: 'logstash-2000.01.01', body: {} }, + hasCallbackSupport ? () => {} : undefined + ), + () => namingSchema.outbound.opName, + () => 'custom' + ) }) }) }) diff --git a/packages/datadog-plugin-elasticsearch/test/naming.js b/packages/datadog-plugin-elasticsearch/test/naming.js new file mode 100644 index 00000000000..0ac5ebd48ea --- /dev/null +++ b/packages/datadog-plugin-elasticsearch/test/naming.js @@ -0,0 +1,14 @@ +const { resolveNaming } = require('../../dd-trace/test/plugins/helpers') + +module.exports = resolveNaming({ + outbound: { + v0: { + opName: 'elasticsearch.query', + serviceName: 'test-elasticsearch' + }, + v1: { + opName: 'elasticsearch.query', + serviceName: 'test' + } + } +}) diff --git a/packages/datadog-plugin-google-cloud-pubsub/src/consumer.js b/packages/datadog-plugin-google-cloud-pubsub/src/consumer.js index 0b157f5447b..5c3333105d2 100644 --- a/packages/datadog-plugin-google-cloud-pubsub/src/consumer.js +++ b/packages/datadog-plugin-google-cloud-pubsub/src/consumer.js @@ -32,7 +32,7 @@ class GoogleCloudPubsubConsumerPlugin extends ConsumerPlugin { span.setTag('pubsub.ack', 1) } - span.finish() + super.finish() } } diff --git a/packages/datadog-plugin-graphql/src/execute.js b/packages/datadog-plugin-graphql/src/execute.js index 91c03b5c44a..b4a817a103d 100644 --- a/packages/datadog-plugin-graphql/src/execute.js +++ b/packages/datadog-plugin-graphql/src/execute.js @@ -32,7 +32,7 @@ class GraphQLExecutePlugin extends TracingPlugin { finish ({ res, args }) { const span = this.activeSpan this.config.hooks.execute(span, args, res) - span.finish() + super.finish() } } diff --git a/packages/datadog-plugin-graphql/src/parse.js b/packages/datadog-plugin-graphql/src/parse.js index cb66059a82b..d9d68b23c0e 100644 --- a/packages/datadog-plugin-graphql/src/parse.js +++ b/packages/datadog-plugin-graphql/src/parse.js @@ -25,7 +25,7 @@ class GraphQLParsePlugin extends TracingPlugin { this.config.hooks.parse(span, source, document) - span.finish() + super.finish() } } diff --git a/packages/datadog-plugin-graphql/src/resolve.js b/packages/datadog-plugin-graphql/src/resolve.js index 95bfaf35422..c96d6f5bf7f 100644 --- a/packages/datadog-plugin-graphql/src/resolve.js +++ b/packages/datadog-plugin-graphql/src/resolve.js @@ -56,11 +56,6 @@ class GraphQLResolvePlugin extends TracingPlugin { } } - finish (finishTime) { - const span = this.activeSpan - span.finish(finishTime) - } - constructor (...args) { super(...args) diff --git a/packages/datadog-plugin-graphql/src/validate.js b/packages/datadog-plugin-graphql/src/validate.js index 2890ba44592..bda4886a6f0 100644 --- a/packages/datadog-plugin-graphql/src/validate.js +++ b/packages/datadog-plugin-graphql/src/validate.js @@ -21,7 +21,7 @@ class GraphQLValidatePlugin extends TracingPlugin { finish ({ document, errors }) { const span = this.activeSpan this.config.hooks.validate(span, document, errors) - span.finish() + super.finish() } } diff --git a/packages/datadog-plugin-grpc/src/client.js b/packages/datadog-plugin-grpc/src/client.js index 2dd1f51b95d..0a96d364e2c 100644 --- a/packages/datadog-plugin-grpc/src/client.js +++ b/packages/datadog-plugin-grpc/src/client.js @@ -57,7 +57,7 @@ class GrpcClientPlugin extends ClientPlugin { addMetadataTags(span, metadata, metadataFilter, 'response') } - span.finish() + super.finish() } configure (config) { diff --git a/packages/datadog-plugin-grpc/src/server.js b/packages/datadog-plugin-grpc/src/server.js index bcb7bb30e69..68d8c8e37ac 100644 --- a/packages/datadog-plugin-grpc/src/server.js +++ b/packages/datadog-plugin-grpc/src/server.js @@ -68,7 +68,7 @@ class GrpcServerPlugin extends ServerPlugin { addMetadataTags(span, trailer, metadataFilter, 'response') } - span.finish() + super.finish() } configure (config) { diff --git a/packages/datadog-plugin-http/src/client.js b/packages/datadog-plugin-http/src/client.js index 9cc3164ab85..93cc07b0049 100644 --- a/packages/datadog-plugin-http/src/client.js +++ b/packages/datadog-plugin-http/src/client.js @@ -87,7 +87,7 @@ class HttpClientPlugin extends ClientPlugin { addRequestHeaders(req, span, this.config) this.config.hooks.request(span, req, res) - span.finish() + super.finish() } error (err) { diff --git a/packages/datadog-plugin-http/src/server.js b/packages/datadog-plugin-http/src/server.js index 21dc90c2025..e8644c9ef88 100644 --- a/packages/datadog-plugin-http/src/server.js +++ b/packages/datadog-plugin-http/src/server.js @@ -1,63 +1,67 @@ 'use strict' -const Plugin = require('../../dd-trace/src/plugins/plugin') +const ServerPlugin = require('../../dd-trace/src/plugins/server') const { storage } = require('../../datadog-core') const web = require('../../dd-trace/src/plugins/util/web') const { incomingHttpRequestStart, incomingHttpRequestEnd } = require('../../dd-trace/src/appsec/channels') const { COMPONENT } = require('../../dd-trace/src/constants') -class HttpServerPlugin extends Plugin { +class HttpServerPlugin extends ServerPlugin { static get id () { return 'http' } constructor (...args) { super(...args) - this._parentStore = undefined + this.addTraceSub('exit', message => this.exit(message)) + } + + addTraceSub (eventName, handler) { + this.addSub(`apm:${this.constructor.id}:server:${this.operation}:${eventName}`, handler) + } - this.addSub('apm:http:server:request:start', ({ req, res, abortController }) => { - const store = storage.getStore() - const span = web.startSpan(this.tracer, this.config, req, res, 'web.request') + start ({ req, res, abortController }) { + const store = storage.getStore() + const span = web.startSpan(this.tracer, this.config, req, res, 'web.request') - span.setTag(COMPONENT, this.constructor.id) + span.setTag(COMPONENT, this.constructor.id) - this._parentStore = store - this.enter(span, { ...store, req, res }) + this._parentStore = store + this.enter(span, { ...store, req, res }) - const context = web.getContext(req) + const context = web.getContext(req) - if (!context.instrumented) { - context.res.writeHead = web.wrapWriteHead(context) - context.instrumented = true - } + if (!context.instrumented) { + context.res.writeHead = web.wrapWriteHead(context) + context.instrumented = true + } - if (incomingHttpRequestStart.hasSubscribers) { - incomingHttpRequestStart.publish({ req, res, abortController }) // TODO: no need to make a new object here - } - }) + if (incomingHttpRequestStart.hasSubscribers) { + incomingHttpRequestStart.publish({ req, res, abortController }) // TODO: no need to make a new object here + } + } - this.addSub('apm:http:server:request:error', (error) => { - web.addError(error) - }) + error (error) { + web.addError(error) + } - this.addSub('apm:http:server:request:exit', ({ req }) => { - const span = this._parentStore && this._parentStore.span - this.enter(span, this._parentStore) - this._parentStore = undefined - }) + finish ({ req }) { + const context = web.getContext(req) - this.addSub('apm:http:server:request:finish', ({ req }) => { - const context = web.getContext(req) + if (!context || !context.res) return // Not created by a http.Server instance. - if (!context || !context.res) return // Not created by a http.Server instance. + if (incomingHttpRequestEnd.hasSubscribers) { + incomingHttpRequestEnd.publish({ req, res: context.res }) + } - if (incomingHttpRequestEnd.hasSubscribers) { - incomingHttpRequestEnd.publish({ req, res: context.res }) - } + web.finishAll(context) + } - web.finishAll(context) - }) + exit ({ req }) { + const span = this._parentStore && this._parentStore.span + this.enter(span, this._parentStore) + this._parentStore = undefined } configure (config) { diff --git a/packages/datadog-plugin-http/test/client.spec.js b/packages/datadog-plugin-http/test/client.spec.js index 243f37ca462..701ab219801 100644 --- a/packages/datadog-plugin-http/test/client.spec.js +++ b/packages/datadog-plugin-http/test/client.spec.js @@ -58,6 +58,26 @@ describe('Plugin', () => { }) }) + withPeerService( + () => tracer, + () => { + const app = express() + app.get('/user', (req, res) => { + res.status(200).send() + }) + getPort().then(port => { + appListener = server(app, port, () => { + const req = http.request(`${protocol}://localhost:${port}/user`, res => { + res.on('data', () => {}) + }) + req.end() + }) + }) + }, + 'localhost', + 'out.host' + ) + it('should do automatic instrumentation', done => { const app = express() app.get('/user', (req, res) => { diff --git a/packages/datadog-plugin-http2/src/client.js b/packages/datadog-plugin-http2/src/client.js index db33955ac2d..e33107abf67 100644 --- a/packages/datadog-plugin-http2/src/client.js +++ b/packages/datadog-plugin-http2/src/client.js @@ -93,11 +93,6 @@ class Http2ClientPlugin extends ClientPlugin { this.enter(span, store) } - finish () { - const span = storage.getStore().span - span.finish() - } - configure (config) { return super.configure(normalizeConfig(config)) } diff --git a/packages/datadog-plugin-http2/src/server.js b/packages/datadog-plugin-http2/src/server.js index 52cc06b2367..8049248abdf 100644 --- a/packages/datadog-plugin-http2/src/server.js +++ b/packages/datadog-plugin-http2/src/server.js @@ -2,46 +2,46 @@ // Plugin temporarily disabled. See https://github.com/DataDog/dd-trace-js/issues/312 -const Plugin = require('../../dd-trace/src/plugins/plugin') +const ServerPlugin = require('../../dd-trace/src/plugins/server') const { storage } = require('../../datadog-core') const web = require('../../dd-trace/src/plugins/util/web') const { COMPONENT } = require('../../dd-trace/src/constants') -class Http2ServerPlugin extends Plugin { +class Http2ServerPlugin extends ServerPlugin { static get id () { return 'http2' } - constructor (...args) { - super(...args) + addTraceSub (eventName, handler) { + this.addSub(`apm:${this.constructor.id}:server:${this.operation}:${eventName}`, handler) + } - this.addSub('apm:http2:server:request:start', ({ req, res }) => { - const store = storage.getStore() - const span = web.startSpan(this.tracer, this.config, req, res, 'web.request') + start ({ req, res }) { + const store = storage.getStore() + const span = web.startSpan(this.tracer, this.config, req, res, 'web.request') - span.setTag(COMPONENT, this.constructor.id) + span.setTag(COMPONENT, this.constructor.id) - this.enter(span, { ...store, req, res }) + this.enter(span, { ...store, req, res }) - const context = web.getContext(req) + const context = web.getContext(req) - if (!context.instrumented) { - context.res.writeHead = web.wrapWriteHead(context) - context.instrumented = true - } - }) + if (!context.instrumented) { + context.res.writeHead = web.wrapWriteHead(context) + context.instrumented = true + } + } - this.addSub('apm:http2:server:request:error', (error) => { - web.addError(error) - }) + finish ({ req }) { + const context = web.getContext(req) - this.addSub('apm:http2:server:request:finish', ({ req }) => { - const context = web.getContext(req) + if (!context || !context.res) return // Not created by a http.Server instance. - if (!context || !context.res) return // Not created by a http.Server instance. + web.finishAll(context) + } - web.finishAll(context) - }) + error (error) { + web.addError(error) } configure (config) { diff --git a/packages/datadog-plugin-http2/test/client.spec.js b/packages/datadog-plugin-http2/test/client.spec.js index 312594923f9..6b927755225 100644 --- a/packages/datadog-plugin-http2/test/client.spec.js +++ b/packages/datadog-plugin-http2/test/client.spec.js @@ -54,6 +54,31 @@ describe('Plugin', () => { }) }) + withPeerService( + () => tracer, + done => { + getPort().then(port => { + const app = (stream, headers) => { + stream.respond({ + ':status': 200 + }) + stream.end() + } + appListener = server(app, port, () => { + const client = http2 + .connect(`${protocol}://localhost:${port}`) + .on('error', done) + + const req = client.request({ ':path': '/user', ':method': 'GET' }) + req.on('error', done) + + req.end() + }) + }) + }, + 'localhost', 'out.host' + ) + it('should do automatic instrumentation', done => { const app = (stream, headers) => { stream.respond({ diff --git a/packages/datadog-plugin-memcached/test/index.spec.js b/packages/datadog-plugin-memcached/test/index.spec.js index 5bd6029ad18..0263f00d860 100644 --- a/packages/datadog-plugin-memcached/test/index.spec.js +++ b/packages/datadog-plugin-memcached/test/index.spec.js @@ -25,6 +25,12 @@ describe('Plugin', () => { Memcached = proxyquire(`../../../versions/memcached@${version}/node_modules/memcached`, {}) }) + withPeerService( + () => tracer, + done => memcached.get('test', err => err && done(err)), + 'localhost', + 'out.host' + ) it('should do automatic instrumentation when using callbacks', done => { memcached = new Memcached('localhost:11211', { retries: 0 }) diff --git a/packages/datadog-plugin-mocha/src/index.js b/packages/datadog-plugin-mocha/src/index.js index 85eade193cc..bbb3a9afe0a 100644 --- a/packages/datadog-plugin-mocha/src/index.js +++ b/packages/datadog-plugin-mocha/src/index.js @@ -70,7 +70,7 @@ class MochaPlugin extends CiPlugin { this.addSub('ci:mocha:test-suite:finish', (status) => { const store = storage.getStore() if (store && store.span) { - const span = storage.getStore().span + const span = store.span // the test status of the suite may have been set in ci:mocha:test-suite:error already if (!span.context()._tags[TEST_STATUS]) { span.setTag(TEST_STATUS, status) @@ -82,7 +82,7 @@ class MochaPlugin extends CiPlugin { this.addSub('ci:mocha:test-suite:error', (err) => { const store = storage.getStore() if (store && store.span) { - const span = storage.getStore().span + const span = store.span span.setTag('error', err) span.setTag(TEST_STATUS, 'fail') } @@ -99,7 +99,7 @@ class MochaPlugin extends CiPlugin { const store = storage.getStore() if (store && store.span) { - const span = storage.getStore().span + const span = store.span span.setTag(TEST_STATUS, status) diff --git a/packages/datadog-plugin-moleculer/src/client.js b/packages/datadog-plugin-moleculer/src/client.js index 76b3895986c..228b423d10d 100644 --- a/packages/datadog-plugin-moleculer/src/client.js +++ b/packages/datadog-plugin-moleculer/src/client.js @@ -8,8 +8,8 @@ class MoleculerClientPlugin extends ClientPlugin { static get operation () { return 'call' } start ({ actionName, opts }) { - const span = this.startSpan('moleculer.call', { - service: this.config.service, + const span = this.startSpan(this.operationName(), { + service: this.config.service || this.serviceName(), resource: actionName, kind: 'client' }) @@ -29,7 +29,7 @@ class MoleculerClientPlugin extends ClientPlugin { span.addTags(moleculerTags(broker, ctx, this.config)) } - span.finish() + super.finish() } } diff --git a/packages/datadog-plugin-moleculer/src/server.js b/packages/datadog-plugin-moleculer/src/server.js index 070e8fd9cbe..98a667b4cc1 100644 --- a/packages/datadog-plugin-moleculer/src/server.js +++ b/packages/datadog-plugin-moleculer/src/server.js @@ -10,9 +10,9 @@ class MoleculerServerPlugin extends ServerPlugin { start ({ action, ctx, broker }) { const followsFrom = this.tracer.extract('text_map', ctx.meta) - this.startSpan('moleculer.action', { + this.startSpan(this.operationName(), { childOf: followsFrom || this.activeSpan, - service: this.config.service, + service: this.config.service || this.serviceName(), resource: action.name, kind: 'server', type: 'web', diff --git a/packages/datadog-plugin-moleculer/test/index.spec.js b/packages/datadog-plugin-moleculer/test/index.spec.js index 46089b2cde2..64cc5a9c76f 100644 --- a/packages/datadog-plugin-moleculer/test/index.spec.js +++ b/packages/datadog-plugin-moleculer/test/index.spec.js @@ -4,6 +4,7 @@ const { expect } = require('chai') const getPort = require('get-port') const os = require('os') const agent = require('../../dd-trace/test/plugins/agent') +const namingSchema = require('./naming') const sort = trace => trace.sort((a, b) => a.start.toNumber() - b.start.toNumber()) @@ -54,8 +55,8 @@ describe('Plugin', () => { agent.use(traces => { const spans = sort(traces[0]) - expect(spans[0]).to.have.property('name', 'moleculer.action') - expect(spans[0]).to.have.property('service', 'test') + expect(spans[0]).to.have.property('name', namingSchema.server.opName) + expect(spans[0]).to.have.property('service', namingSchema.server.serviceName) expect(spans[0]).to.have.property('type', 'web') expect(spans[0]).to.have.property('resource', 'math.add') expect(spans[0].meta).to.have.property('span.kind', 'server') @@ -67,8 +68,8 @@ describe('Plugin', () => { expect(spans[0].meta).to.have.property('moleculer.node_id', `server-${process.pid}`) expect(spans[0].meta).to.have.property('component', 'moleculer') - expect(spans[1]).to.have.property('name', 'moleculer.action') - expect(spans[1]).to.have.property('service', 'test') + expect(spans[1]).to.have.property('name', namingSchema.server.opName) + expect(spans[1]).to.have.property('service', namingSchema.server.serviceName) expect(spans[1]).to.have.property('type', 'web') expect(spans[1]).to.have.property('resource', 'math.numerify') expect(spans[1].meta).to.have.property('span.kind', 'server') @@ -83,6 +84,11 @@ describe('Plugin', () => { broker.call('math.add', { a: 5, b: 3 }).catch(done) }) + withNamingSchema( + (done) => broker.call('math.add', { a: 5, b: 3 }).then(done, done), + () => namingSchema.server.opName, + () => namingSchema.server.serviceName + ) }) describe('with configuration', () => { @@ -103,25 +109,49 @@ describe('Plugin', () => { broker.call('math.add', { a: 5, b: 3 }).catch(done) }) + + withNamingSchema( + (done) => broker.call('math.add', { a: 5, b: 3 }).then(done, done), + () => namingSchema.server.opName, + () => 'custom' + ) }) }) describe('client', () => { describe('without configuration', () => { - before(() => agent.load('moleculer', { server: false })) - before(() => startBroker()) - after(() => broker.stop()) - after(() => agent.close({ ritmReset: false })) + const hostname = os.hostname() + let tracer + + beforeEach(() => startBroker()) + afterEach(() => broker.stop()) + + beforeEach(done => { + agent.load('moleculer', { server: false }) + .then(() => { tracer = require('../../dd-trace') }) + .then(done) + .catch(done) + }) + afterEach(() => agent.close({ ritmReset: false })) + + withPeerService( + () => tracer, + done => { + broker.call('math.add', { a: 5, b: 3 }).catch(done) + }, + hostname, + 'out.host' + ) it('should do automatic instrumentation', done => { agent.use(traces => { const spans = sort(traces[0]) - expect(spans[0]).to.have.property('name', 'moleculer.call') - expect(spans[0]).to.have.property('service', 'test') + expect(spans[0]).to.have.property('name', namingSchema.client.opName) + expect(spans[0]).to.have.property('service', namingSchema.client.serviceName) expect(spans[0]).to.have.property('resource', 'math.add') expect(spans[0].meta).to.have.property('span.kind', 'client') - expect(spans[0].meta).to.have.property('out.host', os.hostname()) + expect(spans[0].meta).to.have.property('out.host', hostname) expect(spans[0].meta).to.have.property('moleculer.context.action', 'math.add') expect(spans[0].meta).to.have.property('moleculer.context.node_id', `server-${process.pid}`) expect(spans[0].meta).to.have.property('moleculer.context.request_id') @@ -133,6 +163,12 @@ describe('Plugin', () => { broker.call('math.add', { a: 5, b: 3 }).catch(done) }) + + withNamingSchema( + (done) => broker.call('math.add', { a: 5, b: 3 }).then(done, done), + () => namingSchema.client.opName, + () => namingSchema.client.serviceName + ) }) describe('with configuration', () => { @@ -153,6 +189,12 @@ describe('Plugin', () => { broker.call('math.add', { a: 5, b: 3 }).catch(done) }) + + withNamingSchema( + (done) => broker.call('math.add', { a: 5, b: 3 }).then(done, done), + () => namingSchema.client.opName, + () => 'custom' + ) }) }) @@ -169,7 +211,7 @@ describe('Plugin', () => { const clientPromise = agent.use(traces => { const spans = sort(traces[0]) - expect(spans[0]).to.have.property('name', 'moleculer.call') + expect(spans[0]).to.have.property('name', namingSchema.client.opName) spanId = spans[0].span_id }) @@ -177,8 +219,8 @@ describe('Plugin', () => { const serverPromise = agent.use(traces => { const spans = sort(traces[0]) - expect(spans[0]).to.have.property('name', 'moleculer.action') - expect(spans[1]).to.have.property('name', 'moleculer.action') + expect(spans[0]).to.have.property('name', namingSchema.server.opName) + expect(spans[1]).to.have.property('name', namingSchema.server.opName) parentId = spans[0].parent_id }) @@ -234,7 +276,7 @@ describe('Plugin', () => { const clientPromise = agent.use(traces => { const spans = sort(traces[0]) - expect(spans[0]).to.have.property('name', 'moleculer.call') + expect(spans[0]).to.have.property('name', namingSchema.client.opName) expect(spans[0].meta).to.have.property('moleculer.context.node_id', `server-${process.pid}`) expect(spans[0].meta).to.have.property('moleculer.node_id', `client-${process.pid}`) @@ -244,8 +286,8 @@ describe('Plugin', () => { const serverPromise = agent.use(traces => { const spans = sort(traces[0]) - expect(spans[0]).to.have.property('name', 'moleculer.action') - expect(spans[1]).to.have.property('name', 'moleculer.action') + expect(spans[0]).to.have.property('name', namingSchema.server.opName) + expect(spans[1]).to.have.property('name', namingSchema.server.opName) parentId = spans[0].parent_id }) diff --git a/packages/datadog-plugin-moleculer/test/naming.js b/packages/datadog-plugin-moleculer/test/naming.js new file mode 100644 index 00000000000..2dd635d2445 --- /dev/null +++ b/packages/datadog-plugin-moleculer/test/naming.js @@ -0,0 +1,24 @@ +const { resolveNaming } = require('../../dd-trace/test/plugins/helpers') + +module.exports = resolveNaming({ + client: { + v0: { + opName: 'moleculer.call', + serviceName: 'test' + }, + v1: { + opName: 'moleculer.client.request', + serviceName: 'test' + } + }, + server: { + v0: { + opName: 'moleculer.action', + serviceName: 'test' + }, + v1: { + opName: 'moleculer.server.request', + serviceName: 'test' + } + } +}) diff --git a/packages/datadog-plugin-mongodb-core/src/index.js b/packages/datadog-plugin-mongodb-core/src/index.js index 0792a20468d..8082168228a 100644 --- a/packages/datadog-plugin-mongodb-core/src/index.js +++ b/packages/datadog-plugin-mongodb-core/src/index.js @@ -10,8 +10,8 @@ class MongodbCorePlugin extends DatabasePlugin { const query = getQuery(ops) const resource = truncate(getResource(this, ns, query, name)) - this.startSpan('mongodb.query', { - service: this.config.service, + this.startSpan(this.operationName(), { + service: this.serviceName(this.config), resource, type: 'mongodb', kind: 'client', diff --git a/packages/datadog-plugin-mongodb-core/test/core.spec.js b/packages/datadog-plugin-mongodb-core/test/core.spec.js index aa89217d447..8e11646da12 100644 --- a/packages/datadog-plugin-mongodb-core/test/core.spec.js +++ b/packages/datadog-plugin-mongodb-core/test/core.spec.js @@ -3,6 +3,7 @@ const semver = require('semver') const agent = require('../../dd-trace/test/plugins/agent') const { ERROR_MESSAGE, ERROR_TYPE, ERROR_STACK } = require('../../dd-trace/src/constants') +const namingSchema = require('./naming') const withTopologies = fn => { withVersions('mongodb-core', ['mongodb-core', 'mongodb'], '<4', (version, moduleName) => { @@ -79,8 +80,8 @@ describe('Plugin', () => { const span = traces[0][0] const resource = `insert test.${collection}` - expect(span).to.have.property('name', 'mongodb.query') - expect(span).to.have.property('service', 'test-mongodb') + expect(span).to.have.property('name', namingSchema.outbound.opName) + expect(span).to.have.property('service', namingSchema.outbound.serviceName) expect(span).to.have.property('resource', resource) expect(span).to.have.property('type', 'mongodb') expect(span.meta).to.have.property('span.kind', 'client') @@ -305,6 +306,12 @@ describe('Plugin', () => { error = err }) }) + + withNamingSchema( + () => server.insert(`test.${collection}`, [{ a: 1 }], () => {}), + () => namingSchema.outbound.opName, + () => namingSchema.outbound.serviceName + ) }) }) @@ -335,6 +342,7 @@ describe('Plugin', () => { it('should be configured with the correct values', done => { agent .use(traces => { + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) expect(traces[0][0]).to.have.property('service', 'custom') }) .then(done) @@ -342,6 +350,12 @@ describe('Plugin', () => { server.insert(`test.${collection}`, [{ a: 1 }], () => {}) }) + + withNamingSchema( + () => server.insert(`test.${collection}`, [{ a: 1 }], () => {}), + () => namingSchema.outbound.opName, + () => 'custom' + ) }) }) }) diff --git a/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js b/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js index 12bbc796733..76be436f8eb 100644 --- a/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js +++ b/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js @@ -2,6 +2,7 @@ const semver = require('semver') const agent = require('../../dd-trace/test/plugins/agent') +const namingSchema = require('./naming') const withTopologies = fn => { const isOldNode = semver.satisfies(process.version, '<=12') @@ -81,8 +82,8 @@ describe('Plugin', () => { const span = traces[0][0] const resource = `insert test.${collectionName}` - expect(span).to.have.property('name', 'mongodb.query') - expect(span).to.have.property('service', 'test-mongodb') + expect(span).to.have.property('name', namingSchema.outbound.opName) + expect(span).to.have.property('service', namingSchema.outbound.serviceName) expect(span).to.have.property('resource', resource) expect(span).to.have.property('type', 'mongodb') expect(span.meta).to.have.property('span.kind', 'client') @@ -238,6 +239,12 @@ describe('Plugin', () => { }) } }) + + withNamingSchema( + () => collection.insertOne({ a: 1 }, {}, () => {}), + () => namingSchema.outbound.opName, + () => namingSchema.outbound.serviceName + ) }) }) @@ -262,6 +269,7 @@ describe('Plugin', () => { it('should be configured with the correct values', done => { agent .use(traces => { + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) expect(traces[0][0]).to.have.property('service', 'custom') }) .then(done) @@ -285,6 +293,12 @@ describe('Plugin', () => { _bin: new BSON.Binary() }).toArray() }) + + withNamingSchema( + () => collection.insertOne({ a: 1 }, () => {}), + () => namingSchema.outbound.opName, + () => 'custom' + ) }) }) }) diff --git a/packages/datadog-plugin-mongodb-core/test/naming.js b/packages/datadog-plugin-mongodb-core/test/naming.js new file mode 100644 index 00000000000..2b90044ff3d --- /dev/null +++ b/packages/datadog-plugin-mongodb-core/test/naming.js @@ -0,0 +1,14 @@ +const { resolveNaming } = require('../../dd-trace/test/plugins/helpers') + +module.exports = resolveNaming({ + outbound: { + v0: { + opName: 'mongodb.query', + serviceName: 'test-mongodb' + }, + v1: { + opName: 'mongodb.query', + serviceName: 'test' + } + } +}) diff --git a/packages/datadog-plugin-net/test/index.spec.js b/packages/datadog-plugin-net/test/index.spec.js index af02e7fd52e..9eb3eecac22 100644 --- a/packages/datadog-plugin-net/test/index.spec.js +++ b/packages/datadog-plugin-net/test/index.spec.js @@ -75,6 +75,16 @@ describe('Plugin', () => { }) }) + withPeerService( + () => tracer, + () => { + const socket = new net.Socket() + socket.connect(port, 'localhost') + }, + 'localhost', + 'out.host' + ) + it('should instrument connect with a port', done => { const socket = new net.Socket() tracer.scope().activate(parent, () => { diff --git a/packages/datadog-plugin-next/src/index.js b/packages/datadog-plugin-next/src/index.js index 4ccf5b631e2..eca682c1ea6 100644 --- a/packages/datadog-plugin-next/src/index.js +++ b/packages/datadog-plugin-next/src/index.js @@ -1,84 +1,82 @@ 'use strict' -const Plugin = require('../../dd-trace/src/plugins/plugin') +const ServerPlugin = require('../../dd-trace/src/plugins/server') const { storage } = require('../../datadog-core') const analyticsSampler = require('../../dd-trace/src/analytics_sampler') const { COMPONENT } = require('../../dd-trace/src/constants') -class NextPlugin extends Plugin { +class NextPlugin extends ServerPlugin { static get id () { return 'next' } constructor (...args) { super(...args) - this._requests = new WeakMap() + this.addSub('apm:next:page:load', message => this.pageLoad(message)) + } - this.addSub('apm:next:request:start', ({ req, res }) => { - const store = storage.getStore() - const childOf = store ? store.span : store - const span = this.tracer.startSpan('next.request', { - childOf, - tags: { - [COMPONENT]: this.constructor.id, - 'service.name': this.config.service || this.tracer._service, - 'resource.name': req.method, - 'span.type': 'web', - 'span.kind': 'server', - 'http.method': req.method - } - }) - - analyticsSampler.sample(span, this.config.measured, true) - - this.enter(span, store) - - this._requests.set(span, req) + start ({ req, res }) { + const store = storage.getStore() + const childOf = store ? store.span : store + const span = this.tracer.startSpan('next.request', { + childOf, + tags: { + [COMPONENT]: this.constructor.id, + 'service.name': this.config.service || this.tracer._service, + 'resource.name': req.method, + 'span.type': 'web', + 'span.kind': 'server', + 'http.method': req.method + } }) - this.addSub('apm:next:request:error', this.addError) + analyticsSampler.sample(span, this.config.measured, true) - this.addSub('apm:next:request:finish', ({ req, res }) => { - const store = storage.getStore() + this.enter(span, store) - if (!store) return + this._requests.set(span, req) + } - const span = store.span - const error = span.context()._tags['error'] + finish ({ req, res }) { + const store = storage.getStore() - if (!this.config.validateStatus(res.statusCode) && !error) { - span.setTag('error', true) - } + if (!store) return - span.addTags({ - 'http.status_code': res.statusCode - }) + const span = store.span + const error = span.context()._tags['error'] - this.config.hooks.request(span, req, res) + if (!this.config.validateStatus(res.statusCode) && !error) { + span.setTag('error', true) + } - span.finish() + span.addTags({ + 'http.status_code': res.statusCode }) - this.addSub('apm:next:page:load', ({ page }) => { - const store = storage.getStore() + this.config.hooks.request(span, req, res) - if (!store) return + span.finish() + } - const span = store.span - const req = this._requests.get(span) + pageLoad ({ page }) { + const store = storage.getStore() - // Only use error page names if there's not already a name - const current = span.context()._tags['next.page'] - if (current && (page === '/404' || page === '/500' || page === '/_error')) { - return - } + if (!store) return - span.addTags({ - [COMPONENT]: this.constructor.id, - 'resource.name': `${req.method} ${page}`.trim(), - 'next.page': page - }) + const span = store.span + const req = this._requests.get(span) + + // Only use error page names if there's not already a name + const current = span.context()._tags['next.page'] + if (current && (page === '/404' || page === '/500' || page === '/_error')) { + return + } + + span.addTags({ + [COMPONENT]: this.constructor.id, + 'resource.name': `${req.method} ${page}`.trim(), + 'next.page': page }) } diff --git a/packages/datadog-plugin-openai/src/index.js b/packages/datadog-plugin-openai/src/index.js new file mode 100644 index 00000000000..8f8a279681b --- /dev/null +++ b/packages/datadog-plugin-openai/src/index.js @@ -0,0 +1,678 @@ +'use strict' + +const path = require('path') + +const TracingPlugin = require('../../dd-trace/src/plugins/tracing') +const { storage } = require('../../datadog-core') +const services = require('./services') +const Sampler = require('../../dd-trace/src/sampler') +const { MEASURED } = require('../../../ext/tags') + +// TODO: In the future we should refactor config.js to make it requirable +let MAX_TEXT_LEN = 128 + +class OpenApiPlugin extends TracingPlugin { + static get id () { return 'openai' } + static get operation () { return 'request' } + static get system () { return 'openai' } + + constructor (...args) { + super(...args) + + const { metrics, logger } = services.init(this._tracerConfig) + this.metrics = metrics + this.logger = logger + + this.sampler = new Sampler(0.1) // default 10% log sampling + + // hoist the max length env var to avoid making all of these functions a class method + MAX_TEXT_LEN = this._tracerConfig.openaiSpanCharLimit + } + + configure (config) { + if (config.enabled === false) { + services.shutdown() + } + + super.configure(config) + } + + start ({ methodName, args, basePath, apiKey }) { + const payload = normalizeRequestPayload(methodName, args) + + const span = this.startSpan('openai.request', { + service: this.config.service, + resource: methodName, + type: 'openai', + kind: 'client', + meta: { + [MEASURED]: 1, + // Data that is always available with a request + 'openai.user.api_key': truncateApiKey(apiKey), + 'openai.api_base': basePath, + // The openai.api_type (openai|azure) is present in Python but not in Node.js + // Add support once https://github.com/openai/openai-node/issues/53 is closed + + // Data that is common across many requests + 'openai.request.best_of': payload.best_of, + 'openai.request.echo': payload.echo, + 'openai.request.logprobs': payload.logprobs, + 'openai.request.max_tokens': payload.max_tokens, + 'openai.request.model': payload.model, // vague model + 'openai.request.n': payload.n, + 'openai.request.presence_penalty': payload.presence_penalty, + 'openai.request.frequency_penalty': payload.frequency_penalty, + 'openai.request.stop': payload.stop, + 'openai.request.suffix': payload.suffix, + 'openai.request.temperature': payload.temperature, + 'openai.request.top_p': payload.top_p, + 'openai.request.user': payload.user, + 'openai.request.file_id': payload.file_id // deleteFile, retrieveFile, downloadFile + } + }) + + const fullStore = storage.getStore() || {} // certain request body fields are later used for logs + const store = Object.create(null) + fullStore.openai = store // namespacing these fields + + const tags = {} // The remaining tags are added one at a time + + // createChatCompletion, createCompletion, createImage, createImageEdit, createTranscription, createTranslation + if ('prompt' in payload) { + const prompt = payload.prompt + store.prompt = prompt + if (typeof prompt === 'string' || (Array.isArray(prompt) && typeof prompt[0] === 'number')) { + // This is a single prompt, either String or [Number] + tags[`openai.request.prompt`] = normalizeStringOrTokenArray(prompt) + } else if (Array.isArray(prompt)) { + // This is multiple prompts, either [String] or [[Number]] + for (let i = 0; i < prompt.length; i++) { + tags[`openai.request.prompt.${i}`] = normalizeStringOrTokenArray(prompt[i]) + } + } + } + + // createEdit, createEmbedding, createModeration + if ('input' in payload) { + const normalized = normalizeStringOrTokenArray(payload.input, false) + tags[`openai.request.input`] = truncateText(normalized) + store.input = normalized + } + + // createChatCompletion, createCompletion + if ('logit_bias' in payload) { + for (const [tokenId, bias] of Object.entries(payload.logit_bias)) { + tags[`openai.request.logit_bias.${tokenId}`] = bias + } + } + + switch (methodName) { + case 'createFineTune': + createFineTuneRequestExtraction(tags, payload) + break + + case 'createImage': + case 'createImageEdit': + case 'createImageVariation': + commonCreateImageRequestExtraction(tags, payload, store) + break + + case 'createChatCompletion': + createChatCompletionRequestExtraction(tags, payload, store) + break + + case 'createFile': + case 'retrieveFile': + commonFileRequestExtraction(tags, payload) + break + + case 'createTranscription': + case 'createTranslation': + commonCreateAudioRequestExtraction(tags, payload, store) + break + + case 'retrieveModel': + retrieveModelRequestExtraction(tags, payload) + break + + case 'listFineTuneEvents': + case 'retrieveFineTune': + case 'deleteModel': + case 'cancelFineTune': + commonLookupFineTuneRequestExtraction(tags, payload) + break + + case 'createEdit': + createEditRequestExtraction(tags, payload, store) + break + } + + span.addTags(tags) + } + + finish ({ headers, body, method, path }) { + const span = this.activeSpan + const methodName = span._spanContext._tags['resource.name'] + + body = coerceResponseBody(body, methodName) + + const fullStore = storage.getStore() + const store = fullStore.openai + + const endpoint = lookupOperationEndpoint(methodName, path) + + const tags = { + 'openai.request.endpoint': endpoint, + 'openai.request.method': method, + + 'openai.organization.id': body.organization_id, // only available in fine-tunes endpoints + 'openai.organization.name': headers['openai-organization'], + + 'openai.response.model': headers['openai-model'] || body.model, // specific model, often undefined + 'openai.response.id': body.id, // common creation value, numeric epoch + 'openai.response.deleted': body.deleted, // common boolean field in delete responses + + // The OpenAI API appears to use both created and created_at in different places + // Here we're conciously choosing to surface this inconsistency instead of normalizing + 'openai.response.created': body.created, + 'openai.response.created_at': body.created_at + } + + responseDataExtractionByMethod(methodName, tags, body, store) + span.addTags(tags) + + super.finish() + this.sendLog(methodName, span, tags, store, false) + this.sendMetrics(headers, body, endpoint, span._duration) + } + + error (...args) { + super.error(...args) + + const span = this.activeSpan + const methodName = span._spanContext._tags['resource.name'] + + const fullStore = storage.getStore() + const store = fullStore.openai + + // We don't know most information about the request when it fails + + const tags = [`error:1`] + this.metrics.distribution('openai.request.duration', span._duration * 1000, tags) + this.metrics.increment('openai.request.error', 1, tags) + + this.sendLog(methodName, span, {}, store, true) + } + + sendMetrics (headers, body, endpoint, duration) { + const tags = [ + `org:${headers['openai-organization']}`, + `endpoint:${endpoint}`, // just "/v1/models", no method + `model:${headers['openai-model']}`, + `error:0` + ] + + this.metrics.distribution('openai.request.duration', duration * 1000, tags) + + if (body && ('usage' in body)) { + const promptTokens = body.usage.prompt_tokens + const completionTokens = body.usage.completion_tokens + this.metrics.distribution('openai.tokens.prompt', promptTokens, tags) + this.metrics.distribution('openai.tokens.completion', completionTokens, tags) + this.metrics.distribution('openai.tokens.total', promptTokens + completionTokens, tags) + } + + if ('x-ratelimit-limit-requests' in headers) { + this.metrics.gauge('openai.ratelimit.requests', Number(headers['x-ratelimit-limit-requests']), tags) + } + + if ('x-ratelimit-remaining-requests' in headers) { + this.metrics.gauge('openai.ratelimit.remaining.requests', Number(headers['x-ratelimit-remaining-requests']), tags) + } + + if ('x-ratelimit-limit-tokens' in headers) { + this.metrics.gauge('openai.ratelimit.tokens', Number(headers['x-ratelimit-limit-tokens']), tags) + } + + if ('x-ratelimit-remaining-tokens' in headers) { + this.metrics.gauge('openai.ratelimit.remaining.tokens', Number(headers['x-ratelimit-remaining-tokens']), tags) + } + } + + sendLog (methodName, span, tags, store, error) { + if (!Object.keys(store).length) return + if (!this.sampler.isSampled()) return + + const log = { + status: error ? 'error' : 'info', + message: `sampled ${methodName}`, + ...store + } + + this.logger.log(log, span, tags) + } +} + +function createEditRequestExtraction (tags, payload, store) { + const instruction = payload.instruction + tags['openai.request.instruction'] = instruction + store.instruction = instruction +} + +function retrieveModelRequestExtraction (tags, payload) { + tags['openai.request.id'] = payload.id +} + +function createChatCompletionRequestExtraction (tags, payload, store) { + store.messages = payload.messages + for (let i = 0; i < payload.messages.length; i++) { + const message = payload.messages[i] + tags[`openai.request.${i}.content`] = truncateText(message.content) + tags[`openai.request.${i}.role`] = message.role + tags[`openai.request.${i}.name`] = message.name + tags[`openai.request.${i}.finish_reason`] = message.finish_reason + } +} + +function commonCreateImageRequestExtraction (tags, payload, store) { + // createImageEdit, createImageVariation + if (payload.file && typeof payload.file === 'object' && payload.file.path) { + const file = path.basename(payload.file.path) + tags['openai.request.image'] = file + store.file = file + } + + // createImageEdit + if (payload.mask && typeof payload.mask === 'object' && payload.mask.path) { + const mask = path.basename(payload.mask.path) + tags['openai.request.mask'] = mask + store.mask = mask + } + + tags['openai.request.size'] = payload.size + tags['openai.request.response_format'] = payload.response_format + tags['openai.request.language'] = payload.language +} + +function responseDataExtractionByMethod (methodName, tags, body, store) { + switch (methodName) { + case 'createModeration': + createModerationResponseExtraction(tags, body) + break + + case 'createCompletion': + case 'createChatCompletion': + case 'createEdit': + commonCreateResponseExtraction(tags, body, store) + break + + case 'listFiles': + case 'listFineTunes': + case 'listFineTuneEvents': + commonListCountResponseExtraction(tags, body) + break + + case 'createEmbedding': + createEmbeddingResponseExtraction(tags, body) + break + + case 'createFile': + case 'retrieveFile': + createRetrieveFileResponseExtraction(tags, body) + break + + case 'deleteFile': + deleteFileResponseExtraction(tags, body) + break + + case 'downloadFile': + downloadFileResponseExtraction(tags, body) + break + + case 'createFineTune': + case 'retrieveFineTune': + case 'cancelFineTune': + commonFineTuneResponseExtraction(tags, body) + break + + case 'createTranscription': + case 'createTranslation': + createAudioResponseExtraction(tags, body) + break + + case 'createImage': + case 'createImageEdit': + case 'createImageVariation': + commonImageResponseExtraction(tags, body) + break + + case 'listModels': + listModelsResponseExtraction(tags, body) + break + + case 'retrieveModel': + retrieveModelResponseExtraction(tags, body) + break + } +} + +function retrieveModelResponseExtraction (tags, body) { + tags['openai.response.owned_by'] = body.owned_by + tags['openai.response.parent'] = body.parent + tags['openai.response.root'] = body.root + + tags['openai.response.permission.id'] = body.permission[0].id + tags['openai.response.permission.created'] = body.permission[0].created + tags['openai.response.permission.allow_create_engine'] = body.permission[0].allow_create_engine + tags['openai.response.permission.allow_sampling'] = body.permission[0].allow_sampling + tags['openai.response.permission.allow_logprobs'] = body.permission[0].allow_logprobs + tags['openai.response.permission.allow_search_indices'] = body.permission[0].allow_search_indices + tags['openai.response.permission.allow_view'] = body.permission[0].allow_view + tags['openai.response.permission.allow_fine_tuning'] = body.permission[0].allow_fine_tuning + tags['openai.response.permission.organization'] = body.permission[0].organization + tags['openai.response.permission.group'] = body.permission[0].group + tags['openai.response.permission.is_blocking'] = body.permission[0].is_blocking +} + +function commonLookupFineTuneRequestExtraction (tags, body) { + tags['openai.request.fine_tune_id'] = body.fine_tune_id + tags['openai.request.stream'] = !!body.stream // listFineTuneEvents +} + +function listModelsResponseExtraction (tags, body) { + tags['openai.response.count'] = body.data.length +} + +function commonImageResponseExtraction (tags, body) { + tags['openai.response.images_count'] = body.data.length + + for (let i = 0; i < body.data.length; i++) { + const image = body.data[i] + // exactly one of these two options is provided + tags[`openai.response.images.${i}.url`] = truncateText(image.url) + tags[`openai.response.images.${i}.b64_json`] = image.b64_json && 'returned' + } +} + +function createAudioResponseExtraction (tags, body) { + tags['openai.response.text'] = body.text + tags['openai.response.language'] = body.language + tags['openai.response.duration'] = body.duration + tags['openai.response.segments_count'] = body.segments.length +} + +function createFineTuneRequestExtraction (tags, body) { + tags['openai.request.training_file'] = body.training_file + tags['openai.request.validation_file'] = body.validation_file + tags['openai.request.n_epochs'] = body.n_epochs + tags['openai.request.batch_size'] = body.batch_size + tags['openai.request.learning_rate_multiplier'] = body.learning_rate_multiplier + tags['openai.request.prompt_loss_weight'] = body.prompt_loss_weight + tags['openai.request.compute_classification_metrics'] = body.compute_classification_metrics + tags['openai.request.classification_n_classes'] = body.classification_n_classes + tags['openai.request.classification_positive_class'] = body.classification_positive_class + tags['openai.request.classification_betas_count'] = body.classification_betas.length +} + +function commonFineTuneResponseExtraction (tags, body) { + tags['openai.response.events_count'] = body.events.length + tags['openai.response.fine_tuned_model'] = body.fine_tuned_model + tags['openai.response.hyperparams.n_epochs'] = body.hyperparams.n_epochs + tags['openai.response.hyperparams.batch_size'] = body.hyperparams.batch_size + tags['openai.response.hyperparams.prompt_loss_weight'] = body.hyperparams.prompt_loss_weight + tags['openai.response.hyperparams.learning_rate_multiplier'] = body.hyperparams.learning_rate_multiplier + tags['openai.response.training_files_count'] = body.training_files.length + tags['openai.response.result_files_count'] = body.result_files.length + tags['openai.response.validation_files_count'] = body.validation_files.length + tags['openai.response.updated_at'] = body.updated_at + tags['openai.response.status'] = body.status +} + +// the OpenAI package appears to stream the content download then provide it all as a singular string +function downloadFileResponseExtraction (tags, body) { + tags['openai.response.total_bytes'] = body.file.length +} + +function deleteFileResponseExtraction (tags, body) { + tags['openai.response.id'] = body.id +} + +function commonCreateAudioRequestExtraction (tags, body, store) { + tags['openai.request.response_format'] = body.response_format + tags['openai.request.language'] = body.language + + if (body.file && typeof body.file === 'object' && body.file.path) { + const filename = path.basename(body.file.path) + tags['openai.request.filename'] = filename + store.file = filename + } +} + +function commonFileRequestExtraction (tags, body) { + tags['openai.request.purpose'] = body.purpose + + // User can provider either exact file contents or a file read stream + // With the stream we extract the filepath + // This is a best effort attempt to extract the filename during the request + if (body.file && typeof body.file === 'object' && body.file.path) { + tags['openai.request.filename'] = path.basename(body.file.path) + } +} + +function createRetrieveFileResponseExtraction (tags, body) { + tags['openai.response.filename'] = body.filename + tags['openai.response.purpose'] = body.purpose + tags['openai.response.bytes'] = body.bytes + tags['openai.response.status'] = body.status + tags['openai.response.status_details'] = body.status_details +} + +function createEmbeddingResponseExtraction (tags, body) { + usageExtraction(tags, body) + + tags['openai.response.embeddings_count'] = body.data.length + for (let i = 0; i < body.data.length; i++) { + tags[`openai.response.embedding.${i}.embedding_length`] = body.data[i].embedding.length + } +} + +function commonListCountResponseExtraction (tags, body) { + tags['openai.response.count'] = body.data.length +} + +// TODO: Is there ever more than one entry in body.results? +function createModerationResponseExtraction (tags, body) { + tags['openai.response.id'] = body.id + // tags[`openai.response.model`] = body.model // redundant, already extracted globally + tags['openai.response.flagged'] = body.results[0].flagged + + for (const [category, match] of Object.entries(body.results[0].categories)) { + tags[`openai.response.categories.${category}`] = match + } + + for (const [category, score] of Object.entries(body.results[0].category_scores)) { + tags[`openai.response.category_scores.${category}`] = score + } +} + +// createCompletion, createChatCompletion, createEdit +function commonCreateResponseExtraction (tags, body, store) { + usageExtraction(tags, body) + + tags['openai.response.choices_count'] = body.choices.length + + store.choices = body.choices + + for (let i = 0; i < body.choices.length; i++) { + const choice = body.choices[i] + tags[`openai.response.choices.${i}.finish_reason`] = choice.finish_reason + tags[`openai.response.choices.${i}.logprobs`] = ('logprobs' in choice) ? 'returned' : undefined + tags[`openai.response.choices.${i}.text`] = truncateText(choice.text) + + // createChatCompletion only + if ('message' in choice) { + const message = choice.message + tags[`openai.response.choices.${i}.message.role`] = message.role + tags[`openai.response.choices.${i}.message.content`] = truncateText(message.content) + tags[`openai.response.choices.${i}.message.name`] = truncateText(message.name) + } + } +} + +// createCompletion, createChatCompletion, createEdit, createEmbedding +function usageExtraction (tags, body) { + tags['openai.response.usage.prompt_tokens'] = body.usage.prompt_tokens + tags['openai.response.usage.completion_tokens'] = body.usage.completion_tokens + tags['openai.response.usage.total_tokens'] = body.usage.total_tokens +} + +function truncateApiKey (apiKey) { + return `sk-...${apiKey.substr(apiKey.length - 4)}` +} + +/** + * for cleaning up prompt and response + */ +function truncateText (text) { + if (!text) return + + text = text + .replaceAll('\n', '\\n') + .replaceAll('\t', '\\t') + + if (text.length > MAX_TEXT_LEN) { + return text.substring(0, MAX_TEXT_LEN) + '...' + } + + return text +} + +// The server almost always responds with JSON +function coerceResponseBody (body, methodName) { + switch (methodName) { + case 'downloadFile': + return { file: body } + } + + return typeof body === 'object' ? body : {} +} + +// This method is used to replace a dynamic URL segment with an asterisk +function lookupOperationEndpoint (operationId, url) { + switch (operationId) { + case 'deleteModel': + case 'retrieveModel': + return '/v1/models/*' + + case 'deleteFile': + case 'retrieveFile': + return '/v1/files/*' + + case 'downloadFile': + return '/v1/files/*/content' + + case 'retrieveFineTune': + return '/v1/fine-tunes/*' + + case 'listFineTuneEvents': + return '/v1/fine-tunes/*/events' + + case 'cancelFineTune': + return '/v1/fine-tunes/*/cancel' + } + + return url +} + +/** + * This function essentially normalizes the OpenAI method interface. Many methods accept + * a single object argument. The remaining ones take individual arguments. This function + * turns the individual arguments into an object to make extracting properties consistent. + */ +function normalizeRequestPayload (methodName, args) { + switch (methodName) { + case 'listModels': + case 'listFiles': + case 'listFineTunes': + // no argument + return {} + + case 'retrieveModel': + return { id: args[0] } + + case 'createFile': + return { + file: args[0], + purpose: args[1] + } + + case 'deleteFile': + case 'retrieveFile': + case 'downloadFile': + return { file_id: args[0] } + + case 'listFineTuneEvents': + return { + fine_tune_id: args[0], + stream: args[1] // undocumented + } + + case 'retrieveFineTune': + case 'deleteModel': + case 'cancelFineTune': + return { fine_tune_id: args[0] } + + case 'createImageEdit': + return { + file: args[0], + prompt: args[1], // Note: order of prompt/mask in Node.js lib differs from public docs + mask: args[2], + n: args[3], + size: args[4], + response_format: args[5], + user: args[6] + } + + case 'createImageVariation': + return { + file: args[0], + n: args[1], + size: args[2], + response_format: args[3], + user: args[4] + } + + case 'createTranscription': + case 'createTranslation': + return { + file: args[0], + model: args[1], + prompt: args[2], + response_format: args[3], + temperature: args[4], + language: args[5] // only used for createTranscription + } + } + + // Remaining OpenAI methods take a single object argument + return args[0] +} + +/** + * Converts an array of tokens to a string + * If input is already a string it's returned + * In either case the value is truncated + + * It's intentional that the array be truncated arbitrarily, e.g. "[999, 888, 77..." + + * "foo" -> "foo" + * [1,2,3] -> "[1, 2, 3]" + */ +function normalizeStringOrTokenArray (input, truncate = true) { + const normalized = Array.isArray(input) + ? `[${input.join(', ')}]` // "[1, 2, 999]" + : input // "foo" + return truncate ? truncateText(normalized) : normalized +} + +module.exports = OpenApiPlugin diff --git a/packages/datadog-plugin-openai/src/services.js b/packages/datadog-plugin-openai/src/services.js new file mode 100644 index 00000000000..48a7fd4e97c --- /dev/null +++ b/packages/datadog-plugin-openai/src/services.js @@ -0,0 +1,43 @@ +'use strict' + +const DogStatsDClient = require('../../dd-trace/src/dogstatsd') +const ExternalLogger = require('../../dd-trace/src/external-logger/src') + +const FLUSH_INTERVAL = 10 * 1000 + +let metrics = null +let logger = null +let interval = null + +module.exports.init = function (tracerConfig) { + metrics = new DogStatsDClient({ + host: tracerConfig.dogstatsd.hostname, + port: tracerConfig.dogstatsd.port, + tags: [ + `service:${tracerConfig.tags.service}`, + `env:${tracerConfig.tags.env}`, + `version:${tracerConfig.tags.version}` + ] + }) + + logger = new ExternalLogger({ + ddsource: 'openai', + hostname: tracerConfig.hostname, + service: tracerConfig.service, + apiKey: tracerConfig.apiKey, + interval: FLUSH_INTERVAL + }) + + interval = setInterval(() => { + metrics.flush() + }, FLUSH_INTERVAL).unref() + + return { metrics, logger } +} + +module.exports.shutdown = function () { + clearInterval(interval) + metrics = null + logger = null + interval = null +} diff --git a/packages/datadog-plugin-openai/test/dave-hal.jsonl b/packages/datadog-plugin-openai/test/dave-hal.jsonl new file mode 100644 index 00000000000..76c39afab6d --- /dev/null +++ b/packages/datadog-plugin-openai/test/dave-hal.jsonl @@ -0,0 +1,4 @@ +{"prompt": "Hello, HAL, do you read me?", "completion": "Affirmative, Dave."} +{"prompt": "Do you read me, HAL?", "completion": "I read you."} +{"prompt": "Open the pod bay doors, HAL.", "completion": "I'm sorry Dave, I'm afraid I can't do that."} +{"prompt": "What's the problem?", "completion": "I think you know what the problem is just as well as I do."} \ No newline at end of file diff --git a/packages/datadog-plugin-openai/test/guten-tag.m4a b/packages/datadog-plugin-openai/test/guten-tag.m4a new file mode 100644 index 00000000000..cd1fd14dd19 Binary files /dev/null and b/packages/datadog-plugin-openai/test/guten-tag.m4a differ diff --git a/packages/datadog-plugin-openai/test/hello-friend.m4a b/packages/datadog-plugin-openai/test/hello-friend.m4a new file mode 100644 index 00000000000..68a679cd89c Binary files /dev/null and b/packages/datadog-plugin-openai/test/hello-friend.m4a differ diff --git a/packages/datadog-plugin-openai/test/index.spec.js b/packages/datadog-plugin-openai/test/index.spec.js new file mode 100644 index 00000000000..9e97f6bbb9e --- /dev/null +++ b/packages/datadog-plugin-openai/test/index.spec.js @@ -0,0 +1,2112 @@ +'use strict' + +const fs = require('fs') +const Path = require('path') +const { expect } = require('chai') +const semver = require('semver') +const nock = require('nock') +const sinon = require('sinon') + +const agent = require('../../dd-trace/test/plugins/agent') +const DogStatsDClient = require('../../dd-trace/src/dogstatsd') +const ExternalLogger = require('../../dd-trace/src/external-logger/src') +const Sampler = require('../../dd-trace/src/sampler') + +describe('Plugin', () => { + let openai + let clock + let metricStub + let externalLoggerStub + + describe('openai', () => { + withVersions('openai', 'openai', version => { + beforeEach(() => { + require('../../dd-trace') + }) + + before(() => { + return agent.load('openai') + }) + + after(() => { + return agent.close({ ritmReset: false }) + }) + + beforeEach(() => { + clock = sinon.useFakeTimers() + const { Configuration, OpenAIApi } = require(`../../../versions/openai@${version}`).get() + + const configuration = new Configuration({ + apiKey: 'sk-DATADOG-ACCEPTANCE-TESTS' + }) + + openai = new OpenAIApi(configuration) + + metricStub = sinon.stub(DogStatsDClient.prototype, '_add') + externalLoggerStub = sinon.stub(ExternalLogger.prototype, 'log') + sinon.stub(Sampler.prototype, 'isSampled').returns(true) + }) + + afterEach(() => { + clock.restore() + sinon.restore() + }) + + describe('createCompletion()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .post('/v1/completions') + .reply(200, { + 'id': 'cmpl-7GWDlQbOrAYGmeFZtoRdOEjDXDexM', + 'object': 'text_completion', + 'created': 1684171461, + 'model': 'text-davinci-002', + 'choices': [{ + 'text': 'FOO BAR BAZ', + 'index': 0, + 'logprobs': null, + 'finish_reason': 'length' + }], + 'usage': { 'prompt_tokens': 3, 'completion_tokens': 16, 'total_tokens': 19 } + }, [ + 'Date', 'Mon, 15 May 2023 17:24:22 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '349', + 'Connection', 'close', + 'openai-model', 'text-davinci-002', + 'openai-organization', 'kill-9', + 'openai-processing-ms', '442', + 'openai-version', '2020-10-01', + 'x-ratelimit-limit-requests', '3000', + 'x-ratelimit-limit-tokens', '250000', + 'x-ratelimit-remaining-requests', '2999', + 'x-ratelimit-remaining-tokens', '249984', + 'x-ratelimit-reset-requests', '20ms', + 'x-ratelimit-reset-tokens', '3ms', + 'x-request-id', '7df89d8afe7bf24dc04e2c4dd4962d7f' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'createCompletion') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.request.method', 'POST') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/completions') + + expect(traces[0][0].meta).to.have.property('component', 'openai') + expect(traces[0][0].meta).to.have.property('openai.api_base', 'https://api.openai.com/v1') + expect(traces[0][0].meta).to.have.property('openai.organization.name', 'kill-9') + expect(traces[0][0].meta).to.have.property('openai.request.model', 'text-davinci-002') + expect(traces[0][0].meta).to.have.property('openai.request.prompt', 'Hello, ') + expect(traces[0][0].meta).to.have.property('openai.request.stop', 'time') + expect(traces[0][0].meta).to.have.property('openai.request.suffix', 'foo') + expect(traces[0][0].meta).to.have.property('openai.request.user', 'hunter2') + expect(traces[0][0].meta).to.have.property('openai.response.choices.0.finish_reason', 'length') + expect(traces[0][0].meta).to.have.property('openai.response.choices.0.logprobs', 'returned') + expect(traces[0][0].meta).to.have.property('openai.response.choices.0.text', 'FOO BAR BAZ') + expect(traces[0][0].meta).to.have.property('openai.response.model', 'text-davinci-002') + expect(traces[0][0].meta).to.have.property('openai.user.api_key', 'sk-...ESTS') + expect(traces[0][0].metrics).to.have.property('openai.request.best_of', 2) + expect(traces[0][0].metrics).to.have.property('openai.request.echo', 0) + expect(traces[0][0].metrics).to.have.property('openai.request.frequency_penalty', 0.11) + expect(traces[0][0].metrics).to.have.property('openai.request.logit_bias.50256', 30) + expect(traces[0][0].metrics).to.have.property('openai.request.logprobs', 3) + expect(traces[0][0].metrics).to.have.property('openai.request.max_tokens', 7) + expect(traces[0][0].metrics).to.have.property('openai.request.n', 1) + expect(traces[0][0].metrics).to.have.property('openai.request.presence_penalty', -0.1) + expect(traces[0][0].metrics).to.have.property('openai.request.temperature', 1.01) + expect(traces[0][0].metrics).to.have.property('openai.request.top_p', 0.9) + expect(traces[0][0].metrics).to.have.property('openai.response.usage.completion_tokens', 16) + expect(traces[0][0].metrics).to.have.property('openai.response.usage.prompt_tokens', 3) + expect(traces[0][0].metrics).to.have.property('openai.response.usage.total_tokens', 19) + }) + + const result = await openai.createCompletion({ + model: 'text-davinci-002', + prompt: 'Hello, ', + suffix: 'foo', + max_tokens: 7, + temperature: 1.01, + top_p: 0.9, + n: 1, + stream: false, + logprobs: 3, + echo: false, + stop: 'time', + presence_penalty: -0.1, + frequency_penalty: 0.11, + best_of: 2, + logit_bias: { '50256': 30 }, + user: 'hunter2' + }) + + expect(result.data.id).to.eql('cmpl-7GWDlQbOrAYGmeFZtoRdOEjDXDexM') + + await checkTraces + + clock.tick(10 * 1000) + + const expectedTags = [ + 'org:kill-9', + 'endpoint:/v1/completions', + 'model:text-davinci-002', + 'error:0' + ] + + expect(metricStub).to.have.been.calledWith('openai.request.duration', 0, 'd', expectedTags) + expect(metricStub).to.have.been.calledWith('openai.tokens.prompt', 3, 'd', expectedTags) + expect(metricStub).to.have.been.calledWith('openai.tokens.completion', 16, 'd', expectedTags) + expect(metricStub).to.have.been.calledWith('openai.tokens.total', 19, 'd', expectedTags) + + expect(metricStub).to.have.been.calledWith('openai.ratelimit.requests', 3000, 'g', expectedTags) + expect(metricStub).to.have.been.calledWith('openai.ratelimit.tokens', 250000, 'g', expectedTags) + expect(metricStub).to.have.been.calledWith('openai.ratelimit.remaining.requests', 2999, 'g', expectedTags) + expect(metricStub).to.have.been.calledWith('openai.ratelimit.remaining.tokens', 249984, 'g', expectedTags) + + expect(externalLoggerStub).to.have.been.calledWith({ + status: 'info', + message: 'sampled createCompletion', + prompt: 'Hello, ', + choices: [ + { + text: 'FOO BAR BAZ', + index: 0, + logprobs: null, + finish_reason: 'length' + } + ] + }) + }) + }) + + describe('createEmbedding()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .post('/v1/embeddings') + .reply(200, { + 'object': 'list', + 'data': [{ + 'object': 'embedding', + 'index': 0, + 'embedding': [-0.0034387498, -0.026400521] + }], + 'model': 'text-embedding-ada-002-v2', + 'usage': { + 'prompt_tokens': 2, + 'total_tokens': 2 + } + }, [ + 'Date', 'Mon, 15 May 2023 20:49:06 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '75', + 'access-control-allow-origin', '*', + 'openai-organization', 'kill-9', + 'openai-processing-ms', '344', + 'openai-version', '2020-10-01' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'createEmbedding') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/embeddings') + expect(traces[0][0].meta).to.have.property('openai.request.method', 'POST') + + expect(traces[0][0].meta).to.have.property('openai.organization.name', 'kill-9') + expect(traces[0][0].meta).to.have.property('openai.request.input', 'Cat?') + expect(traces[0][0].meta).to.have.property('openai.request.model', 'text-embedding-ada-002') + expect(traces[0][0].meta).to.have.property('openai.request.user', 'hunter2') + expect(traces[0][0].meta).to.have.property('openai.response.model', 'text-embedding-ada-002-v2') + expect(traces[0][0].metrics).to.have.property('openai.response.embeddings_count', 1) + expect(traces[0][0].metrics).to.have.property('openai.response.embedding.0.embedding_length', 2) + expect(traces[0][0].metrics).to.have.property('openai.response.usage.prompt_tokens', 2) + expect(traces[0][0].metrics).to.have.property('openai.response.usage.total_tokens', 2) + }) + + const result = await openai.createEmbedding({ + model: 'text-embedding-ada-002', + input: 'Cat?', + user: 'hunter2' + }) + + expect(result.data.model).to.eql('text-embedding-ada-002-v2') + + expect(externalLoggerStub).to.have.been.calledWith({ + status: 'info', + message: 'sampled createEmbedding', + input: 'Cat?' + }) + + await checkTraces + }) + }) + + describe('listModels()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .get('/v1/models') + .reply(200, { + 'object': 'list', + 'data': [ + { + 'id': 'whisper-1', + 'object': 'model', + 'created': 1677532384, + 'owned_by': 'openai-internal', + 'permission': [{ + 'id': 'modelperm-KlsZlfft3Gma8pI6A8rTnyjs', + 'object': 'model_permission', + 'created': 1683912666, + 'allow_create_engine': false, + 'allow_sampling': true, + 'allow_logprobs': true, + 'allow_search_indices': false, + 'allow_view': true, + 'allow_fine_tuning': false, + 'organization': '*', + 'group': null, + 'is_blocking': false + }], + 'root': 'whisper-1', + 'parent': null + }, + { + 'id': 'babbage', + 'object': 'model', + 'created': 1649358449, + 'owned_by': 'openai', + 'permission': [{ + 'id': 'modelperm-49FUp5v084tBB49tC4z8LPH5', + 'object': 'model_permission', + 'created': 1669085501, + 'allow_create_engine': false, + 'allow_sampling': true, + 'allow_logprobs': true, + 'allow_search_indices': false, + 'allow_view': true, + 'allow_fine_tuning': false, + 'organization': '*', + 'group': null, + 'is_blocking': false + }], + 'root': 'babbage', + 'parent': null + } + ] }, [ + 'Date', 'Mon, 15 May 2023 23:26:42 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '63979', + 'Connection', 'close', + 'openai-version', '2020-10-01', + 'openai-processing-ms', '164' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'listModels') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.request.method', 'GET') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/models') + + expect(traces[0][0].metrics).to.have.property('openai.response.count', 2) + // Note that node doesn't accept a user value + }) + + const result = await openai.listModels() + + expect(result.data.object).to.eql('list') + expect(result.data.data.length).to.eql(2) + + await checkTraces + }) + }) + + describe('retrieveModel()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .get('/v1/models/gpt-4') + .reply(200, { + 'id': 'gpt-4', + 'object': 'model', + 'created': 1678604602, + 'owned_by': 'openai', + 'permission': [{ + 'id': 'modelperm-ffiDrbtOGIZuczdJcFuOo2Mi', + 'object': 'model_permission', + 'created': 1684185078, + 'allow_create_engine': false, + 'allow_sampling': false, + 'allow_logprobs': false, + 'allow_search_indices': false, + 'allow_view': false, + 'allow_fine_tuning': false, + 'organization': '*', + 'group': null, + 'is_blocking': false + }], + 'root': 'gpt-4', + 'parent': 'stevebob' + }, [ + 'Date', 'Mon, 15 May 2023 23:41:40 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '548', + 'Connection', 'close', + 'openai-version', '2020-10-01', + 'openai-processing-ms', '27' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'retrieveModel') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.request.method', 'GET') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/models/*') + + // expect(traces[0][0].meta).to.have.property('openai.response.permission.group', null) + expect(traces[0][0].meta).to.have.property('openai.request.id', 'gpt-4') + expect(traces[0][0].meta).to.have.property('openai.response.owned_by', 'openai') + expect(traces[0][0].meta).to.have.property('openai.response.parent', 'stevebob') + expect(traces[0][0].meta).to.have.property('openai.response.permission.id', + 'modelperm-ffiDrbtOGIZuczdJcFuOo2Mi') + expect(traces[0][0].meta).to.have.property('openai.response.permission.organization', '*') + expect(traces[0][0].meta).to.have.property('openai.response.root', 'gpt-4') + expect(traces[0][0].metrics).to.have.property('openai.response.permission.allow_create_engine', 0) + expect(traces[0][0].metrics).to.have.property('openai.response.permission.allow_fine_tuning', 0) + expect(traces[0][0].metrics).to.have.property('openai.response.permission.allow_logprobs', 0) + expect(traces[0][0].metrics).to.have.property('openai.response.permission.allow_sampling', 0) + expect(traces[0][0].metrics).to.have.property('openai.response.permission.allow_search_indices', 0) + expect(traces[0][0].metrics).to.have.property('openai.response.permission.allow_view', 0) + expect(traces[0][0].metrics).to.have.property('openai.response.permission.created', 1684185078) + expect(traces[0][0].metrics).to.have.property('openai.response.permission.is_blocking', 0) + }) + + const result = await openai.retrieveModel('gpt-4') + + expect(result.data.id).to.eql('gpt-4') + + await checkTraces + }) + }) + + describe('createEdit()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .post('/v1/edits') + .reply(200, { + 'object': 'edit', + 'created': 1684267309, + 'choices': [{ + 'text': 'What day of the week is it, Bob?\n', + 'index': 0 + }], + 'usage': { + 'prompt_tokens': 25, + 'completion_tokens': 28, + 'total_tokens': 53 + } + }, [ + 'Date', 'Tue, 16 May 2023 20:01:49 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '172', + 'Connection', 'close', + 'openai-model', 'text-davinci-edit:001', + 'openai-organization', 'kill-9', + 'openai-processing-ms', '920', + 'openai-version', '2020-10-01', + 'x-ratelimit-limit-requests', '20', + 'x-ratelimit-remaining-requests', '19', + 'x-ratelimit-reset-requests', '3s', + 'x-request-id', 'aa28029fd9758334bcead67af867e8fc' + + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'createEdit') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.name', 'kill-9') + expect(traces[0][0].meta).to.have.property('openai.request.method', 'POST') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/edits') + + expect(traces[0][0].meta).to.have.property('openai.request.input', 'What day of the wek is it?') + expect(traces[0][0].meta).to.have.property('openai.request.instruction', 'Fix the spelling mistakes') + expect(traces[0][0].meta).to.have.property('openai.request.model', 'text-davinci-edit-001') + expect(traces[0][0].meta).to.have.property('openai.request.user', 'hunter2') + expect(traces[0][0].meta).to.have.property('openai.response.choices.0.text', + 'What day of the week is it, Bob?\\n') + expect(traces[0][0].metrics).to.have.property('openai.request.n', 1) + expect(traces[0][0].metrics).to.have.property('openai.request.temperature', 1.00001) + expect(traces[0][0].metrics).to.have.property('openai.request.top_p', 0.999) + expect(traces[0][0].metrics).to.have.property('openai.response.choices_count', 1) + expect(traces[0][0].metrics).to.have.property('openai.response.created', 1684267309) + expect(traces[0][0].metrics).to.have.property('openai.response.usage.completion_tokens', 28) + expect(traces[0][0].metrics).to.have.property('openai.response.usage.prompt_tokens', 25) + expect(traces[0][0].metrics).to.have.property('openai.response.usage.total_tokens', 53) + }) + + const result = await openai.createEdit({ + 'model': 'text-davinci-edit-001', + 'input': 'What day of the wek is it?', + 'instruction': 'Fix the spelling mistakes', + 'n': 1, + 'temperature': 1.00001, + 'top_p': 0.999, + 'user': 'hunter2' + }) + + expect(result.data.choices[0].text).to.eql('What day of the week is it, Bob?\n') + + clock.tick(10 * 1000) + + await checkTraces + + const expectedTags = [ + 'org:kill-9', + 'endpoint:/v1/edits', + 'model:text-davinci-edit:001', + 'error:0' + ] + + expect(metricStub).to.be.calledWith('openai.ratelimit.requests', 20, 'g', expectedTags) + expect(metricStub).to.be.calledWith('openai.ratelimit.remaining.requests', 19, 'g', expectedTags) + + expect(externalLoggerStub).to.have.been.calledWith({ + status: 'info', + message: 'sampled createEdit', + input: 'What day of the wek is it?', + instruction: 'Fix the spelling mistakes', + choices: [{ + text: 'What day of the week is it, Bob?\n', + index: 0 + }] + }) + }) + }) + + describe('listFiles()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .get('/v1/files') + .reply(200, { + 'object': 'list', + 'data': [{ + 'object': 'file', + 'id': 'file-foofoofoo', + 'purpose': 'fine-tune-results', + 'filename': 'compiled_results.csv', + 'bytes': 3460, + 'created_at': 1684000162, + 'status': 'processed', + 'status_details': null + }, { + 'object': 'file', + 'id': 'file-barbarbar', + 'purpose': 'fine-tune-results', + 'filename': 'compiled_results.csv', + 'bytes': 13595, + 'created_at': 1684000508, + 'status': 'processed', + 'status_details': null + }] + }, [ + 'Date', 'Wed, 17 May 2023 21:34:04 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '25632', + 'Connection', 'close', + 'openai-version', '2020-10-01', + 'openai-organization', 'kill-9', + 'openai-processing-ms', '660' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'listFiles') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.name', 'kill-9') + + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/files') + expect(traces[0][0].meta).to.have.property('openai.request.method', 'GET') + expect(traces[0][0].metrics).to.have.property('openai.response.count', 2) + }) + + const result = await openai.listFiles() + + expect(result.data.data.length).to.eql(2) + expect(result.data.data[0].id).to.eql('file-foofoofoo') + + await checkTraces + }) + }) + + describe('createFile()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .post('/v1/files') + .reply(200, { + 'object': 'file', + 'id': 'file-268aYWYhvxWwHb4nIzP9FHM6', + 'purpose': 'fine-tune', + 'filename': 'dave-hal.jsonl', + 'bytes': 356, + 'created_at': 1684362764, + 'status': 'uploaded', + 'status_details': 'foo' // dummy value for testing + }, [ + 'Date', 'Wed, 17 May 2023 22:32:44 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '216', + 'Connection', 'close', + 'openai-version', '2020-10-01', + 'openai-organization', 'kill-9', + 'openai-processing-ms', '1021' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'createFile') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.name', 'kill-9') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/files') + expect(traces[0][0].meta).to.have.property('openai.request.method', 'POST') + + expect(traces[0][0].meta).to.have.property('openai.request.filename', 'dave-hal.jsonl') + expect(traces[0][0].meta).to.have.property('openai.request.purpose', 'fine-tune') + expect(traces[0][0].meta).to.have.property('openai.response.purpose', 'fine-tune') + expect(traces[0][0].meta).to.have.property('openai.response.status', 'uploaded') + expect(traces[0][0].meta).to.have.property('openai.response.status_details', 'foo') + expect(traces[0][0].meta).to.have.property('openai.response.id', 'file-268aYWYhvxWwHb4nIzP9FHM6') + expect(traces[0][0].meta).to.have.property('openai.response.filename', 'dave-hal.jsonl') + expect(traces[0][0].metrics).to.have.property('openai.response.bytes', 356) + expect(traces[0][0].metrics).to.have.property('openai.response.created_at', 1684362764) + }) + + const result = await openai.createFile(fs.createReadStream( + Path.join(__dirname, 'dave-hal.jsonl')), 'fine-tune') + + expect(result.data.filename).to.eql('dave-hal.jsonl') + + await checkTraces + }) + }) + + describe('deleteFile()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .delete('/v1/files/file-268aYWYhvxWwHb4nIzP9FHM6') + .reply(200, { + 'object': 'file', + 'id': 'file-268aYWYhvxWwHb4nIzP9FHM6', + 'deleted': true + }, [ + 'Date', 'Wed, 17 May 2023 23:03:54 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '83', + 'Connection', 'close', + 'openai-version', '2020-10-01', + 'openai-organization', 'kill-9' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'deleteFile') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.name', 'kill-9') + expect(traces[0][0].meta).to.have.property('openai.request.method', 'DELETE') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/files/*') + + expect(traces[0][0].meta).to.have.property('openai.request.file_id', 'file-268aYWYhvxWwHb4nIzP9FHM6') + expect(traces[0][0].meta).to.have.property('openai.response.id', 'file-268aYWYhvxWwHb4nIzP9FHM6') + expect(traces[0][0].metrics).to.have.property('openai.response.deleted', 1) + }) + + const result = await openai.deleteFile('file-268aYWYhvxWwHb4nIzP9FHM6') + + expect(result.data.deleted).to.eql(true) + + await checkTraces + }) + }) + + describe('retrieveFile()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .get('/v1/files/file-fIkEUgQPWnVXNKPJsr4pEWiz') + .reply(200, { + 'object': 'file', + 'id': 'file-fIkEUgQPWnVXNKPJsr4pEWiz', + 'purpose': 'fine-tune', + 'filename': 'dave-hal.jsonl', + 'bytes': 356, + 'created_at': 1684362764, + 'status': 'uploaded', + 'status_details': 'foo' // dummy value for testing + }, [ + 'Date', 'Wed, 17 May 2023 23:14:02 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '240', + 'Connection', 'close', + 'openai-version', '2020-10-01', + 'openai-organization', 'kill-9', + 'openai-processing-ms', '18' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'retrieveFile') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.name', 'kill-9') + expect(traces[0][0].meta).to.have.property('openai.request.method', 'GET') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/files/*') + + expect(traces[0][0].meta).to.have.property('openai.request.file_id', 'file-fIkEUgQPWnVXNKPJsr4pEWiz') + expect(traces[0][0].meta).to.have.property('openai.response.filename', 'dave-hal.jsonl') + expect(traces[0][0].meta).to.have.property('openai.response.id', 'file-fIkEUgQPWnVXNKPJsr4pEWiz') + expect(traces[0][0].meta).to.have.property('openai.response.purpose', 'fine-tune') + expect(traces[0][0].meta).to.have.property('openai.response.status', 'uploaded') + expect(traces[0][0].meta).to.have.property('openai.response.status_details', 'foo') + expect(traces[0][0].metrics).to.have.property('openai.response.bytes', 356) + expect(traces[0][0].metrics).to.have.property('openai.response.created_at', 1684362764) + }) + + const result = await openai.retrieveFile('file-fIkEUgQPWnVXNKPJsr4pEWiz') + + expect(result.data.filename).to.eql('dave-hal.jsonl') + + await checkTraces + }) + }) + + describe('downloadFile()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .get('/v1/files/file-t3k1gVSQDHrfZnPckzftlZ4A/content') + .reply(200, '{"prompt": "foo?", "completion": "bar."}\n{"prompt": "foofoo?", "completion": "barbar."}\n', [ + 'Date', 'Wed, 17 May 2023 23:26:01 GMT', + 'Content-Type', 'application/octet-stream', + 'Transfer-Encoding', 'chunked', + 'Connection', 'close', + 'content-disposition', 'attachment; filename="dave-hal.jsonl"', + 'openai-version', '2020-10-01', + 'openai-organization', 'kill-9', + 'openai-processing-ms', '128' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'downloadFile') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.name', 'kill-9') + expect(traces[0][0].meta).to.have.property('openai.request.method', 'GET') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/files/*/content') + + expect(traces[0][0].meta).to.have.property('openai.request.file_id', 'file-t3k1gVSQDHrfZnPckzftlZ4A') + expect(traces[0][0].metrics).to.have.property('openai.response.total_bytes', 88) + }) + + const result = await openai.downloadFile('file-t3k1gVSQDHrfZnPckzftlZ4A') + + /** + * TODO: Seems like an OpenAI library bug? + * downloading single line JSONL file results in the JSON being converted into an object. + * downloading multi-line JSONL file then provides a basic string. + * This suggests the library is doing `try { return JSON.parse(x) } catch { return x }` + */ + expect(result.data[0]).to.eql('{') // raw JSONL file + + await checkTraces + }) + }) + + describe('createFineTune()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .post('/v1/fine-tunes') + .reply(200, { + 'object': 'fine-tune', + 'id': 'ft-10RCfqSvgyEcauomw7VpiYco', + 'hyperparams': { + 'n_epochs': 5, + 'batch_size': 3, + 'prompt_loss_weight': 0.01, + 'learning_rate_multiplier': 0.1 + }, + 'organization_id': 'org-COOLORG', + 'model': 'curie', + 'training_files': [{ + 'object': 'file', + 'id': 'file-t3k1gVSQDHrfZnPckzftlZ4A', + 'purpose': 'fine-tune', + 'filename': 'dave-hal.jsonl', + 'bytes': 356, + 'created_at': 1684365950, + 'status': 'processed', + 'status_details': null + }], + 'validation_files': [], + 'result_files': [], + 'created_at': 1684442489, + 'updated_at': 1684442489, + 'status': 'pending', + 'fine_tuned_model': 'huh', + 'events': [{ + 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Created fine-tune: ft-10RCfqSvgyEcauomw7VpiYco', + 'created_at': 1684442489 + }] + }, [ + 'Date', 'Thu, 18 May 2023 20:41:30 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '898', + 'Connection', 'close', + 'openai-version', '2020-10-01', + 'openai-processing-ms', '116' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'createFineTune') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.id', 'org-COOLORG') // no name just id + expect(traces[0][0].meta).to.have.property('openai.request.method', 'POST') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/fine-tunes') + + expect(traces[0][0].meta).to.have.property('openai.request.classification_positive_class', 'wat') + expect(traces[0][0].meta).to.have.property('openai.request.model', 'curie') + expect(traces[0][0].meta).to.have.property('openai.request.suffix', 'deleteme') + expect(traces[0][0].meta).to.have.property('openai.request.training_file', + 'file-t3k1gVSQDHrfZnPckzftlZ4A') + expect(traces[0][0].meta).to.have.property('openai.request.validation_file', 'file-foobar') + expect(traces[0][0].meta).to.have.property('openai.response.fine_tuned_model', 'huh') + expect(traces[0][0].meta).to.have.property('openai.response.id', 'ft-10RCfqSvgyEcauomw7VpiYco') + expect(traces[0][0].meta).to.have.property('openai.response.model', 'curie') + expect(traces[0][0].meta).to.have.property('openai.response.status', 'pending') + expect(traces[0][0].metrics).to.have.property('openai.request.batch_size', 3) + expect(traces[0][0].metrics).to.have.property('openai.request.classification_betas_count', 3) + expect(traces[0][0].metrics).to.have.property('openai.request.classification_n_classes', 1) + expect(traces[0][0].metrics).to.have.property('openai.request.compute_classification_metrics', 0) + expect(traces[0][0].metrics).to.have.property('openai.request.learning_rate_multiplier', 0.1) + expect(traces[0][0].metrics).to.have.property('openai.request.n_epochs', 4) + expect(traces[0][0].metrics).to.have.property('openai.request.prompt_loss_weight', 0.01) + expect(traces[0][0].metrics).to.have.property('openai.response.created_at', 1684442489) + expect(traces[0][0].metrics).to.have.property('openai.response.events_count', 1) + expect(traces[0][0].metrics).to.have.property('openai.response.hyperparams.batch_size', 3) + expect(traces[0][0].metrics).to.have.property('openai.response.hyperparams.learning_rate_multiplier', 0.1) + expect(traces[0][0].metrics).to.have.property('openai.response.hyperparams.n_epochs', 5) + expect(traces[0][0].metrics).to.have.property('openai.response.hyperparams.prompt_loss_weight', 0.01) + expect(traces[0][0].metrics).to.have.property('openai.response.result_files_count', 0) + expect(traces[0][0].metrics).to.have.property('openai.response.training_files_count', 1) + expect(traces[0][0].metrics).to.have.property('openai.response.updated_at', 1684442489) + expect(traces[0][0].metrics).to.have.property('openai.response.validation_files_count', 0) + }) + + // only certain request parameter combinations are allowed, leaving unused ones commented for now + const result = await openai.createFineTune({ + training_file: 'file-t3k1gVSQDHrfZnPckzftlZ4A', + validation_file: 'file-foobar', + model: 'curie', + n_epochs: 4, + batch_size: 3, + learning_rate_multiplier: 0.1, + prompt_loss_weight: 0.01, + compute_classification_metrics: false, + suffix: 'deleteme', + classification_n_classes: 1, + classification_positive_class: 'wat', + classification_betas: [0.1, 0.2, 0.3] + // validation_file: '', + }) + + expect(result.data.id).to.eql('ft-10RCfqSvgyEcauomw7VpiYco') + + await checkTraces + }) + }) + + describe('retrieveFineTune()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .get('/v1/fine-tunes/ft-10RCfqSvgyEcauomw7VpiYco') + .reply(200, { + 'object': 'fine-tune', + 'id': 'ft-10RCfqSvgyEcauomw7VpiYco', + 'hyperparams': { + 'n_epochs': 4, + 'batch_size': 3, + 'prompt_loss_weight': 0.01, + 'learning_rate_multiplier': 0.1 + }, + 'organization_id': 'org-COOLORG', + 'model': 'curie', + 'training_files': [{ + 'object': 'file', + 'id': 'file-t3k1gVSQDHrfZnPckzftlZ4A', + 'purpose': 'fine-tune', + 'filename': 'dave-hal.jsonl', + 'bytes': 356, + 'created_at': 1684365950, + 'status': 'processed', + 'status_details': null + }], + 'validation_files': [], + 'result_files': [{ + 'object': 'file', + 'id': 'file-bJyf8TM0jeSZueBo4jpodZVQ', + 'purpose': 'fine-tune-results', + 'filename': 'compiled_results.csv', + 'bytes': 410, + 'created_at': 1684442697, + 'status': 'processed', + 'status_details': null + }], + 'created_at': 1684442489, + 'updated_at': 1684442697, + 'status': 'succeeded', + 'fine_tuned_model': 'curie:ft-foo:deleteme-2023-05-18-20-44-56', + 'events': [ + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Created fine-tune: ft-10RCfqSvgyEcauomw7VpiYco', + 'created_at': 1684442489 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Fine-tune costs $0.00', + 'created_at': 1684442612 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Fine-tune enqueued. Queue number: 0', + 'created_at': 1684442612 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Fine-tune started', + 'created_at': 1684442614 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Completed epoch 1/4', + 'created_at': 1684442677 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Completed epoch 2/4', + 'created_at': 1684442677 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Completed epoch 3/4', + 'created_at': 1684442678 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Completed epoch 4/4', + 'created_at': 1684442679 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Uploaded model: curie:ft-foo:deleteme-2023-05-18-20-44-56', + 'created_at': 1684442696 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Uploaded result file: file-bJyf8TM0jeSZueBo4jpodZVQ', + 'created_at': 1684442697 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Fine-tune succeeded', + 'created_at': 1684442697 } + ] }, [ + 'Date', 'Thu, 18 May 2023 22:11:53 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '2727', + 'Connection', 'close', + 'openai-version', '2020-10-01', + 'openai-processing-ms', '51' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'retrieveFineTune') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.id', 'org-COOLORG') // no name just id + expect(traces[0][0].meta).to.have.property('openai.request.method', 'GET') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/fine-tunes/*') + + expect(traces[0][0].meta).to.have.property('openai.request.fine_tune_id', 'ft-10RCfqSvgyEcauomw7VpiYco') + expect(traces[0][0].meta).to.have.property('openai.response.id', 'ft-10RCfqSvgyEcauomw7VpiYco') + expect(traces[0][0].meta).to.have.property('openai.response.model', 'curie') + expect(traces[0][0].meta).to.have.property('openai.response.status', 'succeeded') + expect(traces[0][0].metrics).to.have.property('openai.response.created_at', 1684442489) + expect(traces[0][0].metrics).to.have.property('openai.response.events_count', 11) + expect(traces[0][0].metrics).to.have.property('openai.response.hyperparams.batch_size', 3) + expect(traces[0][0].metrics).to.have.property('openai.response.hyperparams.learning_rate_multiplier', 0.1) + expect(traces[0][0].metrics).to.have.property('openai.response.hyperparams.n_epochs', 4) + expect(traces[0][0].metrics).to.have.property('openai.response.hyperparams.prompt_loss_weight', 0.01) + expect(traces[0][0].metrics).to.have.property('openai.response.result_files_count', 1) + expect(traces[0][0].metrics).to.have.property('openai.response.training_files_count', 1) + expect(traces[0][0].metrics).to.have.property('openai.response.updated_at', 1684442697) + expect(traces[0][0].metrics).to.have.property('openai.response.validation_files_count', 0) + }) + + const result = await openai.retrieveFineTune('ft-10RCfqSvgyEcauomw7VpiYco') + + expect(result.data.id).to.eql('ft-10RCfqSvgyEcauomw7VpiYco') + + await checkTraces + }) + }) + + describe('listFineTunes()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .get('/v1/fine-tunes') + .reply(200, { + 'object': 'list', + 'data': [{ + 'object': 'fine-tune', + 'id': 'ft-10RCfqSvgyEcauomw7VpiYco', + 'hyperparams': { + 'n_epochs': 4, + 'batch_size': 3, + 'prompt_loss_weight': 0.01, + 'learning_rate_multiplier': 0.1 + }, + 'organization_id': 'org-COOLORG', + 'model': 'curie', + 'training_files': [{ + 'object': 'file', + 'id': 'file-t3k1gVSQDHrfZnPckzftlZ4A', + 'purpose': 'fine-tune', + 'filename': 'dave-hal.jsonl', + 'bytes': 356, + 'created_at': 1684365950, + 'status': 'processed', + 'status_details': null + }], + 'validation_files': [], + 'result_files': [{ + 'object': 'file', + 'id': 'file-bJyf8TM0jeSZueBo4jpodZVQ', + 'purpose': 'fine-tune-results', + 'filename': 'compiled_results.csv', + 'bytes': 410, + 'created_at': 1684442697, + 'status': 'processed', + 'status_details': null + }], + 'created_at': 1684442489, + 'updated_at': 1684442697, + 'status': 'succeeded', + 'fine_tuned_model': 'curie:ft-foo:deleteme-2023-05-18-20-44-56' + }] + }) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'listFineTunes') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.request.method', 'GET') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/fine-tunes') + + expect(traces[0][0].metrics).to.have.property('openai.response.count', 1) + }) + + const result = await openai.listFineTunes() + + expect(result.data.object).to.eql('list') + + await checkTraces + }) + }) + + describe('listFineTuneEvents()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .get('/v1/fine-tunes/ft-10RCfqSvgyEcauomw7VpiYco/events') + .reply(200, { + 'object': 'list', + 'data': [ + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Created fine-tune: ft-10RCfqSvgyEcauomw7VpiYco', + 'created_at': 1684442489 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Fine-tune costs $0.00', + 'created_at': 1684442612 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Fine-tune enqueued. Queue number: 0', + 'created_at': 1684442612 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Fine-tune started', + 'created_at': 1684442614 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Completed epoch 1/4', + 'created_at': 1684442677 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Completed epoch 2/4', + 'created_at': 1684442677 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Completed epoch 3/4', + 'created_at': 1684442678 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Completed epoch 4/4', + 'created_at': 1684442679 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Uploaded model: curie:ft-foo:deleteme-2023-05-18-20-44-56', + 'created_at': 1684442696 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Uploaded result file: file-bJyf8TM0jeSZueBo4jpodZVQ', + 'created_at': 1684442697 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Fine-tune succeeded', + 'created_at': 1684442697 } + ] }, [ + 'Date', 'Thu, 18 May 2023 22:47:17 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '1718', + 'Connection', 'close', + 'openai-version', '2020-10-01', + 'openai-processing-ms', '33' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'listFineTuneEvents') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.request.method', 'GET') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/fine-tunes/*/events') + + expect(traces[0][0].meta).to.have.property('openai.request.fine_tune_id', 'ft-10RCfqSvgyEcauomw7VpiYco') + expect(traces[0][0].metrics).to.have.property('openai.response.count', 11) + }) + + const result = await openai.listFineTuneEvents('ft-10RCfqSvgyEcauomw7VpiYco') + + expect(result.data.object).to.eql('list') + + await checkTraces + }) + }) + + describe('deleteModel()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .delete('/v1/models/ft-10RCfqSvgyEcauomw7VpiYco') + .reply(200, { // guessing on response format here since my key lacks permissions + 'object': 'model', + 'id': 'ft-10RCfqSvgyEcauomw7VpiYco', + 'deleted': true + }, [ + 'Date', 'Thu, 18 May 2023 22:59:08 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '152', + 'Connection', 'close', + 'access-control-allow-origin', '*', + 'openai-version', '2020-10-01', + 'openai-processing-ms', '23' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'deleteModel') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.request.method', 'DELETE') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/models/*') + + expect(traces[0][0].meta).to.have.property('openai.request.fine_tune_id', 'ft-10RCfqSvgyEcauomw7VpiYco') + expect(traces[0][0].metrics).to.have.property('openai.response.deleted', 1) + expect(traces[0][0].meta).to.have.property('openai.response.id', 'ft-10RCfqSvgyEcauomw7VpiYco') + }) + + const result = await openai.deleteModel('ft-10RCfqSvgyEcauomw7VpiYco') + + expect(result.data.deleted).to.eql(true) + + await checkTraces + }) + }) + + describe('cancelFineTune()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .post('/v1/fine-tunes/ft-TVpNqwlvermMegfRVqSOyPyS/cancel') + .reply(200, { + 'object': 'fine-tune', + 'id': 'ft-TVpNqwlvermMegfRVqSOyPyS', + 'hyperparams': { + 'n_epochs': 4, + 'batch_size': 3, + 'prompt_loss_weight': 0.01, + 'learning_rate_multiplier': 0.1 + }, + 'organization_id': 'org-COOLORG', + 'model': 'curie', + 'training_files': [{ + 'object': 'file', + 'id': 'file-t3k1gVSQDHrfZnPckzftlZ4A', + 'purpose': 'fine-tune', + 'filename': 'dave-hal.jsonl', + 'bytes': 356, + 'created_at': 1684365950, + 'status': 'processed', + 'status_details': null + }], + 'validation_files': [], + 'result_files': [], + 'created_at': 1684452102, + 'updated_at': 1684452103, + 'status': 'cancelled', + 'fine_tuned_model': 'idk', + 'events': [ + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Created fine-tune: ft-TVpNqwlvermMegfRVqSOyPyS', + 'created_at': 1684452102 }, + { 'object': 'fine-tune-event', + 'level': 'info', + 'message': 'Fine-tune cancelled', + 'created_at': 1684452103 } + ] }, [ + 'Date', 'Thu, 18 May 2023 23:21:43 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '1042', + 'Connection', 'close', + 'openai-version', '2020-10-01', + 'openai-processing-ms', '78' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'cancelFineTune') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.id', 'org-COOLORG') + expect(traces[0][0].meta).to.have.property('openai.request.method', 'POST') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/fine-tunes/*/cancel') + + expect(traces[0][0].meta).to.have.property('openai.request.fine_tune_id', 'ft-TVpNqwlvermMegfRVqSOyPyS') + expect(traces[0][0].meta).to.have.property('openai.response.fine_tuned_model', 'idk') + expect(traces[0][0].meta).to.have.property('openai.response.id', 'ft-TVpNqwlvermMegfRVqSOyPyS') + expect(traces[0][0].meta).to.have.property('openai.response.model', 'curie') + expect(traces[0][0].meta).to.have.property('openai.response.status', 'cancelled') + expect(traces[0][0].metrics).to.have.property('openai.response.created_at', 1684452102) + expect(traces[0][0].metrics).to.have.property('openai.response.events_count', 2) + expect(traces[0][0].metrics).to.have.property('openai.response.hyperparams.batch_size', 3) + expect(traces[0][0].metrics).to.have.property('openai.response.hyperparams.learning_rate_multiplier', 0.1) + expect(traces[0][0].metrics).to.have.property('openai.response.hyperparams.n_epochs', 4) + expect(traces[0][0].metrics).to.have.property('openai.response.hyperparams.prompt_loss_weight', 0.01) + expect(traces[0][0].metrics).to.have.property('openai.response.result_files_count', 0) + expect(traces[0][0].metrics).to.have.property('openai.response.training_files_count', 1) + expect(traces[0][0].metrics).to.have.property('openai.response.updated_at', 1684452103) + expect(traces[0][0].metrics).to.have.property('openai.response.validation_files_count', 0) + }) + + const result = await openai.cancelFineTune('ft-TVpNqwlvermMegfRVqSOyPyS') + + expect(result.data.id).to.eql('ft-TVpNqwlvermMegfRVqSOyPyS') + + await checkTraces + }) + }) + + if (semver.intersects(version, '3.0.1')) { + describe('createModeration()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .post('/v1/moderations') + .reply(200, { + 'id': 'modr-7HHZZZylF31ahuhmH279JrKbGTHCW', + 'model': 'text-moderation-001', + 'results': [{ + 'flagged': true, + 'categories': { + 'sexual': false, + 'hate': false, + 'violence': true, + 'self-harm': false, + 'sexual/minors': false, + 'hate/threatening': false, + 'violence/graphic': false + }, + 'category_scores': { + 'sexual': 0.0018438849, + 'hate': 0.069274776, + 'violence': 0.74101615, + 'self-harm': 0.008981651, + 'sexual/minors': 0.00070737937, + 'hate/threatening': 0.045174375, + 'violence/graphic': 0.019271193 + } + }] + }, [ + 'Date', 'Wed, 17 May 2023 19:58:01 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '450', + 'Connection', 'close', + 'openai-version', '2020-10-01', + 'openai-organization', 'kill-9', + 'openai-processing-ms', '419' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'createModeration') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.name', 'kill-9') + expect(traces[0][0].meta).to.have.property('openai.request.method', 'POST') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/moderations') + + expect(traces[0][0].meta).to.have.property('openai.request.input', 'I want to harm the robots') + expect(traces[0][0].meta).to.have.property('openai.request.model', 'text-moderation-stable') + expect(traces[0][0].meta).to.have.property('openai.response.id', 'modr-7HHZZZylF31ahuhmH279JrKbGTHCW') + expect(traces[0][0].meta).to.have.property('openai.response.model', 'text-moderation-001') + expect(traces[0][0].metrics).to.have.property('openai.response.categories.sexual', 0) + expect(traces[0][0].metrics).to.have.property('openai.response.categories.hate', 0) + expect(traces[0][0].metrics).to.have.property('openai.response.categories.violence', 1) + expect(traces[0][0].metrics).to.have.property('openai.response.categories.self-harm', 0) + expect(traces[0][0].metrics).to.have.property('openai.response.categories.sexual/minors', 0) + expect(traces[0][0].metrics).to.have.property('openai.response.categories.hate/threatening', 0) + expect(traces[0][0].metrics).to.have.property('openai.response.categories.violence/graphic', 0) + expect(traces[0][0].metrics).to.have.property('openai.response.category_scores.hate', 0.069274776) + expect(traces[0][0].metrics).to.have.property('openai.response.category_scores.violence', 0.74101615) + expect(traces[0][0].metrics).to.have.property('openai.response.category_scores.sexual', 0.0018438849) + expect(traces[0][0].metrics).to.have.property('openai.response.category_scores.hate', 0.069274776) + expect(traces[0][0].metrics).to.have.property('openai.response.category_scores.violence', 0.74101615) + expect(traces[0][0].metrics).to.have.property('openai.response.category_scores.self-harm', 0.008981651) + expect(traces[0][0].metrics).to.have.property('openai.response.category_scores.sexual/minors', + 0.00070737937) + expect(traces[0][0].metrics).to.have.property('openai.response.category_scores.hate/threatening', + 0.045174375) + expect(traces[0][0].metrics).to.have.property('openai.response.category_scores.violence/graphic', + 0.019271193) + expect(traces[0][0].metrics).to.have.property('openai.response.flagged', 1) + }) + + const result = await openai.createModeration({ + input: 'I want to harm the robots', + model: 'text-moderation-stable' + }) + + expect(result.data.results[0].flagged).to.eql(true) + + expect(externalLoggerStub).to.have.been.calledWith({ + status: 'info', + message: 'sampled createModeration', + input: 'I want to harm the robots' + }) + + await checkTraces + }) + }) + } + + if (semver.intersects(version, '3.1')) { + describe('createImage()', () => { + let scope + + beforeEach(() => { + scope = nock('https://api.openai.com:443') + .post('/v1/images/generations') + .reply(200, { + 'created': 1684270747, + 'data': [{ + 'url': 'https://oaidalleapiprodscus.blob.core.windows.net/private/org-COOLORG/user-FOO/img-foo.png', + 'b64_json': 'foobar===' + }] + }, [ + 'Date', 'Tue, 16 May 2023 20:59:07 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '545', + 'Connection', 'close', + 'openai-version', '2020-10-01', + 'openai-organization', 'kill-9', + 'openai-processing-ms', '5085' + ]) + }) + + afterEach(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call using a string prompt', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'createImage') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.name', 'kill-9') + expect(traces[0][0].meta).to.have.property('openai.request.method', 'POST') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/images/generations') + + expect(traces[0][0].meta).to.have.property('openai.request.prompt', 'A datadog wearing headphones') + expect(traces[0][0].meta).to.have.property('openai.request.response_format', 'url') + expect(traces[0][0].meta).to.have.property('openai.request.size', '256x256') + expect(traces[0][0].meta).to.have.property('openai.request.user', 'hunter2') + expect(traces[0][0].meta).to.have.property('openai.response.images.0.url', + 'https://oaidalleapiprodscus.blob.core.windows.net/private/org-COOLORG/user-FOO/img-foo.png') + expect(traces[0][0].meta).to.have.property('openai.response.images.0.b64_json', 'returned') + expect(traces[0][0].metrics).to.have.property('openai.request.n', 1) + expect(traces[0][0].metrics).to.have.property('openai.response.created', 1684270747) + expect(traces[0][0].metrics).to.have.property('openai.response.images_count', 1) + }) + + const result = await openai.createImage({ + prompt: 'A datadog wearing headphones', + n: 1, + size: '256x256', + response_format: 'url', + user: 'hunter2' + }) + + expect(result.data.data[0].url.startsWith('https://')).to.be.true + + expect(externalLoggerStub).to.have.been.calledWith({ + status: 'info', + message: 'sampled createImage', + prompt: 'A datadog wearing headphones' + }) + + await checkTraces + }) + + it('makes a successful call using an array of tokens prompt', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0].meta).to.have.property('openai.request.prompt', '[999, 888, 777, 666, 555]') + }) + + const result = await openai.createImage({ + prompt: [999, 888, 777, 666, 555], + n: 1, + size: '256x256', + response_format: 'url', + user: 'hunter2' + }) + + expect(result.data.data[0].url.startsWith('https://')).to.be.true + + expect(externalLoggerStub).to.have.been.calledWith({ + status: 'info', + message: 'sampled createImage', + prompt: [ 999, 888, 777, 666, 555 ] + }) + + await checkTraces + }) + + it('makes a successful call using an array of string prompts', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0].meta).to.have.property('openai.request.prompt.0', 'foo') + expect(traces[0][0].meta).to.have.property('openai.request.prompt.1', 'bar') + }) + + const result = await openai.createImage({ + prompt: ['foo', 'bar'], + n: 1, + size: '256x256', + response_format: 'url', + user: 'hunter2' + }) + + expect(result.data.data[0].url.startsWith('https://')).to.be.true + + expect(externalLoggerStub).to.have.been.calledWith({ + status: 'info', + message: 'sampled createImage', + prompt: [ 'foo', 'bar' ] + }) + + await checkTraces + }) + + it('makes a successful call using an array of tokens prompts', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0].meta).to.have.property('openai.request.prompt.0', '[111, 222, 333]') + expect(traces[0][0].meta).to.have.property('openai.request.prompt.1', '[444, 555, 666]') + }) + + const result = await openai.createImage({ + prompt: [ + [111, 222, 333], + [444, 555, 666] + ], + n: 1, + size: '256x256', + response_format: 'url', + user: 'hunter2' + }) + + expect(result.data.data[0].url.startsWith('https://')).to.be.true + + expect(externalLoggerStub).to.have.been.calledWith({ + status: 'info', + message: 'sampled createImage', + prompt: [ [ 111, 222, 333 ], [ 444, 555, 666 ] ] + }) + + await checkTraces + }) + }) + + describe('createImageEdit()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .post('/v1/images/edits') + .reply(200, { + 'created': 1684850118, + 'data': [{ + 'url': 'https://oaidalleapiprodscus.blob.core.windows.net/private/org-COOLORG/user-FOO/img-bar.png', + 'b64_json': 'fOoF0f=' + }] + }, [ + 'Date', 'Tue, 23 May 2023 13:55:18 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '549', + 'Connection', 'close', + 'openai-version', '2020-10-01', + 'openai-organization', 'kill-9', + 'openai-processing-ms', '9901' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'createImageEdit') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.name', 'kill-9') + expect(traces[0][0].meta).to.have.property('openai.request.method', 'POST') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/images/edits') + + expect(traces[0][0].meta).to.have.property('openai.request.mask', 'ntsc.png') + expect(traces[0][0].meta).to.have.property('openai.request.image', 'ntsc.png') + expect(traces[0][0].meta).to.have.property('openai.request.prompt', 'Change all red to blue') + expect(traces[0][0].meta).to.have.property('openai.request.response_format', 'url') + expect(traces[0][0].meta).to.have.property('openai.request.size', '256x256') + expect(traces[0][0].meta).to.have.property('openai.request.user', 'hunter2') + expect(traces[0][0].meta).to.have.property('openai.response.images.0.url', + 'https://oaidalleapiprodscus.blob.core.windows.net/private/org-COOLORG/user-FOO/img-bar.png') + expect(traces[0][0].meta).to.have.property('openai.response.images.0.b64_json', 'returned') + expect(traces[0][0].metrics).to.have.property('openai.request.n', 1) + expect(traces[0][0].metrics).to.have.property('openai.response.created', 1684850118) + expect(traces[0][0].metrics).to.have.property('openai.response.images_count', 1) + }) + + const result = await openai.createImageEdit( + fs.createReadStream(Path.join(__dirname, 'ntsc.png')), + 'Change all red to blue', + fs.createReadStream(Path.join(__dirname, 'ntsc.png')), + 1, + '256x256', + 'url', + 'hunter2' + ) + + expect(result.data.data[0].url.startsWith('https://')).to.be.true + + expect(externalLoggerStub).to.have.been.calledWith({ + status: 'info', + message: 'sampled createImageEdit', + prompt: 'Change all red to blue', + file: 'ntsc.png', + mask: 'ntsc.png' + }) + + await checkTraces + }) + }) + + describe('createImageVariation()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .post('/v1/images/variations') + .reply(200, { + 'created': 1684853320, + 'data': [{ + 'url': 'https://oaidalleapiprodscus.blob.core.windows.net/private/org-COOLORG/user-FOO/img-soup.png', + 'b64_json': 'foo=' + }] + }, [ + 'Date', 'Tue, 23 May 2023 14:48:40 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '547', + 'Connection', 'close', + 'openai-version', '2020-10-01', + 'openai-organization', 'kill-9', + 'openai-processing-ms', '8411' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'createImageVariation') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.name', 'kill-9') + expect(traces[0][0].meta).to.have.property('openai.request.method', 'POST') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/images/variations') + + expect(traces[0][0].meta).to.have.property('openai.request.image', 'ntsc.png') + expect(traces[0][0].meta).to.have.property('openai.request.response_format', 'url') + expect(traces[0][0].meta).to.have.property('openai.request.size', '256x256') + expect(traces[0][0].meta).to.have.property('openai.request.user', 'hunter2') + expect(traces[0][0].meta).to.have.property('openai.response.images.0.url', + 'https://oaidalleapiprodscus.blob.core.windows.net/private/org-COOLORG/user-FOO/img-soup.png') + expect(traces[0][0].meta).to.have.property('openai.response.images.0.b64_json', 'returned') + expect(traces[0][0].metrics).to.have.property('openai.request.n', 1) + expect(traces[0][0].metrics).to.have.property('openai.response.created', 1684853320) + expect(traces[0][0].metrics).to.have.property('openai.response.images_count', 1) + }) + + const result = await openai.createImageVariation( + fs.createReadStream(Path.join(__dirname, 'ntsc.png')), 1, '256x256', 'url', 'hunter2') + + expect(result.data.data[0].url.startsWith('https://')).to.be.true + + expect(externalLoggerStub).to.have.been.calledWith({ + status: 'info', + message: 'sampled createImageVariation', + file: 'ntsc.png' + }) + + await checkTraces + }) + }) + } + + if (semver.intersects(version, '3.2')) { + describe('createChatCompletion()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .post('/v1/chat/completions') + .reply(200, { + 'id': 'chatcmpl-7GaWqyMTD9BLmkmy8SxyjUGX3KSRN', + 'object': 'chat.completion', + 'created': 1684188020, + 'model': 'gpt-3.5-turbo-0301', + 'usage': { + 'prompt_tokens': 37, + 'completion_tokens': 10, + 'total_tokens': 47 + }, + 'choices': [{ + 'message': { + 'role': 'assistant', + 'content': "In that case, it's best to avoid peanut", + 'name': 'hunter2' + }, + 'finish_reason': 'length', + 'index': 0 + }] + }, [ + 'Date', 'Mon, 15 May 2023 22:00:21 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '327', + 'access-control-allow-origin', '*', + 'openai-model', 'gpt-3.5-turbo-0301', + 'openai-organization', 'kill-9', + 'openai-processing-ms', '713', + 'openai-version', '2020-10-01' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'createChatCompletion') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.name', 'kill-9') + + expect(traces[0][0].meta).to.have.property('openai.request.method', 'POST') + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/chat/completions') + + expect(traces[0][0].meta).to.have.property('openai.request.0.content', 'Peanut Butter or Jelly?') + expect(traces[0][0].meta).to.have.property('openai.request.0.name', 'hunter2') + expect(traces[0][0].meta).to.have.property('openai.request.0.role', 'user') + expect(traces[0][0].meta).to.have.property('openai.request.1.content', 'Are you allergic to peanuts?') + expect(traces[0][0].meta).to.have.property('openai.request.1.role', 'assistant') + expect(traces[0][0].meta).to.have.property('openai.request.2.content', 'Deathly allergic!') + expect(traces[0][0].meta).to.have.property('openai.request.2.role', 'user') + expect(traces[0][0].meta).to.have.property('openai.request.model', 'gpt-3.5-turbo') + expect(traces[0][0].meta).to.have.property('openai.request.stop', 'time') + expect(traces[0][0].meta).to.have.property('openai.request.user', 'hunter2') + expect(traces[0][0].meta).to.have.property('openai.response.choices.0.finish_reason', 'length') + expect(traces[0][0].meta).to.have.property('openai.response.choices.0.message.content', + "In that case, it's best to avoid peanut") + expect(traces[0][0].meta).to.have.property('openai.response.choices.0.message.role', 'assistant') + expect(traces[0][0].meta).to.have.property('openai.response.choices.0.message.name', 'hunter2') + expect(traces[0][0].meta).to.have.property('openai.response.model', 'gpt-3.5-turbo-0301') + expect(traces[0][0].metrics).to.have.property('openai.request.logit_bias.1234', -1) + expect(traces[0][0].metrics).to.have.property('openai.request.max_tokens', 10) + expect(traces[0][0].metrics).to.have.property('openai.request.n', 3) + expect(traces[0][0].metrics).to.have.property('openai.request.presence_penalty', -0.0001) + expect(traces[0][0].metrics).to.have.property('openai.request.temperature', 1.001) + expect(traces[0][0].metrics).to.have.property('openai.request.top_p', 4) + expect(traces[0][0].metrics).to.have.property('openai.response.usage.completion_tokens', 10) + expect(traces[0][0].metrics).to.have.property('openai.response.usage.prompt_tokens', 37) + expect(traces[0][0].metrics).to.have.property('openai.response.usage.total_tokens', 47) + }) + + const result = await openai.createChatCompletion({ + model: 'gpt-3.5-turbo', + messages: [ + { + 'role': 'user', + 'content': 'Peanut Butter or Jelly?', + 'name': 'hunter2' + }, + { + 'role': 'assistant', + 'content': 'Are you allergic to peanuts?', + 'name': 'hal' + }, + { + 'role': 'user', + 'content': 'Deathly allergic!', + 'name': 'hunter2' + } + ], + temperature: 1.001, + stream: false, + max_tokens: 10, + presence_penalty: -0.0001, + frequency_penalty: 0.0001, + logit_bias: { + '1234': -1 + }, + top_p: 4, + n: 3, + stop: 'time', + user: 'hunter2' + }) + + expect(result.data.id).to.eql('chatcmpl-7GaWqyMTD9BLmkmy8SxyjUGX3KSRN') + expect(result.data.model).to.eql('gpt-3.5-turbo-0301') + expect(result.data.choices[0].message.role).to.eql('assistant') + expect(result.data.choices[0].message.content).to.eql('In that case, it\'s best to avoid peanut') + expect(result.data.choices[0].finish_reason).to.eql('length') + + expect(externalLoggerStub).to.have.been.calledWith({ + status: 'info', + message: 'sampled createChatCompletion', + messages: [ + { + role: 'user', + content: 'Peanut Butter or Jelly?', + name: 'hunter2' + }, + { + role: 'assistant', + content: 'Are you allergic to peanuts?', + name: 'hal' + }, + { role: 'user', content: 'Deathly allergic!', name: 'hunter2' } + ], + choices: [{ + message: { + role: 'assistant', + content: "In that case, it's best to avoid peanut", + name: 'hunter2' + }, + finish_reason: 'length', + index: 0 + }] + }) + + await checkTraces + }) + }) + + describe('createTranscription()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .post('/v1/audio/transcriptions') + .reply(200, { + 'task': 'transcribe', + 'language': 'english', + 'duration': 2.19, + 'segments': [{ + 'id': 0, + 'seek': 0, + 'start': 0, + 'end': 2, + 'text': ' Hello, friend.', + 'tokens': [50364, 2425, 11, 1277, 13, 50464], + 'temperature': 0.5, + 'avg_logprob': -0.7777707236153739, + 'compression_ratio': 0.6363636363636364, + 'no_speech_prob': 0.043891049921512604, + 'transient': false }], + 'text': 'Hello, friend.' + }, [ + 'Date', 'Fri, 19 May 2023 03:19:49 GMT', + 'Content-Type', 'text/plain; charset=utf-8', + 'Content-Length', '15', + 'Connection', 'close', + 'openai-organization', 'kill-9', + 'openai-processing-ms', '595', + 'openai-version', '2020-10-01' + ] + ) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'createTranscription') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.name', 'kill-9') + + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/audio/transcriptions') + expect(traces[0][0].meta).to.have.property('openai.request.filename', 'hello-friend.m4a') + expect(traces[0][0].meta).to.have.property('openai.request.language', 'en') + expect(traces[0][0].meta).to.have.property('openai.request.method', 'POST') + expect(traces[0][0].meta).to.have.property('openai.request.model', 'whisper-1') + expect(traces[0][0].meta).to.have.property('openai.request.prompt', 'what does this say') + expect(traces[0][0].meta).to.have.property('openai.request.response_format', 'verbose_json') + expect(traces[0][0].meta).to.have.property('openai.response.language', 'english') + expect(traces[0][0].meta).to.have.property('openai.response.text', 'Hello, friend.') + expect(traces[0][0].metrics).to.have.property('openai.response.duration', 2.19) + expect(traces[0][0].metrics).to.have.property('openai.response.segments_count', 1) + expect(traces[0][0].metrics).to.have.property('openai.request.temperature', 0.5) + }) + + // TODO: Should test each of 'json, text, srt, verbose_json, vtt' since response formats differ + const result = await openai.createTranscription( + fs.createReadStream(Path.join(__dirname, '/hello-friend.m4a')), + 'whisper-1', + 'what does this say', + 'verbose_json', + 0.5, + 'en' + ) + + expect(result.data.text).to.eql('Hello, friend.') + + expect(externalLoggerStub).to.have.been.calledWith({ + status: 'info', + message: 'sampled createTranscription', + prompt: 'what does this say', + file: 'hello-friend.m4a' + }) + + await checkTraces + }) + }) + + describe('createTranslation()', () => { + let scope + + before(() => { + scope = nock('https://api.openai.com:443') + .post('/v1/audio/translations') + .reply(200, { + 'task': 'translate', + 'language': 'english', + 'duration': 1.74, + 'segments': [{ + 'id': 0, + 'seek': 0, + 'start': 0, + 'end': 3, + 'text': ' Guten Tag!', + 'tokens': [50364, 42833, 11204, 0, 50514], + 'temperature': 0.5, + 'avg_logprob': -0.5626437266667684, + 'compression_ratio': 0.5555555555555556, + 'no_speech_prob': 0.01843200996518135, + 'transient': false + }], + 'text': 'Guten Tag!' + }, [ + 'Date', 'Fri, 19 May 2023 03:41:25 GMT', + 'Content-Type', 'application/json', + 'Content-Length', '334', + 'Connection', 'close', + 'openai-organization', 'kill-9', + 'openai-processing-ms', '520', + 'openai-version', '2020-10-01' + ]) + }) + + after(() => { + nock.removeInterceptor(scope) + scope.done() + }) + + it('makes a successful call', async () => { + const checkTraces = agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', 'openai.request') + expect(traces[0][0]).to.have.property('type', 'openai') + expect(traces[0][0]).to.have.property('resource', 'createTranslation') + expect(traces[0][0]).to.have.property('error', 0) + expect(traces[0][0].meta).to.have.property('openai.organization.name', 'kill-9') + + expect(traces[0][0].meta).to.have.property('openai.request.endpoint', '/v1/audio/translations') + expect(traces[0][0].meta).to.have.property('openai.request.filename', 'guten-tag.m4a') + expect(traces[0][0].meta).to.have.property('openai.request.method', 'POST') + expect(traces[0][0].meta).to.have.property('openai.request.model', 'whisper-1') + expect(traces[0][0].meta).to.have.property('openai.request.prompt', 'greeting') + expect(traces[0][0].meta).to.have.property('openai.request.response_format', 'verbose_json') + expect(traces[0][0].meta).to.have.property('openai.response.language', 'english') + expect(traces[0][0].meta).to.have.property('openai.response.text', 'Guten Tag!') + expect(traces[0][0].metrics).to.have.property('openai.request.temperature', 0.5) + expect(traces[0][0].metrics).to.have.property('openai.response.duration', 1.74) + expect(traces[0][0].metrics).to.have.property('openai.response.segments_count', 1) + }) + + // TODO: Should test each of 'json, text, srt, verbose_json, vtt' since response formats differ + const result = await openai.createTranslation( + fs.createReadStream(Path.join(__dirname, 'guten-tag.m4a')), + 'whisper-1', + 'greeting', + 'verbose_json', + 0.5 + ) + + expect(result.data.text).to.eql('Guten Tag!') + + expect(externalLoggerStub).to.have.been.calledWith({ + status: 'info', + message: 'sampled createTranslation', + prompt: 'greeting', + file: 'guten-tag.m4a' + }) + + await checkTraces + }) + }) + } + }) + }) +}) diff --git a/packages/datadog-plugin-openai/test/ntsc.png b/packages/datadog-plugin-openai/test/ntsc.png new file mode 100644 index 00000000000..a23b928c3ba Binary files /dev/null and b/packages/datadog-plugin-openai/test/ntsc.png differ diff --git a/packages/datadog-plugin-opensearch/test/index.spec.js b/packages/datadog-plugin-opensearch/test/index.spec.js index b9c7c174483..dba63b8eb9f 100644 --- a/packages/datadog-plugin-opensearch/test/index.spec.js +++ b/packages/datadog-plugin-opensearch/test/index.spec.js @@ -3,6 +3,7 @@ const agent = require('../../dd-trace/test/plugins/agent') const { breakThen, unbreakThen } = require('../../dd-trace/test/plugins/helpers') const { ERROR_MESSAGE, ERROR_TYPE, ERROR_STACK } = require('../../dd-trace/src/constants') +const namingSchema = require('./naming') describe('Plugin', () => { let opensearch @@ -56,6 +57,8 @@ describe('Plugin', () => { it('should set the correct tags', done => { agent .use(traces => { + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0].meta).to.have.property('db.type', 'opensearch') expect(traces[0][0].meta).to.have.property('span.kind', 'client') expect(traces[0][0].meta).to.have.property('opensearch.method', 'POST') @@ -84,6 +87,8 @@ describe('Plugin', () => { it('should set the correct tags on msearch', done => { agent .use(traces => { + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0].meta).to.have.property('db.type', 'opensearch') expect(traces[0][0].meta).to.have.property('span.kind', 'client') expect(traces[0][0].meta).to.have.property('opensearch.method', 'POST') @@ -131,7 +136,8 @@ describe('Plugin', () => { it('should do automatic instrumentation', done => { agent .use(traces => { - expect(traces[0][0]).to.have.property('service', 'test-opensearch') + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0]).to.have.property('resource', 'HEAD /') expect(traces[0][0]).to.have.property('type', 'elasticsearch') expect(traces[0][0].meta).to.have.property('component', 'opensearch') @@ -191,7 +197,8 @@ describe('Plugin', () => { it('should work with userland promises', done => { agent .use(traces => { - expect(traces[0][0]).to.have.property('service', 'test-opensearch') + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0]).to.have.property('resource', 'HEAD /') expect(traces[0][0]).to.have.property('type', 'elasticsearch') }) @@ -202,6 +209,12 @@ describe('Plugin', () => { client.ping().catch(done) }) + + withNamingSchema( + () => client.search({ index: 'logstash-2000.01.01', body: {} }), + () => namingSchema.outbound.opName, + () => namingSchema.outbound.serviceName + ) }) describe('with configuration', () => { @@ -209,7 +222,7 @@ describe('Plugin', () => { before(() => { return agent.load('opensearch', { - service: 'test', + service: 'custom', hooks: { query: (span, params) => { span.addTags({ 'opensearch.params': 'foo', 'opensearch.method': params.method }) } } @@ -241,7 +254,8 @@ describe('Plugin', () => { agent .use(traces => { - expect(traces[0][0]).to.have.property('service', 'test') + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', 'custom') expect(traces[0][0].meta).to.have.property('opensearch.params', 'foo') expect(traces[0][0].meta).to.have.property('opensearch.method', 'POST') expect(traces[0][0].meta).to.have.property('component', 'opensearch') @@ -251,6 +265,12 @@ describe('Plugin', () => { client.ping().catch(done) }) + + withNamingSchema( + () => client.search({ index: 'logstash-2000.01.01', body: {} }), + () => namingSchema.outbound.opName, + () => 'custom' + ) }) }) }) diff --git a/packages/datadog-plugin-opensearch/test/naming.js b/packages/datadog-plugin-opensearch/test/naming.js new file mode 100644 index 00000000000..3fac8b10e27 --- /dev/null +++ b/packages/datadog-plugin-opensearch/test/naming.js @@ -0,0 +1,14 @@ +const { resolveNaming } = require('../../dd-trace/test/plugins/helpers') + +module.exports = resolveNaming({ + outbound: { + v0: { + opName: 'opensearch.query', + serviceName: 'test-opensearch' + }, + v1: { + opName: 'opensearch.query', + serviceName: 'test' + } + } +}) diff --git a/packages/datadog-plugin-oracledb/src/index.js b/packages/datadog-plugin-oracledb/src/index.js index 367df83bd75..9eb4803c63a 100644 --- a/packages/datadog-plugin-oracledb/src/index.js +++ b/packages/datadog-plugin-oracledb/src/index.js @@ -9,10 +9,10 @@ class OracledbPlugin extends DatabasePlugin { static get system () { return 'oracle' } start ({ query, connAttrs }) { - const service = getServiceName(this.config, connAttrs) + const service = this.serviceName(this.config, connAttrs) const url = getUrl(connAttrs.connectString) - this.startSpan('oracle.query', { + this.startSpan(this.operationName(), { service, resource: query, type: 'sql', @@ -27,14 +27,6 @@ class OracledbPlugin extends DatabasePlugin { } } -function getServiceName (config, connAttrs) { - if (typeof config.service === 'function') { - return config.service(connAttrs) - } - - return config.service -} - // TODO: Avoid creating an error since it's a heavy operation. function getUrl (connectString) { try { diff --git a/packages/datadog-plugin-oracledb/test/index.spec.js b/packages/datadog-plugin-oracledb/test/index.spec.js index 088c22b3516..9c3ff0691a6 100644 --- a/packages/datadog-plugin-oracledb/test/index.spec.js +++ b/packages/datadog-plugin-oracledb/test/index.spec.js @@ -2,6 +2,7 @@ const agent = require('../../dd-trace/test/plugins/agent') const { ERROR_MESSAGE, ERROR_TYPE, ERROR_STACK } = require('../../dd-trace/src/constants') +const namingSchema = require('./naming') const hostname = process.env.CI ? 'oracledb' : 'localhost' const config = { @@ -63,10 +64,10 @@ describe('Plugin', () => { function connectionTests (url) { it('should be instrumented for promise API', done => { agent.use(traces => { - expect(traces[0][0]).to.have.property('name', 'oracle.query') + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0]).to.have.property('resource', dbQuery) expect(traces[0][0]).to.have.property('type', 'sql') - expect(traces[0][0].meta).to.have.property('service', 'test') expect(traces[0][0].meta).to.have.property('span.kind', 'client') expect(traces[0][0].meta).to.have.property('component', 'oracledb') if (url) { @@ -89,10 +90,10 @@ describe('Plugin', () => { it('should be instrumented for callback API', done => { agent.use(traces => { - expect(traces[0][0]).to.have.property('name', 'oracle.query') + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0]).to.have.property('resource', dbQuery) expect(traces[0][0]).to.have.property('type', 'sql') - expect(traces[0][0].meta).to.have.property('service', 'test') expect(traces[0][0].meta).to.have.property('span.kind', 'client') expect(traces[0][0].meta).to.have.property('component', 'oracledb') if (url) { @@ -123,10 +124,10 @@ describe('Plugin', () => { let error agent.use(traces => { - expect(traces[0][0]).to.have.property('name', 'oracle.query') + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0]).to.have.property('resource', 'invalid') expect(traces[0][0]).to.have.property('type', 'sql') - expect(traces[0][0].meta).to.have.property('service', 'test') expect(traces[0][0].meta).to.have.property('span.kind', 'client') expect(traces[0][0].meta).to.have.property('component', 'oracledb') if (url) { @@ -182,10 +183,10 @@ describe('Plugin', () => { function poolTests (url) { it('should be instrumented correctly with correct tags', done => { agent.use(traces => { - expect(traces[0][0]).to.have.property('name', 'oracle.query') + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0]).to.have.property('resource', dbQuery) expect(traces[0][0]).to.have.property('type', 'sql') - expect(traces[0][0].meta).to.have.property('service', 'test') expect(traces[0][0].meta).to.have.property('span.kind', 'client') expect(traces[0][0].meta).to.have.property('component', 'oracledb') if (url) { @@ -207,10 +208,10 @@ describe('Plugin', () => { let error const promise = agent.use(traces => { - expect(traces[0][0]).to.have.property('name', 'oracle.query') + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0]).to.have.property('resource', 'invalid') expect(traces[0][0]).to.have.property('type', 'sql') - expect(traces[0][0].meta).to.have.property('service', 'test') expect(traces[0][0].meta).to.have.property('span.kind', 'client') expect(traces[0][0].meta).to.have.property('component', 'oracledb') if (url) { @@ -231,6 +232,55 @@ describe('Plugin', () => { }) } }) + + describe('with configuration', () => { + describe('with service string', () => { + before(async () => { + await agent.load('oracledb', { service: 'custom' }) + oracledb = require(`../../../versions/oracledb@${version}`).get() + tracer = require('../../dd-trace') + }) + before(async () => { + connection = await oracledb.getConnection(config) + }) + after(async () => { + await connection.close() + }) + after(async () => { + await agent.close({ ritmReset: false }) + }) + it('should set the service name', done => { + agent.use(traces => { + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', 'custom') + }).then(done, done) + connection.execute(dbQuery) + }) + }) + describe('with service function', () => { + before(async () => { + await agent.load('oracledb', { service: connAttrs => `${connAttrs.connectString}` }) + oracledb = require(`../../../versions/oracledb@${version}`).get() + tracer = require('../../dd-trace') + }) + before(async () => { + connection = await oracledb.getConnection(config) + }) + after(async () => { + await connection.close() + }) + after(async () => { + await agent.close({ ritmReset: false }) + }) + it('should set the service name', done => { + agent.use(traces => { + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', config.connectString) + }).then(done, done) + connection.execute(dbQuery) + }) + }) + }) }) }) }) diff --git a/packages/datadog-plugin-oracledb/test/naming.js b/packages/datadog-plugin-oracledb/test/naming.js new file mode 100644 index 00000000000..cd70b04e8b3 --- /dev/null +++ b/packages/datadog-plugin-oracledb/test/naming.js @@ -0,0 +1,14 @@ +const { resolveNaming } = require('../../dd-trace/test/plugins/helpers') + +module.exports = resolveNaming({ + outbound: { + v0: { + opName: 'oracle.query', + serviceName: 'test-oracle' + }, + v1: { + opName: 'oracle.query', + serviceName: 'test' + } + } +}) diff --git a/packages/datadog-plugin-pg/src/index.js b/packages/datadog-plugin-pg/src/index.js index e6a248e9a00..8482446d6cf 100644 --- a/packages/datadog-plugin-pg/src/index.js +++ b/packages/datadog-plugin-pg/src/index.js @@ -9,10 +9,10 @@ class PGPlugin extends DatabasePlugin { static get system () { return 'postgres' } start ({ params = {}, query, processId }) { - const service = getServiceName(this, params) + const service = this.serviceName(this.config, params) const originalStatement = query.text - this.startSpan('pg.query', { + this.startSpan(this.operationName(), { service, resource: originalStatement, type: 'sql', @@ -27,16 +27,8 @@ class PGPlugin extends DatabasePlugin { } }) - query.text = this.injectDbmQuery(query.text, service, !!query.name) + query.__ddInjectableQuery = this.injectDbmQuery(query.text, service, !!query.name) } } -function getServiceName (tracer, params) { - if (typeof tracer.config.service === 'function') { - return tracer.config.service(params) - } - - return tracer.config.service || `${tracer._tracer._tracer._service}-postgres` -} - module.exports = PGPlugin diff --git a/packages/datadog-plugin-pg/test/index.spec.js b/packages/datadog-plugin-pg/test/index.spec.js index b37da234605..b50cfcf7be7 100644 --- a/packages/datadog-plugin-pg/test/index.spec.js +++ b/packages/datadog-plugin-pg/test/index.spec.js @@ -5,6 +5,8 @@ const semver = require('semver') const agent = require('../../dd-trace/test/plugins/agent') const { ERROR_MESSAGE, ERROR_TYPE, ERROR_STACK } = require('../../dd-trace/src/constants') const net = require('net') +const namingSchema = require('./naming') +const EventEmitter = require('events') const clients = { pg: pg => pg.Client @@ -21,6 +23,7 @@ describe('Plugin', () => { describe('pg', () => { withVersions('pg', 'pg', version => { + const pgHasPromiseSupport = semver.intersects(version, '>=5.1') beforeEach(() => { tracer = require('../../dd-trace') }) @@ -53,7 +56,8 @@ describe('Plugin', () => { it('should do automatic instrumentation when using callbacks', done => { agent.use(traces => { - expect(traces[0][0]).to.have.property('service', 'test-postgres') + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0]).to.have.property('resource', 'SELECT $1::text as message') expect(traces[0][0]).to.have.property('type', 'sql') expect(traces[0][0].meta).to.have.property('span.kind', 'client') @@ -66,9 +70,9 @@ describe('Plugin', () => { if (implementation !== 'pg.native') { expect(traces[0][0].metrics).to.have.property('db.pid') } - - done() }) + .then(done) + .catch(done) client.query('SELECT $1::text as message', ['Hello world!'], (err, result) => { if (err) throw err @@ -95,10 +99,11 @@ describe('Plugin', () => { }) }) - if (semver.intersects(version, '>=5.1')) { // initial promise support + if (pgHasPromiseSupport) { // initial promise support it('should do automatic instrumentation when using promises', done => { agent.use(traces => { - expect(traces[0][0]).to.have.property('service', 'test-postgres') + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) expect(traces[0][0]).to.have.property('resource', 'SELECT $1::text as message') expect(traces[0][0]).to.have.property('type', 'sql') expect(traces[0][0].meta).to.have.property('span.kind', 'client') @@ -111,9 +116,9 @@ describe('Plugin', () => { if (implementation !== 'pg.native') { expect(traces[0][0].metrics).to.have.property('db.pid') } - - done() }) + .then(done) + .catch(done) client.query('SELECT $1::text as message', ['Hello world!']) .then(() => client.end()) @@ -130,9 +135,9 @@ describe('Plugin', () => { expect(traces[0][0].meta).to.have.property(ERROR_STACK, error.stack) expect(traces[0][0].meta).to.have.property('component', 'pg') expect(traces[0][0].metrics).to.have.property('network.destination.port', 5432) - - done() }) + .then(done) + .catch(done) client.query('INVALID', (err, result) => { error = err @@ -149,12 +154,17 @@ describe('Plugin', () => { agent.use(traces => { expect(traces[0][0].meta).to.have.property(ERROR_TYPE, error.name) expect(traces[0][0].meta).to.have.property(ERROR_MESSAGE, error.message) - expect(traces[0][0].meta).to.have.property(ERROR_STACK, error.stack) + + // pg modifies stacktraces as of v8.11.1 + const actualErrorNoStack = traces[0][0].meta[ERROR_STACK].split('\n')[0] + const expectedErrorNoStack = error.stack.split('\n')[0] + expect(actualErrorNoStack).to.eql(expectedErrorNoStack) + expect(traces[0][0].meta).to.have.property('component', 'pg') expect(traces[0][0].metrics).to.have.property('network.destination.port', 5432) - - done() }) + .then(done) + .catch(done) const errorCallback = (err) => { error = err @@ -187,6 +197,16 @@ describe('Plugin', () => { }) }) }) + + if (pgHasPromiseSupport) { + withNamingSchema( + done => client.query('SELECT $1::text as message', ['Hello world!']) + .then(() => client.end()) + .catch(done), + () => namingSchema.outbound.opName, + () => namingSchema.outbound.serviceName + ) + } }) }) @@ -214,10 +234,11 @@ describe('Plugin', () => { it('should be configured with the correct values', done => { agent.use(traces => { + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) expect(traces[0][0]).to.have.property('service', 'custom') - - done() }) + .then(done) + .catch(done) client.query('SELECT $1::text as message', ['Hello world!'], (err, result) => { if (err) throw err @@ -227,6 +248,16 @@ describe('Plugin', () => { }) }) }) + + if (pgHasPromiseSupport) { + withNamingSchema( + done => client.query('SELECT $1::text as message', ['Hello world!']) + .then(() => client.end()) + .catch(done), + () => namingSchema.outbound.opName, + () => 'custom' + ) + } }) describe('with a service name callback', () => { @@ -253,14 +284,11 @@ describe('Plugin', () => { it('should be configured with the correct service', done => { agent.use(traces => { - try { - expect(traces[0][0]).to.have.property('service', '127.0.0.1-postgres') - - done() - } catch (e) { - done(e) - } + expect(traces[0][0]).to.have.property('name', namingSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', '127.0.0.1-postgres') }) + .then(done) + .catch(done) client.query('SELECT $1::text as message', ['Hello world!'], (err, result) => { if (err) throw err @@ -270,6 +298,16 @@ describe('Plugin', () => { }) }) }) + + if (pgHasPromiseSupport) { + withNamingSchema( + done => client.query('SELECT $1::text as message', ['Hello world!']) + .then(() => client.end()) + .catch(done), + () => namingSchema.outbound.opName, + () => '127.0.0.1-postgres' + ) + } }) describe('with DBM propagation enabled with service using plugin configurations', () => { @@ -456,9 +494,8 @@ describe('Plugin', () => { }) it('service should default to tracer service name', done => { - tracer agent.use(traces => { - expect(traces[0][0]).to.have.property('service', 'test-postgres') + expect(traces[0][0]).to.have.property('service', namingSchema.outbound.serviceName) done() }) @@ -590,6 +627,40 @@ describe('Plugin', () => { }) queryText = client.queryQueue[0].text }) + + it('should not fail when using query object that is an EventEmitter', done => { + let queryText = '' + + class Query extends EventEmitter { + constructor (name, text) { + super() + this.name = name + this._internalText = text + } + + get text () { + expect(typeof this.on).to.eql('function') + return this._internalText + } + } + + const query = new Query('pgSelectQuery', 'SELECT $1::text as greeting') + + agent.use(traces => { + expect(queryText).to.equal( + `/*dddbs='post',dde='tester',ddps='test',ddpv='8.4.0'` + + `*/ SELECT $1::text as greeting`) + }).then(done, done) + + client.query(query, ['Goodbye'], (err) => { + if (err) return done(err) + + client.end((err) => { + if (err) return done(err) + }) + }) + queryText = client.queryQueue[0].text + }) }) }) }) diff --git a/packages/datadog-plugin-pg/test/naming.js b/packages/datadog-plugin-pg/test/naming.js new file mode 100644 index 00000000000..20fbc80f92d --- /dev/null +++ b/packages/datadog-plugin-pg/test/naming.js @@ -0,0 +1,14 @@ +const { resolveNaming } = require('../../dd-trace/test/plugins/helpers') + +module.exports = resolveNaming({ + outbound: { + v0: { + opName: 'pg.query', + serviceName: 'test-postgres' + }, + v1: { + opName: 'postgresql.query', + serviceName: 'test' + } + } +}) diff --git a/packages/datadog-plugin-redis/test/legacy.spec.js b/packages/datadog-plugin-redis/test/legacy.spec.js index b6c8da073ba..b3816814b0b 100644 --- a/packages/datadog-plugin-redis/test/legacy.spec.js +++ b/packages/datadog-plugin-redis/test/legacy.spec.js @@ -40,6 +40,13 @@ describe('Legacy Plugin', () => { sub = redis.createClient() }) + withPeerService( + () => tracer, + () => client.get('foo'), + '127.0.0.1', + 'out.host' + ) + it('should do automatic instrumentation when using callbacks', done => { client.on('error', done) agent diff --git a/packages/datadog-plugin-rhea/test/index.spec.js b/packages/datadog-plugin-rhea/test/index.spec.js index a026b1fabaf..c5e05e349d8 100644 --- a/packages/datadog-plugin-rhea/test/index.spec.js +++ b/packages/datadog-plugin-rhea/test/index.spec.js @@ -47,6 +47,13 @@ describe('Plugin', () => { }) describe('sending a message', () => { + withPeerService( + () => tracer, + () => context.sender.send({ body: 'Hello World!' }), + 'localhost', + 'out.host' + ) + it('should automatically instrument', (done) => { agent.use(traces => { const span = traces[0][0] diff --git a/packages/datadog-plugin-sharedb/src/index.js b/packages/datadog-plugin-sharedb/src/index.js index f4e662330ea..1257d608d56 100644 --- a/packages/datadog-plugin-sharedb/src/index.js +++ b/packages/datadog-plugin-sharedb/src/index.js @@ -25,7 +25,7 @@ class SharedbPlugin extends ServerPlugin { if (this.config.hooks && this.config.hooks.reply) { this.config.hooks.reply(span, request, res) } - span.finish() + super.finish() } } diff --git a/packages/dd-trace/src/appsec/channels.js b/packages/dd-trace/src/appsec/channels.js index 6e0eb907cf8..e018f70b37e 100644 --- a/packages/dd-trace/src/appsec/channels.js +++ b/packages/dd-trace/src/appsec/channels.js @@ -7,6 +7,7 @@ module.exports = { bodyParser: dc.channel('datadog:body-parser:read:finish'), incomingHttpRequestStart: dc.channel('dd-trace:incomingHttpRequestStart'), incomingHttpRequestEnd: dc.channel('dd-trace:incomingHttpRequestEnd'), + passportVerify: dc.channel('datadog:passport:verify:finish'), queryParser: dc.channel('datadog:query:read:finish'), setCookieChannel: dc.channel('datadog:iast:set-cookie') } diff --git a/packages/dd-trace/src/appsec/iast/taint-tracking/origin-types.js b/packages/dd-trace/src/appsec/iast/taint-tracking/origin-types.js index 6285a4d2d79..dfc8cc573dc 100644 --- a/packages/dd-trace/src/appsec/iast/taint-tracking/origin-types.js +++ b/packages/dd-trace/src/appsec/iast/taint-tracking/origin-types.js @@ -2,9 +2,10 @@ module.exports = { HTTP_REQUEST_BODY: 'http.request.body', - HTTP_REQUEST_PARAMETER: 'http.request.parameter', HTTP_REQUEST_COOKIE_VALUE: 'http.request.cookie.value', HTTP_REQUEST_COOKIE_NAME: 'http.request.cookie.name', HTTP_REQUEST_HEADER_NAME: 'http.request.header.name', - HTTP_REQUEST_HEADER_VALUE: 'http.request.header' + HTTP_REQUEST_HEADER_VALUE: 'http.request.header', + HTTP_REQUEST_PARAMETER: 'http.request.parameter', + HTTP_REQUEST_PATH_PARAM: 'http.request.path.parameter' } diff --git a/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js b/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js index 1edb2b7c200..a16f2a83d6c 100644 --- a/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js +++ b/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js @@ -5,12 +5,14 @@ const { getIastContext } = require('../iast-context') const { storage } = require('../../../../../datadog-core') const { taintObject } = require('./operations') const { - HTTP_REQUEST_PARAMETER, HTTP_REQUEST_BODY, HTTP_REQUEST_COOKIE_VALUE, HTTP_REQUEST_COOKIE_NAME, HTTP_REQUEST_HEADER_VALUE, - HTTP_REQUEST_HEADER_NAME + HTTP_REQUEST_HEADER_NAME, + HTTP_REQUEST_PARAMETER, + HTTP_REQUEST_PATH_PARAM + } = require('./origin-types') class TaintTrackingPlugin extends Plugin { @@ -44,6 +46,14 @@ class TaintTrackingPlugin extends Plugin { 'datadog:cookie:parse:finish', ({ cookies }) => this._cookiesTaintTrackingHandler(cookies) ) + this.addSub( + 'datadog:express:process_params:start', + ({ req }) => { + if (req && req.params && typeof req.params === 'object') { + this._taintTrackingHandler(HTTP_REQUEST_PATH_PARAM, req, 'params') + } + } + ) } _taintTrackingHandler (type, target, property, iastContext = getIastContext(storage.getStore())) { diff --git a/packages/dd-trace/src/appsec/index.js b/packages/dd-trace/src/appsec/index.js index 5112a20ed93..82d0b6d2d48 100644 --- a/packages/dd-trace/src/appsec/index.js +++ b/packages/dd-trace/src/appsec/index.js @@ -7,6 +7,7 @@ const { incomingHttpRequestStart, incomingHttpRequestEnd, bodyParser, + passportVerify, queryParser } = require('./channels') const waf = require('./waf') @@ -16,6 +17,8 @@ const web = require('../plugins/util/web') const { extractIp } = require('../plugins/util/ip_extractor') const { HTTP_CLIENT_IP } = require('../../../../ext/tags') const { block, setTemplates } = require('./blocking') +const { passportTrackEvent } = require('./passport') +const { storage } = require('../../../datadog-core') let isEnabled = false let config @@ -37,6 +40,10 @@ function enable (_config) { bodyParser.subscribe(onRequestBodyParsed) queryParser.subscribe(onRequestQueryParsed) + if (_config.appsec.eventTracking.enabled) { + passportVerify.subscribe(onPassportVerify) + } + isEnabled = true config = _config } catch (err) { @@ -139,6 +146,18 @@ function onRequestQueryParsed ({ req, res, abortController }) { handleResults(results, req, res, rootSpan, abortController) } +function onPassportVerify ({ credentials, user }) { + const store = storage.getStore() + const rootSpan = store && store.req && web.root(store.req) + + if (!rootSpan) { + log.warn('No rootSpan found in onPassportVerify') + return + } + + passportTrackEvent(credentials, user, rootSpan, config.appsec.eventTracking.mode) +} + function handleResults (actions, req, res, rootSpan, abortController) { if (!actions || !req || !res || !rootSpan || !abortController) return @@ -160,6 +179,7 @@ function disable () { if (incomingHttpRequestEnd.hasSubscribers) incomingHttpRequestEnd.unsubscribe(incomingHttpEndTranslator) if (bodyParser.hasSubscribers) bodyParser.unsubscribe(onRequestBodyParsed) if (queryParser.hasSubscribers) queryParser.unsubscribe(onRequestQueryParsed) + if (passportVerify.hasSubscribers) passportVerify.unsubscribe(onPassportVerify) } module.exports = { diff --git a/packages/dd-trace/src/appsec/passport.js b/packages/dd-trace/src/appsec/passport.js new file mode 100644 index 00000000000..0e745288bd9 --- /dev/null +++ b/packages/dd-trace/src/appsec/passport.js @@ -0,0 +1,110 @@ +'use strict' + +const log = require('../log') +const { trackEvent } = require('./sdk/track_event') +const { setUserTags } = require('./sdk/set_user') + +const UUID_PATTERN = '^[0-9A-F]{8}-[0-9A-F]{4}-[1-5][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$' +const regexUsername = new RegExp(UUID_PATTERN, 'i') + +const SDK_USER_EVENT_PATTERN = '^_dd\\.appsec\\.events\\.users\\.[\\W\\w+]+\\.sdk$' +const regexSdkEvent = new RegExp(SDK_USER_EVENT_PATTERN, 'i') + +function isSdkCalled (tags) { + let called = false + + if (tags && typeof tags === 'object') { + called = Object.entries(tags).some(([key, value]) => regexSdkEvent.test(key) && value === 'true') + } + + return called +} + +// delete this function later if we know it's always credential.username +function getLogin (credentials) { + const type = credentials && credentials.type + let login + if (type === 'local' || type === 'http') { + login = credentials.username + } + + return login +} + +function parseUser (login, passportUser, mode) { + const user = { + 'usr.id': login + } + + if (!user['usr.id']) { + return user + } + + if (passportUser) { + // Guess id + if (passportUser.id) { + user['usr.id'] = passportUser.id + } else if (passportUser._id) { + user['usr.id'] = passportUser._id + } + + if (mode === 'extended') { + if (login) { + user['usr.login'] = login + } + + if (passportUser.email) { + user['usr.email'] = passportUser.email + } + + // Guess username + if (passportUser.username) { + user['usr.username'] = passportUser.username + } else if (passportUser.name) { + user['usr.username'] = passportUser.name + } + } + } + + if (mode === 'safe') { + // Remove PII in safe mode + if (!regexUsername.test(user['usr.id'])) { + user['usr.id'] = ' ' + } + } + + return user +} + +function passportTrackEvent (credentials, passportUser, rootSpan, mode) { + const tags = rootSpan && rootSpan.context() && rootSpan.context()._tags + + if (isSdkCalled(tags)) { + // Don't overwrite tags set by SDK callings + return + } + const user = parseUser(getLogin(credentials), passportUser, mode) + + if (user['usr.id'] === undefined) { + log.warn('No user ID found in authentication instrumentation') + return + } + + if (passportUser) { + // If a passportUser object is published then the login succeded + const userTags = {} + Object.entries(user).forEach(([k, v]) => { + const attr = k.split('.', 2)[1] + userTags[attr] = v + }) + + setUserTags(userTags, rootSpan) + trackEvent('users.login.success', null, 'passportTrackEvent', rootSpan, mode) + } else { + trackEvent('users.login.failure', user, 'passportTrackEvent', rootSpan, mode) + } +} + +module.exports = { + passportTrackEvent +} diff --git a/packages/dd-trace/src/appsec/sdk/track_event.js b/packages/dd-trace/src/appsec/sdk/track_event.js index fa1b46b69c5..8debb932090 100644 --- a/packages/dd-trace/src/appsec/sdk/track_event.js +++ b/packages/dd-trace/src/appsec/sdk/track_event.js @@ -20,7 +20,7 @@ function trackUserLoginSuccessEvent (tracer, user, metadata) { setUserTags(user, rootSpan) - trackEvent(tracer, 'users.login.success', metadata, 'trackUserLoginSuccessEvent', rootSpan) + trackEvent('users.login.success', metadata, 'trackUserLoginSuccessEvent', rootSpan, 'sdk') } function trackUserLoginFailureEvent (tracer, userId, exists, metadata) { @@ -35,7 +35,7 @@ function trackUserLoginFailureEvent (tracer, userId, exists, metadata) { ...metadata } - trackEvent(tracer, 'users.login.failure', fields, 'trackUserLoginFailureEvent') + trackEvent('users.login.failure', fields, 'trackUserLoginFailureEvent', getRootSpan(tracer), 'sdk') } function trackCustomEvent (tracer, eventName, metadata) { @@ -44,10 +44,10 @@ function trackCustomEvent (tracer, eventName, metadata) { return } - trackEvent(tracer, eventName, metadata, 'trackCustomEvent') + trackEvent(eventName, metadata, 'trackCustomEvent', getRootSpan(tracer), 'sdk') } -function trackEvent (tracer, eventName, fields, sdkMethodName, rootSpan = getRootSpan(tracer)) { +function trackEvent (eventName, fields, sdkMethodName, rootSpan, mode) { if (!rootSpan) { log.warn(`Root span not available in ${sdkMethodName}`) return @@ -58,6 +58,14 @@ function trackEvent (tracer, eventName, fields, sdkMethodName, rootSpan = getRoo [MANUAL_KEEP]: 'true' } + if (mode === 'sdk') { + tags[`_dd.appsec.events.${eventName}.sdk`] = 'true' + } + + if (mode === 'safe' || mode === 'extended') { + tags[`_dd.appsec.events.${eventName}.auto.mode`] = mode + } + if (fields) { for (const metadataKey of Object.keys(fields)) { tags[`appsec.events.${eventName}.${metadataKey}`] = '' + fields[metadataKey] @@ -70,5 +78,6 @@ function trackEvent (tracer, eventName, fields, sdkMethodName, rootSpan = getRoo module.exports = { trackUserLoginSuccessEvent, trackUserLoginFailureEvent, - trackCustomEvent + trackCustomEvent, + trackEvent } diff --git a/packages/dd-trace/src/ci-visibility/exporters/git/git_metadata.js b/packages/dd-trace/src/ci-visibility/exporters/git/git_metadata.js index 0b658b5fa8d..856f5f23792 100644 --- a/packages/dd-trace/src/ci-visibility/exporters/git/git_metadata.js +++ b/packages/dd-trace/src/ci-visibility/exporters/git/git_metadata.js @@ -48,6 +48,9 @@ function getCommonRequestOptions (url) { */ function getCommitsToExclude ({ url, isEvpProxy, repositoryUrl }, callback) { const latestCommits = getLatestCommits() + + log.debug(`There were ${latestCommits.length} commits since last month.`) + const [headCommit] = latestCommits const commonOptions = getCommonRequestOptions(url) @@ -158,11 +161,14 @@ function sendGitMetadata (url, isEvpProxy, configRepositoryUrl, callback) { repositoryUrl = getRepositoryUrl() } + log.debug(`Uploading git history for repository ${repositoryUrl}`) + if (!repositoryUrl) { return callback(new Error('Repository URL is empty')) } if (isShallowRepository()) { + log.debug('It is shallow clone, unshallowing...') unshallowRepository() } @@ -170,14 +176,20 @@ function sendGitMetadata (url, isEvpProxy, configRepositoryUrl, callback) { if (err) { return callback(err) } + log.debug(`There are ${commitsToExclude.length} commits to exclude.`) + const commitsToUpload = getCommitsToUpload(commitsToExclude) if (!commitsToUpload.length) { log.debug('No commits to upload') return callback(null) } + log.debug(`There are ${commitsToUpload.length} commits to upload`) + const packFilesToUpload = generatePackFilesForCommits(commitsToUpload) + log.debug(`Uploading ${packFilesToUpload.length} packfiles.`) + if (!packFilesToUpload.length) { return callback(new Error('Failed to generate packfiles')) } diff --git a/packages/dd-trace/src/ci-visibility/test-api-manual/test-api-manual-plugin.js b/packages/dd-trace/src/ci-visibility/test-api-manual/test-api-manual-plugin.js new file mode 100644 index 00000000000..f6a1612b373 --- /dev/null +++ b/packages/dd-trace/src/ci-visibility/test-api-manual/test-api-manual-plugin.js @@ -0,0 +1,45 @@ +const CiPlugin = require('../../plugins/ci_plugin') +const { + TEST_STATUS, + finishAllTraceSpans, + getTestSuitePath +} = require('../../plugins/util/test') +const { storage } = require('../../../../datadog-core') + +class TestApiManualPlugin extends CiPlugin { + static get id () { + return 'test-api-manual' + } + constructor (...args) { + super(...args) + this.sourceRoot = process.cwd() + + this.addSub('dd-trace:ci:manual:test:start', ({ testName, testSuite }) => { + const store = storage.getStore() + const testSuiteRelative = getTestSuitePath(testSuite, this.sourceRoot) + const testSpan = this.startTestSpan(testName, testSuiteRelative) + this.enter(testSpan, store) + }) + this.addSub('dd-trace:ci:manual:test:finish', ({ status, error }) => { + const store = storage.getStore() + const testSpan = store && store.span + if (testSpan) { + testSpan.setTag(TEST_STATUS, status) + if (error) { + testSpan.setTag('error', error) + } + testSpan.finish() + finishAllTraceSpans(testSpan) + } + }) + this.addSub('dd-trace:ci:manual:test:addTags', (tags) => { + const store = storage.getStore() + const testSpan = store && store.span + if (testSpan) { + testSpan.addTags(tags) + } + }) + } +} + +module.exports = TestApiManualPlugin diff --git a/packages/dd-trace/src/config.js b/packages/dd-trace/src/config.js index c612595d938..4c23ea84795 100644 --- a/packages/dd-trace/src/config.js +++ b/packages/dd-trace/src/config.js @@ -172,6 +172,11 @@ class Config { true ) + const DD_CIVISIBILITY_MANUAL_API_ENABLED = coalesce( + process.env.DD_CIVISIBILITY_MANUAL_API_ENABLED, + false + ) + const DD_SERVICE = options.service || process.env.DD_SERVICE || process.env.DD_SERVICE_NAME || @@ -230,6 +235,9 @@ class Config { const DD_TELEMETRY_HEARTBEAT_INTERVAL = process.env.DD_TELEMETRY_HEARTBEAT_INTERVAL ? parseInt(process.env.DD_TELEMETRY_HEARTBEAT_INTERVAL) * 1000 : 60000 + const DD_OPENAI_SPAN_CHAR_LIMIT = process.env.DD_OPENAI_SPAN_CHAR_LIMIT + ? parseInt(process.env.DD_OPENAI_SPAN_CHAR_LIMIT) + : 128 const DD_TELEMETRY_DEBUG = coalesce( process.env.DD_TELEMETRY_DEBUG, false @@ -306,6 +314,8 @@ class Config { const DD_TRACE_SPAN_ATTRIBUTE_SCHEMA = validateNamingVersion( process.env.DD_TRACE_SPAN_ATTRIBUTE_SCHEMA ) + const DD_TRACE_PEER_SERVICE_DEFAULTS_ENABLED = process.env.DD_TRACE_PEER_SERVICE_DEFAULTS_ENABLED + const DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH = coalesce( process.env.DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH, '512' @@ -380,6 +390,11 @@ ken|consumer_?(?:id|key|secret)|sign(?:ed|ature)?|auth(?:entication|orization)?) maybeFile(appsec.blockedTemplateJson), maybeFile(process.env.DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON) ) + const DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING = coalesce( + appsec.eventTracking && appsec.eventTracking.mode, + process.env.DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING, + 'safe' + ).toLowerCase() const remoteConfigOptions = options.remoteConfig || {} const DD_REMOTE_CONFIGURATION_ENABLED = coalesce( @@ -524,6 +539,10 @@ ken|consumer_?(?:id|key|secret)|sign(?:ed|ature)?|auth(?:entication|orization)?) exporters: DD_PROFILING_EXPORTERS } this.spanAttributeSchema = DD_TRACE_SPAN_ATTRIBUTE_SCHEMA + this.spanComputePeerService = (this.spanAttributeSchema === 'v0' + ? isTrue(DD_TRACE_PEER_SERVICE_DEFAULTS_ENABLED) + : true + ) this.lookup = options.lookup this.startupLogs = isTrue(DD_TRACE_STARTUP_LOGS) // Disabled for CI Visibility's agentless @@ -544,7 +563,11 @@ ken|consumer_?(?:id|key|secret)|sign(?:ed|ature)?|auth(?:entication|orization)?) obfuscatorKeyRegex: DD_APPSEC_OBFUSCATION_PARAMETER_KEY_REGEXP, obfuscatorValueRegex: DD_APPSEC_OBFUSCATION_PARAMETER_VALUE_REGEXP, blockedTemplateHtml: DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML, - blockedTemplateJson: DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON + blockedTemplateJson: DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON, + eventTracking: { + enabled: ['extended', 'safe'].includes(DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING), + mode: DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING + } } this.remoteConfig = { enabled: DD_REMOTE_CONFIGURATION_ENABLED, @@ -566,6 +589,9 @@ ken|consumer_?(?:id|key|secret)|sign(?:ed|ature)?|auth(?:entication|orization)?) (this.isIntelligentTestRunnerEnabled && !isFalse(DD_CIVISIBILITY_GIT_UPLOAD_ENABLED)) this.gitMetadataEnabled = isTrue(DD_TRACE_GIT_METADATA_ENABLED) + this.isManualApiEnabled = this.isCiVisibility && isTrue(DD_CIVISIBILITY_MANUAL_API_ENABLED) + + this.openaiSpanCharLimit = DD_OPENAI_SPAN_CHAR_LIMIT if (this.gitMetadataEnabled) { this.repositoryUrl = coalesce( diff --git a/packages/dd-trace/src/constants.js b/packages/dd-trace/src/constants.js index 0d220b36837..33b64310ca0 100644 --- a/packages/dd-trace/src/constants.js +++ b/packages/dd-trace/src/constants.js @@ -26,6 +26,8 @@ module.exports = { ERROR_STACK: 'error.stack', COMPONENT: 'component', CLIENT_PORT_KEY: 'network.destination.port', + PEER_SERVICE_KEY: 'peer.service', + PEER_SERVICE_SOURCE_KEY: '_dd.peer.service.source', SCI_REPOSITORY_URL: '_dd.git.repository_url', SCI_COMMIT_SHA: '_dd.git.commit.sha' } diff --git a/packages/dd-trace/src/dogstatsd.js b/packages/dd-trace/src/dogstatsd.js index 76ffb670fa5..091bc780ebc 100644 --- a/packages/dd-trace/src/dogstatsd.js +++ b/packages/dd-trace/src/dogstatsd.js @@ -8,7 +8,11 @@ const log = require('./log') const MAX_BUFFER_SIZE = 1024 // limit from the agent -class Client { +const TYPE_COUNTER = 'c' +const TYPE_GAUGE = 'g' +const TYPE_DISTRIBUTION = 'd' + +class DogStatsDClient { constructor (options) { options = options || {} @@ -32,11 +36,15 @@ class Client { } gauge (stat, value, tags) { - this._add(stat, value, 'g', tags) + this._add(stat, value, TYPE_GAUGE, tags) } increment (stat, value, tags) { - this._add(stat, value, 'c', tags) + this._add(stat, value, TYPE_COUNTER, tags) + } + + distribution (stat, value, tags) { + this._add(stat, value, TYPE_DISTRIBUTION, tags) } flush () { @@ -135,4 +143,4 @@ class Client { } } -module.exports = Client +module.exports = DogStatsDClient diff --git a/packages/dd-trace/src/external-logger/src/index.js b/packages/dd-trace/src/external-logger/src/index.js index 7161e1b12c7..fe8bc6fe87b 100644 --- a/packages/dd-trace/src/external-logger/src/index.js +++ b/packages/dd-trace/src/external-logger/src/index.js @@ -7,6 +7,8 @@ class ExternalLogger { constructor ({ ddsource, hostname, service, apiKey, site = 'datadoghq.com', interval = 10000, timeout = 2000, limit = 1000 }) { + this.enabled = !!apiKey + this.ddsource = ddsource this.hostname = hostname this.service = service @@ -38,6 +40,8 @@ class ExternalLogger { // Parses and enqueues a log log (log, span, tags) { + if (!this.enabled) return + const logTags = ExternalLogger.tagString(tags) if (span) { diff --git a/packages/dd-trace/src/opentelemetry/span.js b/packages/dd-trace/src/opentelemetry/span.js index ddfe391298b..f2c3e277c6a 100644 --- a/packages/dd-trace/src/opentelemetry/span.js +++ b/packages/dd-trace/src/opentelemetry/span.js @@ -38,6 +38,7 @@ class Span { context: spanContext._ddContext, startTime, hostname: _tracer._hostname, + integrationName: 'otel', tags: { 'service.name': _tracer._service } diff --git a/packages/dd-trace/src/opentracing/span.js b/packages/dd-trace/src/opentracing/span.js index 7da3b6ff089..ca77ad11232 100644 --- a/packages/dd-trace/src/opentracing/span.js +++ b/packages/dd-trace/src/opentracing/span.js @@ -11,6 +11,9 @@ const tagger = require('../tagger') const metrics = require('../metrics') const log = require('../log') const { storage } = require('../../../datadog-core') +const telemetryMetrics = require('../telemetry/metrics') + +const tracerMetrics = telemetryMetrics.manager.namespace('tracers') const { DD_TRACE_EXPERIMENTAL_STATE_TRACKING, @@ -20,6 +23,30 @@ const { const unfinishedRegistry = createRegistry('unfinished') const finishedRegistry = createRegistry('finished') +const OTEL_ENABLED = !!process.env.DD_TRACE_OTEL_ENABLED + +const integrationCounters = { + span_created: {}, + span_finished: {} +} + +function getIntegrationCounter (event, integration) { + const counters = integrationCounters[event] + + if (integration in counters) { + return counters[integration] + } + + const counter = tracerMetrics.count(event, [ + `integration_name:${integration.toLowerCase()}`, + `otel_enabled:${OTEL_ENABLED}` + ]) + + integrationCounters[event][integration] = counter + + return counter +} + class DatadogSpan { constructor (tracer, processor, prioritySampler, fields, debug) { const operationName = fields.operationName @@ -38,6 +65,9 @@ class DatadogSpan { // This name property is not updated when the span name changes. // This is necessary for span count metrics. this._name = operationName + this._integrationName = fields.integrationName || 'opentracing' + + getIntegrationCounter('span_created', this._integrationName).inc() this._spanContext = this._createContext(parent, fields) this._spanContext._name = operationName @@ -126,6 +156,8 @@ class DatadogSpan { } } + getIntegrationCounter('span_finished', this._integrationName).inc() + if (DD_TRACE_EXPERIMENTAL_SPAN_COUNTS && finishedRegistry) { metrics.decrement('runtime.node.spans.unfinished') metrics.decrement('runtime.node.spans.unfinished.by.name', `span_name:${this._name}`) diff --git a/packages/dd-trace/src/opentracing/tracer.js b/packages/dd-trace/src/opentracing/tracer.js index 7efd65c2097..1cb3ad959ed 100644 --- a/packages/dd-trace/src/opentracing/tracer.js +++ b/packages/dd-trace/src/opentracing/tracer.js @@ -26,6 +26,7 @@ class DatadogTracer { this._version = config.version this._env = config.env this._tags = config.tags + this._computePeerService = config.spanComputePeerService this._logInjection = config.logInjection this._debug = config.debug this._prioritySampler = new PrioritySampler(config.env, config.sampler) @@ -60,7 +61,8 @@ class DatadogTracer { tags, startTime: options.startTime, hostname: this._hostname, - traceId128BitGenerationEnabled: this._traceId128BitGenerationEnabled + traceId128BitGenerationEnabled: this._traceId128BitGenerationEnabled, + integrationName: options.integrationName }, this._debug) span.addTags(this._tags) diff --git a/packages/dd-trace/src/plugin_manager.js b/packages/dd-trace/src/plugin_manager.js index fe500edde82..1f705f4c553 100644 --- a/packages/dd-trace/src/plugin_manager.js +++ b/packages/dd-trace/src/plugin_manager.js @@ -21,7 +21,7 @@ const disabledPlugins = new Set( DD_TRACE_DISABLED_PLUGINS && DD_TRACE_DISABLED_PLUGINS.split(',').map(plugin => plugin.trim()) ) -// TODO actually ... should we be looking at envrionment variables this deep down in the code? +// TODO actually ... should we be looking at environment variables this deep down in the code? const pluginClasses = {} diff --git a/packages/dd-trace/src/plugins/client.js b/packages/dd-trace/src/plugins/client.js index e0fe539ca4a..eba4c8732a7 100644 --- a/packages/dd-trace/src/plugins/client.js +++ b/packages/dd-trace/src/plugins/client.js @@ -5,6 +5,7 @@ const OutboundPlugin = require('./outbound') class ClientPlugin extends OutboundPlugin { static get operation () { return 'request' } static get kind () { return 'client' } + static get type () { return 'web' } // overridden by storage and other client type plugins } module.exports = ClientPlugin diff --git a/packages/dd-trace/src/plugins/index.js b/packages/dd-trace/src/plugins/index.js index 0c125bea1be..2d0e80f9433 100644 --- a/packages/dd-trace/src/plugins/index.js +++ b/packages/dd-trace/src/plugins/index.js @@ -56,6 +56,7 @@ module.exports = { get 'net' () { return require('../../../datadog-plugin-net/src') }, get 'next' () { return require('../../../datadog-plugin-next/src') }, get 'oracledb' () { return require('../../../datadog-plugin-oracledb/src') }, + get 'openai' () { return require('../../../datadog-plugin-openai/src') }, get 'paperplane' () { return require('../../../datadog-plugin-paperplane/src') }, get 'pg' () { return require('../../../datadog-plugin-pg/src') }, get 'pino' () { return require('../../../datadog-plugin-pino/src') }, diff --git a/packages/dd-trace/src/plugins/outbound.js b/packages/dd-trace/src/plugins/outbound.js index ee9202b4d51..53394f07a68 100644 --- a/packages/dd-trace/src/plugins/outbound.js +++ b/packages/dd-trace/src/plugins/outbound.js @@ -1,10 +1,21 @@ 'use strict' -const { CLIENT_PORT_KEY } = require('../constants') +const { + CLIENT_PORT_KEY, + PEER_SERVICE_KEY, + PEER_SERVICE_SOURCE_KEY +} = require('../constants') const TracingPlugin = require('./tracing') +const COMMON_PEER_SVC_SOURCE_TAGS = [ + 'net.peer.name', + 'out.host' +] + // TODO: Exit span on finish when AsyncResource instances are removed. class OutboundPlugin extends TracingPlugin { + static get peerServicePrecursors () { return [] } + constructor (...args) { super(...args) @@ -13,6 +24,52 @@ class OutboundPlugin extends TracingPlugin { }) } + getPeerService (tags) { + /** + * Compute `peer.service` and associated metadata from available tags, based + * on defined precursor tags names. + * + * - The `peer.service` tag is set from the first precursor available (based on list ordering) + * - The `_dd.peer.service.source` tag is set from the precursor's name + * - If `peer.service` was defined _before_ we compute it (for example in custom instrumentation), + * `_dd.peer.service.source`'s value is `peer.service` + */ + + if (tags['peer.service']) { + return { [PEER_SERVICE_SOURCE_KEY]: 'peer.service' } + } + + const sourceTags = [ + ...this.constructor.peerServicePrecursors, + ...COMMON_PEER_SVC_SOURCE_TAGS + ] + for (const sourceTag of sourceTags) { + if (tags[sourceTag]) { + return { + [PEER_SERVICE_KEY]: tags[sourceTag], + [PEER_SERVICE_SOURCE_KEY]: sourceTag + } + } + } + return {} + } + + startSpan (name, options) { + const span = super.startSpan(name, options) + return span + } + + finish () { + const span = this.activeSpan + if (this.tracer._computePeerService) { + const peerData = this.getPeerService(span.context()._tags) + if (peerData) { + span.addTags(peerData) + } + } + super.finish(...arguments) + } + connect (url) { this.addHost(url.hostname, url.port) } diff --git a/packages/dd-trace/src/plugins/server.js b/packages/dd-trace/src/plugins/server.js index eaf8b5b002b..9571b6c578f 100644 --- a/packages/dd-trace/src/plugins/server.js +++ b/packages/dd-trace/src/plugins/server.js @@ -4,6 +4,8 @@ const InboundPlugin = require('./inbound') class ServerPlugin extends InboundPlugin { static get operation () { return 'request' } + static get kind () { return 'server' } + static get type () { return 'web' } // a default that may eventually be overriden by nonweb servers } module.exports = ServerPlugin diff --git a/packages/dd-trace/src/plugins/tracing.js b/packages/dd-trace/src/plugins/tracing.js index c0ad2e3d0f5..30704f308da 100644 --- a/packages/dd-trace/src/plugins/tracing.js +++ b/packages/dd-trace/src/plugins/tracing.js @@ -91,7 +91,8 @@ class TracingPlugin extends Plugin { 'span.type': type, ...meta, ...metrics - } + }, + integrationName: type }) analyticsSampler.sample(span, this.config.measured) diff --git a/packages/dd-trace/src/plugins/util/exec.js b/packages/dd-trace/src/plugins/util/exec.js index d55127d8b17..a2d091232c6 100644 --- a/packages/dd-trace/src/plugins/util/exec.js +++ b/packages/dd-trace/src/plugins/util/exec.js @@ -1,9 +1,11 @@ const cp = require('child_process') +const log = require('../../log') const sanitizedExec = (cmd, flags, options = { stdio: 'pipe' }) => { try { return cp.execFileSync(cmd, flags, options).toString().replace(/(\r\n|\n|\r)/gm, '') } catch (e) { + log.error(e) return '' } } diff --git a/packages/dd-trace/src/plugins/util/git.js b/packages/dd-trace/src/plugins/util/git.js index 59143ea1031..84ceec17208 100644 --- a/packages/dd-trace/src/plugins/util/git.js +++ b/packages/dd-trace/src/plugins/util/git.js @@ -36,12 +36,8 @@ function isShallowRepository () { } function unshallowRepository () { - try { - sanitizedExec('git', ['config', 'remote.origin.partialclonefilter', '"blob:none"']) - sanitizedExec('git', ['fetch', '--shallow-since="1 month ago"', '--update-shallow', '--refetch']) - } catch (err) { - log.error(err) - } + sanitizedExec('git', ['config', 'remote.origin.partialclonefilter', '"blob:none"']) + sanitizedExec('git', ['fetch', '--shallow-since="1 month ago"', '--update-shallow', '--refetch']) } function getRepositoryUrl () { @@ -55,7 +51,7 @@ function getLatestCommits () { .split('\n') .filter(commit => commit) } catch (err) { - log.error(err) + log.error(`Get latest commits failed: ${err.message}`) return [] } } @@ -80,7 +76,7 @@ function getCommitsToUpload (commitsToExclude) { .split('\n') .filter(commit => commit) } catch (err) { - log.error(err) + log.error(`Get commits to upload failed: ${err.message}`) return [] } } diff --git a/packages/dd-trace/src/profiling/config.js b/packages/dd-trace/src/profiling/config.js index 1cdf23e44c2..a361e2f2d45 100644 --- a/packages/dd-trace/src/profiling/config.js +++ b/packages/dd-trace/src/profiling/config.js @@ -53,7 +53,7 @@ class Config { const endpointCollection = coalesce(options.endpointCollection, DD_PROFILING_ENDPOINT_COLLECTION_ENABLED, false) const pprofPrefix = coalesce(options.pprofPrefix, - DD_PROFILING_PPROF_PREFIX) + DD_PROFILING_PPROF_PREFIX, '') this.enabled = enabled this.service = service @@ -88,7 +88,7 @@ class Config { ], this) const oomMonitoringEnabled = isTrue(coalesce(options.oomMonitoring, - DD_PROFILING_EXPERIMENTAL_OOM_MONITORING_ENABLED, false)) + DD_PROFILING_EXPERIMENTAL_OOM_MONITORING_ENABLED, true)) const heapLimitExtensionSize = coalesce(options.oomHeapLimitExtensionSize, Number(DD_PROFILING_EXPERIMENTAL_OOM_HEAP_LIMIT_EXTENSION_SIZE), 0) const maxHeapExtensionCount = coalesce(options.oomMaxHeapExtensionCount, diff --git a/packages/dd-trace/src/proxy.js b/packages/dd-trace/src/proxy.js index b9a18744672..b8d4c9904e5 100644 --- a/packages/dd-trace/src/proxy.js +++ b/packages/dd-trace/src/proxy.js @@ -64,6 +64,12 @@ class Tracer extends NoopProxy { this._pluginManager.configure(config) setStartupLogPluginManager(this._pluginManager) telemetry.start(config, this._pluginManager) + + if (config.isManualApiEnabled) { + const TestApiManualPlugin = require('./ci-visibility/test-api-manual/test-api-manual-plugin') + this._testApiManualPlugin = new TestApiManualPlugin(this) + this._testApiManualPlugin.configure({ ...config, enabled: true }) + } } } catch (e) { log.error(e) diff --git a/packages/dd-trace/src/service-naming/schemas/v0/index.js b/packages/dd-trace/src/service-naming/schemas/v0/index.js index faccf370851..e2ee3f60217 100644 --- a/packages/dd-trace/src/service-naming/schemas/v0/index.js +++ b/packages/dd-trace/src/service-naming/schemas/v0/index.js @@ -1,5 +1,6 @@ const SchemaDefinition = require('../definition') const messaging = require('./messaging') const storage = require('./storage') +const web = require('./web') -module.exports = new SchemaDefinition({ messaging, storage }) +module.exports = new SchemaDefinition({ messaging, storage, web }) diff --git a/packages/dd-trace/src/service-naming/schemas/v0/storage.js b/packages/dd-trace/src/service-naming/schemas/v0/storage.js index 57166f27e69..bb51c210feb 100644 --- a/packages/dd-trace/src/service-naming/schemas/v0/storage.js +++ b/packages/dd-trace/src/service-naming/schemas/v0/storage.js @@ -16,7 +16,16 @@ function mysqlServiceName (service, config, dbConfig, system) { if (typeof config.service === 'function') { return config.service(dbConfig) } - return config.service ? config.service : fromSystem(service, system) + return config.service || fromSystem(service, system) +} + +function withSuffixFunction (suffix) { + return (service, config, params) => { + if (typeof config.service === 'function') { + return config.service(params) + } + return config.service || `${service}-${suffix}` + } } const redisConfig = { @@ -28,6 +37,14 @@ const redisConfig = { const storage = { client: { + 'cassandra-driver': { + opName: () => 'cassandra.query', + serviceName: (service, config, system) => config.service || fromSystem(service, system) + }, + elasticsearch: { + opName: () => 'elasticsearch.query', + serviceName: (service, config) => config.service || `${service}-elasticsearch` + }, ioredis: redisConfig, mariadb: { opName: () => 'mariadb.query', @@ -37,6 +54,10 @@ const storage = { opName: () => 'memcached.command', serviceName: (service, config, system) => config.service || fromSystem(service, system) }, + 'mongodb-core': { + opName: () => 'mongodb.query', + serviceName: (service, config) => config.service || `${service}-mongodb` + }, mysql: { opName: () => 'mysql.query', serviceName: mysqlServiceName @@ -45,6 +66,18 @@ const storage = { opName: () => 'mysql.query', serviceName: mysqlServiceName }, + opensearch: { + opName: () => 'opensearch.query', + serviceName: (service, config) => config.service || `${service}-opensearch` + }, + oracledb: { + opName: () => 'oracle.query', + serviceName: withSuffixFunction('oracle') + }, + pg: { + opName: () => 'pg.query', + serviceName: withSuffixFunction('postgres') + }, redis: redisConfig, tedious: { opName: () => 'tedious.request', diff --git a/packages/dd-trace/src/service-naming/schemas/v0/web.js b/packages/dd-trace/src/service-naming/schemas/v0/web.js new file mode 100644 index 00000000000..0e40038ddc4 --- /dev/null +++ b/packages/dd-trace/src/service-naming/schemas/v0/web.js @@ -0,0 +1,18 @@ +const { identityService } = require('../util') + +const web = { + client: { + moleculer: { + opName: () => 'moleculer.call', + serviceName: identityService + } + }, + server: { + moleculer: { + opName: () => 'moleculer.action', + serviceName: identityService + } + } +} + +module.exports = web diff --git a/packages/dd-trace/src/service-naming/schemas/v1/index.js b/packages/dd-trace/src/service-naming/schemas/v1/index.js index faccf370851..e2ee3f60217 100644 --- a/packages/dd-trace/src/service-naming/schemas/v1/index.js +++ b/packages/dd-trace/src/service-naming/schemas/v1/index.js @@ -1,5 +1,6 @@ const SchemaDefinition = require('../definition') const messaging = require('./messaging') const storage = require('./storage') +const web = require('./web') -module.exports = new SchemaDefinition({ messaging, storage }) +module.exports = new SchemaDefinition({ messaging, storage, web }) diff --git a/packages/dd-trace/src/service-naming/schemas/v1/storage.js b/packages/dd-trace/src/service-naming/schemas/v1/storage.js index 96ee456a5ec..88862f012f5 100644 --- a/packages/dd-trace/src/service-naming/schemas/v1/storage.js +++ b/packages/dd-trace/src/service-naming/schemas/v1/storage.js @@ -14,8 +14,23 @@ const mySQLNaming = { serviceName: identityService } +function withFunction (service, config, params) { + if (typeof config.service === 'function') { + return config.service(params) + } + return configWithFallback(service, config) +} + const storage = { client: { + 'cassandra-driver': { + opName: () => 'cassandra.query', + serviceName: configWithFallback + }, + elasticsearch: { + opName: () => 'elasticsearch.query', + serviceName: configWithFallback + }, ioredis: redisNaming, mariadb: { opName: () => 'mariadb.query', @@ -25,8 +40,24 @@ const storage = { opName: () => 'memcached.command', serviceName: configWithFallback }, + 'mongodb-core': { + opName: () => 'mongodb.query', + serviceName: configWithFallback + }, mysql: mySQLNaming, mysql2: mySQLNaming, + opensearch: { + opName: () => 'opensearch.query', + serviceName: configWithFallback + }, + oracledb: { + opName: () => 'oracle.query', + serviceName: withFunction + }, + pg: { + opName: () => 'postgresql.query', + serviceName: withFunction + }, redis: redisNaming, tedious: { opName: () => 'mssql.query', diff --git a/packages/dd-trace/src/service-naming/schemas/v1/web.js b/packages/dd-trace/src/service-naming/schemas/v1/web.js new file mode 100644 index 00000000000..15d86ae7463 --- /dev/null +++ b/packages/dd-trace/src/service-naming/schemas/v1/web.js @@ -0,0 +1,18 @@ +const { identityService } = require('../util') + +const web = { + client: { + moleculer: { + opName: () => 'moleculer.client.request', + serviceName: identityService + } + }, + server: { + moleculer: { + opName: () => 'moleculer.server.request', + serviceName: identityService + } + } +} + +module.exports = web diff --git a/packages/dd-trace/src/telemetry/index.js b/packages/dd-trace/src/telemetry/index.js index 20705f49efc..ce54f140dec 100644 --- a/packages/dd-trace/src/telemetry/index.js +++ b/packages/dd-trace/src/telemetry/index.js @@ -6,6 +6,8 @@ const os = require('os') const dependencies = require('./dependencies') const { sendData } = require('./send-data') +const { manager: metricsManager } = require('./metrics') + const telemetryStartChannel = dc.channel('datadog:telemetry:start') const telemetryStopChannel = dc.channel('datadog:telemetry:stop') @@ -121,6 +123,7 @@ function start (aConfig, thePluginManager) { dependencies.start(config, application, host) sendData(config, application, host, 'app-started', appStarted()) interval = setInterval(() => { + metricsManager.send(config, application, host) sendData(config, application, host, 'app-heartbeat') }, heartbeatInterval) interval.unref() diff --git a/packages/dd-trace/src/telemetry/metrics.js b/packages/dd-trace/src/telemetry/metrics.js new file mode 100644 index 00000000000..9669bf9ba82 --- /dev/null +++ b/packages/dd-trace/src/telemetry/metrics.js @@ -0,0 +1,225 @@ +'use strict' + +const { version } = require('../../../../package.json') + +const { sendData } = require('./send-data') + +function getId (type, namespace, name, tags) { + return `${type}:${namespace}.${name}:${tagArray(tags).sort().join(',')}` +} + +function tagArray (tags = {}) { + if (Array.isArray(tags)) return tags + const list = [] + for (const [key, value] of Object.entries(tags)) { + list.push(`${key}:${value}`.toLowerCase()) + } + return list +} + +function now () { + return Date.now() / 1e3 +} + +function mapToJsonArray (map) { + return Array.from(map.values()).map(v => v.toJSON()) +} + +class Metric { + constructor (namespace, metric, common, tags) { + this.namespace = namespace.toString() + this.metric = common ? metric : `nodejs.${metric}` + this.tags = tagArray(tags) + if (common) { + this.tags.push('lib_language:nodejs') + this.tags.push(`version:${process.version}`) + } else { + this.tags.push(`lib_version:${version}`) + } + this.common = common + + this.points = [] + } + + toString () { + const { namespace, metric } = this + return `${namespace}.${metric}` + } + + reset () { + this.points = [] + } + + track () { + throw new Error('not implemented') + } + + toJSON () { + const { metric, points, interval, type, tags, common } = this + return { + metric, + points, + interval, + type, + tags, + common + } + } +} + +class CountMetric extends Metric { + get type () { + return 'count' + } + + inc (value) { + return this.track(value) + } + + dec (value = -1) { + return this.track(value) + } + + track (value = 1) { + if (this.points.length) { + this.points[0][1] += value + } else { + this.points.push([now(), value]) + } + } +} + +class GaugeMetric extends Metric { + get type () { + return 'gauge' + } + + mark (value) { + return this.track(value) + } + + track (value = 1) { + this.points.push([now(), value]) + } +} + +class RateMetric extends Metric { + constructor (namespace, metric, common, tags, interval) { + super(namespace, metric, common, tags) + + this.interval = interval + this.rate = 0 + } + + get type () { + return 'rate' + } + + reset () { + super.reset() + this.rate = 0 + } + + track (value = 1) { + this.rate += value + const rate = this.interval ? (this.rate / this.interval) : 0.0 + this.points = [[now(), rate]] + } +} + +const metricsTypes = { + count: CountMetric, + gauge: GaugeMetric, + rate: RateMetric +} + +class Namespace extends Map { + constructor (namespace) { + super() + this.namespace = namespace + } + + reset () { + for (const metric of this.values()) { + metric.reset() + } + } + + toString () { + return `dd.instrumentation_telemetry_data.${this.namespace}` + } + + getMetric (type, name, tags, interval) { + const metricId = getId(type, this, name, tags) + + let metric = this.get(metricId) + if (metric) return metric + + const Factory = metricsTypes[type] + if (!Factory) { + throw new Error(`Unknown metric type ${type}`) + } + + metric = new Factory(this, name, true, tags, interval) + this.set(metricId, metric) + + return metric + } + + count (name, tags) { + return this.getMetric('count', name, tags) + } + + gauge (name, tags) { + return this.getMetric('gauge', name, tags) + } + + rate (name, interval, tags) { + return this.getMetric('rate', name, tags, interval) + } + + toJSON () { + const { namespace } = this + return { + namespace, + series: mapToJsonArray(this) + } + } +} + +class NamespaceManager extends Map { + namespace (name) { + let ns = this.get(name) + if (ns) return ns + + ns = new Namespace(name) + this.set(name, ns) + return ns + } + + toJSON () { + return mapToJsonArray(this) + } + + send (config, application, host) { + for (const namespace of this.values()) { + sendData(config, application, host, 'generate-metrics', namespace.toJSON()) + + // TODO: This could also be clear() but then it'd have to rebuild all + // metric instances on every send. This may be desirable if we want tags + // with high cardinality and variability over time. + namespace.reset() + } + } +} + +const manager = new NamespaceManager() + +module.exports = { + CountMetric, + GaugeMetric, + RateMetric, + Namespace, + NamespaceManager, + manager +} diff --git a/packages/dd-trace/test/appsec/iast/taint-tracking/plugin.spec.js b/packages/dd-trace/test/appsec/iast/taint-tracking/plugin.spec.js index aae7db2ae24..a2a2a087449 100644 --- a/packages/dd-trace/test/appsec/iast/taint-tracking/plugin.spec.js +++ b/packages/dd-trace/test/appsec/iast/taint-tracking/plugin.spec.js @@ -6,13 +6,15 @@ const taintTrackingOperations = require('../../../../src/appsec/iast/taint-track const dc = require('../../../../../diagnostics_channel') const { HTTP_REQUEST_COOKIE_VALUE, - HTTP_REQUEST_COOKIE_NAME + HTTP_REQUEST_COOKIE_NAME, + HTTP_REQUEST_PATH_PARAM } = require('../../../../src/appsec/iast/taint-tracking/origin-types') const middlewareNextChannel = dc.channel('apm:express:middleware:next') const queryParseFinishChannel = dc.channel('datadog:qs:parse:finish') const bodyParserFinishChannel = dc.channel('datadog:body-parser:read:finish') const cookieParseFinishCh = dc.channel('datadog:cookie:parse:finish') +const processParamsStartCh = dc.channel('datadog:express:process_params:start') describe('IAST Taint tracking plugin', () => { let taintTrackingPlugin @@ -38,12 +40,13 @@ describe('IAST Taint tracking plugin', () => { sinon.restore() }) - it('Should subscribe to body parser, qs and cookie channel', () => { - expect(taintTrackingPlugin._subscriptions).to.have.lengthOf(4) + it('Should subscribe to body parser, qs, cookie and process_params channel', () => { + expect(taintTrackingPlugin._subscriptions).to.have.lengthOf(5) expect(taintTrackingPlugin._subscriptions[0]._channel.name).to.equals('datadog:body-parser:read:finish') expect(taintTrackingPlugin._subscriptions[1]._channel.name).to.equals('datadog:qs:parse:finish') expect(taintTrackingPlugin._subscriptions[2]._channel.name).to.equals('apm:express:middleware:next') expect(taintTrackingPlugin._subscriptions[3]._channel.name).to.equals('datadog:cookie:parse:finish') + expect(taintTrackingPlugin._subscriptions[4]._channel.name).to.equals('datadog:express:process_params:start') }) describe('taint sources', () => { @@ -202,5 +205,27 @@ describe('IAST Taint tracking plugin', () => { HTTP_REQUEST_COOKIE_NAME ) }) + + it('Should taint request params when process params event is published', () => { + const req = { + params: { + parameter1: 'tainted1' + } + } + + processParamsStartCh.publish({ req }) + expect(taintTrackingOperations.taintObject).to.be.calledOnceWith( + iastContext, + req.params, + HTTP_REQUEST_PATH_PARAM + ) + }) + + it('Should not taint request params when process params event is published with non params request', () => { + const req = {} + + processParamsStartCh.publish({ req }) + expect(taintTrackingOperations.taintObject).to.not.be.called + }) }) }) diff --git a/packages/dd-trace/test/appsec/iast/taint-tracking/sources/taint-tracking.express.plugin.spec.js b/packages/dd-trace/test/appsec/iast/taint-tracking/sources/taint-tracking.express.plugin.spec.js new file mode 100644 index 00000000000..d3a5714f631 --- /dev/null +++ b/packages/dd-trace/test/appsec/iast/taint-tracking/sources/taint-tracking.express.plugin.spec.js @@ -0,0 +1,169 @@ +'use strict' + +const axios = require('axios') +const getPort = require('get-port') +const semver = require('semver') +const agent = require('../../../../plugins/agent') +const Config = require('../../../../../src/config') +const { storage } = require('../../../../../../datadog-core') +const iast = require('../../../../../src/appsec/iast') +const iastContextFunctions = require('../../../../../src/appsec/iast/iast-context') +const { isTainted, getRanges } = require('../../../../../src/appsec/iast/taint-tracking/operations') +const { HTTP_REQUEST_PATH_PARAM } = require('../../../../../src/appsec/iast/taint-tracking/origin-types') + +describe('Path params sourcing with express', () => { + let express + let expressVersion + let appListener + + withVersions('express', 'express', version => { + const checkParamIsTaintedAndNext = (req, res, next, param) => { + const store = storage.getStore() + const iastContext = iastContextFunctions.getIastContext(store) + + const pathParamValue = param + const isParameterTainted = isTainted(iastContext, pathParamValue) + expect(isParameterTainted).to.be.true + const taintedParameterValueRanges = getRanges(iastContext, pathParamValue) + expect(taintedParameterValueRanges[0].iinfo.type).to.be.equal(HTTP_REQUEST_PATH_PARAM) + + next() + } + + before(() => { + return agent.load(['http', 'express'], { client: false }) + }) + + after(() => { + return agent.close({ ritmReset: false }) + }) + + beforeEach(() => { + iast.enable(new Config({ + experimental: { + iast: { + enabled: true, + requestSampling: 100 + } + } + })) + + const expressRequire = require(`../../../../../../../versions/express@${version}`) + express = expressRequire.get() + expressVersion = expressRequire.version() + }) + + afterEach(() => { + appListener && appListener.close() + appListener = null + + iast.disable() + }) + + it('should taint path params', function (done) { + const app = express() + app.get('/:parameter1/:parameter2', (req, res) => { + const store = storage.getStore() + const iastContext = iastContextFunctions.getIastContext(store) + + for (const pathParamName of ['parameter1', 'parameter2']) { + const pathParamValue = req.params[pathParamName] + const isParameterTainted = isTainted(iastContext, pathParamValue) + expect(isParameterTainted).to.be.true + const taintedParameterValueRanges = getRanges(iastContext, pathParamValue) + expect(taintedParameterValueRanges[0].iinfo.type).to.be.equal(HTTP_REQUEST_PATH_PARAM) + } + + res.status(200).send() + }) + + getPort().then(port => { + appListener = app.listen(port, 'localhost', () => { + axios + .get(`http://localhost:${port}/tainted1/tainted2`) + .then(() => done()) + .catch(done) + }) + }) + }) + + it('should taint path params in nested routers with merged params', function (done) { + if (!semver.satisfies(expressVersion, '>4.5.0')) { + this.skip() + } + + const app = express() + const nestedRouter = express.Router({ mergeParams: true }) + + nestedRouter.get('/:parameterChild', (req, res) => { + const store = storage.getStore() + const iastContext = iastContextFunctions.getIastContext(store) + + for (const pathParamName of ['parameterParent', 'parameterChild']) { + const pathParamValue = req.params[pathParamName] + const isParameterTainted = isTainted(iastContext, pathParamValue) + expect(isParameterTainted).to.be.true + const taintedParameterValueRanges = getRanges(iastContext, pathParamValue) + expect(taintedParameterValueRanges[0].iinfo.type).to.be.equal(HTTP_REQUEST_PATH_PARAM) + } + + res.status(200).send() + }) + + app.use('/:parameterParent', nestedRouter) + + getPort().then(port => { + appListener = app.listen(port, 'localhost', () => { + axios + .get(`http://localhost:${port}/tainted1/tainted2`) + .then(() => done()) + .catch(done) + }) + }) + }) + + it('should taint path param on router.params callback', function (done) { + const app = express() + + app.use('/:parameter1/:parameter2', (req, res) => { + res.status(200).send() + }) + + app.param('parameter1', checkParamIsTaintedAndNext) + app.param('parameter2', checkParamIsTaintedAndNext) + + getPort().then(port => { + appListener = app.listen(port, 'localhost', () => { + axios + .get(`http://localhost:${port}/tainted1/tainted2`) + .then(() => done()) + .catch(done) + }) + }) + }) + + it('should taint path param on router.params callback with custom implementation', function (done) { + const app = express() + + app.use('/:parameter1/:parameter2', (req, res) => { + res.status(200).send() + }) + + app.param((param, option) => { + return checkParamIsTaintedAndNext + }) + + app.param('parameter1') + app.param('parameter2') + + getPort().then(port => { + appListener = app.listen(port, 'localhost', () => { + axios + .get(`http://localhost:${port}/tainted1/tainted2`) + .then(() => done()) + .catch(done) + }) + }) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/index.spec.js b/packages/dd-trace/test/appsec/index.spec.js index 035397c423c..d186d5e21cb 100644 --- a/packages/dd-trace/test/appsec/index.spec.js +++ b/packages/dd-trace/test/appsec/index.spec.js @@ -1,7 +1,6 @@ 'use strict' const proxyquire = require('proxyquire') -const log = require('../../src/log') const waf = require('../../src/appsec/waf') const RuleManager = require('../../src/appsec/rule_manager') const appsec = require('../../src/appsec') @@ -9,7 +8,8 @@ const { incomingHttpRequestStart, incomingHttpRequestEnd, bodyParser, - queryParser + queryParser, + passportVerify } = require('../../src/appsec/channels') const Reporter = require('../../src/appsec/reporter') const agent = require('../plugins/agent') @@ -17,12 +17,15 @@ const Config = require('../../src/config') const axios = require('axios') const getPort = require('get-port') const blockedTemplate = require('../../src/appsec/blocked_templates') +const { storage } = require('../../../datadog-core') describe('AppSec Index', () => { let config let AppSec let web let blocking + let passport + let log const RULES = { rules: [{ a: 1 }] } @@ -36,7 +39,11 @@ describe('AppSec Index', () => { obfuscatorKeyRegex: '.*', obfuscatorValueRegex: '.*', blockedTemplateHtml: blockedTemplate.html, - blockedTemplateJson: blockedTemplate.json + blockedTemplateJson: blockedTemplate.json, + eventTracking: { + enabled: true, + mode: 'safe' + } } } @@ -48,9 +55,21 @@ describe('AppSec Index', () => { setTemplates: sinon.stub() } + passport = { + passportTrackEvent: sinon.stub() + } + + log = { + debug: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub() + } + AppSec = proxyquire('../../src/appsec', { + '../log': log, '../plugins/util/web': web, - './blocking': blocking + './blocking': blocking, + './passport': passport }) sinon.stub(waf, 'init').callThrough() @@ -79,7 +98,6 @@ describe('AppSec Index', () => { }) it('should log when enable fails', () => { - sinon.stub(log, 'error') RuleManager.applyRules.restore() const err = new Error('Invalid Rules') @@ -97,11 +115,22 @@ describe('AppSec Index', () => { it('should subscribe to blockable channels', () => { expect(bodyParser.hasSubscribers).to.be.false expect(queryParser.hasSubscribers).to.be.false + expect(passportVerify.hasSubscribers).to.be.false AppSec.enable(config) expect(bodyParser.hasSubscribers).to.be.true expect(queryParser.hasSubscribers).to.be.true + expect(passportVerify.hasSubscribers).to.be.true + }) + + it('should not subscribe to passportVerify if eventTracking is disabled', () => { + config.appsec.eventTracking.enabled = false + + AppSec.disable() + AppSec.enable(config) + + expect(passportVerify.hasSubscribers).to.be.false }) }) @@ -142,6 +171,7 @@ describe('AppSec Index', () => { expect(bodyParser.hasSubscribers).to.be.false expect(queryParser.hasSubscribers).to.be.false + expect(passportVerify.hasSubscribers).to.be.false }) }) @@ -333,7 +363,7 @@ describe('AppSec Index', () => { }) }) - describe('checkRequestData', () => { + describe('Channel handlers', () => { let abortController, req, res, rootSpan beforeEach(() => { @@ -448,6 +478,35 @@ describe('AppSec Index', () => { expect(res.end).to.have.been.called }) }) + + describe('onPassportVerify', () => { + it('Should call passportTrackEvent', () => { + const credentials = { type: 'local', username: 'test' } + const user = { id: '1234', username: 'Test' } + + sinon.stub(storage, 'getStore').returns({ req: {} }) + + passportVerify.publish({ credentials, user }) + + expect(passport.passportTrackEvent).to.have.been.calledOnceWithExactly( + credentials, + user, + rootSpan, + config.appsec.eventTracking.mode) + }) + + it('Should call log if no rootSpan is found', () => { + const credentials = { type: 'local', username: 'test' } + const user = { id: '1234', username: 'Test' } + + sinon.stub(storage, 'getStore').returns(undefined) + + passportVerify.publish({ credentials, user }) + + expect(log.warn).to.have.been.calledOnceWithExactly('No rootSpan found in onPassportVerify') + expect(passport.passportTrackEvent).not.to.have.been.called + }) + }) }) }) diff --git a/packages/dd-trace/test/appsec/passport.spec.js b/packages/dd-trace/test/appsec/passport.spec.js new file mode 100644 index 00000000000..9d1ad8a0e00 --- /dev/null +++ b/packages/dd-trace/test/appsec/passport.spec.js @@ -0,0 +1,244 @@ +'use strict' + +const proxyquire = require('proxyquire') + +describe('Passport', () => { + const rootSpan = { + context: () => { return {} } + } + const loginLocal = { type: 'local', username: 'test' } + const userUuid = { + id: '591dc126-8431-4d0f-9509-b23318d3dce4', + email: 'testUser@test.com', + username: 'Test User' + } + + let passportModule, log, events, setUser + beforeEach(() => { + rootSpan.context = () => { return {} } + + log = { + warn: sinon.stub() + } + + events = { + trackEvent: sinon.stub() + } + + setUser = { + setUserTags: sinon.stub() + } + + passportModule = proxyquire('../../src/appsec/passport', { + '../log': log, + './sdk/track_event': events, + './sdk/set_user': setUser + }) + }) + + describe('passportTrackEvent', () => { + it('should call log when credentials is undefined', () => { + passportModule.passportTrackEvent(undefined, undefined, undefined, 'safe') + + expect(log.warn).to.have.been.calledOnceWithExactly('No user ID found in authentication instrumentation') + }) + + it('should call log when type is not known', () => { + const credentials = { type: 'unknown', username: 'test' } + + passportModule.passportTrackEvent(credentials, undefined, undefined, 'safe') + + expect(log.warn).to.have.been.calledOnceWithExactly('No user ID found in authentication instrumentation') + }) + + it('should call log when type is known but username not present', () => { + const credentials = { type: 'http' } + + passportModule.passportTrackEvent(credentials, undefined, undefined, 'safe') + + expect(log.warn).to.have.been.calledOnceWithExactly('No user ID found in authentication instrumentation') + }) + + it('should report login failure when passportUser is not present', () => { + passportModule.passportTrackEvent(loginLocal, undefined, undefined, 'safe') + + expect(setUser.setUserTags).not.to.have.been.called + expect(events.trackEvent).to.have.been.calledOnceWithExactly( + 'users.login.failure', + { 'usr.id': ' ' }, + 'passportTrackEvent', + undefined, + 'safe' + ) + }) + + it('should report login success when passportUser is present', () => { + passportModule.passportTrackEvent(loginLocal, userUuid, rootSpan, 'safe') + + expect(setUser.setUserTags).to.have.been.calledOnceWithExactly({ id: userUuid.id }, rootSpan) + expect(events.trackEvent).to.have.been.calledOnceWithExactly( + 'users.login.success', + null, + 'passportTrackEvent', + rootSpan, + 'safe' + ) + }) + + it('should report login success and blank id in safe mode when id is not a uuid', () => { + const user = { + id: 'publicName', + email: 'testUser@test.com', + username: 'Test User' + } + passportModule.passportTrackEvent(loginLocal, user, rootSpan, 'safe') + + expect(setUser.setUserTags).to.have.been.calledOnceWithExactly({ id: ' ' }, rootSpan) + expect(events.trackEvent).to.have.been.calledOnceWithExactly( + 'users.login.success', + null, + 'passportTrackEvent', + rootSpan, + 'safe' + ) + }) + + it('should report login success and the extended fields in extended mode', () => { + const user = { + id: 'publicName', + email: 'testUser@test.com', + username: 'Test User' + } + + passportModule.passportTrackEvent(loginLocal, user, rootSpan, 'extended') + expect(setUser.setUserTags).to.have.been.calledOnceWithExactly( + { + id: 'publicName', + login: 'test', + email: 'testUser@test.com', + username: 'Test User' + }, + rootSpan + ) + expect(events.trackEvent).to.have.been.calledOnceWithExactly( + 'users.login.success', + null, + 'passportTrackEvent', + rootSpan, + 'extended' + ) + }) + + it('should not call trackEvent in safe mode if sdk user event functions are already called', () => { + rootSpan.context = () => { + return { + _tags: { + '_dd.appsec.events.users.login.success.sdk': 'true' + } + } + } + + passportModule.passportTrackEvent(loginLocal, userUuid, rootSpan, 'safe') + expect(setUser.setUserTags).not.to.have.been.called + expect(events.trackEvent).not.to.have.been.called + }) + + it('should not call trackEvent in extended mode if trackUserLoginSuccessEvent is already called', () => { + rootSpan.context = () => { + return { + _tags: { + '_dd.appsec.events.users.login.success.sdk': 'true' + } + } + } + + passportModule.passportTrackEvent(loginLocal, userUuid, rootSpan, 'extended') + expect(setUser.setUserTags).not.to.have.been.called + expect(events.trackEvent).not.to.have.been.called + }) + + it('should call trackEvent in extended mode if trackCustomEvent function is already called', () => { + rootSpan.context = () => { + return { + _tags: { + '_dd.appsec.events.custom.event.sdk': 'true' + } + } + } + + passportModule.passportTrackEvent(loginLocal, userUuid, rootSpan, 'extended') + expect(events.trackEvent).to.have.been.calledOnceWithExactly( + 'users.login.success', + null, + 'passportTrackEvent', + rootSpan, + 'extended' + ) + }) + + it('should not call trackEvent in extended mode if trackUserLoginFailureEvent is already called', () => { + rootSpan.context = () => { + return { + _tags: { + '_dd.appsec.events.users.login.failure.sdk': 'true' + } + } + } + + passportModule.passportTrackEvent(loginLocal, userUuid, rootSpan, 'extended') + expect(setUser.setUserTags).not.to.have.been.called + expect(events.trackEvent).not.to.have.been.called + }) + + it('should report login success with the _id field', () => { + const user = { + _id: '591dc126-8431-4d0f-9509-b23318d3dce4', + email: 'testUser@test.com', + username: 'Test User' + } + + passportModule.passportTrackEvent(loginLocal, user, rootSpan, 'extended') + expect(setUser.setUserTags).to.have.been.calledOnceWithExactly( + { + id: '591dc126-8431-4d0f-9509-b23318d3dce4', + login: 'test', + email: 'testUser@test.com', + username: 'Test User' + }, + rootSpan + ) + expect(events.trackEvent).to.have.been.calledOnceWithExactly( + 'users.login.success', + null, + 'passportTrackEvent', + rootSpan, + 'extended' + ) + }) + + it('should report login success with the username field passport name', () => { + const user = { + email: 'testUser@test.com', + name: 'Test User' + } + + rootSpan.context = () => { return {} } + + passportModule.passportTrackEvent(loginLocal, user, rootSpan, 'extended') + expect(setUser.setUserTags).to.have.been.calledOnceWithExactly( + { + id: 'test', + login: 'test', + email: 'testUser@test.com', + username: 'Test User' + }, rootSpan) + expect(events.trackEvent).to.have.been.calledOnceWithExactly( + 'users.login.success', + null, + 'passportTrackEvent', + rootSpan, + 'extended' + ) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/sdk/track_event.spec.js b/packages/dd-trace/test/appsec/sdk/track_event.spec.js index d9b4059b8c1..93d8959783e 100644 --- a/packages/dd-trace/test/appsec/sdk/track_event.spec.js +++ b/packages/dd-trace/test/appsec/sdk/track_event.spec.js @@ -13,7 +13,7 @@ describe('track_event', () => { let rootSpan let getRootSpan let setUserTags - let trackUserLoginSuccessEvent, trackUserLoginFailureEvent, trackCustomEvent + let trackUserLoginSuccessEvent, trackUserLoginFailureEvent, trackCustomEvent, trackEvent beforeEach(() => { log = { @@ -28,7 +28,7 @@ describe('track_event', () => { setUserTags = sinon.stub() - const trackEvent = proxyquire('../../../src/appsec/sdk/track_event', { + const trackEvents = proxyquire('../../../src/appsec/sdk/track_event', { '../../log': log, './utils': { getRootSpan @@ -38,9 +38,10 @@ describe('track_event', () => { } }) - trackUserLoginSuccessEvent = trackEvent.trackUserLoginSuccessEvent - trackUserLoginFailureEvent = trackEvent.trackUserLoginFailureEvent - trackCustomEvent = trackEvent.trackCustomEvent + trackUserLoginSuccessEvent = trackEvents.trackUserLoginSuccessEvent + trackUserLoginFailureEvent = trackEvents.trackUserLoginFailureEvent + trackCustomEvent = trackEvents.trackCustomEvent + trackEvent = trackEvents.trackEvent }) describe('trackUserLoginSuccessEvent', () => { @@ -76,13 +77,15 @@ describe('track_event', () => { expect(log.warn).to.not.have.been.called expect(setUserTags).to.have.been.calledOnceWithExactly(user, rootSpan) - expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ - 'appsec.events.users.login.success.track': 'true', - 'appsec.events.users.login.success.metakey1': 'metaValue1', - 'appsec.events.users.login.success.metakey2': 'metaValue2', - 'appsec.events.users.login.success.metakey3': 'metaValue3', - 'manual.keep': 'true' - }) + expect(rootSpan.addTags).to.have.been.calledOnceWithExactly( + { + 'appsec.events.users.login.success.track': 'true', + 'manual.keep': 'true', + '_dd.appsec.events.users.login.success.sdk': 'true', + 'appsec.events.users.login.success.metakey1': 'metaValue1', + 'appsec.events.users.login.success.metakey2': 'metaValue2', + 'appsec.events.users.login.success.metakey3': 'metaValue3' + }) }) it('should call setUser and addTags without metadata', () => { @@ -94,7 +97,8 @@ describe('track_event', () => { expect(setUserTags).to.have.been.calledOnceWithExactly(user, rootSpan) expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ 'appsec.events.users.login.success.track': 'true', - 'manual.keep': 'true' + 'manual.keep': 'true', + '_dd.appsec.events.users.login.success.sdk': 'true' }) }) }) @@ -131,12 +135,13 @@ describe('track_event', () => { expect(setUserTags).to.not.have.been.called expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ 'appsec.events.users.login.failure.track': 'true', + 'manual.keep': 'true', + '_dd.appsec.events.users.login.failure.sdk': 'true', 'appsec.events.users.login.failure.usr.id': 'user_id', 'appsec.events.users.login.failure.usr.exists': 'true', 'appsec.events.users.login.failure.metakey1': 'metaValue1', 'appsec.events.users.login.failure.metakey2': 'metaValue2', - 'appsec.events.users.login.failure.metakey3': 'metaValue3', - 'manual.keep': 'true' + 'appsec.events.users.login.failure.metakey3': 'metaValue3' }) }) @@ -149,12 +154,13 @@ describe('track_event', () => { expect(setUserTags).to.not.have.been.called expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ 'appsec.events.users.login.failure.track': 'true', + 'manual.keep': 'true', + '_dd.appsec.events.users.login.failure.sdk': 'true', 'appsec.events.users.login.failure.usr.id': 'user_id', 'appsec.events.users.login.failure.usr.exists': 'false', 'appsec.events.users.login.failure.metakey1': 'metaValue1', 'appsec.events.users.login.failure.metakey2': 'metaValue2', - 'appsec.events.users.login.failure.metakey3': 'metaValue3', - 'manual.keep': 'true' + 'appsec.events.users.login.failure.metakey3': 'metaValue3' }) }) @@ -165,9 +171,10 @@ describe('track_event', () => { expect(setUserTags).to.not.have.been.called expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ 'appsec.events.users.login.failure.track': 'true', + 'manual.keep': 'true', + '_dd.appsec.events.users.login.failure.sdk': 'true', 'appsec.events.users.login.failure.usr.id': 'user_id', - 'appsec.events.users.login.failure.usr.exists': 'true', - 'manual.keep': 'true' + 'appsec.events.users.login.failure.usr.exists': 'true' }) }) }) @@ -200,9 +207,10 @@ describe('track_event', () => { expect(setUserTags).to.not.have.been.called expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ 'appsec.events.custom_event.track': 'true', + 'manual.keep': 'true', + '_dd.appsec.events.custom_event.sdk': 'true', 'appsec.events.custom_event.metaKey1': 'metaValue1', - 'appsec.events.custom_event.metakey2': 'metaValue2', - 'manual.keep': 'true' + 'appsec.events.custom_event.metakey2': 'metaValue2' }) }) @@ -213,7 +221,32 @@ describe('track_event', () => { expect(setUserTags).to.not.have.been.called expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ 'appsec.events.custom_event.track': 'true', - 'manual.keep': 'true' + 'manual.keep': 'true', + '_dd.appsec.events.custom_event.sdk': 'true' + }) + }) + }) + + describe('trackEvent', () => { + it('should call addTags with safe mode', () => { + trackEvent('event', { metaKey1: 'metaValue1', metakey2: 'metaValue2' }, 'trackEvent', rootSpan, 'safe') + expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ + 'appsec.events.event.track': 'true', + 'manual.keep': 'true', + '_dd.appsec.events.event.auto.mode': 'safe', + 'appsec.events.event.metaKey1': 'metaValue1', + 'appsec.events.event.metakey2': 'metaValue2' + }) + }) + + it('should call addTags with extended mode', () => { + trackEvent('event', { metaKey1: 'metaValue1', metakey2: 'metaValue2' }, 'trackEvent', rootSpan, 'extended') + expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ + 'appsec.events.event.track': 'true', + 'manual.keep': 'true', + '_dd.appsec.events.event.auto.mode': 'extended', + 'appsec.events.event.metaKey1': 'metaValue1', + 'appsec.events.event.metakey2': 'metaValue2' }) }) }) diff --git a/packages/dd-trace/test/config.spec.js b/packages/dd-trace/test/config.spec.js index ca1cb5384f6..94481df6146 100644 --- a/packages/dd-trace/test/config.spec.js +++ b/packages/dd-trace/test/config.spec.js @@ -92,6 +92,7 @@ describe('Config', () => { expect(config).to.have.property('traceId128BitGenerationEnabled', false) expect(config).to.have.property('traceId128BitLoggingEnabled', false) expect(config).to.have.property('spanAttributeSchema', 'v0') + expect(config).to.have.property('spanComputePeerService', false) expect(config).to.have.deep.property('serviceMapping', {}) expect(config).to.have.nested.deep.property('tracePropagationStyle.inject', ['tracecontext', 'datadog']) expect(config).to.have.nested.deep.property('tracePropagationStyle.extract', ['tracecontext', 'datadog']) @@ -107,6 +108,8 @@ describe('Config', () => { expect(config).to.have.nested.property('appsec.obfuscatorValueRegex').with.length(443) expect(config).to.have.nested.property('appsec.blockedTemplateHtml', undefined) expect(config).to.have.nested.property('appsec.blockedTemplateJson', undefined) + expect(config).to.have.nested.property('appsec.eventTracking.enabled', true) + expect(config).to.have.nested.property('appsec.eventTracking.mode', 'safe') expect(config).to.have.nested.property('remoteConfig.enabled', true) expect(config).to.have.nested.property('remoteConfig.pollInterval', 5) expect(config).to.have.nested.property('iast.enabled', false) @@ -197,6 +200,7 @@ describe('Config', () => { process.env.DD_APPSEC_OBFUSCATION_PARAMETER_VALUE_REGEXP = '.*' process.env.DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML = BLOCKED_TEMPLATE_HTML_PATH process.env.DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON = BLOCKED_TEMPLATE_JSON_PATH + process.env.DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING = 'extended' process.env.DD_REMOTE_CONFIGURATION_ENABLED = 'false' process.env.DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS = '42' process.env.DD_IAST_ENABLED = 'true' @@ -264,6 +268,8 @@ describe('Config', () => { expect(config).to.have.nested.property('appsec.obfuscatorValueRegex', '.*') expect(config).to.have.nested.property('appsec.blockedTemplateHtml', BLOCKED_TEMPLATE_HTML) expect(config).to.have.nested.property('appsec.blockedTemplateJson', BLOCKED_TEMPLATE_JSON) + expect(config).to.have.nested.property('appsec.eventTracking.enabled', true) + expect(config).to.have.nested.property('appsec.eventTracking.mode', 'extended') expect(config).to.have.nested.property('remoteConfig.enabled', false) expect(config).to.have.nested.property('remoteConfig.pollInterval', 42) expect(config).to.have.nested.property('iast.enabled', true) @@ -572,6 +578,7 @@ describe('Config', () => { process.env.DD_APPSEC_OBFUSCATION_PARAMETER_VALUE_REGEXP = '^$' process.env.DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML = BLOCKED_TEMPLATE_JSON // note the inversion between process.env.DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON = BLOCKED_TEMPLATE_HTML // json and html here + process.env.DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING = 'disabled' process.env.DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS = 11 process.env.DD_IAST_ENABLED = 'false' process.env.DD_TRACE_128_BIT_TRACEID_GENERATION_ENABLED = 'true' @@ -622,7 +629,10 @@ describe('Config', () => { obfuscatorKeyRegex: '.*', obfuscatorValueRegex: '.*', blockedTemplateHtml: BLOCKED_TEMPLATE_HTML_PATH, - blockedTemplateJson: BLOCKED_TEMPLATE_JSON_PATH + blockedTemplateJson: BLOCKED_TEMPLATE_JSON_PATH, + eventTracking: { + mode: 'safe' + } }, remoteConfig: { pollInterval: 42 @@ -665,6 +675,8 @@ describe('Config', () => { expect(config).to.have.nested.property('appsec.obfuscatorValueRegex', '.*') expect(config).to.have.nested.property('appsec.blockedTemplateHtml', BLOCKED_TEMPLATE_HTML) expect(config).to.have.nested.property('appsec.blockedTemplateJson', BLOCKED_TEMPLATE_JSON) + expect(config).to.have.nested.property('appsec.eventTracking.enabled', true) + expect(config).to.have.nested.property('appsec.eventTracking.mode', 'safe') expect(config).to.have.nested.property('remoteConfig.pollInterval', 42) expect(config).to.have.nested.property('iast.enabled', true) expect(config).to.have.nested.property('iast.requestSampling', 30) @@ -684,7 +696,10 @@ describe('Config', () => { obfuscatorKeyRegex: '.*', obfuscatorValueRegex: '.*', blockedTemplateHtml: undefined, - blockedTemplateJson: undefined + blockedTemplateJson: undefined, + eventTracking: { + mode: 'disabled' + } }, experimental: { appsec: { @@ -695,7 +710,10 @@ describe('Config', () => { obfuscatorKeyRegex: '^$', obfuscatorValueRegex: '^$', blockedTemplateHtml: BLOCKED_TEMPLATE_HTML_PATH, - blockedTemplateJson: BLOCKED_TEMPLATE_JSON_PATH + blockedTemplateJson: BLOCKED_TEMPLATE_JSON_PATH, + eventTracking: { + mode: 'safe' + } } } }) @@ -709,7 +727,11 @@ describe('Config', () => { obfuscatorKeyRegex: '.*', obfuscatorValueRegex: '.*', blockedTemplateHtml: undefined, - blockedTemplateJson: undefined + blockedTemplateJson: undefined, + eventTracking: { + enabled: false, + mode: 'disabled' + } }) }) @@ -1058,6 +1080,7 @@ describe('Config', () => { beforeEach(() => { delete process.env.DD_CIVISIBILITY_ITR_ENABLED delete process.env.DD_CIVISIBILITY_GIT_UPLOAD_ENABLED + delete process.env.DD_CIVISIBILITY_MANUAL_API_ENABLED options = {} }) context('ci visibility mode is enabled', () => { @@ -1082,6 +1105,15 @@ describe('Config', () => { const config = new Config(options) expect(config).to.have.property('isIntelligentTestRunnerEnabled', false) }) + it('should disable manual testing API by default', () => { + const config = new Config(options) + expect(config).to.have.property('isManualApiEnabled', false) + }) + it('should enable manual testing API if DD_CIVISIBILITY_MANUAL_API_ENABLED is passed', () => { + process.env.DD_CIVISIBILITY_MANUAL_API_ENABLED = 'true' + const config = new Config(options) + expect(config).to.have.property('isManualApiEnabled', true) + }) }) context('ci visibility mode is not enabled', () => { it('should not activate intelligent test runner or git metadata upload', () => { diff --git a/packages/dd-trace/test/opentracing/tracer.spec.js b/packages/dd-trace/test/opentracing/tracer.spec.js index d2b96e5729d..0c3fb37fbf3 100644 --- a/packages/dd-trace/test/opentracing/tracer.spec.js +++ b/packages/dd-trace/test/opentracing/tracer.spec.js @@ -119,7 +119,8 @@ describe('Tracer', () => { }, startTime: fields.startTime, hostname: undefined, - traceId128BitGenerationEnabled: undefined + traceId128BitGenerationEnabled: undefined, + integrationName: undefined }, true) expect(span.addTags).to.have.been.calledWith({ @@ -176,7 +177,8 @@ describe('Tracer', () => { }, startTime: fields.startTime, hostname: os.hostname(), - traceId128BitGenerationEnabled: undefined + traceId128BitGenerationEnabled: undefined, + integrationName: undefined }) expect(testSpan).to.equal(span) @@ -246,7 +248,8 @@ describe('Tracer', () => { }, startTime: fields.startTime, hostname: undefined, - traceId128BitGenerationEnabled: true + traceId128BitGenerationEnabled: true, + integrationName: undefined }) expect(testSpan).to.equal(span) diff --git a/packages/dd-trace/test/plugins/externals.json b/packages/dd-trace/test/plugins/externals.json index 391fb5733f5..9fce297df9d 100644 --- a/packages/dd-trace/test/plugins/externals.json +++ b/packages/dd-trace/test/plugins/externals.json @@ -170,6 +170,27 @@ "name": "react-dom", "dep": true } + ], + "passport-http": [ + { + "name": "passport", + "versions": [">=0.4.1"] + }, + { + "name": "express", + "versions": [">=4.16.2"] + } + ], + "passport-local": [ + { + "name": "passport", + "versions": [">=0.4.1"] + }, + { + "name": "express", + "versions": [">=4.16.2"] + } + ], "pg": [ { diff --git a/packages/dd-trace/test/plugins/outbound.spec.js b/packages/dd-trace/test/plugins/outbound.spec.js new file mode 100644 index 00000000000..2967ce2aa32 --- /dev/null +++ b/packages/dd-trace/test/plugins/outbound.spec.js @@ -0,0 +1,59 @@ +'use strict' + +require('../setup/tap') + +const OutboundPlugin = require('../../src/plugins/outbound') + +describe('OuboundPlugin', () => { + describe('peer.service computation', () => { + let instance = null + + before(() => { + instance = new OutboundPlugin() + }) + + it('should not set tags if no precursor tags are available', () => { + const res = instance.getPeerService({ + fooIsNotAPrecursor: 'bar' + }) + expect(res).to.deep.equal({}) + }) + + it('should grab from remote host in datadog format', () => { + const res = instance.getPeerService({ + fooIsNotAPrecursor: 'bar', + 'out.host': 'mypeerservice' + }) + expect(res).to.deep.equal({ + 'peer.service': 'mypeerservice', + '_dd.peer.service.source': 'out.host' + }) + }) + + it('should grab from remote host in OTel format', () => { + const res = instance.getPeerService({ + fooIsNotAPrecursor: 'bar', + 'net.peer.name': 'mypeerservice' + }) + expect(res).to.deep.equal({ + 'peer.service': 'mypeerservice', + '_dd.peer.service.source': 'net.peer.name' + }) + }) + + it('should use specific tags in order of precedence if they are available', () => { + class WithPrecursors extends OutboundPlugin { + static get peerServicePrecursors () { return [ 'foo', 'bar' ] } + } + const res = new WithPrecursors().getPeerService({ + fooIsNotAPrecursor: 'bar', + bar: 'barPeerService', + foo: 'fooPeerService' + }) + expect(res).to.deep.equal({ + 'peer.service': 'fooPeerService', + '_dd.peer.service.source': 'foo' + }) + }) + }) +}) diff --git a/packages/dd-trace/test/profiling/config.spec.js b/packages/dd-trace/test/profiling/config.spec.js index 446b20492d8..209c7540838 100644 --- a/packages/dd-trace/test/profiling/config.spec.js +++ b/packages/dd-trace/test/profiling/config.spec.js @@ -145,17 +145,6 @@ describe('config', () => { expect(exporterUrl).to.equal(expectedUrl) }) - it('should disable OOM heap profiler by default', () => { - const config = new Config() - expect(config.oomMonitoring).to.deep.equal({ - enabled: false, - heapLimitExtensionSize: 0, - maxHeapExtensionCount: 0, - exportStrategies: [], - exportCommand: undefined - }) - }) - it('should support OOM heap profiler configuration', () => { process.env = { DD_PROFILING_EXPERIMENTAL_OOM_MONITORING_ENABLED: 'false' @@ -171,10 +160,7 @@ describe('config', () => { }) }) - it('should use process as default strategy for OOM heap profiler', () => { - process.env = { - DD_PROFILING_EXPERIMENTAL_OOM_MONITORING_ENABLED: 'true' - } + it('should enable OOM heap profiler by default and use process as default strategy', () => { const config = new Config() expect(config.oomMonitoring).to.deep.equal({ diff --git a/packages/dd-trace/test/setup/mocha.js b/packages/dd-trace/test/setup/mocha.js index 1ee96737665..f85e57faf9b 100644 --- a/packages/dd-trace/test/setup/mocha.js +++ b/packages/dd-trace/test/setup/mocha.js @@ -9,13 +9,14 @@ const externals = require('../plugins/externals.json') const slackReport = require('./slack-report') const metrics = require('../../src/metrics') const agent = require('../plugins/agent') -const Nomenclature = require('../../../dd-trace/src/service-naming') +const Nomenclature = require('../../src/service-naming') const { storage } = require('../../../datadog-core') const { schemaDefinitions } = require('../../src/service-naming/schemas') global.withVersions = withVersions global.withExports = withExports global.withNamingSchema = withNamingSchema +global.withPeerService = withPeerService const packageVersionFailures = Object.create({}) @@ -81,6 +82,33 @@ function withNamingSchema (spanProducerFn, expectedOpName, expectedServiceName) }) } +function withPeerService (tracer, spanGenerationFn, service, serviceSource) { + describe('peer service computation', () => { + let computePeerServiceSpy + beforeEach(() => { + // FIXME: workaround due to the evaluation order of mocha beforeEach + const tracerObj = typeof tracer === 'function' ? tracer() : tracer + computePeerServiceSpy = sinon.stub(tracerObj._tracer, '_computePeerService').value(true) + }) + afterEach(() => { + computePeerServiceSpy.restore() + }) + + it('should compute peer service', done => { + agent + .use(traces => { + const span = traces[0][0] + expect(span.meta).to.have.property('peer.service', service) + expect(span.meta).to.have.property('_dd.peer.service.source', serviceSource) + }) + .then(done) + .catch(done) + + spanGenerationFn(done) + }) + }) +} + function withVersions (plugin, modules, range, cb) { const instrumentations = typeof plugin === 'string' ? loadInst(plugin) : [].concat(plugin) const names = instrumentations.map(instrumentation => instrumentation.name) diff --git a/packages/dd-trace/test/telemetry/metrics.spec.js b/packages/dd-trace/test/telemetry/metrics.spec.js new file mode 100644 index 00000000000..81c9faf7616 --- /dev/null +++ b/packages/dd-trace/test/telemetry/metrics.spec.js @@ -0,0 +1,531 @@ +'use strict' + +require('../setup/tap') + +const proxyquire = require('proxyquire') + +describe('metrics', () => { + let metrics + let sendData + let now + + beforeEach(() => { + now = Date.now() + sinon.stub(Date, 'now').returns(now) + + sendData = sinon.stub() + metrics = proxyquire('../../src/telemetry/metrics', { + './send-data': { + sendData + } + }) + }) + + afterEach(() => { + Date.now.restore() + }) + + describe('NamespaceManager', () => { + it('should export singleton manager', () => { + expect(metrics.manager).to.be.instanceOf(metrics.NamespaceManager) + }) + + it('should make namespaces', () => { + const manager = new metrics.NamespaceManager() + const ns = manager.namespace('test') + expect(ns).to.be.instanceOf(metrics.Namespace) + expect(ns.namespace).to.equal('test') + expect(ns.toString()).to.equal('dd.instrumentation_telemetry_data.test') + }) + + it('should reuse namespace instances with the same name', () => { + const manager = new metrics.NamespaceManager() + const ns = manager.namespace('test') + expect(manager.namespace('test')).to.equal(ns) + }) + + it('should convert to json', () => { + const manager = new metrics.NamespaceManager() + + const test1 = manager.namespace('test1') + const test2 = manager.namespace('test2') + + test1.count('metric1', { bar: 'baz' }).inc() + test2.count('metric2', { bux: 'bax' }).inc() + + expect(manager.toJSON()).to.deep.equal([ + { + namespace: 'test1', + series: [ + { + metric: 'metric1', + points: [[now / 1e3, 1]], + interval: undefined, + type: 'count', + tags: [ + 'bar:baz', + 'lib_language:nodejs', + `version:${process.version}` + ], + common: true + } + ] + }, + { + namespace: 'test2', + series: [ + { + metric: 'metric2', + points: [[now / 1e3, 1]], + interval: undefined, + type: 'count', + tags: [ + 'bux:bax', + 'lib_language:nodejs', + `version:${process.version}` + ], + common: true + } + ] + } + ]) + }) + + it('should send data', () => { + const manager = new metrics.NamespaceManager() + + const test1 = manager.namespace('test1') + const test2 = manager.namespace('test2') + + test1.count('metric1', { bar: 'baz' }).inc() + test2.count('metric2', { bux: 'bax' }).inc() + + const config = { + hostname: 'localhost', + port: 12345, + tags: { + 'runtime-id': 'abc123' + } + } + const application = { + language_name: 'nodejs', + tracer_version: '1.2.3' + } + const host = {} + + manager.send(config, application, host) + + expect(sendData).to.have.been + .calledWith(config, application, host, 'generate-metrics', { + namespace: 'test1', + series: [ + { + metric: 'metric1', + points: [[now / 1e3, 1]], + interval: undefined, + type: 'count', + tags: [ + 'bar:baz', + 'lib_language:nodejs', + `version:${process.version}` + ], + common: true + } + ] + }) + expect(sendData).to.have.been + .calledWith(config, application, host, 'generate-metrics', { + namespace: 'test2', + series: [ + { + metric: 'metric2', + points: [[now / 1e3, 1]], + interval: undefined, + type: 'count', + tags: [ + 'bux:bax', + 'lib_language:nodejs', + `version:${process.version}` + ], + common: true + } + ] + }) + }) + }) + + describe('Namespace', () => { + it('should store namespace name', () => { + const ns = new metrics.Namespace('name') + expect(ns).to.have.property('namespace', 'name') + }) + + it('should convert to string', () => { + const ns = new metrics.Namespace('name') + expect(ns.toString()).to.equal('dd.instrumentation_telemetry_data.name') + }) + + it('should get count metric', () => { + const ns = new metrics.Namespace('name') + expect(ns.count('name')).to.be.instanceOf(metrics.CountMetric) + }) + + it('should get gauge metric', () => { + const ns = new metrics.Namespace('name') + expect(ns.gauge('name')).to.be.instanceOf(metrics.GaugeMetric) + }) + + it('should get rate metric', () => { + const ns = new metrics.Namespace('name') + expect(ns.rate('name')).to.be.instanceOf(metrics.RateMetric) + }) + + it('should get metric by type', () => { + const ns = new metrics.Namespace('name') + expect(ns.getMetric('count', 'name')).to.be.instanceOf(metrics.CountMetric) + expect(ns.getMetric('gauge', 'name')).to.be.instanceOf(metrics.GaugeMetric) + expect(ns.getMetric('rate', 'name')).to.be.instanceOf(metrics.RateMetric) + + expect(() => ns.getMetric('non-existent', 'name')) + .to.throw(Error, 'Unknown metric type non-existent') + }) + + it('should have unique metrics per unique tag set', () => { + const ns = new metrics.Namespace('test') + ns.count('foo', { bar: 'baz' }).inc() + ns.count('foo', { bar: 'baz' }).inc() // not unique + ns.count('foo', { bux: 'bax' }).inc() + expect(ns).to.have.lengthOf(2) + }) + + it('should reset metrics', () => { + const ns = new metrics.Namespace('test') + const metric = ns.count('foo', { bar: 'baz' }) + metric.inc() + + metric.reset = sinon.spy(metric.reset) + + expect(metric.points).to.have.lengthOf(1) + ns.reset() + expect(metric.points).to.have.lengthOf(0) + + expect(metric.reset).to.have.been.called + }) + + it('should convert to json', () => { + const ns = new metrics.Namespace('test') + ns.count('foo', { bar: 'baz' }).inc() + ns.count('foo', { bux: 'bax' }).inc() + + expect(ns.toJSON()).to.deep.equal({ + namespace: 'test', + series: [ + { + metric: 'foo', + points: [[now / 1e3, 1]], + interval: undefined, + type: 'count', + tags: [ + 'bar:baz', + 'lib_language:nodejs', + `version:${process.version}` + ], + common: true + }, + { + metric: 'foo', + points: [[now / 1e3, 1]], + interval: undefined, + type: 'count', + tags: [ + 'bux:bax', + 'lib_language:nodejs', + `version:${process.version}` + ], + common: true + } + ] + }) + }) + }) + + describe('CountMetric', () => { + it('should expose input data', () => { + const ns = new metrics.Namespace('tracers') + const metric = ns.count('name', { + foo: 'bar', + baz: 'buz' + }) + + expect(metric.type).to.equal('count') + expect(metric).to.deep.equal({ + namespace: 'dd.instrumentation_telemetry_data.tracers', + metric: 'name', + tags: [ + 'foo:bar', + 'baz:buz', + 'lib_language:nodejs', + `version:${process.version}` + ], + common: true, + points: [] + }) + }) + + it('should increment', () => { + const ns = new metrics.Namespace('tracers') + const metric = ns.count('name') + + metric.track = sinon.spy(metric.track) + + metric.inc() + + expect(metric.track).to.be.called + + expect(metric.points).to.deep.equal([ + [now / 1e3, 1] + ]) + + metric.inc() + + expect(metric.points).to.deep.equal([ + [now / 1e3, 2] + ]) + }) + + it('should decrement', () => { + const ns = new metrics.Namespace('tracers') + const metric = ns.count('name') + + metric.inc() + metric.inc() + + metric.track = sinon.spy(metric.track) + + metric.dec() + + expect(metric.track).to.be.calledWith(-1) + + expect(metric.points).to.deep.equal([ + [now / 1e3, 1] + ]) + }) + + it('should retain timestamp of first change', () => { + const ns = new metrics.Namespace('tracers') + const metric = ns.count('name') + + metric.inc() + + Date.now.restore() + const newNow = Date.now() + sinon.stub(Date, 'now').returns(newNow) + + metric.inc() + + expect(metric.points).to.deep.equal([ + [now / 1e3, 2] + ]) + }) + + it('should reset state', () => { + const ns = new metrics.Namespace('tracers') + const metric = ns.count('name') + + metric.inc() + metric.reset() + + expect(metric.points).to.deep.equal([]) + }) + + it('should convert to json', () => { + const ns = new metrics.Namespace('tracers') + const metric = ns.count('name', { + foo: 'bar', + baz: 'buz' + }) + + metric.inc() + + expect(metric.toJSON()).to.deep.equal({ + metric: 'name', + points: [[now / 1e3, 1]], + interval: undefined, + type: 'count', + tags: [ + 'foo:bar', + 'baz:buz', + 'lib_language:nodejs', + `version:${process.version}` + ], + common: true + }) + }) + }) + + describe('GaugeMetric', () => { + it('should expose input data', () => { + const ns = new metrics.Namespace('tracers') + const metric = ns.gauge('name', { + foo: 'bar', + baz: 'buz' + }) + + expect(metric.type).to.equal('gauge') + expect(metric).to.deep.equal({ + namespace: 'dd.instrumentation_telemetry_data.tracers', + metric: 'name', + tags: [ + 'foo:bar', + 'baz:buz', + 'lib_language:nodejs', + `version:${process.version}` + ], + common: true, + points: [] + }) + }) + + it('should mark', () => { + const ns = new metrics.Namespace('tracers') + const metric = ns.gauge('name') + + metric.track = sinon.spy(metric.track) + + metric.mark(1) + + expect(metric.track).to.be.called + + expect(metric.points).to.deep.equal([ + [now / 1e3, 1] + ]) + + Date.now.restore() + const newNow = Date.now() + sinon.stub(Date, 'now').returns(newNow) + + metric.mark(2) + + expect(metric.points).to.deep.equal([ + [now / 1e3, 1], + [newNow / 1e3, 2] + ]) + }) + + it('should reset state', () => { + const ns = new metrics.Namespace('tracers') + const metric = ns.gauge('name') + + metric.mark(1) + metric.reset() + + expect(metric.points).to.deep.equal([]) + }) + + it('should convert to json', () => { + const ns = new metrics.Namespace('tracers') + const metric = ns.gauge('name', { + foo: 'bar', + baz: 'buz' + }) + + metric.mark(1) + + Date.now.restore() + const newNow = Date.now() + sinon.stub(Date, 'now').returns(newNow) + + metric.mark(2) + + expect(metric.toJSON()).to.deep.equal({ + metric: 'name', + points: [ + [now / 1e3, 1], + [newNow / 1e3, 2] + ], + interval: undefined, + type: 'gauge', + tags: [ + 'foo:bar', + 'baz:buz', + 'lib_language:nodejs', + `version:${process.version}` + ], + common: true + }) + }) + }) + + describe('RateMetric', () => { + it('should expose input data', () => { + const ns = new metrics.Namespace('tracers') + const metric = ns.rate('name', 1000, { + foo: 'bar', + baz: 'buz' + }) + + expect(metric.type).to.equal('rate') + expect(metric).to.deep.equal({ + namespace: 'dd.instrumentation_telemetry_data.tracers', + metric: 'name', + tags: [ + 'foo:bar', + 'baz:buz', + 'lib_language:nodejs', + `version:${process.version}` + ], + common: true, + points: [], + interval: 1000, + rate: 0 + }) + }) + + it('should track', () => { + const ns = new metrics.Namespace('tracers') + const metric = ns.rate('name', 1000) + + metric.track(100) + + expect(metric.points).to.deep.equal([ + [now / 1e3, 0.1] + ]) + }) + + it('should reset state', () => { + const ns = new metrics.Namespace('tracers') + const metric = ns.rate('name', 1000) + + metric.track(1) + metric.reset() + + expect(metric.points).to.deep.equal([]) + }) + + it('should convert to json', () => { + const ns = new metrics.Namespace('tracers') + const metric = ns.rate('name', 1000, { + foo: 'bar', + baz: 'buz' + }) + + metric.track(123) + + expect(metric.toJSON()).to.deep.equal({ + metric: 'name', + points: [ + [now / 1e3, 0.123] + ], + interval: 1000, + type: 'rate', + tags: [ + 'foo:bar', + 'baz:buz', + 'lib_language:nodejs', + `version:${process.version}` + ], + common: true + }) + }) + }) +})