|
| 1 | +--- |
| 2 | +name: add-new-instrumentation |
| 3 | +description: Guide for adding new instrumentation and plugins to dd-trace. Use when creating a new plugin, adding instrumentation for a third-party library, or when the user asks about adding new instrumentations or plugins. |
| 4 | +--- |
| 5 | + |
| 6 | +# Adding New Instrumentation |
| 7 | + |
| 8 | +## Architecture Overview |
| 9 | + |
| 10 | +The instrumentation system has two layers that communicate via Node.js diagnostic channels: |
| 11 | + |
| 12 | +1. **Instrumentation** (`packages/datadog-instrumentations/src/<name>.js`) — hooks into third-party library internals using `addHook()` and `shimmer`, then publishes events to named diagnostic channels. |
| 13 | +2. **Plugin** (`packages/datadog-plugin-<name>/src/index.js`) — subscribes to those channels to implement APM tracing logic (spans, metadata, errors). |
| 14 | + |
| 15 | +This separation means you almost always need to create **both** files. |
| 16 | + |
| 17 | +## Step 1: Create the Instrumentation File |
| 18 | + |
| 19 | +Create `packages/datadog-instrumentations/src/<name>.js`. The following is a starting-point template — adapt the wrapped method(s), context fields, and channel operations to match the actual library's API. Read 1-2 existing instrumentations for the library type you're adding (e.g. `kafkajs.js` for messaging, `redis.js` for caching) before writing yours. |
| 20 | + |
| 21 | +```javascript |
| 22 | +'use strict' |
| 23 | + |
| 24 | +const { channel, addHook } = require('./helpers/instrument') |
| 25 | +const shimmer = require('../../../datadog-shimmer') |
| 26 | + |
| 27 | +// Channel naming convention: apm:<name>:<operation>:<event> |
| 28 | +// Events: start, finish, error, async-start, async-finish |
| 29 | +const startCh = channel('apm:<name>:<operation>:start') |
| 30 | +const finishCh = channel('apm:<name>:<operation>:finish') |
| 31 | +const errorCh = channel('apm:<name>:<operation>:error') |
| 32 | + |
| 33 | +addHook({ name: '<module-name>', versions: ['>=1.0'] }, (moduleExports) => { |
| 34 | + shimmer.wrap(moduleExports, 'methodToWrap', function (original) { |
| 35 | + return function wrappedMethod (...args) { |
| 36 | + if (!startCh.hasSubscribers) { |
| 37 | + return original.apply(this, args) |
| 38 | + } |
| 39 | + |
| 40 | + const ctx = { /* relevant context */ } |
| 41 | + return startCh.runStores(ctx, () => { |
| 42 | + try { |
| 43 | + const result = original.apply(this, args) |
| 44 | + finishCh.publish(ctx) |
| 45 | + return result |
| 46 | + } catch (err) { |
| 47 | + ctx.error = err |
| 48 | + errorCh.publish(ctx) |
| 49 | + throw err |
| 50 | + } |
| 51 | + }) |
| 52 | + } |
| 53 | + }) |
| 54 | + return moduleExports |
| 55 | +}) |
| 56 | +``` |
| 57 | + |
| 58 | +**Key patterns:** |
| 59 | +- Always guard with `if (!startCh.hasSubscribers)` for performance — skip instrumentation if no plugin is listening |
| 60 | +- Use `startCh.runStores(ctx, () => {...})` to propagate async context |
| 61 | +- Use `shimmer.wrap()` to patch methods non-destructively |
| 62 | +- The `versions` array is a semver range; check existing instrumentations for precedents |
| 63 | +- For multiple files in a package: use `file: 'path/within/package.js'` in `addHook` |
| 64 | +- For multiple module names mapping to the same hooks: call `addHook` multiple times |
| 65 | + |
| 66 | +## Step 2: Create the Plugin Directory and File |
| 67 | + |
| 68 | +```bash |
| 69 | +mkdir -p packages/datadog-plugin-<name>/{src,test} |
| 70 | +``` |
| 71 | + |
| 72 | +### Choosing the Right Base Class |
| 73 | + |
| 74 | +| Scenario | Base Class | Import Path | |
| 75 | +|---|---|---| |
| 76 | +| Creating trace spans for a single operation type | `TracingPlugin` | `../../dd-trace/src/plugins/tracing` | |
| 77 | +| Wrapping an outbound client call (HTTP, gRPC, DB) | `OutboundPlugin` extends `TracingPlugin` | `../../dd-trace/src/plugins/outbound` | |
| 78 | +| Wrapping an inbound server/consumer call | `InboundPlugin` extends `TracingPlugin` | `../../dd-trace/src/plugins/inbound` | |
| 79 | +| Key-value cache client (Redis, Memcached) | `CachePlugin` extends `TracingPlugin` | `../../dd-trace/src/plugins/cache` | |
| 80 | +| Multiple sub-concerns (producer + consumer, or tracing + code-origin) | `CompositePlugin` | `../../dd-trace/src/plugins/composite` | |
| 81 | +| Non-tracing feature only | `Plugin` | `../../dd-trace/src/plugins/plugin` | |
| 82 | + |
| 83 | +### Template: Simple TracingPlugin |
| 84 | + |
| 85 | +```javascript |
| 86 | +'use strict' |
| 87 | + |
| 88 | +const TracingPlugin = require('../../dd-trace/src/plugins/tracing') |
| 89 | + |
| 90 | +class MyPlugin extends TracingPlugin { |
| 91 | + static id = '<name>' // must match module name |
| 92 | + static operation = '<operation>' // e.g., 'query', 'send', 'request' |
| 93 | + static system = '<system>' // e.g., 'redis', 'kafka' (used for peer.service) |
| 94 | + |
| 95 | + bindStart (ctx) { |
| 96 | + const { relevantField } = ctx |
| 97 | + |
| 98 | + this.startSpan({ |
| 99 | + resource: relevantField, |
| 100 | + service: this.serviceName(), |
| 101 | + meta: { |
| 102 | + 'some.tag': relevantField |
| 103 | + } |
| 104 | + }, ctx) |
| 105 | + } |
| 106 | + |
| 107 | + bindFinish (ctx) { |
| 108 | + this.finish() |
| 109 | + } |
| 110 | + |
| 111 | + bindError (ctx) { |
| 112 | + this.finish(ctx.error) |
| 113 | + } |
| 114 | +} |
| 115 | + |
| 116 | +module.exports = MyPlugin |
| 117 | +``` |
| 118 | + |
| 119 | +### Template: CompositePlugin |
| 120 | + |
| 121 | +```javascript |
| 122 | +'use strict' |
| 123 | + |
| 124 | +const CompositePlugin = require('../../dd-trace/src/plugins/composite') |
| 125 | +const ProducerPlugin = require('./producer') |
| 126 | +const ConsumerPlugin = require('./consumer') |
| 127 | + |
| 128 | +class MyPlugin extends CompositePlugin { |
| 129 | + static id = '<name>' |
| 130 | + |
| 131 | + static get plugins () { |
| 132 | + return { |
| 133 | + producer: ProducerPlugin, |
| 134 | + consumer: ConsumerPlugin |
| 135 | + } |
| 136 | + } |
| 137 | +} |
| 138 | + |
| 139 | +module.exports = MyPlugin |
| 140 | +``` |
| 141 | + |
| 142 | +For composite plugins, create separate files in `src/` for each sub-plugin (e.g., `src/producer.js`, `src/consumer.js`). |
| 143 | + |
| 144 | +## Step 3: Register the Plugin |
| 145 | + |
| 146 | +Add an entry to `packages/dd-trace/src/plugins/index.js`: |
| 147 | + |
| 148 | +```javascript |
| 149 | +// Inside the plugins object: |
| 150 | +get '<module-name>' () { return require('../../../datadog-plugin-<name>/src') }, |
| 151 | +``` |
| 152 | + |
| 153 | +If multiple npm package names map to the same plugin (e.g., `redis` and `@redis/client`), add one getter per name. |
| 154 | + |
| 155 | +## Step 4: Add TypeScript Definitions |
| 156 | + |
| 157 | +In `index.d.ts`, add to the `plugins` namespace: |
| 158 | + |
| 159 | +```typescript |
| 160 | +// In the Plugins interface: |
| 161 | +'<name>': plugins.<name>; |
| 162 | + |
| 163 | +// Add a plugin interface (in alphabetical order with other plugin interfaces): |
| 164 | +interface <name> extends Instrumentation {} |
| 165 | +// Or with config options: |
| 166 | +interface <name> extends Instrumentation { |
| 167 | + optionName?: string | boolean; |
| 168 | +} |
| 169 | +``` |
| 170 | + |
| 171 | +## Step 5: Update docs/test.ts |
| 172 | + |
| 173 | +Add a type-check call in `docs/test.ts`: |
| 174 | + |
| 175 | +```typescript |
| 176 | +tracer.use('<name>'); |
| 177 | +// Or with options: |
| 178 | +tracer.use('<name>', { optionName: 'value' }); |
| 179 | +``` |
| 180 | + |
| 181 | +## Step 6: Document in docs/API.md |
| 182 | + |
| 183 | +Add a section in `docs/API.md` (alphabetically ordered): |
| 184 | + |
| 185 | +```markdown |
| 186 | +<h5 id="<name>"><h5> |
| 187 | + |
| 188 | +This plugin automatically patches the [<LibraryName>](<url>) module. |
| 189 | + |
| 190 | +| Option | Default | Description | |
| 191 | +|--------|---------|-------------| |
| 192 | +| `service` | | Service name override. | |
| 193 | +``` |
| 194 | + |
| 195 | +## Step 7: Add to CI Workflow |
| 196 | + |
| 197 | +Add a job to `.github/workflows/apm-integrations.yml`: |
| 198 | + |
| 199 | +```yaml |
| 200 | +<name>: |
| 201 | + runs-on: ubuntu-latest |
| 202 | + env: |
| 203 | + PLUGINS: <name> |
| 204 | + # SERVICES: <docker-service> # if external services needed |
| 205 | + steps: |
| 206 | + - uses: actions/checkout@v4 |
| 207 | + - uses: ./.github/actions/testagent/start |
| 208 | + - uses: ./.github/actions/node |
| 209 | + with: |
| 210 | + version: ${{ matrix.node-version }} |
| 211 | + - uses: ./.github/actions/install |
| 212 | + - run: yarn test:plugins:ci |
| 213 | + strategy: |
| 214 | + matrix: |
| 215 | + node-version: [18, 22] |
| 216 | +``` |
| 217 | +
|
| 218 | +Check `.github/workflows/apm-integrations.yml` for the exact current step format used by other plugins. |
| 219 | + |
| 220 | +## Step 8: Write Tests |
| 221 | + |
| 222 | +### Unit Tests |
| 223 | + |
| 224 | +Create `packages/datadog-plugin-<name>/test/index.spec.js`: |
| 225 | + |
| 226 | +```javascript |
| 227 | +'use strict' |
| 228 | +
|
| 229 | +const agent = require('../../dd-trace/test/plugins/agent') |
| 230 | +const { withVersions } = require('../../dd-trace/test/setup/mocha') |
| 231 | +
|
| 232 | +describe('Plugin', () => { |
| 233 | + describe('<name>', () => { |
| 234 | + withVersions('<name>', '<module-name>', (version) => { |
| 235 | + let myLib |
| 236 | +
|
| 237 | + beforeEach(() => { |
| 238 | + return agent.load('<name>') |
| 239 | + }) |
| 240 | +
|
| 241 | + beforeEach(() => { |
| 242 | + myLib = require(`../../../versions/<module-name>@${version}`) |
| 243 | + }) |
| 244 | + |
| 245 | + afterEach(() => { |
| 246 | + return agent.close({ ritmReset: false }) |
| 247 | + }) |
| 248 | + |
| 249 | + it('should create a span', (done) => { |
| 250 | + agent.use(traces => { |
| 251 | + const span = traces[0][0] |
| 252 | + expect(span.name).to.equal('<name>.<operation>') |
| 253 | + expect(span.service).to.equal('test-<name>') |
| 254 | + }).then(done, done) |
| 255 | + |
| 256 | + // trigger the instrumented operation |
| 257 | + }) |
| 258 | + }) |
| 259 | + }) |
| 260 | +}) |
| 261 | +``` |
| 262 | + |
| 263 | +**Key test helpers:** |
| 264 | +- `withVersions(pluginName, moduleName, cb)` — runs tests across installed versions |
| 265 | +- `agent.load(pluginName)` — starts a test agent and loads the plugin |
| 266 | +- `agent.close({ ritmReset: false })` — tears down (use `ritmReset: false` to preserve require cache) |
| 267 | +- `agent.use(traces => { ... })` — asserts on captured traces |
| 268 | +- `withNamingSchema(agent, ...)` — tests naming schema conventions |
| 269 | +- `withPeerService(agent, ...)` — tests peer service tag |
| 270 | + |
| 271 | +### ESM Integration Tests |
| 272 | + |
| 273 | +ESM tests verify the plugin works with native ES module imports. They live in `packages/datadog-plugin-<name>/test/integration-test/` and use a `FakeAgent` to assert on captured spans. |
| 274 | + |
| 275 | +Create `packages/datadog-plugin-<name>/test/integration-test/server.mjs` — a minimal ESM script that initialises the tracer and triggers the instrumented operation: |
| 276 | + |
| 277 | +```javascript |
| 278 | +import 'dd-trace/init.js' |
| 279 | +import myLib from '<module-name>' |
| 280 | + |
| 281 | +// trigger the instrumented operation |
| 282 | +await myLib.someOperation() |
| 283 | +``` |
| 284 | + |
| 285 | +Create `packages/datadog-plugin-<name>/test/integration-test/client.spec.js` — the test that spawns the ESM server and asserts spans arrive: |
| 286 | + |
| 287 | +```javascript |
| 288 | +'use strict' |
| 289 | + |
| 290 | +const assert = require('node:assert/strict') |
| 291 | + |
| 292 | +const { |
| 293 | + FakeAgent, |
| 294 | + sandboxCwd, |
| 295 | + useSandbox, |
| 296 | + checkSpansForServiceName, |
| 297 | + spawnPluginIntegrationTestProcAndExpectExit, |
| 298 | + varySandbox, |
| 299 | +} = require('../../../../integration-tests/helpers') |
| 300 | +const { withVersions } = require('../../../dd-trace/test/setup/mocha') |
| 301 | + |
| 302 | +describe('esm', () => { |
| 303 | + let agent |
| 304 | + let proc |
| 305 | + let variants |
| 306 | + |
| 307 | + withVersions('<name>', '<module-name>', version => { |
| 308 | + useSandbox([`'<module-name>@${version}'`], false, [ |
| 309 | + './packages/datadog-plugin-<name>/test/integration-test/*']) |
| 310 | + |
| 311 | + beforeEach(async () => { |
| 312 | + agent = await new FakeAgent().start() |
| 313 | + }) |
| 314 | + |
| 315 | + before(async function () { |
| 316 | + variants = varySandbox('server.mjs', '<module-name>', '<namedExport>') |
| 317 | + }) |
| 318 | + |
| 319 | + afterEach(async () => { |
| 320 | + proc && proc.kill() |
| 321 | + await agent.stop() |
| 322 | + }) |
| 323 | + |
| 324 | + for (const variant of varySandbox.VARIANTS) { |
| 325 | + it(`is instrumented ${variant}`, async () => { |
| 326 | + const res = agent.assertMessageReceived(({ headers, payload }) => { |
| 327 | + assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) |
| 328 | + assert.ok(Array.isArray(payload)) |
| 329 | + assert.strictEqual(checkSpansForServiceName(payload, '<name>.<operation>'), true) |
| 330 | + }) |
| 331 | + |
| 332 | + proc = await spawnPluginIntegrationTestProcAndExpectExit(sandboxCwd(), variants[variant], agent.port) |
| 333 | + |
| 334 | + await res |
| 335 | + }).timeout(20000) |
| 336 | + } |
| 337 | + }) |
| 338 | +}) |
| 339 | +``` |
| 340 | + |
| 341 | +**Key points for ESM tests:** |
| 342 | +- `varySandbox('server.mjs', bindingName, namedExport)` generates three import-style variants (`default`, `star`, `destructure`) from `server.mjs` so the instrumentation is verified under all ESM import patterns. |
| 343 | +- `varySandbox.VARIANTS` is `['default', 'star', 'destructure']`. |
| 344 | +- Pass `byPassDefault: true` as the fifth argument to `varySandbox` when the module has no default export (named-only packages). |
| 345 | +- `useSandbox` installs the package versions into a temp sandbox dir; the second argument controls whether it runs `yarn install` inside the sandbox. |
| 346 | +- `spawnPluginIntegrationTestProcAndExpectExit` spawns `node <script>` with `DD_TRACE_AGENT_PORT` set to the `FakeAgent` port. |
| 347 | +- Each `it` must have a generous timeout (e.g. `20000`) because sandbox setup and process spawning take time. |
| 348 | + |
| 349 | +## Running Tests |
| 350 | + |
| 351 | +```bash |
| 352 | +# Run the unit plugin test |
| 353 | +./node_modules/.bin/mocha packages/datadog-plugin-<name>/test/index.spec.js |
| 354 | + |
| 355 | +# Or via the test:plugins script (unit tests only) |
| 356 | +PLUGINS="<name>" npm run test:plugins |
| 357 | + |
| 358 | +# Run the ESM integration tests |
| 359 | +PLUGINS="<name>" npm run test:integration:plugins |
| 360 | +``` |
| 361 | + |
| 362 | +## Reference Files |
| 363 | + |
| 364 | +- Instrumentation helpers: `packages/datadog-instrumentations/src/helpers/instrument.js` |
| 365 | +- Plugin registration: `packages/dd-trace/src/plugins/index.js` |
| 366 | +- Example simple plugin: `packages/datadog-plugin-redis/src/` |
| 367 | +- Example composite plugin: `packages/datadog-plugin-kafkajs/src/` |
| 368 | +- Example instrumentation: `packages/datadog-instrumentations/src/kafkajs.js` |
| 369 | +- Example instrumentation: `packages/datadog-instrumentations/src/redis.js` |
0 commit comments