graphql / graphql/dataloader

[REQUEST] TracingChannel support for observability

Open
#393 0 comments 0 reactions 0 assignees View on GitHub
enhancement
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.