-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(server-utils): Rewrite @opentelemetry/instrumentation-dataloader to orchestrion
#22236
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1127320
f0a35ca
cee6fe0
9c88b56
bbae2cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { dataloaderChannelIntegration } from '@sentry/server-utils/orchestrion'; | ||
| import type { Integration, IntegrationFn } from '@sentry/core'; | ||
| import { defineIntegration, extendIntegration } from '@sentry/core'; | ||
| import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; | ||
|
|
||
| const INTEGRATION_NAME = 'DenoDataloader' as const; | ||
|
|
||
| /** | ||
| * Create spans for `dataloader` load/batch operations under Deno. | ||
| * | ||
| * `dataloader` channels are injected by the orchestrion runtime hook at load time. | ||
| * The `@sentry/deno/import` loader must be active for this integration to | ||
| * record anything. | ||
| * | ||
| * The channel-subscription logic is shared with the other server runtimes in | ||
| * `@sentry/server-utils`. This just installs Deno's `AsyncLocalStorage` context | ||
| * strategy (so spans nest under the active span and survive dataloader's deferred | ||
| * batch dispatch) before delegating. | ||
| */ | ||
| const _denoDataloaderIntegration = (() => { | ||
| const inner = dataloaderChannelIntegration(); | ||
|
|
||
| return extendIntegration(inner, { | ||
| name: INTEGRATION_NAME, | ||
| setupOnce() { | ||
| setAsyncLocalStorageAsyncContextStrategy(); | ||
| }, | ||
| }); | ||
| }) satisfies IntegrationFn; | ||
|
|
||
| export const denoDataloaderIntegration = defineIntegration(_denoDataloaderIntegration) as () => Integration & { | ||
| name: 'DenoDataloader'; | ||
| setupOnce: () => void; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,8 @@ | ||
| import { DataloaderInstrumentation } from './vendored/instrumentation'; | ||
| import type { IntegrationFn } from '@sentry/core'; | ||
| import { defineIntegration } from '@sentry/core'; | ||
| import { generateInstrumentOnce } from '@sentry/node-core'; | ||
| import { dataloaderChannelIntegration, isOrchestrionInjected } from '@sentry/server-utils/orchestrion'; | ||
| import { DataloaderInstrumentation } from './vendored/instrumentation'; | ||
|
|
||
| const INTEGRATION_NAME = 'Dataloader' as const; | ||
|
|
||
|
|
@@ -11,7 +12,15 @@ const _dataloaderIntegration = (() => { | |
| return { | ||
| name: INTEGRATION_NAME, | ||
| setupOnce() { | ||
| instrumentDataloader(); | ||
| // Decide here, not in the factory: the runtime channel injection runs inside `Sentry.init()`, | ||
| // after the integrations array has already been built, so `isOrchestrionInjected()` is only | ||
| // reliable by `setupOnce`. When the diagnostics channels are injected (runtime hook or bundler | ||
| // plugin), subscribe to them; otherwise fall back to the vendored OTel instrumentation. | ||
| if (isOrchestrionInjected()) { | ||
| dataloaderChannelIntegration().setupOnce?.(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. low/consistency nit: It seems like this defines a whole integration, only to call
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a bit awkward, I think exposing The Nest is kinda self contained over there, so maybe it's a bit different? I'd lean toward leaving it, but happy to change it to match if you feel strongly. WDYT? |
||
| } else { | ||
| instrumentDataloader(); | ||
| } | ||
| }, | ||
| }; | ||
| }) satisfies IntegrationFn; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| import * as diagnosticsChannel from 'node:diagnostics_channel'; | ||
| import type { IntegrationFn, Span, StartSpanOptions } from '@sentry/core'; | ||
| import { | ||
| debug, | ||
| defineIntegration, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, | ||
| SPAN_KIND, | ||
| startInactiveSpan, | ||
| startSpan, | ||
| waitForTracingChannelBinding, | ||
| } from '@sentry/core'; | ||
| import { DEBUG_BUILD } from '../../debug-build'; | ||
| import type { ChannelName } from '../../orchestrion/channels'; | ||
| import { CHANNELS } from '../../orchestrion/channels'; | ||
| import type { TracingChannelPayloadWithSpan } from '../../tracing-channel'; | ||
| import { bindTracingChannelToSpan } from '../../tracing-channel'; | ||
|
|
||
| // NOTE: this uses the same name as the OTel integration by design. | ||
| // When enabled, the OTel 'Dataloader' integration is omitted from the default set. | ||
| const INTEGRATION_NAME = 'Dataloader' as const; | ||
|
|
||
| const MODULE_NAME = 'dataloader'; | ||
| const ORIGIN = 'auto.db.orchestrion.dataloader'; | ||
|
|
||
| // `load`, `loadMany` and `batch` are cache reads; the rest are cache mutations that get no `op`. | ||
| const CACHE_GET_OP = 'cache.get'; | ||
|
|
||
| type Operation = 'load' | 'loadMany' | 'batch' | 'prime' | 'clear' | 'clearAll'; | ||
|
|
||
| // The link shape shared between a `load` span and the `batch` span it triggers. | ||
| type DataLoaderSpanLink = { context: ReturnType<Span['spanContext']> }; | ||
|
|
||
| // The private batch object `dataloader` stores on the loader. We stash the pending `load` span links | ||
| // here (matching the vendored OTel instrumentation) so the batch span can link back to them. | ||
| interface DataLoaderBatch { | ||
| spanLinks?: DataLoaderSpanLink[]; | ||
| } | ||
|
|
||
| interface DataLoaderInstance { | ||
| name?: string | null; | ||
| _batch?: DataLoaderBatch | null; | ||
| } | ||
|
|
||
| /** | ||
| * The shape orchestrion's transform attaches to the tracing-channel `context`. Documented here rather | ||
| * than imported because orchestrion's runtime doesn't export it. | ||
| */ | ||
| interface DataLoaderChannelContext { | ||
| arguments: unknown[]; | ||
| self?: DataLoaderInstance; | ||
| result?: unknown; | ||
| error?: unknown; | ||
| } | ||
|
|
||
| // Marks a wrapped `batchLoadFn` so a re-used loader (or a double construct) isn't wrapped twice. | ||
| const WRAPPED = Symbol('sentry.dataloader.wrapped'); | ||
|
|
||
| function getSpanName(loader: DataLoaderInstance | undefined, operation: Operation): string { | ||
| const name = loader?.name; | ||
|
|
||
| return name ? `${MODULE_NAME}.${operation} ${name}` : `${MODULE_NAME}.${operation}`; | ||
| } | ||
|
|
||
| function makeSpanOptions(loader: DataLoaderInstance | undefined, operation: Operation): StartSpanOptions { | ||
| const isCacheGet = operation === 'load' || operation === 'loadMany' || operation === 'batch'; | ||
|
|
||
| return { | ||
| name: getSpanName(loader, operation), | ||
| // Every direct operation (`load`/`loadMany`/`prime`/`clear`/`clearAll`) is a client call, matching | ||
| // the vendored OTel instrumentation. The `batch` runs off a deferred tick with no obvious network | ||
| // peer, so it gets no kind. | ||
| kind: operation === 'batch' ? undefined : SPAN_KIND.CLIENT, | ||
| op: isCacheGet ? CACHE_GET_OP : undefined, | ||
|
logaretm marked this conversation as resolved.
|
||
| onlyIfParent: true, | ||
| attributes: { | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| const _dataloaderChannelIntegration = (() => { | ||
|
logaretm marked this conversation as resolved.
|
||
| return { | ||
| name: INTEGRATION_NAME, | ||
| setupOnce() { | ||
| // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. | ||
| if (!diagnosticsChannel.tracingChannel) { | ||
| return; | ||
| } | ||
|
|
||
| DEBUG_BUILD && debug.log('[orchestrion:dataloader] subscribing to dataloader tracing channels'); | ||
|
|
||
| waitForTracingChannelBinding(() => { | ||
|
logaretm marked this conversation as resolved.
|
||
| subscribeConstruct(); | ||
| subscribeLoad(); | ||
| subscribeSimpleOperation(CHANNELS.DATALOADER_LOAD_MANY, 'loadMany'); | ||
| subscribeSimpleOperation(CHANNELS.DATALOADER_PRIME, 'prime'); | ||
| subscribeSimpleOperation(CHANNELS.DATALOADER_CLEAR, 'clear'); | ||
| subscribeSimpleOperation(CHANNELS.DATALOADER_CLEAR_ALL, 'clearAll'); | ||
| }); | ||
| }, | ||
| }; | ||
| }) satisfies IntegrationFn; | ||
|
|
||
| /** | ||
| * Wraps the user's `batchLoadFn` (constructor arg 0) so the batch span opens when it runs on the | ||
| * deferred dispatch tick. The span links back to the `load` calls that populated the batch. | ||
| */ | ||
| function subscribeConstruct(): void { | ||
| diagnosticsChannel | ||
| .tracingChannel<DataLoaderChannelContext>(CHANNELS.DATALOADER_CONSTRUCT) | ||
| .start.subscribe(message => { | ||
| const data = message as DataLoaderChannelContext; | ||
| const batchLoadFn = data.arguments[0]; | ||
| if (typeof batchLoadFn !== 'function' || (batchLoadFn as { [WRAPPED]?: boolean })[WRAPPED]) { | ||
| return; | ||
| } | ||
|
|
||
| const original = batchLoadFn as (...args: unknown[]) => unknown; | ||
| const wrapped = function (this: DataLoaderInstance, ...args: unknown[]): unknown { | ||
| return startSpan({ ...makeSpanOptions(this, 'batch'), links: this._batch?.spanLinks }, () => | ||
| original.apply(this, args), | ||
| ); | ||
| }; | ||
| (wrapped as { [WRAPPED]?: boolean })[WRAPPED] = true; | ||
| data.arguments[0] = wrapped; | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * `load` is a cache read that additionally records its span so the batch it feeds into can link back. | ||
| * | ||
| * The span itself is `Async` (ends on `asyncEnd`, so its duration covers the awaited load). But the | ||
| * link has to be recorded earlier, on the synchronous `end` (fired when `load` returns): the batch | ||
| * dispatches on a deferred tick and reads `spanLinks` at batch-span creation, which happens BEFORE | ||
| * `load`'s promise resolves (`asyncEnd`) — so recording at span end would be too late and drop the | ||
| * link. The sync `end` runs after `dataloader` has assigned `this._batch` in `load`'s body and before | ||
| * the deferred dispatch, so the link lands in time. | ||
| */ | ||
| function subscribeLoad(): void { | ||
| const channel = diagnosticsChannel.tracingChannel<DataLoaderChannelContext>(CHANNELS.DATALOADER_LOAD); | ||
|
|
||
| bindTracingChannelToSpan(channel, data => startInactiveSpanFor(data.self, 'load'), { requiresParentSpan: true }); | ||
|
|
||
| channel.end.subscribe(message => { | ||
| const data = message as TracingChannelPayloadWithSpan<DataLoaderChannelContext>; | ||
| const span = data._sentrySpan; | ||
| const batch = data.self?._batch; | ||
| if (span && batch && span.isRecording()) { | ||
| (batch.spanLinks ??= []).push({ context: span.spanContext() }); | ||
| } | ||
| }); | ||
| } | ||
|
isaacs marked this conversation as resolved.
|
||
|
|
||
| function subscribeSimpleOperation(channelName: ChannelName, operation: Operation): void { | ||
| bindTracingChannelToSpan( | ||
| diagnosticsChannel.tracingChannel<DataLoaderChannelContext>(channelName), | ||
| data => startInactiveSpanFor(data.self, operation), | ||
| { requiresParentSpan: true }, | ||
| ); | ||
| } | ||
|
|
||
| function startInactiveSpanFor(loader: DataLoaderInstance | undefined, operation: Operation): Span { | ||
| return startInactiveSpan(makeSpanOptions(loader, operation)); | ||
| } | ||
|
|
||
| /** | ||
| * EXPERIMENTAL: orchestrion-driven `dataloader` integration. | ||
| * | ||
| * Subscribes to the `orchestrion:dataloader:*` diagnostics_channels that the orchestrion code | ||
| * transform injects into `dataloader`'s constructor and prototype methods. Requires the orchestrion | ||
| * runtime hook or bundler plugin to be active. | ||
| */ | ||
| export const dataloaderChannelIntegration = defineIntegration(_dataloaderChannelIntegration); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,55 @@ | ||
| import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; | ||
|
|
||
| // TODO: Stub for the `dataloader` orchestrion integration (ports `@opentelemetry/instrumentation-dataloader`). | ||
| export const dataloaderConfig: InstrumentationConfig[] = []; | ||
| // `dataloader` ships a single transpiled CommonJS `index.js`. Its class methods are emitted as | ||
| // `_proto.<name> = function <name>() {}` (named function *expressions*), so they match on | ||
| // `expressionName` rather than `methodName`. The constructor is a named function declaration. | ||
| // The version range mirrors `supportedVersions` in the vendored OTel instrumentation. | ||
| const module = { name: 'dataloader', versionRange: '>=2.0.0 <3', filePath: 'index.js' } as const; | ||
|
|
||
| export const dataloaderChannels = {} as const; | ||
| export const dataloaderConfig = [ | ||
| // Wrap the constructor so the subscriber can wrap the user's `batchLoadFn` (arg 0). The batch span | ||
| // is opened when that wrapped function actually runs (on the deferred dispatch tick), mirroring the | ||
| // vendored OTel instrumentation which also wraps `batchLoadFn` at construction time. | ||
| { | ||
| channelName: 'construct', | ||
| module, | ||
| functionQuery: { functionName: 'DataLoader', kind: 'Sync' }, | ||
| }, | ||
| // `load`/`loadMany` return Promises, so they're `Async`: the span ends on `asyncEnd` (when the | ||
| // load resolves), capturing the real latency and enclosing the deferred `batch` span — matching the | ||
| // vendored OTel `startSpan`. `prime`/`clear`/`clearAll` return `this` synchronously, so they stay `Sync`. | ||
| { | ||
| channelName: 'load', | ||
| module, | ||
| functionQuery: { expressionName: 'load', kind: 'Async' }, | ||
| }, | ||
| { | ||
| channelName: 'loadMany', | ||
| module, | ||
| functionQuery: { expressionName: 'loadMany', kind: 'Async' }, | ||
| }, | ||
| { | ||
| channelName: 'prime', | ||
| module, | ||
| functionQuery: { expressionName: 'prime', kind: 'Sync' }, | ||
| }, | ||
| { | ||
| channelName: 'clear', | ||
| module, | ||
| functionQuery: { expressionName: 'clear', kind: 'Sync' }, | ||
| }, | ||
| { | ||
| channelName: 'clearAll', | ||
| module, | ||
| functionQuery: { expressionName: 'clearAll', kind: 'Sync' }, | ||
| }, | ||
| ] satisfies InstrumentationConfig[]; | ||
|
|
||
| export const dataloaderChannels = { | ||
| DATALOADER_CONSTRUCT: 'orchestrion:dataloader:construct', | ||
| DATALOADER_LOAD: 'orchestrion:dataloader:load', | ||
| DATALOADER_LOAD_MANY: 'orchestrion:dataloader:loadMany', | ||
| DATALOADER_PRIME: 'orchestrion:dataloader:prime', | ||
| DATALOADER_CLEAR: 'orchestrion:dataloader:clear', | ||
| DATALOADER_CLEAR_ALL: 'orchestrion:dataloader:clearAll', | ||
| } as const; |
Uh oh!
There was an error while loading. Please reload this page.