Skip to content

Commit 3719e1e

Browse files
mydeaclaude
andcommitted
feat(cloudflare): Instrument module-scope orchestrion channels on workerd
On workerd, `diagnostics_channel` `publish()`/`runStores()` throw at module/global scope, so a dependency wrapped by build-time instrumentation and instantiated at module scope (`const app = new Hono()`) either throws or — because the SDK's client is cached and `init()` runs per-request — is never captured, since no subscriber exists yet at module-eval time. Route the orchestrion emit side through a workerd-safe `diagnostics_channel` façade (via the transformer's global `dcModule`, sourced from a new `@sentry/cloudflare/orchestrion-diagnostics-channel` subpath). It delegates to the real channels, forces the emit wrapper past its no-subscriber short-circuit, and when publish can't run at module scope, defers the event onto a module-scope queue that `wrapRequestHandlerWithInit` flushes at the top of the first request — after `init()` has subscribed. Deferral is gated on whether a subscriber was actually present, so a top-level `init()` (subscriber already there) delivers exactly once and the per-request case delivers exactly once at flush. In a request the façade is a transparent pass-through, so lifecycle/span channels behave as on Node. Root cause: `Channel.hasSubscribers` is a getter on Node but a method on workerd, and `publish` throws at global scope after delivering to any current subscribers — both verified against a real workerd (`wrangler dev`, `nodejs_compat`). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 982b514 commit 3719e1e

7 files changed

Lines changed: 223 additions & 1 deletion

File tree

‎packages/cloudflare/package.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@
4141
"types": "./build/types/vite/index.d.ts",
4242
"import": "./build/esm/prod/vite/index.js",
4343
"require": "./build/cjs/prod/vite/index.js"
44+
},
45+
"./orchestrion-diagnostics-channel": {
46+
"types": "./build/types/orchestrion-diagnostics-channel.d.ts",
47+
"import": "./build/esm/prod/orchestrion-diagnostics-channel.js",
48+
"require": "./build/cjs/prod/orchestrion-diagnostics-channel.js"
4449
}
4550
},
4651
"publishConfig": {

‎packages/cloudflare/rollup.npm.config.mjs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu
22

33
export default makeNPMConfigVariants(
44
makeBaseNPMConfig({
5-
entrypoints: ['src/index.ts', 'src/request.ts', 'src/vite/index.ts'],
5+
entrypoints: [
6+
'src/index.ts',
7+
'src/request.ts',
8+
'src/vite/index.ts',
9+
'src/orchestrion-diagnostics-channel.ts',
10+
],
611
}),
712
{ splitDevProd: true },
813
);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Deferral for orchestrion channel events that could not be published when they occurred.
3+
*
4+
* On workerd, `channel.publish()`/`runStores()` throw at module/global scope, so a library
5+
* instantiated at module scope (`const app = new Hono()`) cannot emit its wrapping event
6+
* there. {@link ./orchestrion-diagnostics-channel} defers those events here;
7+
* {@link flushDeferredChannelEvents} emits them once a request is being handled (where
8+
* publish works) and the SDK's `init()` has registered subscribers.
9+
*
10+
* This module imports NO `node:*` builtin, so it is safe to pull into the request wrapper on
11+
* runtimes without `nodejs_compat` — there the queue is always empty (no channels were ever
12+
* injected) and the flush is a no-op.
13+
*/
14+
import type { TracingChannel } from 'node:diagnostics_channel';
15+
import { debug } from '@sentry/core';
16+
import { DEBUG_BUILD } from './debug-build';
17+
18+
type Ctx = Record<string, unknown>;
19+
20+
interface DeferredChannelEvent {
21+
channel: TracingChannel;
22+
ctx: Ctx;
23+
hadError: boolean;
24+
}
25+
26+
const deferred: DeferredChannelEvent[] = [];
27+
const DEFERRED = Symbol('sentryOrchestrionDeferred');
28+
29+
/** workerd's global-scope guard is the only error we degrade on; anything else is a real fault. */
30+
export function isGlobalScopeError(error: unknown): boolean {
31+
return error instanceof Error && /within global scope/.test(error.message);
32+
}
33+
34+
/** Queue a channel event that could not be published now, at most once per context object. */
35+
export function deferChannelEvent(channel: TracingChannel, ctx: Ctx): void {
36+
if ((ctx as Record<symbol, unknown>)[DEFERRED]) {
37+
return;
38+
}
39+
(ctx as Record<symbol, unknown>)[DEFERRED] = true;
40+
deferred.push({ channel, ctx, hadError: 'error' in ctx });
41+
}
42+
43+
/**
44+
* Emit every deferred channel event via the real channel. Called at the top of the request
45+
* handler, after `init()` has subscribed. Uses bare `start`/`end` publish (never `runStores`),
46+
* so no `bindStore` producer runs and no span is parented to this request — only the plain
47+
* subscribers (the instance patchers) fire. Idempotent: it drains the queue, so later requests
48+
* and re-entrant calls are no-ops.
49+
*/
50+
export function flushDeferredChannelEvents(): void {
51+
if (deferred.length === 0) {
52+
return;
53+
}
54+
const pending = deferred.splice(0, deferred.length);
55+
for (const { channel, ctx, hadError } of pending) {
56+
try {
57+
channel.start.publish(ctx);
58+
if (hadError) {
59+
channel.error.publish(ctx);
60+
}
61+
channel.end.publish(ctx);
62+
} catch (error) {
63+
DEBUG_BUILD && debug.warn('[orchestrion] failed to emit a deferred channel event', error);
64+
}
65+
}
66+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* A workerd-safe `diagnostics_channel` façade for the orchestrion EMIT side, and the
3+
* `dcModule` target for the Cloudflare orchestrion build (see `vite/index.ts`): orchestrion's
4+
* transformed code imports `tracingChannel` from here (its default export) instead of
5+
* `node:diagnostics_channel`.
6+
*
7+
* On workerd, `channel.publish()` and `channel.runStores()` throw at module/global scope
8+
* ("Disallowed operation called within global scope …"); only inside a request handler do they
9+
* work. `subscribe` and channel creation are fine anywhere. Orchestrion's transformed code emits
10+
* `start.runStores(...)` / `end.publish(...)` at each wrapped call site, so a library
11+
* instantiated at module scope (`const app = new Hono()`) throws the moment it is wrapped — or,
12+
* if no subscriber exists yet, is skipped by the wrapper's `if (!hasSubscribers) return
13+
* __apm$traced()` short-circuit and never instrumented.
14+
*
15+
* This façade delegates to the real `node:diagnostics_channel` singletons — so subscribers, which
16+
* keep subscribing through `node:diagnostics_channel`, are reached unchanged — and changes two
17+
* things:
18+
*
19+
* 1. `hasSubscribers` always reports `true`, so the wrapper never short-circuits. On Cloudflare a
20+
* channel is only injected for an integration that is actually bundled, so the short-circuit
21+
* buys nothing — and disabling it is what lets the wrapper run (and reach us) at module scope,
22+
* before the per-request cached `init()` has subscribed.
23+
* 2. When a delegated `publish`/`runStores` throws the global-scope error, the event is queued via
24+
* {@link deferChannelEvent} instead. {@link flushDeferredChannelEvents}, called at the top of
25+
* the request handler after `init()`, emits it via the real channel — now inside a request,
26+
* where publish works.
27+
*
28+
* In a request handler nothing throws, so every call is a transparent pass-through to the real
29+
* channel: lifecycle channels (spans, context binding) behave exactly as on Node.
30+
*/
31+
import * as realDc from 'node:diagnostics_channel';
32+
import { deferChannelEvent, isGlobalScopeError } from './orchestrion-deferred-channels';
33+
34+
type Ctx = Record<string, unknown>;
35+
36+
// One façade per channel name (node's `tracingChannel` is itself a per-name singleton), built
37+
// once when the transformed module declares `const CH = tracingChannel(name)`.
38+
const cache = new Map<string, realDc.TracingChannel>();
39+
40+
// `Channel.hasSubscribers` is a getter on Node but a method on workerd — normalize both to a
41+
// boolean. (This shape difference is also why the emit wrapper never short-circuits on workerd:
42+
// its `if (!ch.start.hasSubscribers)` guard reads a truthy method reference.)
43+
function channelHasSubscribers(ch: realDc.Channel): boolean {
44+
const value = (ch as { hasSubscribers: unknown }).hasSubscribers;
45+
return typeof value === 'function' ? Boolean((value as () => unknown).call(ch)) : Boolean(value);
46+
}
47+
48+
function wrapSubChannel(tracing: realDc.TracingChannel, real: realDc.Channel): realDc.Channel {
49+
return {
50+
name: real.name,
51+
// A method (not a getter) so it reads truthy as a property in the emit guard *and* is callable,
52+
// matching workerd's native shape. Forced `true`: on Cloudflare a channel is only injected for a
53+
// bundled integration, so the wrapper's no-subscriber short-circuit buys nothing — and disabling
54+
// it is what lets the wrapper run (and reach us) at module scope before `init()` has subscribed.
55+
hasSubscribers(): boolean {
56+
return true;
57+
},
58+
publish(ctx: Ctx): void {
59+
// Decide deferral by whether anyone is subscribed *now*, not by whether publish throws:
60+
// - With subscribers, workerd delivers to them before throwing the global-scope error (or,
61+
// in a request, doesn't throw at all) — so they get it exactly once; don't defer.
62+
// - With none — the common per-request cached-init case — nobody received it, so defer for a
63+
// subscriber that registers later (at `init()`), which then gets it exactly once.
64+
const hadSubscribers = channelHasSubscribers(real);
65+
try {
66+
real.publish(ctx);
67+
} catch (error) {
68+
if (!isGlobalScopeError(error)) {
69+
throw error;
70+
}
71+
}
72+
if (!hadSubscribers) {
73+
deferChannelEvent(tracing, ctx);
74+
}
75+
},
76+
runStores(ctx: Ctx, fn: (...args: unknown[]) => unknown, ...rest: unknown[]): unknown {
77+
try {
78+
return real.runStores(ctx, fn, ...rest);
79+
} catch (error) {
80+
if (!isGlobalScopeError(error)) {
81+
throw error;
82+
}
83+
// publish() is gated too, so the store can't be established — just run the wrapped call
84+
// so its return value (e.g. the constructed instance) is produced now. Its own
85+
// `end.publish` runs back through this façade and is deferred.
86+
return fn();
87+
}
88+
},
89+
subscribe: real.subscribe.bind(real),
90+
unsubscribe: real.unsubscribe.bind(real),
91+
bindStore: real.bindStore.bind(real),
92+
unbindStore: real.unbindStore.bind(real),
93+
} as unknown as realDc.Channel;
94+
}
95+
96+
export function tracingChannel<T = Ctx>(name: string): realDc.TracingChannel<T> {
97+
const cached = cache.get(name);
98+
if (cached) {
99+
return cached;
100+
}
101+
102+
const real = realDc.tracingChannel(name);
103+
const facade = {
104+
start: wrapSubChannel(real, real.start),
105+
end: wrapSubChannel(real, real.end),
106+
asyncStart: wrapSubChannel(real, real.asyncStart),
107+
asyncEnd: wrapSubChannel(real, real.asyncEnd),
108+
error: wrapSubChannel(real, real.error),
109+
// Iterator (`:next`) channels call these on the tracingChannel itself; delegate to the real
110+
// implementation (they only run in-request, where publish/runStores are allowed).
111+
traceSync: real.traceSync.bind(real),
112+
tracePromise: real.tracePromise.bind(real),
113+
traceCallback: real.traceCallback.bind(real),
114+
subscribe: real.subscribe.bind(real),
115+
unsubscribe: real.unsubscribe.bind(real),
116+
} as unknown as realDc.TracingChannel<T>;
117+
118+
cache.set(name, facade);
119+
return facade;
120+
}
121+
122+
export const channel = realDc.channel;
123+
export const subscribe = realDc.subscribe;
124+
export const unsubscribe = realDc.unsubscribe;
125+
126+
// Orchestrion's emit imports the default and destructures `tracingChannel`.
127+
export default { tracingChannel, channel, subscribe, unsubscribe };

‎packages/cloudflare/src/vite/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOp
8585
return [
8686
sentryOrchestrionPlugin({
8787
buildTimeInstrumentation: options.buildTimeInstrumentation,
88+
// Route the injected `tracingChannel` imports through the workerd-safe façade so a
89+
// dependency wrapped at module scope (`const app = new Hono()`) doesn't throw on workerd.
90+
dcModule: '@sentry/cloudflare/orchestrion-diagnostics-channel',
8891
}),
8992
...(options.autoInstrumentation !== false
9093
? [sentryCloudflareAutoInstrumentPlugin({ wranglerConfigPath: options.wranglerConfigPath })]

‎packages/cloudflare/src/wrapRequestHandlerWithInit.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
winterCGHeadersToDict,
2020
} from '@sentry/core';
2121
import { captureIncomingRequestBody } from './integrations/httpServer';
22+
import { flushDeferredChannelEvents } from './orchestrion-deferred-channels';
2223
import type { CloudflareClient, CloudflareOptions } from './client';
2324
import type { ExecutionContextCompat } from './executionContext';
2425
import { flushAndDispose, getOriginalWaitUntil } from './flush';
@@ -82,6 +83,11 @@ export function wrapRequestHandlerWithInit(
8283
const client = initSdk({ ...options, ctx: context });
8384
isolationScope.setClient(client);
8485

86+
// Emit any orchestrion channel events deferred at module scope (e.g. a top-level
87+
// `const app = new Hono()` wrapped by build-time instrumentation): we're now in a
88+
// request, where workerd allows publish, and init() above has registered subscribers.
89+
flushDeferredChannelEvents();
90+
8591
const urlObject = parseStringToURLObject(request.url);
8692
const [rawName, attributes] = getHttpSpanDetailsFromUrlObject(
8793
urlObject,

‎packages/server-utils/src/orchestrion/bundler/options.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ export type PluginOptions = {
2727
* run.
2828
*/
2929
customTransforms?: Record<string, CustomTransform>;
30+
/**
31+
* Module specifier the transformed code imports its `tracingChannel` from, in
32+
* place of `node:diagnostics_channel`. The Cloudflare build points this at a
33+
* workerd-safe façade so channels wrapped at module scope don't throw. This is
34+
* global — `code-transformer` ignores per-config `dcModule` (the matcher's value
35+
* overwrites it) — but the façade delegates transparently in a request, so
36+
* routing every channel through it is harmless.
37+
*/
38+
dcModule?: string;
3039
};
3140

3241
/**
@@ -74,6 +83,7 @@ export function orchestrionTransformOptions(
7483
return {
7584
instrumentations: [...SENTRY_INSTRUMENTATIONS, ...(options.instrumentations || [])],
7685
customTransforms: { ...options.customTransforms, ...moduleInjectedTransforms() },
86+
...(options.dcModule && { dcModule: options.dcModule }),
7787
...(injectDiagnostics && { injectDiagnostics: () => ORCHESTRION_BUNDLER_MARKER_BANNER }),
7888
};
7989
}

0 commit comments

Comments
 (0)