[REQUEST] TracingChannel support for observability
- Dominant language
- JavaScript
- Stars
- 13.4k
- Forks
- 518
- Avg merge
- 2d 4h
- Merged PRs (30d)
- 2
Description
## Overview
I'd like to propose first-class [`TracingChannel`](https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel) support in `dataloader`, following [`undici`](https://github.com/nodejs/undici) in Node.js core and its sibling in the GraphQL org, [`graphql-js`](https://github.com/graphql/graphql-js/pull/4670).
`TracingChannel` is built on `diagnostics_channel` for tracing async operations. It exposes structured lifecycle channels (`start`, `end`, `error`, `asyncStart`, `asyncEnd`) and propagates async context correctly.
## Motivation
DataLoader is on the hot path of nearly every GraphQL server, and it has no built-in instrumentation. So every APM monkey-patches it: `@opentelemetry/instrumentation-dataloader` patches `load`, `loadMany`, `prime`, `clear`, and `clearAll` on the prototype, plus wraps the `DataLoader` constructor to intercept the user's `batchLoadFn` and trace batch dispatch. Datadog and Sentry do the same. The usual fragility applies:
- **Runtime lock-in:** RITM/IITM rely on Node.js module loader internals (`Module._resolveFilename`, `module.register()`). They don't work on Bun or Deno.
- **ESM fragility:** IITM depends on Node's evolving module hooks, a persistent source of breakage in OTEL JS.
- **Initialization ordering:** patching must happen before `dataloader` is first imported, or instrumentation silently no-ops.
- **Bundling:** instrumented modules must stay externalized, which is hard when frameworks bundle server code into single files.
There's a DataLoader-specific cost too. Batching decouples `load(key)` from the eventual `batchLoadFn([...keys])` across an async boundary, so the OTel patch wraps the user's `batchLoadFn`, stashes per-key span contexts on the internal `_batch`, and rebuilds the load-to-batch link graph by hand. Native emission removes all of it: the engine knows exactly when a batch is scheduled, which keys it holds, and when the promise settles.
With `TracingChannel`, instrumentation libraries become **subscribers**, not **patches**: independent, order-free, and with no dependency on internals like `_batch`.
## Proposed Tracing Channels
Async operations use [`TracingChannel`](https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel) (`start`, `end`, `asyncStart`, `asyncEnd`, `error`). Synchronous cache operations use plain `diagnostics_channel` point events.
### Async operations (`TracingChannel`, `tracePromise`)
| TracingChannel | Tracks | Context fields |
|---|---|---|
| `dataloader:load` | `load(key)` to per-key resolution/error | `name`, `key` |
| `dataloader:loadMany` | `loadMany(keys)` to settled array | `name`, `keys` |
| `dataloader:batch` | `batchLoadFn(keys)` dispatch until its promise settles | `name`, `keys`, `batchSize` |
### Cache operations (plain point events)
Synchronous and fire-and-forget. Included for parity with the spans OTel emits today.
| Channel | Tracks | Context fields |
|---|---|---|
| `dataloader:prime` | `prime(key, value)` | `name`, `key` |
| `dataloader:clear` | `clear(key)` | `name`, `key` |
| `dataloader:clearAll` | `clearAll()` | `name` |
## How APM Tools Use This
### Today: patch 5 prototype methods + wrap the user's `batchLoadFn`
```js
// Simplified from @opentelemetry/instrumentation-dataloader
wrap(DataLoader.prototype, 'constructor', /* intercept the user batchLoadFn */);
wrap(DataLoader.prototype, 'load', original => function patchedLoad(key) {
const span = tracer.startSpan(getSpanName(this, 'load'));
// push spanContext into this._batch.spanLinks so the batch span can link back
return context.with(/* ... */, () => original.call(this, key));
});
wrap(DataLoader.prototype, 'loadMany', /* ... */);
wrap(DataLoader.prototype, 'prime', /* ... */);
wrap(DataLoader.prototype, 'clear', /* ... */);
wrap(DataLoader.prototype, 'clearAll', /* ... */);
// batch span created inside the wrapped batchLoadFn, links rebuilt from captured contexts
```
Depends on prototype shapes, the internal `_batch`, and the constructor signature, and must install before first import.
### With TracingChannel: subscribe to structured events
```js
const dc = require('node:diagnostics_channel');
dc.tracingChannel('dataloader:batch').subscribe({
start(ctx) {
ctx.span = tracer.startSpan(
ctx.name ? `dataloader.batch ${ctx.name}` : 'dataloader.batch',
{ attributes: { 'dataloader.batch.size': ctx.batchSize } },
);
},
asyncEnd(ctx) { ctx.span?.end(); },
error(ctx) {
ctx.span?.setStatus({ code: SpanStatusCode.ERROR, message: ctx.error?.message });
ctx.span?.recordException(ctx.error);
},
});
dc.tracingChannel('dataloader:load').subscribe({
start(ctx) { ctx.span = tracer.startSpan(ctx.name ? `dataloader.load ${ctx.name}` : 'dataloader.load'); },
asyncEnd(ctx) { ctx.span?.end(); },
error(ctx) { ctx.span?.setStatus({ code: SpanStatusCode.ERROR }); },
});
dc.channel('dataloader:clearAll').subscribe(ctx => { /* counter / annotate active span */ });
```
---
## Prior Art
This follows the pattern adopted or in progress across the ecosystem:
- **`graphql`**: [graphql/graphql-js#4670](https://github.com/graphql/graphql-js/pull/4670) which was released in 17 rc0
- **`undici`** (Node.js core): `TracingChannel` since Node 20.12 ([`undici:request`](https://nodejs.org/api/diagnostics_channel.html#undici-channels))
- **`fastify`**: native (`tracing:fastify.request.handler`)
- **`node-redis`**: [redis/node-redis#3195](https://github.com/redis/node-redis/pull/3195)
- **`ioredis`**: [redis/ioredis#2089](https://github.com/redis/ioredis/pull/2089)
- **`mongoose`**: [Automattic/mongoose#16275](https://github.com/Automattic/mongoose/pull/16275)
---
Just like graphql/graphql-js#4670, I would be happy to PR this and iterate with the team from there. Would you folks be willing to accept a PR for it?
Contributor guide
Assessment
This issue has not been assessed yet.