diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index b8eebd4fdbc..ddd972763bf 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -978,7 +978,7 @@ jobs: ports: - 6379:6379 env: - PLUGINS: redis|ioredis # TODO: move ioredis to its own job + PLUGINS: redis|ioredis|iovalkey # TODO: move ioredis & iovalkey to its own job SERVICES: redis steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/docs/API.md b/docs/API.md index 19827e5977d..070026de853 100644 --- a/docs/API.md +++ b/docs/API.md @@ -56,6 +56,9 @@ tracer.use('pg', {
+ + + @@ -126,6 +129,7 @@ tracer.use('pg', { * [http](./interfaces/export_.plugins.http.html) * [http2](./interfaces/export_.plugins.http2.html) * [ioredis](./interfaces/export_.plugins.ioredis.html) +* [iovalkey](./interfaces/export_.plugins.iovalkey.html) * [jest](./interfaces/export_.plugins.jest.html) * [kafkajs](./interfaces/export_.plugins.kafkajs.html) * [knex](./interfaces/export_.plugins.knex.html) diff --git a/docs/add-redirects.sh b/docs/add-redirects.sh index 92d58ba3263..a7387385d88 100755 --- a/docs/add-redirects.sh +++ b/docs/add-redirects.sh @@ -35,6 +35,7 @@ declare -a plugins=( "http" "http2" "ioredis" + "iovalkey" "jest" "kafkajs" "knex" diff --git a/docs/test.ts b/docs/test.ts index d86ed36a53b..d8ab095e2a6 100644 --- a/docs/test.ts +++ b/docs/test.ts @@ -343,6 +343,9 @@ tracer.use('http2', { tracer.use('ioredis'); tracer.use('ioredis', redisOptions); tracer.use('ioredis', { splitByInstance: true }); +tracer.use('iovalkey'); +tracer.use('iovalkey', redisOptions); +tracer.use('iovalkey', { splitByInstance: true }); tracer.use('jest'); tracer.use('jest', { service: 'jest-service' }); tracer.use('kafkajs'); diff --git a/index.d.ts b/index.d.ts index 33e171a17e6..9bcad12e9e7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -172,6 +172,7 @@ interface Plugins { "http": tracer.plugins.http; "http2": tracer.plugins.http2; "ioredis": tracer.plugins.ioredis; + "iovalkey": tracer.plugins.iovalkey; "jest": tracer.plugins.jest; "kafkajs": tracer.plugins.kafkajs "knex": tracer.plugins.knex; @@ -1593,6 +1594,53 @@ declare namespace tracer { splitByInstance?: boolean; } + /** + * This plugin automatically instruments the + * [iovalkey](https://github.com/valkey-io/iovalkey) module. + */ + interface iovalkey extends Instrumentation { + /** + * List of commands that should be instrumented. Commands must be in + * lowercase for example 'xread'. + * + * @default /^.*$/ + */ + allowlist?: string | RegExp | ((command: string) => boolean) | (string | RegExp | ((command: string) => boolean))[]; + + /** + * Deprecated in favor of `allowlist`. + * + * @deprecated + * @hidden + */ + whitelist?: string | RegExp | ((command: string) => boolean) | (string | RegExp | ((command: string) => boolean))[]; + + /** + * List of commands that should not be instrumented. Takes precedence over + * allowlist if a command matches an entry in both. Commands must be in + * lowercase for example 'xread'. + * + * @default [] + */ + blocklist?: string | RegExp | ((command: string) => boolean) | (string | RegExp | ((command: string) => boolean))[]; + + /** + * Deprecated in favor of `blocklist`. + * + * @deprecated + * @hidden + */ + blacklist?: string | RegExp | ((command: string) => boolean) | (string | RegExp | ((command: string) => boolean))[]; + + /** + * Whether to use a different service name for each Redis instance based + * on the configured connection name of the client. + * + * @default false + */ + splitByInstance?: boolean; + } + /** * This plugin automatically instruments the * [jest](https://github.com/jestjs/jest) module. diff --git a/packages/datadog-instrumentations/src/helpers/hooks.js b/packages/datadog-instrumentations/src/helpers/hooks.js index 3cc455fc120..9a2f663fc2e 100644 --- a/packages/datadog-instrumentations/src/helpers/hooks.js +++ b/packages/datadog-instrumentations/src/helpers/hooks.js @@ -61,6 +61,7 @@ module.exports = { http2: () => require('../http2'), https: () => require('../http'), ioredis: () => require('../ioredis'), + iovalkey: () => require('../valkey'), 'jest-circus': () => require('../jest'), 'jest-config': () => require('../jest'), 'jest-environment-node': () => require('../jest'), diff --git a/packages/datadog-instrumentations/src/iovalkey.js b/packages/datadog-instrumentations/src/iovalkey.js new file mode 100644 index 00000000000..fa7eb241ebf --- /dev/null +++ b/packages/datadog-instrumentations/src/iovalkey.js @@ -0,0 +1,51 @@ +'use strict' + +const { + channel, + addHook, + AsyncResource +} = require('./helpers/instrument') +const shimmer = require('../../datadog-shimmer') + +const startCh = channel('apm:iovalkey:command:start') +const finishCh = channel('apm:iovalkey:command:finish') +const errorCh = channel('apm:iovalkey:command:error') + +addHook({ name: 'iovalkey', versions: ['>=0'] }, Redis => { + shimmer.wrap(Redis.prototype, 'sendCommand', sendCommand => function (command, stream) { + if (!startCh.hasSubscribers) return sendCommand.apply(this, arguments) + + if (!command || !command.promise) return sendCommand.apply(this, arguments) + + const options = this.options || {} + const connectionName = options.connectionName + const db = options.db + const connectionOptions = { host: options.host, port: options.port } + + const asyncResource = new AsyncResource('bound-anonymous-fn') + return asyncResource.runInAsyncScope(() => { + startCh.publish({ db, command: command.name, args: command.args, connectionOptions, connectionName }) + + const onResolve = asyncResource.bind(() => finish(finishCh, errorCh)) + const onReject = asyncResource.bind(err => finish(finishCh, errorCh, err)) + + command.promise.then(onResolve, onReject) + + try { + return sendCommand.apply(this, arguments) + } catch (err) { + errorCh.publish(err) + + throw err + } + }) + }) + return Redis +}) + +function finish (finishCh, errorCh, error) { + if (error) { + errorCh.publish(error) + } + finishCh.publish() +} diff --git a/packages/datadog-plugin-iovalkey/src/index.js b/packages/datadog-plugin-iovalkey/src/index.js new file mode 100644 index 00000000000..2562dbae9f2 --- /dev/null +++ b/packages/datadog-plugin-iovalkey/src/index.js @@ -0,0 +1,11 @@ +'use strict' + +const RedisPlugin = require('../../datadog-plugin-redis/src') + +class IOValkeyPlugin extends RedisPlugin { + static get id () { + return 'iovalkey' + } +} + +module.exports = IOValkeyPlugin diff --git a/packages/datadog-plugin-iovalkey/test/index.spec.js b/packages/datadog-plugin-iovalkey/test/index.spec.js new file mode 100644 index 00000000000..06176ee1cb9 --- /dev/null +++ b/packages/datadog-plugin-iovalkey/test/index.spec.js @@ -0,0 +1,184 @@ +'use strict' + +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 { expectedSchema, rawExpectedSchema } = require('./naming') + +describe('Plugin', () => { + let Redis + let redis + let tracer + + describe('iovalkey', () => { + withVersions('iovalkey', 'iovalkey', version => { + beforeEach(() => { + tracer = require('../../dd-trace') + Redis = require(`../../../versions/iovalkey@${version}`).get() + redis = new Redis({ connectionName: 'test' }) + }) + + afterEach(() => { + unbreakThen(Promise.prototype) + redis.quit() + }) + + describe('without configuration', () => { + before(() => agent.load(['iovalkey'])) + + after(() => agent.close({ ritmReset: false })) + + it('should do automatic instrumentation when using callbacks', done => { + agent.use(() => {}) // wait for initial info command + agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', expectedSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', expectedSchema.outbound.serviceName) + expect(traces[0][0]).to.have.property('resource', 'get') + expect(traces[0][0]).to.have.property('type', 'redis') + expect(traces[0][0].meta).to.have.property('component', 'iovalkey') + expect(traces[0][0].meta).to.have.property('db.name', '0') + expect(traces[0][0].meta).to.have.property('db.type', 'redis') + expect(traces[0][0].meta).to.have.property('span.kind', 'client') + expect(traces[0][0].meta).to.have.property('out.host', 'localhost') + expect(traces[0][0].meta).to.have.property('redis.raw_command', 'GET foo') + expect(traces[0][0].metrics).to.have.property('network.destination.port', 6379) + }) + .then(done) + .catch(done) + + redis.get('foo').catch(done) + }) + + it('should run the callback in the parent context', () => { + const span = {} + + return tracer.scope().activate(span, () => { + return redis.get('foo') + .then(() => { + expect(tracer.scope().active()).to.equal(span) + }) + }) + }) + + it('should handle errors', done => { + let error + + agent.use(() => {}) // wait for initial info command + agent + .use(traces => { + expect(traces[0][0]).to.have.property('error', 1) + 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) + expect(traces[0][0].meta).to.have.property('component', 'iovalkey') + }) + .then(done) + .catch(done) + + redis.set('foo', 123, 'bar') + .catch(err => { + error = err + }) + }) + + it('should work with userland promises', done => { + agent.use(() => {}) // wait for initial info command + agent + .use(traces => { + expect(traces[0][0]).to.have.property('name', expectedSchema.outbound.opName) + expect(traces[0][0]).to.have.property('service', expectedSchema.outbound.serviceName) + expect(traces[0][0]).to.have.property('resource', 'get') + expect(traces[0][0]).to.have.property('type', 'redis') + expect(traces[0][0].meta).to.have.property('db.name', '0') + expect(traces[0][0].meta).to.have.property('db.type', 'redis') + expect(traces[0][0].meta).to.have.property('span.kind', 'client') + expect(traces[0][0].meta).to.have.property('out.host', 'localhost') + expect(traces[0][0].meta).to.have.property('redis.raw_command', 'GET foo') + expect(traces[0][0].meta).to.have.property('component', 'iovalkey') + expect(traces[0][0].metrics).to.have.property('network.destination.port', 6379) + }) + .then(done) + .catch(done) + + breakThen(Promise.prototype) + + redis.get('foo').catch(done) + }) + + withNamingSchema( + done => redis.get('foo').catch(done), + rawExpectedSchema.outbound + ) + }) + + describe('with configuration', () => { + before(() => agent.load('iovalkey', { + service: 'custom', + splitByInstance: true, + allowlist: ['get'] + })) + + after(() => agent.close({ ritmReset: false })) + + it('should be configured with the correct values', done => { + agent + .use(traces => { + expect(traces[0][0]).to.have.property('service', 'custom-test') + }) + .then(done) + .catch(done) + + redis.get('foo').catch(done) + }) + + it('should be able to filter commands', done => { + agent.use(() => {}) // wait for initial command + agent + .use(traces => { + expect(traces[0][0]).to.have.property('resource', 'get') + }) + .then(done) + .catch(done) + + redis.get('foo').catch(done) + }) + + withNamingSchema( + done => redis.get('foo').catch(done), + { + v0: { + opName: 'redis.command', + serviceName: 'custom-test' + }, + v1: { + opName: 'redis.command', + serviceName: 'custom' + } + } + ) + }) + + describe('with legacy configuration', () => { + before(() => agent.load('iovalkey', { + whitelist: ['get'] + })) + + after(() => agent.close({ ritmReset: false })) + + it('should be able to filter commands', done => { + agent.use(() => {}) // wait for initial command + agent + .use(traces => { + expect(traces[0][0]).to.have.property('resource', 'get') + }) + .then(done) + .catch(done) + + redis.get('foo').catch(done) + }) + }) + }) + }) +}) diff --git a/packages/datadog-plugin-iovalkey/test/integration-test/client.spec.js b/packages/datadog-plugin-iovalkey/test/integration-test/client.spec.js new file mode 100644 index 00000000000..6e36ab337e3 --- /dev/null +++ b/packages/datadog-plugin-iovalkey/test/integration-test/client.spec.js @@ -0,0 +1,47 @@ +'use strict' + +const { + FakeAgent, + createSandbox, + checkSpansForServiceName, + spawnPluginIntegrationTestProc +} = require('../../../../integration-tests/helpers') +const { assert } = require('chai') + +describe('esm', () => { + let agent + let proc + let sandbox + withVersions('iovalkey', 'iovalkey', version => { + before(async function () { + this.timeout(20000) + sandbox = await createSandbox([`'iovalkey@${version}'`], false, [ + './packages/datadog-plugin-iovalkey/test/integration-test/*']) + }) + + after(async () => { + await sandbox.remove() + }) + + beforeEach(async () => { + agent = await new FakeAgent().start() + }) + + afterEach(async () => { + proc && proc.kill() + await agent.stop() + }) + + it('is instrumented', async () => { + const res = agent.assertMessageReceived(({ headers, payload }) => { + assert.propertyVal(headers, 'host', `127.0.0.1:${agent.port}`) + assert.isArray(payload) + assert.strictEqual(checkSpansForServiceName(payload, 'redis.command'), true) + }) + + proc = await spawnPluginIntegrationTestProc(sandbox.folder, 'server.mjs', agent.port) + + await res + }).timeout(20000) + }) +}) diff --git a/packages/datadog-plugin-iovalkey/test/integration-test/server.mjs b/packages/datadog-plugin-iovalkey/test/integration-test/server.mjs new file mode 100644 index 00000000000..d8d76fd69bf --- /dev/null +++ b/packages/datadog-plugin-iovalkey/test/integration-test/server.mjs @@ -0,0 +1,6 @@ +import 'dd-trace/init.js' +import Redis from 'iovalkey' + +const client = new Redis({ connectionName: 'test' }) +await client.get('foo') +client.quit() diff --git a/packages/datadog-plugin-iovalkey/test/naming.js b/packages/datadog-plugin-iovalkey/test/naming.js new file mode 100644 index 00000000000..1ed3f17e428 --- /dev/null +++ b/packages/datadog-plugin-iovalkey/test/naming.js @@ -0,0 +1,19 @@ +const { resolveNaming } = require('../../dd-trace/test/plugins/helpers') + +const rawExpectedSchema = { + outbound: { + v0: { + opName: 'redis.command', + serviceName: 'test-redis' + }, + v1: { + opName: 'redis.command', + serviceName: 'test' + } + } +} + +module.exports = { + rawExpectedSchema, + expectedSchema: resolveNaming(rawExpectedSchema) +} diff --git a/packages/dd-trace/src/plugins/index.js b/packages/dd-trace/src/plugins/index.js index 1ccc1f91138..6f3618e9d8b 100644 --- a/packages/dd-trace/src/plugins/index.js +++ b/packages/dd-trace/src/plugins/index.js @@ -48,6 +48,7 @@ module.exports = { get http2 () { return require('../../../datadog-plugin-http2/src') }, get https () { return require('../../../datadog-plugin-http/src') }, get ioredis () { return require('../../../datadog-plugin-ioredis/src') }, + get iovalkey () { return require('../../../datadog-plugin-iovalkey/src') }, get 'jest-circus' () { return require('../../../datadog-plugin-jest/src') }, get 'jest-config' () { return require('../../../datadog-plugin-jest/src') }, get 'jest-environment-node' () { return require('../../../datadog-plugin-jest/src') }, 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 2eecfb95e4d..90ccb7fd872 100644 --- a/packages/dd-trace/src/service-naming/schemas/v0/storage.js +++ b/packages/dd-trace/src/service-naming/schemas/v0/storage.js @@ -57,6 +57,7 @@ const storage = { pluginConfig.service || `${tracerService}-elasticsearch` }, ioredis: redisConfig, + iovalkey: redisConfig, mariadb: { opName: () => 'mariadb.query', serviceName: mysqlServiceName 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 96389e63652..7b2e6308d3c 100644 --- a/packages/dd-trace/src/service-naming/schemas/v1/storage.js +++ b/packages/dd-trace/src/service-naming/schemas/v1/storage.js @@ -38,6 +38,7 @@ const storage = { serviceName: configWithFallback }, ioredis: redisNaming, + iovalkey: redisNaming, mariadb: { opName: () => 'mariadb.query', serviceName: withFunction