Skip to content

Commit ae93c4e

Browse files
mydeaisaacsclaude
committed
fix(server-utils): Ensure orchestrion instrumentation lazy registers on Node (#22518)
Co-Authored-By: isaacs <i@izs.me> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9df27e7 commit ae93c4e

64 files changed

Lines changed: 1270 additions & 966 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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
// Opt into diagnostics-channel injection. This registers the runtime module
5+
// hook and swaps the OTel integrations for the channel-based ones. The scenario
6+
// then checks that the channel subscribers are NOT wired up until the module
7+
// is actually loaded.
8+
Sentry.experimentalUseDiagnosticsChannelInjection();
9+
10+
Sentry.init({
11+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
12+
release: '1.0',
13+
tracesSampleRate: 1.0,
14+
transport: loggingTransport,
15+
});
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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
});

packages/core/src/client.ts

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

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

1206+
/**
1207+
* Emit a hook when an orchestrion-instrumented module is injected at runtime.
1208+
*/
1209+
public emit(hook: 'orchestrion.module-runtime-injected', moduleName: string): void;
1210+
11961211
/**
11971212
* Emit a hook that was previously registered via `on()`.
11981213
*/

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

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,12 @@ 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
SPAN_STATUS_ERROR,
1110
startInactiveSpan,
1211
timestampInSeconds,
13-
waitForTracingChannelBinding,
1412
} from '@sentry/core';
1513
// eslint-disable-next-line typescript/no-deprecated -- NET_PEER_* emitted alongside SERVER_* for backwards compatibility (TODO(v11): remove)
1614
import {
@@ -27,7 +25,8 @@ import {
2725
SERVER_PORT,
2826
URL_FULL,
2927
} from '@sentry/conventions/attributes';
30-
import { DEBUG_BUILD } from '../../debug-build';
28+
import { amqplibModuleNames } from '../../orchestrion/config/amqplib';
29+
import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation';
3130
import { CHANNELS } from '../../orchestrion/channels';
3231
import { bindTracingChannelToSpan } from '../../tracing-channel';
3332

@@ -156,36 +155,24 @@ interface AmqpConnectContext {
156155

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

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

167+
function instrumentAmqplib(): void {
168+
subscribeConnect();
169+
subscribePublish();
170+
subscribeConfirmPublish();
171+
subscribeConsume();
172+
subscribeDispatch();
173+
subscribeSettle();
174+
}
175+
189176
/**
190177
* Producer span for `Channel.prototype.publish`. Creates a PRODUCER span, injects the trace headers
191178
* 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: when enabled, the OTel 'Anthropic_AI'
2423
// integration is dropped from the default set (see the Node opt-in loader).
@@ -47,43 +46,34 @@ interface AnthropicChannelContext {
4746
result?: unknown;
4847
}
4948

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

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

0 commit comments

Comments
 (0)