Skip to content

Commit 36ecc10

Browse files
isaacsmydeaclaudeJPeer264
authored
fix(server-utils): Ensure all orchestrion instrumentation lazy registers (#22518)
This ports and refactors the intent of #22387, using the mechanisms landed on `develop` in #22094, rather than the mechanisms in #22386 which are similar in intent, but substantially different in implementation. The difference from #22387 is entirely in the plumbing underneath the helper. The way that "is my module injected?" and "tell me when it gets injected" are answered, both now use the machinery that already landed. Beyond that, the actual registration, event emitting, double-wrap guard, and integration refactoring, should all look very familiar. --------- Co-authored-by: Francesco Novy <francesco.novy@sentry.io> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Jan Peer Stöcklmair <jan.peer@sentry.io>
1 parent 1e870a5 commit 36ecc10

74 files changed

Lines changed: 1721 additions & 955 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
// `tracesSampleRate: 1.0` turns on span recording, so `init()` registers the
5+
// runtime module hook. The scenario then checks that the channel subscribers
6+
// are NOT wired up until the instrumented module is actually loaded.
7+
Sentry.init({
8+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
9+
release: '1.0',
10+
tracesSampleRate: 1.0,
11+
transport: loggingTransport,
12+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { strict as assert } from 'node:assert';
2+
import { tracingChannel } from 'node:diagnostics_channel';
3+
4+
// Reproduces the force-bundled path (vite SSR, nextjs's bundle-safe packages):
5+
// the module is transformed at BUILD time and inlined, so it is never loaded
6+
// through the runtime module hook and its `orchestrion.module-runtime-injected`
7+
// event never fires. Instead the bundler's `injectDiagnostics` boot banner sets
8+
// `.bundler` and calls the on-inject bridge, which must trigger the lazy
9+
// channel subscription. We simulate that banner here, WITHOUT ever importing
10+
// generic-pool.
11+
12+
const channel = tracingChannel('orchestrion:generic-pool:acquire');
13+
14+
// `init()` (in instrument.mjs) installed the bridge and registered the lazy
15+
// listener, but nothing is injected yet, so the channel has no subscriber.
16+
const marker = globalThis.__SENTRY_ORCHESTRION__;
17+
assert.ok(marker, 'expected __SENTRY_ORCHESTRION__ marker to exist after init');
18+
assert.equal(typeof marker.onInject, 'function', 'expected the on-inject bridge to be installed by init()');
19+
assert.equal(
20+
channel.start.hasSubscribers,
21+
false,
22+
'expected NO subscribers before the bundler banner announces the module',
23+
);
24+
25+
// Simulate the bundler's `injectDiagnostics` boot banner: record the bundled
26+
// module and fire the bridge for it. In a real build this runs when the app
27+
// bundle boots, after `init()`.
28+
marker.bundler = ['generic-pool'];
29+
marker.onInject('generic-pool');
30+
31+
// The bridge re-emitted `orchestrion.module-runtime-injected`, so the
32+
// GenericPool integration must have subscribed, even though generic-pool was
33+
// never loaded through the module hook.
34+
assert.equal(
35+
channel.start.hasSubscribers,
36+
true,
37+
'expected subscribers after the bundler banner fired the on-inject bridge',
38+
);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { strict as assert } from 'node:assert';
2+
import { tracingChannel } from 'node:diagnostics_channel';
3+
4+
// `generic-pool` is a default channel integration and a pure, service-free
5+
// require, so loading it is enough to trigger the runtime hook's injection.
6+
const channel = tracingChannel('orchestrion:generic-pool:acquire');
7+
8+
// After `init()` but BEFORE `generic-pool` is loaded, a lazily-registering
9+
// integration must not have subscribed to the channel yet — otherwise every
10+
// default channel integration would consume channel slots up front (Node caps
11+
// channels in use at 1024), even for modules the app never loads.
12+
assert.equal(
13+
channel.start.hasSubscribers,
14+
false,
15+
'expected NO subscribers on orchestrion:generic-pool:acquire before generic-pool is loaded',
16+
);
17+
18+
// Loading the module triggers the runtime hook to inject it, which is the point
19+
// at which the integration should wire up its channel subscriber.
20+
await import('generic-pool');
21+
22+
assert.equal(
23+
channel.start.hasSubscribers,
24+
true,
25+
'expected subscribers on orchestrion:generic-pool:acquire after generic-pool is loaded',
26+
);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import * as path from 'path';
2+
import { afterAll, test } from 'vitest';
3+
import { conditionalTest } from '../../../utils';
4+
import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
5+
6+
afterAll(() => {
7+
cleanupChildProcesses();
8+
});
9+
10+
// The runtime module hook needs Node >= 18.19; gate on 20 to stay on the stable
11+
// `Module.registerHooks` / `Channel.hasSubscribers` surface.
12+
conditionalTest({ min: 20 })('orchestrion lazy channel registration', () => {
13+
// The scenario self-asserts (via `node:assert`) that a default channel
14+
// integration has NOT subscribed to its channel until the instrumented module
15+
// is loaded, then that it HAS once loaded. A violation throws, which
16+
// `ensureNoErrorOutput` turns into a test failure.
17+
test('does not attach channel listeners until the module is loaded', async () => {
18+
await createRunner(__dirname, 'scenario.mjs')
19+
.withInstrument(path.join(__dirname, 'instrument.mjs'))
20+
.ensureNoErrorOutput()
21+
.start()
22+
.completed();
23+
});
24+
25+
// A force-bundled module (vite SSR / nextjs bundle-safe packages) is never
26+
// loaded through the runtime hook, so it can only trigger subscription via
27+
// the bundler's boot banner → on-inject bridge. The scenario simulates that
28+
// banner and asserts the channel subscribes without the module ever being
29+
// loaded through the hook.
30+
test('subscribes for a bundler-announced module via the on-inject bridge', async () => {
31+
await createRunner(__dirname, 'scenario-bundler.mjs')
32+
.withInstrument(path.join(__dirname, 'instrument.mjs'))
33+
.ensureNoErrorOutput()
34+
.start()
35+
.completed();
36+
});
37+
});

packages/core/src/client.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,16 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
934934
*/
935935
public on(hook: 'stopUIProfiler', callback: () => void): () => void;
936936

937+
/**
938+
* A hook that is called when an orchestrion-instrumented module is injected at
939+
* runtime (by the `--import` module hook). Channel-based integrations use it to
940+
* subscribe their diagnostics-channel listeners lazily, only once the module
941+
* they instrument is actually loaded. Receives the injected module name.
942+
*
943+
* @returns {() => void} A function that, when executed, removes the registered callback.
944+
*/
945+
public on(hook: 'orchestrion.module-runtime-injected', callback: (moduleName: string) => void): () => void;
946+
937947
/**
938948
* Register a hook on this client.
939949
*/
@@ -1198,6 +1208,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
11981208
*/
11991209
public emit(hook: 'stopUIProfiler'): void;
12001210

1211+
/**
1212+
* Emit a hook when an orchestrion-instrumented module is injected at runtime.
1213+
*/
1214+
public emit(hook: 'orchestrion.module-runtime-injected', moduleName: string): void;
1215+
12011216
/**
12021217
* Emit a hook that was previously registered via `on()`.
12031218
*/

packages/core/src/utils/worldwide.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ export type InternalGlobal = {
7171
* `init()` and instantiates them.
7272
*/
7373
integrations?: Map<string, () => Integration>;
74+
/**
75+
* Bridge installed at `init()` by `registerDiagnosticsChannelInjection`.
76+
* The bundler's `injectDiagnostics` boot banner calls it for each
77+
* transformed module, emitting the `orchestrion.module-runtime-injected`
78+
* client event so channel integrations subscribe for force-bundled modules
79+
* (which the runtime module hook never sees). Absent on bundler-only
80+
* runtimes (e.g. `@sentry/cloudflare`), where the banner's call is a
81+
* guarded no-op.
82+
*/
83+
onInject?: (moduleName: string) => void;
7484
};
7585
} & Carrier;
7686

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const _redisIntegration = ((options: RedisOptions = {}) => {
1616
// diagnostics_channel subscription (node-redis >= 5.12.0, ioredis >= 5.11.0, and batches) lives in
1717
// server-utils so it is shared across server runtimes; the orchestrion channel integrations cover
1818
// the older node-redis (`<5.12.0`) and ioredis (`<5.11.0`) ranges. We fold the orchestrion
19-
// subscribers into this integration's `setupOnce` so `Sentry.redisIntegration()` alone instruments
19+
// subscribers into this integration's `setup` so `Sentry.redisIntegration()` alone instruments
2020
// all ranges, even with `defaultIntegrations: []`. All three share the node cache `responseHook`,
2121
// which reads the options set below but only runs at command time, by which point they are set.
2222
const orchestrionIntegrations = [
@@ -28,8 +28,10 @@ const _redisIntegration = ((options: RedisOptions = {}) => {
2828
name: INTEGRATION_NAME,
2929
setupOnce() {
3030
setRedisOptions(options);
31+
},
32+
setup(client) {
3133
for (const integration of orchestrionIntegrations) {
32-
integration.setupOnce?.();
34+
integration.setup?.(client);
3335
}
3436
},
3537
});

packages/node/src/sdk/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,10 @@ function _init(
157157
tracesSampleRate: getTracesSampleRate(options.tracesSampleRate),
158158
};
159159

160-
// Channel-based (orchestrion diagnostics-channel) instrumentation is the default. Gated on span
161-
// recording: the channel integrations only produce spans, so with tracing off there are no
162-
// subscribers and injecting the module hooks would be pointless work. Install the hooks as early
163-
// as possible, before the app imports its instrumented modules.
160+
// Gate channel-based (orchestrion diagnostics-channel) instrumentation on span recording: the
161+
// channel integrations only produce spans, so with tracing off there are no subscribers and
162+
// injecting the module hooks would be pointless work. Install the hooks as early as possible,
163+
// before the app imports its instrumented modules.
164164
const useChannelInjection = hasSpansEnabled(optionsWithResolvedTracing);
165165
if (useChannelInjection) {
166166
registerDiagnosticsChannelInjection();

packages/server-utils/src/integrations/tracing-channel/amqplib.ts

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,13 @@ import * as diagnosticsChannel from 'node:diagnostics_channel';
33
import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core';
44
import {
55
continueTrace,
6-
debug,
76
defineIntegration,
87
getTraceData,
98
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
109
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
1110
SPAN_STATUS_ERROR,
1211
startInactiveSpan,
1312
timestampInSeconds,
14-
waitForTracingChannelBinding,
1513
} from '@sentry/core';
1614
// eslint-disable-next-line typescript/no-deprecated -- NET_PEER_* emitted alongside SERVER_* for backwards compatibility (TODO(v11): remove)
1715
import {
@@ -28,7 +26,8 @@ import {
2826
SERVER_PORT,
2927
URL_FULL,
3028
} from '@sentry/conventions/attributes';
31-
import { DEBUG_BUILD } from '../../debug-build';
29+
import { amqplibModuleNames } from '../../orchestrion/config/amqplib';
30+
import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation';
3231
import { CHANNELS } from '../../orchestrion/channels';
3332
import { bindTracingChannelToSpan } from '../../tracing-channel';
3433

@@ -157,36 +156,24 @@ interface AmqpConnectContext {
157156

158157
const NOOP = (): void => {};
159158

160-
// Guards against subscribing to the amqplib channels more than once in a process. Core dedupes
161-
// `setupOnce` by integration *name*, which is not enough here: the Deno SDK wraps this integration
162-
// under a different name (`DenoAmqplib`) via `extendIntegration`, so adding both would otherwise run
163-
// the subscribe logic twice and emit duplicate spans for every operation.
164-
let subscribed = false;
165-
166159
const _amqplibIntegration = (() => {
167160
return {
168161
name: INTEGRATION_NAME,
169-
setupOnce() {
170-
// `tracingChannel` is unavailable before Node 18.19 so do nothing in that case.
171-
if (!diagnosticsChannel.tracingChannel || subscribed) {
172-
return;
173-
}
174-
subscribed = true;
175-
176-
DEBUG_BUILD && debug.log('[orchestrion:amqplib] subscribing to amqplib tracing channels');
177-
178-
waitForTracingChannelBinding(() => {
179-
subscribeConnect();
180-
subscribePublish();
181-
subscribeConfirmPublish();
182-
subscribeConsume();
183-
subscribeDispatch();
184-
subscribeSettle();
185-
});
162+
setup(client) {
163+
invokeOrchestrionInstrumentation(client, amqplibModuleNames, instrumentAmqplib, []);
186164
},
187165
};
188166
}) satisfies IntegrationFn;
189167

168+
function instrumentAmqplib(): void {
169+
subscribeConnect();
170+
subscribePublish();
171+
subscribeConfirmPublish();
172+
subscribeConsume();
173+
subscribeDispatch();
174+
subscribeSettle();
175+
}
176+
190177
/**
191178
* Producer span for `Channel.prototype.publish`. Creates a PRODUCER span, injects the trace headers
192179
* into the publish options, and ends when the (synchronous) publish call returns. Skips the confirm

packages/server-utils/src/integrations/tracing-channel/anthropic.ts

Lines changed: 23 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
_INTERNAL_shouldSkipAiProviderWrapping,
66
addAnthropicRequestAttributes,
77
addAnthropicResponseAttributes,
8-
debug,
98
defineIntegration,
109
extractAnthropicRequestAttributes,
1110
instrumentAsyncIterableStream,
@@ -14,11 +13,11 @@ import {
1413
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
1514
shouldEnableTruncation,
1615
startInactiveSpan,
17-
waitForTracingChannelBinding,
1816
} from '@sentry/core';
19-
import { DEBUG_BUILD } from '../../debug-build';
2017
import { CHANNELS } from '../../orchestrion/channels';
2118
import { bindTracingChannelToSpan } from '../../tracing-channel';
19+
import { anthropicAiModuleNames } from '../../orchestrion/config/anthropic-ai';
20+
import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation';
2221

2322
// Same name as the OTel integration by design, so the OTel 'Anthropic_AI'
2423
// integration is deduplicated out of the default set.
@@ -45,43 +44,34 @@ interface AnthropicChannelContext {
4544
result?: unknown;
4645
}
4746

48-
let subscribed = false;
49-
5047
const _anthropicIntegration = ((options: AnthropicAiOptions = {}) => {
5148
return {
5249
name: INTEGRATION_NAME,
53-
setupOnce() {
54-
// tracingChannel is unavailable before Node 18.19 and prevent double-subscribe
55-
if (!diagnosticsChannel.tracingChannel || subscribed) {
56-
return;
57-
}
58-
subscribed = true;
59-
60-
// `bindTracingChannelToSpan` needs the async-context binding that `initOpenTelemetry()` registers
61-
// after `setupOnce` runs, so wait for it before subscribing.
62-
waitForTracingChannelBinding(() => {
63-
for (const { channel, operation, methodPath, stream } of INSTRUMENTED_CHANNELS) {
64-
DEBUG_BUILD && debug.log(`[orchestrion:anthropic] subscribing to channel "${channel}"`);
65-
bindTracingChannelToSpan(
66-
diagnosticsChannel.tracingChannel<AnthropicChannelContext>(channel),
67-
data => createGenAiSpan(data, operation, methodPath, options),
68-
{
69-
beforeSpanEnd: (span, data) => {
70-
addAnthropicResponseAttributes(
71-
span,
72-
data.result as AnthropicAiResponse,
73-
resolveAIRecordingOptions(options).recordOutputs,
74-
);
75-
},
76-
deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, stream, options),
77-
},
78-
);
79-
}
80-
});
50+
setup(client) {
51+
invokeOrchestrionInstrumentation(client, anthropicAiModuleNames, instrumentAnthropic, [options]);
8152
},
8253
};
8354
}) satisfies IntegrationFn;
8455

56+
function instrumentAnthropic(options: AnthropicAiOptions): void {
57+
for (const { channel, operation, methodPath, stream } of INSTRUMENTED_CHANNELS) {
58+
bindTracingChannelToSpan(
59+
diagnosticsChannel.tracingChannel<AnthropicChannelContext>(channel),
60+
data => createGenAiSpan(data, operation, methodPath, options),
61+
{
62+
beforeSpanEnd: (span, data) => {
63+
addAnthropicResponseAttributes(
64+
span,
65+
data.result as AnthropicAiResponse,
66+
resolveAIRecordingOptions(options).recordOutputs,
67+
);
68+
},
69+
deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, stream, options),
70+
},
71+
);
72+
}
73+
}
74+
8575
/**
8676
* Build the span for an instrumented call.
8777
* Returning `undefined` opts the payload out so no span is opened.

0 commit comments

Comments
 (0)