Skip to content

Commit 64067a8

Browse files
committed
fix(v10/cloudflare): Auto-instrument classes re-exported from the worker entry
Backport of: #23282
1 parent b627037 commit 64067a8

33 files changed

Lines changed: 855 additions & 63 deletions

File tree

dev-packages/cloudflare-integration-tests/runner.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ type Expected = Envelope | ((envelope: Envelope) => void);
132132

133133
type StartResult = {
134134
completed(): Promise<void>;
135+
/** Every non-ignored envelope received so far, matched or not, for count assertions. */
136+
getReceivedEnvelopes(): Envelope[];
135137
makeRequest<T>(
136138
method: 'get' | 'post',
137139
path: string,
@@ -211,6 +213,7 @@ export function createRunner(...paths: string[]) {
211213
});
212214

213215
const expectedEnvelopeCount = expectedEnvelopes.length;
216+
const receivedEnvelopes: Envelope[] = [];
214217

215218
let envelopeCount = 0;
216219
const envelopeWaiters: { expected: Expected; resolve: () => void; reject: (e: unknown) => void }[] = [];
@@ -256,6 +259,8 @@ export function createRunner(...paths: string[]) {
256259
return;
257260
}
258261

262+
receivedEnvelopes.push(envelope);
263+
259264
// Check per-request waiters first (FIFO order)
260265
if (envelopeWaiters.length > 0) {
261266
const waiter = envelopeWaiters.shift()!;
@@ -416,6 +421,9 @@ export function createRunner(...paths: string[]) {
416421
completed: async function (): Promise<void> {
417422
return isComplete;
418423
},
424+
getReceivedEnvelopes: function (): Envelope[] {
425+
return receivedEnvelopes;
426+
},
419427
makeRequest: async function <T>(
420428
method: 'get' | 'post',
421429
path: string,

dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ interface Env {
88
// `Counter` is imported from another module (`./counter`) where it was already
99
// manually wrapped with `instrumentDurableObjectWithSentry`, then re-exported
1010
// here. The auto-instrument transform runs over this entry and sees
11-
// `export { Counter }`, but `Counter` is an imported binding — not a local class
12-
// declaration — so it cannot (and must not) wrap it. The DO stays instrumented
13-
// solely via the manual wrap in `./counter`, and the plain default export below
14-
// is still auto-wrapped with `withSentry`.
11+
// `export { Counter }`, but nothing in this file reveals that the binding is
12+
// already wrapped, so it emits its wrapper behind a guard
13+
// (`_INTERNAL_wrapUnlessInstrumented`) that returns the manual wrap unchanged
14+
// instead of nesting. The plain default export below is still auto-wrapped
15+
// with `withSentry`.
1516
export { Counter };
1617

1718
export default {

dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,13 @@ function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void {
4343

4444
// `Counter` is manually wrapped with `instrumentDurableObjectWithSentry` in a
4545
// separate module (`./counter`), imported into the entry, and re-exported via a
46-
// plain `export { Counter }`. Because `Counter` is an imported binding rather
47-
// than a local class declaration, the transform cannot wrap it in the entry and
48-
// must leave it alone — no double-wrap, no broken build. The DO stays
49-
// instrumented via the manual wrap, so we still expect a storage-bearing DO
50-
// transaction, alongside the auto-wrapped default export's child-less one.
51-
it('leaves an imported, already-instrumented Durable Object untouched and still wraps the default export', async ({
46+
// plain `export { Counter }`. The transform sees only the imported binding, so it
47+
// emits its wrapper behind `_INTERNAL_wrapUnlessInstrumented`, which recognizes
48+
// the hand-wrapped class and hands it straight back. Without that guard the two
49+
// wrappers nest and every storage call reports twice, so the exactly-two span
50+
// assertion below is the real check. The DO stays instrumented via the manual
51+
// wrap, alongside the auto-wrapped default export's child-less transaction.
52+
it('does not double-instrument an imported, already-wrapped Durable Object and still wraps the default export', async ({
5253
signal,
5354
}) => {
5455
const runner = createRunner(__dirname)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { WorkerEntrypoint } from 'cloudflare:workers';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
}
7+
8+
class GreeterImpl extends WorkerEntrypoint<Env> {
9+
async fetch(): Promise<Response> {
10+
return new Response('Hello from the entrypoint');
11+
}
12+
}
13+
14+
// Manually instrumented here, in a module separate from the worker entry, which
15+
// only imports and re-exports the wrapped class.
16+
export const GreeterEntrypoint = Sentry.withSentry(
17+
(env: Env) => ({ dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0 }),
18+
GreeterImpl,
19+
);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { GreeterEntrypoint } from './greeter';
2+
3+
interface Env {
4+
SENTRY_DSN: string;
5+
SELF: Fetcher;
6+
}
7+
8+
// `GreeterEntrypoint` was already wrapped by hand in `./greeter`. The
9+
// auto-instrument transform cannot see that from this entry (it only knows the
10+
// class from the self service binding in wrangler.jsonc), so it emits its
11+
// wrapper behind `_INTERNAL_wrapUnlessInstrumented`, which hands the manual
12+
// wrap back unchanged instead of nesting a second wrapper around it.
13+
export { GreeterEntrypoint };
14+
15+
export default {
16+
async fetch(request: Request, env: Env): Promise<Response> {
17+
const url = new URL(request.url);
18+
19+
if (url.pathname === '/call-entrypoint') {
20+
return env.SELF.fetch(new Request('https://self/greet'));
21+
}
22+
23+
return new Response('Not found', { status: 404 });
24+
},
25+
} satisfies ExportedHandler<Env>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineCloudflareOptions } from '@sentry/cloudflare';
2+
3+
export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
4+
dsn: env.SENTRY_DSN,
5+
traceLifecycle: 'static',
6+
tracesSampleRate: 1.0,
7+
}));
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { TransactionEvent } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../../runner';
4+
5+
// `GreeterEntrypoint` is hand-wrapped in `./greeter` and only re-exported by
6+
// the entry, so the transform's emitted `_INTERNAL_wrapUnlessInstrumented`
7+
// guard must hand the manual wrap back instead of nesting a second wrapper.
8+
// Nested entrypoint wrappers each instrument `fetch`, which shows up as extra
9+
// spans on the entrypoint transaction, the strict shape below catches that.
10+
it('does not double-instrument an imported, already-wrapped WorkerEntrypoint', async ({ signal }) => {
11+
const runner = createRunner(__dirname)
12+
.unordered()
13+
.expect(envelope => {
14+
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
15+
// The entrypoint's own transaction, child-less when wrapped exactly once.
16+
expect(transactionEvent.transaction).toBe('GET /greet');
17+
expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
18+
expect(transactionEvent.spans ?? []).toHaveLength(0);
19+
})
20+
.expect(envelope => {
21+
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
22+
// The auto-wrapped default export's transaction.
23+
expect(transactionEvent.transaction).toBe('GET /call-entrypoint');
24+
expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
25+
})
26+
.start(signal);
27+
28+
await runner.makeRequest('get', '/call-entrypoint');
29+
await runner.completed();
30+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { cloudflare } from '@cloudflare/vite-plugin';
2+
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
3+
import { defineConfig } from 'vite';
4+
5+
export default defineConfig({
6+
// The Sentry plugin runs first so its build-time transform wraps the worker
7+
// entry and the self-bound `GreeterEntrypoint` before the Cloudflare plugin
8+
// bundles it.
9+
plugins: [
10+
cloudflare(),
11+
sentryCloudflareVitePlugin({
12+
_experimental: {
13+
autoInstrumentation: true,
14+
},
15+
}),
16+
],
17+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"$schema": "../../../node_modules/wrangler/config-schema.json",
3+
"name": "cloudflare-vite-autoinstrument-workerentrypoint-reexport",
4+
// `main` points at the source entry; the Sentry Vite plugin builds from it (so
5+
// the auto-instrument transform runs) and the runner serves the built output.
6+
"main": "index.ts",
7+
"compatibility_date": "2025-06-17",
8+
"compatibility_flags": ["nodejs_compat"],
9+
// Self-service-binding: names the entrypoint class, which is how the
10+
// transform knows to wrap the re-exported binding at all.
11+
"services": [
12+
{
13+
"binding": "SELF",
14+
"service": "cloudflare-vite-autoinstrument-workerentrypoint-reexport",
15+
"entrypoint": "GreeterEntrypoint",
16+
},
17+
],
18+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { MyWorkflow } from './workflow';
2+
3+
interface Env {
4+
SENTRY_DSN: string;
5+
MY_WORKFLOW: Workflow;
6+
}
7+
8+
// `MyWorkflow` was already wrapped by hand in `./workflow`. The auto-instrument
9+
// transform cannot see that from this entry, so it emits its wrapper behind
10+
// `_INTERNAL_wrapUnlessInstrumented`, which hands the manual wrap back unchanged
11+
// instead of nesting a second wrapper around it.
12+
export { MyWorkflow };
13+
14+
export default {
15+
async fetch(request: Request, env: Env): Promise<Response> {
16+
const url = new URL(request.url);
17+
18+
// Issued by the test after `/trigger` returned, its transaction is the
19+
// sentinel proving every earlier envelope (including a duplicate step
20+
// transaction from an accidental double wrap) has been delivered.
21+
if (url.pathname === '/sentinel') {
22+
return new Response('ok');
23+
}
24+
25+
if (url.pathname === '/trigger') {
26+
const instance = await env.MY_WORKFLOW.create();
27+
// Respond only once the workflow finished, so every step envelope (including
28+
// a duplicate from an accidental double wrap) is sent before this request's
29+
// own transaction completes the test's expectations.
30+
for (let i = 0; i < 20; i++) {
31+
try {
32+
const s = await instance.status();
33+
if (s.status === 'complete' || s.status === 'errored') {
34+
return Response.json({ id: instance.id, ...s });
35+
}
36+
} catch {
37+
// status() may not be available in local dev
38+
}
39+
await new Promise(resolve => setTimeout(resolve, 250));
40+
}
41+
return Response.json({ id: instance.id, status: 'timeout' });
42+
}
43+
44+
return new Response('Not found', { status: 404 });
45+
},
46+
} satisfies ExportedHandler<Env>;

0 commit comments

Comments
 (0)