Skip to content

Commit ace18fe

Browse files
committed
feat(server-utils): Rewrite @opentelemetry/instrumentation-dataloader to orchestrion
Replaces the `InstrumentationBase`-based OTel dataloader instrumentation with a diagnostics-channel listener, with orchestrion injecting the channels into `dataloader`'s constructor and prototype methods. `dataloaderIntegration` is opt-in (never a default), so unlike prior migrations the default-swap in `_init` can't apply (a user's explicit instance wins dedup). Instead the `@sentry/node` factory switches to the channel version itself when diagnostics-channel injection is enabled, keeping it opt-in in both modes.
1 parent cb6807a commit ace18fe

9 files changed

Lines changed: 522 additions & 6 deletions

File tree

dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { afterAll, describe, expect } from 'vitest';
2+
import { isOrchestrionEnabled } from '../../../utils';
23
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
34

4-
const ORIGIN = 'auto.db.otel.dataloader';
5+
// The span origin depends on which instrumentation is active. When the generic orchestrion run is
6+
// enabled (via INJECT_ORCHESTRION) the OTel `Dataloader` integration is swapped for the
7+
// diagnostics-channel one, which stamps a different origin.
8+
const ORIGIN = isOrchestrionEnabled() ? 'auto.db.orchestrion.dataloader' : 'auto.db.otel.dataloader';
59
const CACHE_GET_OP = 'cache.get';
610

711
describe('dataloader auto-instrumentation', () => {

packages/node/src/integrations/tracing/dataloader/index.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,36 @@
1-
import { DataloaderInstrumentation } from './vendored/instrumentation';
2-
import type { IntegrationFn } from '@sentry/core';
1+
import type { Integration, IntegrationFn } from '@sentry/core';
32
import { defineIntegration } from '@sentry/core';
43
import { generateInstrumentOnce } from '@sentry/node-core';
4+
import {
5+
isDiagnosticsChannelInjectionEnabled,
6+
resolveDiagnosticsChannelInjection,
7+
} from '../../../sdk/diagnosticsChannelInjection';
8+
import { DataloaderInstrumentation } from './vendored/instrumentation';
59

610
const INTEGRATION_NAME = 'Dataloader' as const;
711

812
export const instrumentDataloader = generateInstrumentOnce(INTEGRATION_NAME, () => new DataloaderInstrumentation());
913

14+
// When the user has opted into diagnostics-channel injection, resolve the orchestrion channel
15+
// integration (kept behind the loader indirection so this file never imports orchestrion directly,
16+
// preserving the tree-shaking boundary). Returns `undefined` otherwise.
17+
function getDataloaderChannelIntegration(): Integration | undefined {
18+
if (!isDiagnosticsChannelInjectionEnabled()) {
19+
return undefined;
20+
}
21+
22+
return resolveDiagnosticsChannelInjection()?.optInChannelIntegrations?.[INTEGRATION_NAME]?.();
23+
}
24+
1025
const _dataloaderIntegration = (() => {
26+
// Unlike the default auto-performance integrations (swapped in `_init`), `dataloaderIntegration` is
27+
// opt-in: the user lists it explicitly, and an explicit instance wins integration dedup over a
28+
// default one. So the swap to the orchestrion channel integration has to happen here in the factory.
29+
const channelIntegration = getDataloaderChannelIntegration();
30+
if (channelIntegration) {
31+
return channelIntegration;
32+
}
33+
1134
return {
1235
name: INTEGRATION_NAME,
1336
setupOnce() {

packages/node/src/sdk/diagnosticsChannelInjection.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ export interface DiagnosticsChannelInjection {
1717
integrations: Integration[] | readonly Integration[];
1818
/** OTel integration names these replace; filtered out of the default set. */
1919
replacedOtelIntegrationNames: string[];
20+
/**
21+
* Opt-in channel integrations keyed by their public (OTel-parity) factory name. Unlike
22+
* `integrations`, these are NOT auto-appended to the default set: they replace OTel integrations
23+
* that are themselves opt-in (e.g. `Dataloader`), so the matching `@sentry/node` factory looks its
24+
* channel version up here when injection is enabled, keeping it opt-in in both modes.
25+
*/
26+
optInChannelIntegrations?: Record<string, () => Integration>;
2027
/** Installs the module hooks that inject the diagnostics channels. */
2128
register: () => void;
2229
/** Warns (DEBUG only) about missing or doubled channel injection. */

packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
channelIntegrations,
3+
dataloaderChannelIntegration,
34
ioredisChannelIntegration,
45
redisChannelIntegration,
56
detectOrchestrionSetup,
@@ -67,6 +68,12 @@ export function experimentalUseDiagnosticsChannelInjection(
6768
redisChannelIntegration({ responseHook: cacheResponseHook }),
6869
],
6970
replacedOtelIntegrationNames,
71+
// Opt-in channel integrations, looked up by name from their `@sentry/node` factory rather than
72+
// auto-appended. `Dataloader` is opt-in (never a default), so `dataloaderIntegration()` swaps
73+
// to the channel version itself when injection is enabled.
74+
optInChannelIntegrations: {
75+
Dataloader: dataloaderChannelIntegration,
76+
},
7077
register: () => registerDiagnosticsChannelInjection(options),
7178
detect: detectOrchestrionSetup,
7279
};
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { Integration } from '@sentry/core';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
const { instrumentCalls, injection } = vi.hoisted(() => ({
5+
instrumentCalls: [] as string[],
6+
injection: { enabled: false, channelIntegration: undefined as Integration | undefined },
7+
}));
8+
9+
// Control the gating flag and the channel integration the loader hands back.
10+
vi.mock('../../../src/sdk/diagnosticsChannelInjection', () => ({
11+
isDiagnosticsChannelInjectionEnabled: () => injection.enabled,
12+
resolveDiagnosticsChannelInjection: () =>
13+
injection.channelIntegration
14+
? { optInChannelIntegrations: { Dataloader: () => injection.channelIntegration } }
15+
: undefined,
16+
}));
17+
18+
// Record which instrumentations actually get generated, without registering real
19+
// OTel module hooks (the creator is never invoked).
20+
vi.mock('@sentry/node-core', async importOriginal => {
21+
const actual = (await importOriginal()) as Record<string, unknown>;
22+
return {
23+
...actual,
24+
generateInstrumentOnce: (name: string) => Object.assign(() => instrumentCalls.push(name), { id: name }),
25+
};
26+
});
27+
28+
import { dataloaderIntegration } from '../../../src/integrations/tracing/dataloader';
29+
30+
describe('dataloaderIntegration orchestrion gating', () => {
31+
beforeEach(() => {
32+
instrumentCalls.length = 0;
33+
injection.enabled = false;
34+
injection.channelIntegration = undefined;
35+
});
36+
37+
it('uses the OTel instrumentation when diagnostics-channel injection is disabled', () => {
38+
injection.enabled = false;
39+
40+
const integration = dataloaderIntegration();
41+
expect(integration.name).toBe('Dataloader');
42+
43+
integration.setupOnce?.();
44+
expect(instrumentCalls).toContain('Dataloader');
45+
});
46+
47+
it('uses the orchestrion channel integration when diagnostics-channel injection is enabled', () => {
48+
injection.enabled = true;
49+
const channelIntegration: Integration = { name: 'Dataloader', setupOnce: vi.fn() };
50+
injection.channelIntegration = channelIntegration;
51+
52+
const integration = dataloaderIntegration();
53+
expect(integration).toBe(channelIntegration);
54+
55+
integration.setupOnce?.();
56+
// The OTel instrumentation is never generated in this mode.
57+
expect(instrumentCalls).not.toContain('Dataloader');
58+
expect(channelIntegration.setupOnce).toHaveBeenCalledTimes(1);
59+
});
60+
});
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import * as diagnosticsChannel from 'node:diagnostics_channel';
2+
import type { IntegrationFn, Span, StartSpanOptions } from '@sentry/core';
3+
import {
4+
debug,
5+
defineIntegration,
6+
getActiveSpan,
7+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
8+
SPAN_KIND,
9+
startInactiveSpan,
10+
startSpan,
11+
waitForTracingChannelBinding,
12+
} from '@sentry/core';
13+
import { DEBUG_BUILD } from '../../debug-build';
14+
import type { ChannelName } from '../../orchestrion/channels';
15+
import { CHANNELS } from '../../orchestrion/channels';
16+
import { bindTracingChannelToSpan } from '../../tracing-channel';
17+
18+
// NOTE: this uses the same name as the OTel integration by design.
19+
// When enabled, the OTel 'Dataloader' integration is omitted from the default set.
20+
const INTEGRATION_NAME = 'Dataloader' as const;
21+
22+
const MODULE_NAME = 'dataloader';
23+
const ORIGIN = 'auto.db.orchestrion.dataloader';
24+
25+
// `load`, `loadMany` and `batch` are cache reads; the rest are cache mutations that get no `op`.
26+
const CACHE_GET_OP = 'cache.get';
27+
28+
type Operation = 'load' | 'loadMany' | 'batch' | 'prime' | 'clear' | 'clearAll';
29+
30+
// The link shape shared between a `load` span and the `batch` span it triggers.
31+
type DataLoaderSpanLink = { context: ReturnType<Span['spanContext']> };
32+
33+
// The private batch object `dataloader` stores on the loader. We stash the pending `load` span links
34+
// here (matching the vendored OTel instrumentation) so the batch span can link back to them.
35+
interface DataLoaderBatch {
36+
spanLinks?: DataLoaderSpanLink[];
37+
}
38+
39+
interface DataLoaderInstance {
40+
name?: string | null;
41+
_batch?: DataLoaderBatch | null;
42+
}
43+
44+
/**
45+
* The shape orchestrion's transform attaches to the tracing-channel `context`. Documented here rather
46+
* than imported because orchestrion's runtime doesn't export it.
47+
*/
48+
interface DataLoaderChannelContext {
49+
arguments: unknown[];
50+
self?: DataLoaderInstance;
51+
result?: unknown;
52+
error?: unknown;
53+
}
54+
55+
// Marks a wrapped `batchLoadFn` so a re-used loader (or a double construct) isn't wrapped twice.
56+
const WRAPPED = Symbol('sentry.dataloader.wrapped');
57+
58+
function getSpanName(loader: DataLoaderInstance | undefined, operation: Operation): string {
59+
const name = loader?.name;
60+
61+
return name ? `${MODULE_NAME}.${operation} ${name}` : `${MODULE_NAME}.${operation}`;
62+
}
63+
64+
function makeSpanOptions(loader: DataLoaderInstance | undefined, operation: Operation): StartSpanOptions {
65+
const isCacheGet = operation === 'load' || operation === 'loadMany' || operation === 'batch';
66+
67+
return {
68+
name: getSpanName(loader, operation),
69+
// The batch runs off a deferred tick and has no obvious network peer, so only `load`/`loadMany`
70+
// get a client kind, matching the vendored OTel instrumentation.
71+
...(operation === 'batch' ? {} : { kind: SPAN_KIND.CLIENT }),
72+
...(isCacheGet ? { op: CACHE_GET_OP } : {}),
73+
onlyIfParent: true,
74+
attributes: {
75+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
76+
},
77+
};
78+
}
79+
80+
let subscribed = false;
81+
82+
const _dataloaderChannelIntegration = (() => {
83+
return {
84+
name: INTEGRATION_NAME,
85+
setupOnce() {
86+
// `tracingChannel` is unavailable before Node 18.19 so do nothing in that case.
87+
if (!diagnosticsChannel.tracingChannel || subscribed) {
88+
return;
89+
}
90+
subscribed = true;
91+
92+
DEBUG_BUILD && debug.log('[orchestrion:dataloader] subscribing to dataloader tracing channels');
93+
94+
waitForTracingChannelBinding(() => {
95+
subscribeConstruct();
96+
subscribeLoad();
97+
subscribeSimpleOperation(CHANNELS.DATALOADER_LOAD_MANY, 'loadMany');
98+
subscribeSimpleOperation(CHANNELS.DATALOADER_PRIME, 'prime');
99+
subscribeSimpleOperation(CHANNELS.DATALOADER_CLEAR, 'clear');
100+
subscribeSimpleOperation(CHANNELS.DATALOADER_CLEAR_ALL, 'clearAll');
101+
});
102+
},
103+
};
104+
}) satisfies IntegrationFn;
105+
106+
/**
107+
* Wraps the user's `batchLoadFn` (constructor arg 0) so the batch span opens when it runs on the
108+
* deferred dispatch tick. The span links back to the `load` calls that populated the batch.
109+
*/
110+
function subscribeConstruct(): void {
111+
diagnosticsChannel
112+
.tracingChannel<DataLoaderChannelContext>(CHANNELS.DATALOADER_CONSTRUCT)
113+
.start.subscribe(message => {
114+
const data = message as DataLoaderChannelContext;
115+
const batchLoadFn = data.arguments[0];
116+
if (typeof batchLoadFn !== 'function' || (batchLoadFn as { [WRAPPED]?: boolean })[WRAPPED]) {
117+
return;
118+
}
119+
120+
const original = batchLoadFn as (...args: unknown[]) => unknown;
121+
const wrapped = function (this: DataLoaderInstance, ...args: unknown[]): unknown {
122+
return startSpan({ ...makeSpanOptions(this, 'batch'), links: this._batch?.spanLinks }, () =>
123+
original.apply(this, args),
124+
);
125+
};
126+
(wrapped as { [WRAPPED]?: boolean })[WRAPPED] = true;
127+
data.arguments[0] = wrapped;
128+
});
129+
}
130+
131+
/**
132+
* `load` is a cache read that additionally records its span so the batch it feeds into can link back.
133+
* The link is recorded on span end (not creation) because `dataloader` only assigns the batch the
134+
* span belongs to inside `load`'s body, after our `start` hook has already run.
135+
*/
136+
function subscribeLoad(): void {
137+
bindTracingChannelToSpan(
138+
diagnosticsChannel.tracingChannel<DataLoaderChannelContext>(CHANNELS.DATALOADER_LOAD),
139+
data => (getActiveSpan() ? startInactiveSpanFor(data.self, 'load') : undefined),
140+
{
141+
beforeSpanEnd(span, data) {
142+
const batch = data.self?._batch;
143+
if (batch && span.isRecording()) {
144+
(batch.spanLinks ??= []).push({ context: span.spanContext() });
145+
}
146+
},
147+
},
148+
);
149+
}
150+
151+
function subscribeSimpleOperation(channelName: ChannelName, operation: Operation): void {
152+
bindTracingChannelToSpan(diagnosticsChannel.tracingChannel<DataLoaderChannelContext>(channelName), data =>
153+
getActiveSpan() ? startInactiveSpanFor(data.self, operation) : undefined,
154+
);
155+
}
156+
157+
function startInactiveSpanFor(loader: DataLoaderInstance | undefined, operation: Operation): Span {
158+
return startInactiveSpan(makeSpanOptions(loader, operation));
159+
}
160+
161+
/**
162+
* EXPERIMENTAL: orchestrion-driven `dataloader` integration.
163+
*
164+
* Subscribes to the `orchestrion:dataloader:*` diagnostics_channels that the orchestrion code
165+
* transform injects into `dataloader`'s constructor and prototype methods. Requires the orchestrion
166+
* runtime hook or bundler plugin to be active.
167+
*/
168+
export const dataloaderChannelIntegration = defineIntegration(_dataloaderChannelIntegration);
Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,52 @@
11
import type { InstrumentationConfig } from '@apm-js-collab/code-transformer';
22

3-
// TODO: Stub for the `dataloader` orchestrion integration (ports `@opentelemetry/instrumentation-dataloader`).
4-
export const dataloaderConfig: InstrumentationConfig[] = [];
3+
// `dataloader` ships a single transpiled CommonJS `index.js`. Its class methods are emitted as
4+
// `_proto.<name> = function <name>() {}` (named function *expressions*), so they match on
5+
// `expressionName` rather than `methodName`. The constructor is a named function declaration.
6+
// The version range mirrors `supportedVersions` in the vendored OTel instrumentation.
7+
const module = { name: 'dataloader', versionRange: '>=2.0.0 <3', filePath: 'index.js' } as const;
58

6-
export const dataloaderChannels = {} as const;
9+
export const dataloaderConfig = [
10+
// Wrap the constructor so the subscriber can wrap the user's `batchLoadFn` (arg 0). The batch span
11+
// is opened when that wrapped function actually runs (on the deferred dispatch tick), mirroring the
12+
// vendored OTel instrumentation which also wraps `batchLoadFn` at construction time.
13+
{
14+
channelName: 'construct',
15+
module,
16+
functionQuery: { functionName: 'DataLoader', kind: 'Sync' },
17+
},
18+
{
19+
channelName: 'load',
20+
module,
21+
functionQuery: { expressionName: 'load', kind: 'Sync' },
22+
},
23+
{
24+
channelName: 'loadMany',
25+
module,
26+
functionQuery: { expressionName: 'loadMany', kind: 'Sync' },
27+
},
28+
{
29+
channelName: 'prime',
30+
module,
31+
functionQuery: { expressionName: 'prime', kind: 'Sync' },
32+
},
33+
{
34+
channelName: 'clear',
35+
module,
36+
functionQuery: { expressionName: 'clear', kind: 'Sync' },
37+
},
38+
{
39+
channelName: 'clearAll',
40+
module,
41+
functionQuery: { expressionName: 'clearAll', kind: 'Sync' },
42+
},
43+
] satisfies InstrumentationConfig[];
44+
45+
export const dataloaderChannels = {
46+
DATALOADER_CONSTRUCT: 'orchestrion:dataloader:construct',
47+
DATALOADER_LOAD: 'orchestrion:dataloader:load',
48+
DATALOADER_LOAD_MANY: 'orchestrion:dataloader:loadMany',
49+
DATALOADER_PRIME: 'orchestrion:dataloader:prime',
50+
DATALOADER_CLEAR: 'orchestrion:dataloader:clear',
51+
DATALOADER_CLEAR_ALL: 'orchestrion:dataloader:clearAll',
52+
} as const;

packages/server-utils/src/orchestrion/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { amqplibChannelIntegration } from '../integrations/tracing-channel/amqplib';
22
import { anthropicChannelIntegration } from '../integrations/tracing-channel/anthropic';
3+
import { dataloaderChannelIntegration } from '../integrations/tracing-channel/dataloader';
34
import { googleGenAIChannelIntegration } from '../integrations/tracing-channel/google-genai';
45
import {
56
graphqlChannelIntegration,
@@ -20,6 +21,7 @@ export { detectOrchestrionSetup, isOrchestrionInjected } from './detect';
2021
export {
2122
amqplibChannelIntegration,
2223
anthropicChannelIntegration,
24+
dataloaderChannelIntegration,
2325
googleGenAIChannelIntegration,
2426
graphqlChannelIntegration,
2527
hapiChannelIntegration,
@@ -54,6 +56,11 @@ export type * from '../integrations/tracing-channel/graphql/graphql-types';
5456
* NOTE: `ioredisChannelIntegration` and `redisChannelIntegration` are intentionally NOT here. They
5557
* only partially replace the composite OTel `Redis` integration and need the node SDK's redis cache
5658
* `responseHook` (which can't live in `server-utils`), so `@sentry/node` wires them up separately.
59+
*
60+
* NOTE: `dataloaderChannelIntegration` is also NOT here. Everything in this map is auto-appended to
61+
* the default integrations, but the OTel `Dataloader` integration is opt-in (never a default), so
62+
* `@sentry/node`'s `dataloaderIntegration()` factory swaps to the channel version itself when the
63+
* user has opted into diagnostics-channel injection, keeping it opt-in in both modes.
5764
*/
5865
export const channelIntegrations = {
5966
postgresIntegration: postgresChannelIntegration,

0 commit comments

Comments
 (0)