Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { DurableObject } from 'cloudflare:workers';

interface Env {
SENTRY_DSN: string;
COUNTER: DurableObjectNamespace<Counter>;
}

// Nothing is wrapped manually, the Vite plugin wraps both exports and enables RPC trace
// propagation for `COUNTER` on its own.
export class Counter extends DurableObject<Env> {
// An uninstrumented receiver would see Sentry's RPC metadata in the trailing optional parameter,
// see https://github.com/getsentry/sentry-javascript/issues/23233.
async increment(by: number, _unused?: number): Promise<{ count: number; argumentCount: number }> {
const count = ((await this.ctx.storage.get<number>('count')) ?? 0) + by;
await this.ctx.storage.put('count', count);
return { count, argumentCount: arguments.length };
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/increment') {
const stub = env.COUNTER.get(env.COUNTER.idFromName('e2e'));
return Response.json(await stub.increment(1));
}

return new Response('Not found', { status: 404 });
},
} satisfies ExportedHandler<Env>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineCloudflareOptions } from '@sentry/cloudflare';

// `rpcTracePropagationTargets` is deliberately absent, the Vite plugin derives `COUNTER` on its own.
export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
traceLifecycle: 'static',
tracesSampleRate: 1.0,
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { TransactionEvent } from '@sentry/core';
import { expect, it } from 'vitest';
import { createRunner } from '../../../runner';

it('propagates the trace over a Durable Object RPC call without configuring the binding', async ({ signal }) => {
let workerTraceId: string | undefined;
let doTraceId: string | undefined;

const runner = createRunner(__dirname)
.unordered()
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
expect(transactionEvent.contexts?.trace?.op).toBe('rpc');
doTraceId = transactionEvent.contexts?.trace?.trace_id;
})
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
workerTraceId = transactionEvent.contexts?.trace?.trace_id;
})
.start(signal);

// `argumentCount` proves the receiver stripped the metadata argument again.
const response = await runner.makeRequest<{ count: number; argumentCount: number }>('get', '/increment');
expect(response).toEqual({ count: 1, argumentCount: 1 });

await runner.completed();

expect(workerTraceId).toBeDefined();
expect(doTraceId).toBe(workerTraceId);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import { defineConfig } from 'vite';

export default defineConfig({
// The Sentry transform wraps the entry before the Cloudflare plugin bundles it.
plugins: [cloudflare(), sentryCloudflareVitePlugin()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "../../../node_modules/wrangler/config-schema.json",
"name": "cloudflare-vite-autoinstrument-durableobject-rpc",
// `main` points at the source entry so the auto-instrument transform runs during the build.
"main": "index.ts",
"compatibility_date": "2025-06-17",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }],
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }],
}
1 change: 1 addition & 0 deletions packages/cloudflare/src/vite/autoInstrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPa
agentClasses,
optionsFn,
optionsImport,
sameWorkerBindings: wranglerConfig.sameWorkerBindings,
});

const wrappedClasses = result?.wrappedClasses ?? new Set<string>();
Expand Down
4 changes: 4 additions & 0 deletions packages/cloudflare/src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export interface SentryCloudflareVitePluginOptions {
* left alone, so this is safe alongside manual instrumentation. Set to
* `false` to opt out.
*
* The plugin also adds the bindings that resolve to the wrapped classes (this worker's own
* Durable Objects and self service bindings) to `rpcTracePropagationTargets`. Bindings to
* other workers stay opt-in, their receivers may not run Sentry.
*
* @default true
*/
autoInstrumentation?: boolean;
Expand Down
55 changes: 52 additions & 3 deletions packages/cloudflare/src/vite/transform.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import MagicString from 'magic-string';
import type { SameWorkerBinding } from './wranglerConfig';
import { detectWorkerEntrypointClasses } from './workerEntrypoint';

const MERGED_OPTIONS_IDENTIFIER = '__SENTRY_OPTIONS__';

// ---------------------------------------------------------------------------
// Minimal ESTree node types for the AST nodes we inspect.
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -108,6 +111,8 @@ export interface TransformContext {
optionsFn: string;
/** Import statement prepended when `optionsFn` references a separate module. */
optionsImport?: string;
/** @see {@link import('./wranglerConfig').WranglerConfig.sameWorkerBindings} */
sameWorkerBindings?: readonly SameWorkerBinding[];
}

export interface TransformResult {
Expand Down Expand Up @@ -144,6 +149,7 @@ export function applyAutoInstrumentTransforms(
): TransformResult | undefined {
const ms = new MagicString(code);
const topLevelClasses = collectTopLevelClasses(ast);
const sameWorkerBindings = ctx.sameWorkerBindings ?? [];
const state: TransformState = {
ms,
needsImport: false,
Expand All @@ -153,6 +159,9 @@ export function applyAutoInstrumentTransforms(
classWrappers: ctx.classWrappers,
agentClasses: ctx.agentClasses ?? new Set<string>(),
workerEntrypointClasses: detectWorkerEntrypointClasses(ast),
// The identifier must be chosen before wrapping, which bindings survive is only known after.
optionsFn: sameWorkerBindings.length > 0 ? MERGED_OPTIONS_IDENTIFIER : ctx.optionsFn,
autoWrapped: new Set<string | undefined>(),
};
const { wrappedClasses } = state;

Expand All @@ -179,6 +188,10 @@ export function applyAutoInstrumentTransforms(
return { code, map: ms.generateMap({ hires: true }), wrappedClasses };
}

// `prepend` inserts before earlier prepends, yielding: Sentry import, options import, declaration.
if (sameWorkerBindings.length > 0) {
ms.prepend(buildMergedOptionsDeclaration(sameWorkerBindings, ctx.optionsFn, state));
}
if (ctx.optionsImport) ms.prepend(ctx.optionsImport);
ms.prepend("import * as __SENTRY__ from '@sentry/cloudflare';\n");

Expand Down Expand Up @@ -212,6 +225,39 @@ interface TransformState {
* so they can be wrapped without a config entry.
*/
workerEntrypointClasses: Set<string>;
optionsFn: string;
/**
* Exported names this transform wrapped itself, unlike `wrappedClasses` which also counts
* hand-wrapped classes. `undefined` marks the default export, mirroring
* {@link SameWorkerBinding.className}.
*/
autoWrapped: Set<string | undefined>;
}

/**
* Builds the callback that merges same-worker binding names into `rpcTracePropagationTargets` at
* runtime, the options object only exists once the callback runs with `env`. Only bindings whose
* class this transform wrapped survive, a hand-wrapped class runs on its own options.
*/
function buildMergedOptionsDeclaration(
sameWorkerBindings: readonly SameWorkerBinding[],
optionsFn: string,
state: TransformState,
): string {
const bindingNames = sameWorkerBindings
.filter(({ className }) => state.autoWrapped.has(className))
.map(({ bindingName }) => bindingName);

if (!bindingNames.length) {
return `const ${MERGED_OPTIONS_IDENTIFIER} = ${optionsFn};\n`;
}

const names = bindingNames.map(name => JSON.stringify(name)).join(', ');
return (
`const ${MERGED_OPTIONS_IDENTIFIER} = (env) => { ` +
`const opts = (${optionsFn})(env); ` +
`return { ...opts, rpcTracePropagationTargets: [${names}, ...(opts?.rpcTracePropagationTargets ?? [])] }; };\n`
);
}

/**
Expand Down Expand Up @@ -263,8 +309,9 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state
// `export default <expr>` → `const __SENTRY_DEFAULT_EXPORT__ = <expr>`
// MagicString positions are always relative to the original source.
state.ms.overwrite(node.start, decl.start, 'const __SENTRY_DEFAULT_EXPORT__ = ');
state.ms.append(`\nexport default __SENTRY__.withSentry(${ctx.optionsFn}, __SENTRY_DEFAULT_EXPORT__);\n`);
state.ms.append(`\nexport default __SENTRY__.withSentry(${state.optionsFn}, __SENTRY_DEFAULT_EXPORT__);\n`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed self-binding on default re-export

Medium Severity

When export default AlreadyWrappedClass skips wrapping because the class was already auto-wrapped as a named export, undefined is never added to autoWrapped. Entrypoint-less self service bindings use className: undefined, so they are filtered out of rpcTracePropagationTargets even though the default export is the instrumented class and would strip the trailing RPC metadata.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a85e9fb. Configure here.

state.needsImport = true;
state.autoWrapped.add(undefined);
}

function handleNamedExport(node: ExportNamedNode, ctx: TransformContext, state: TransformState): void {
Expand Down Expand Up @@ -336,10 +383,11 @@ function wrapInlineClassExport(
// Insert the wrapped re-export after the class body
state.ms.appendLeft(
exportNode.end,
`\nexport const ${className} = __SENTRY__.${WRAPPER_METHODS[kind]}(${ctx.optionsFn}, ${renamedClass});\n`,
`\nexport const ${className} = __SENTRY__.${WRAPPER_METHODS[kind]}(${state.optionsFn}, ${renamedClass});\n`,
);

state.wrappedClasses.add(className);
state.autoWrapped.add(className);
state.renamedLocals.add(className);
state.needsImport = true;
}
Expand All @@ -357,6 +405,7 @@ function wrapSpecifierExport(specifier: ExportSpecifierNode, ctx: TransformConte
if (!localName || !localClass?.id) return;

state.wrappedClasses.add(exportedName);
state.autoWrapped.add(exportedName);
state.needsImport = true;
if (state.renamedLocals.has(localName)) return;
state.renamedLocals.add(localName);
Expand All @@ -367,6 +416,6 @@ function wrapSpecifierExport(specifier: ExportSpecifierNode, ctx: TransformConte
// wrapped) `localName` binding, so the wrapper is NOT exported here.
state.ms.appendLeft(
localClass.end,
`\nconst ${localName} = __SENTRY__.${WRAPPER_METHODS[kind]}(${ctx.optionsFn}, ${renamedClass});\n`,
`\nconst ${localName} = __SENTRY__.${WRAPPER_METHODS[kind]}(${state.optionsFn}, ${renamedClass});\n`,
);
}
38 changes: 38 additions & 0 deletions packages/cloudflare/src/vite/wranglerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ export interface WranglerConfig {
* an export on a *different* worker, which this build can't wrap.
*/
workerEntrypoints: string[];
/**
* Bindings whose RPC receiver lives in this worker, so this build instruments it. An `undefined`
* `className` means the default export. Not deduped by class, two bindings may point at the same
* class and both names have to be listed.
*/
sameWorkerBindings: SameWorkerBinding[];
}

export interface SameWorkerBinding {
bindingName: string;
className?: string;
}

/**
Expand Down Expand Up @@ -62,6 +73,7 @@ export function resolveWranglerConfig(
durableObjects: collectClassBindings(raw.durable_objects?.bindings),
workflows: collectClassBindings(raw.workflows),
workerEntrypoints: collectSelfBoundEntrypoints(raw),
sameWorkerBindings: collectSameWorkerBindings(raw),
},
configDir: dirname(raw.configPath ?? configPath),
};
Expand All @@ -86,6 +98,32 @@ function collectSelfBoundEntrypoints(raw: Unstable_Config): string[] {
return [...entrypoints];
}

/**
* Bindings with a `script_name` or naming another worker target a class this build does not wrap,
* so they are excluded and stay opt-in.
*/
function collectSameWorkerBindings(raw: Unstable_Config): SameWorkerBinding[] {
const bindings: SameWorkerBinding[] = [];

for (const binding of raw.durable_objects?.bindings ?? []) {
if (typeof binding?.name === 'string' && typeof binding.class_name === 'string' && !binding.script_name) {
bindings.push({ bindingName: binding.name, className: binding.class_name });
}
}

for (const binding of raw.services ?? []) {
// A service binding is only ours when `service` names this worker; without a `name` none can be.
if (raw.name && binding?.service === raw.name && typeof binding.binding === 'string') {
bindings.push({
bindingName: binding.binding,
className: typeof binding.entrypoint === 'string' ? binding.entrypoint : undefined,
});
}
}

return bindings;
}

/**
* Map wrangler class bindings (Durable Objects, Workflows — same shape) to the
* `{ name, className }` the transform needs, skipping duplicates and bindings
Expand Down
5 changes: 4 additions & 1 deletion packages/cloudflare/test/vite/autoInstrument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,9 @@ describe('instrument file auto-detection', () => {

const code = ['class DurableObject {}', 'export class MyDO extends DurableObject {}'].join('\n');
const result = await tx(code, entryPath)!;
expect(result.code).toContain('__SENTRY__.instrumentDurableObjectWithSentry(__SENTRY_OPTIONS_CALLBACK__,');
expect(result.code).toContain(
'const __SENTRY_OPTIONS__ = (env) => { const opts = (__SENTRY_OPTIONS_CALLBACK__)(env); return { ...opts, rpcTracePropagationTargets: ["MY_DO", ...(opts?.rpcTracePropagationTargets ?? [])] }; };',
);
expect(result.code).toContain('__SENTRY__.instrumentDurableObjectWithSentry(__SENTRY_OPTIONS__,');
});
});
Loading
Loading