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
8 changes: 8 additions & 0 deletions dev-packages/cloudflare-integration-tests/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ type Expected = Envelope | ((envelope: Envelope) => void);

type StartResult = {
completed(): Promise<void>;
/** Every non-ignored envelope received so far, matched or not, for count assertions. */
getReceivedEnvelopes(): Envelope[];
makeRequest<T>(
method: 'get' | 'post',
path: string,
Expand Down Expand Up @@ -225,6 +227,7 @@ export function createRunner(...paths: string[]) {
});

const expectedEnvelopeCount = expectedEnvelopes.length;
const receivedEnvelopes: Envelope[] = [];

let envelopeCount = 0;
let unexpectedEnvelopeError: Error | undefined;
Expand Down Expand Up @@ -271,6 +274,8 @@ export function createRunner(...paths: string[]) {
return;
}

receivedEnvelopes.push(envelope);

// Resolve per-request waiters first, matching in any order so a request
// expecting multiple envelopes isn't sensitive to their arrival order.
if (envelopeWaiters.length > 0) {
Expand Down Expand Up @@ -444,6 +449,9 @@ export function createRunner(...paths: string[]) {
throw unexpectedEnvelopeError;
}
},
getReceivedEnvelopes: function (): Envelope[] {
return receivedEnvelopes;
},
makeRequest: async function <T>(
method: 'get' | 'post',
path: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ interface Env {
// `Counter` is imported from another module (`./counter`) where it was already
// manually wrapped with `instrumentDurableObjectWithSentry`, then re-exported
// here. The auto-instrument transform runs over this entry and sees
// `export { Counter }`, but `Counter` is an imported binding — not a local class
// declaration — so it cannot (and must not) wrap it. The DO stays instrumented
// solely via the manual wrap in `./counter`, and the plain default export below
// is still auto-wrapped with `withSentry`.
// `export { Counter }`, but nothing in this file reveals that the binding is
// already wrapped, so it emits its wrapper behind a guard
// (`_INTERNAL_wrapUnlessInstrumented`) that returns the manual wrap unchanged
// instead of nesting. The plain default export below is still auto-wrapped
// with `withSentry`.
export { Counter };

export default {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,13 @@ function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void {

// `Counter` is manually wrapped with `instrumentDurableObjectWithSentry` in a
// separate module (`./counter`), imported into the entry, and re-exported via a
// plain `export { Counter }`. Because `Counter` is an imported binding rather
// than a local class declaration, the transform cannot wrap it in the entry and
// must leave it alone — no double-wrap, no broken build. The DO stays
// instrumented via the manual wrap, so we still expect a storage-bearing DO
// transaction, alongside the auto-wrapped default export's child-less one.
it('leaves an imported, already-instrumented Durable Object untouched and still wraps the default export', async ({
// plain `export { Counter }`. The transform sees only the imported binding, so it
// emits its wrapper behind `_INTERNAL_wrapUnlessInstrumented`, which recognizes
// the hand-wrapped class and hands it straight back. Without that guard the two
// wrappers nest and every storage call reports twice, so the exactly-two span
// assertion below is the real check. The DO stays instrumented via the manual
// wrap, alongside the auto-wrapped default export's child-less transaction.
it('does not double-instrument an imported, already-wrapped Durable Object and still wraps the default export', async ({
signal,
}) => {
const runner = createRunner(__dirname)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as Sentry from '@sentry/cloudflare';
import { WorkerEntrypoint } from 'cloudflare:workers';

interface Env {
SENTRY_DSN: string;
}

class GreeterImpl extends WorkerEntrypoint<Env> {
async fetch(): Promise<Response> {
return new Response('Hello from the entrypoint');
}
}

// Manually instrumented here, in a module separate from the worker entry, which
// only imports and re-exports the wrapped class.
export const GreeterEntrypoint = Sentry.withSentry(
(env: Env) => ({ dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0 }),
GreeterImpl,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { GreeterEntrypoint } from './greeter';

interface Env {
SENTRY_DSN: string;
SELF: Fetcher;
}

// `GreeterEntrypoint` was already wrapped by hand in `./greeter`. The
// auto-instrument transform cannot see that from this entry (it only knows the
// class from the self service binding in wrangler.jsonc), so it emits its
// wrapper behind `_INTERNAL_wrapUnlessInstrumented`, which hands the manual
// wrap back unchanged instead of nesting a second wrapper around it.
export { GreeterEntrypoint };

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

if (url.pathname === '/call-entrypoint') {
return env.SELF.fetch(new Request('https://self/greet'));
}

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

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,30 @@
import type { TransactionEvent } from '@sentry/core';
import { expect, it } from 'vitest';
import { createRunner } from '../../../runner';

// `GreeterEntrypoint` is hand-wrapped in `./greeter` and only re-exported by
// the entry, so the transform's emitted `_INTERNAL_wrapUnlessInstrumented`
// guard must hand the manual wrap back instead of nesting a second wrapper.
// Nested entrypoint wrappers each instrument `fetch`, which shows up as extra
// spans on the entrypoint transaction, the strict shape below catches that.
it('does not double-instrument an imported, already-wrapped WorkerEntrypoint', async ({ signal }) => {
const runner = createRunner(__dirname)
.unordered()
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
// The entrypoint's own transaction, child-less when wrapped exactly once.
expect(transactionEvent.transaction).toBe('GET /greet');
expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
expect(transactionEvent.spans ?? []).toHaveLength(0);
})
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
// The auto-wrapped default export's transaction.
expect(transactionEvent.transaction).toBe('GET /call-entrypoint');
expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
})
.start(signal);

await runner.makeRequest('get', '/call-entrypoint');
await runner.completed();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import { defineConfig } from 'vite';

export default defineConfig({
// The Sentry plugin runs first so its build-time transform wraps the worker
// entry and the self-bound `GreeterEntrypoint` before the Cloudflare plugin
// bundles it.
plugins: [cloudflare(), sentryCloudflareVitePlugin()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "../../../node_modules/wrangler/config-schema.json",
"name": "cloudflare-vite-autoinstrument-workerentrypoint-reexport",
// `main` points at the source entry; the Sentry Vite plugin builds from it (so
// the auto-instrument transform runs) and the runner serves the built output.
"main": "index.ts",
"compatibility_date": "2025-06-17",
"compatibility_flags": ["nodejs_compat"],
// Self-service-binding: names the entrypoint class, which is how the
// transform knows to wrap the re-exported binding at all.
"services": [
{
"binding": "SELF",
"service": "cloudflare-vite-autoinstrument-workerentrypoint-reexport",
"entrypoint": "GreeterEntrypoint",
},
],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { MyWorkflow } from './workflow';

interface Env {
SENTRY_DSN: string;
MY_WORKFLOW: Workflow;
}

// `MyWorkflow` was already wrapped by hand in `./workflow`. The auto-instrument
// transform cannot see that from this entry, so it emits its wrapper behind
// `_INTERNAL_wrapUnlessInstrumented`, which hands the manual wrap back unchanged
// instead of nesting a second wrapper around it.
export { MyWorkflow };

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

// Issued by the test after `/trigger` returned, its transaction is the
// sentinel proving every earlier envelope (including a duplicate step
// transaction from an accidental double wrap) has been delivered.
if (url.pathname === '/sentinel') {
return new Response('ok');
}

if (url.pathname === '/trigger') {
const instance = await env.MY_WORKFLOW.create();
// Respond only once the workflow finished, so every step envelope (including
// a duplicate from an accidental double wrap) is sent before this request's
// own transaction completes the test's expectations.
for (let i = 0; i < 20; i++) {
try {
const s = await instance.status();
if (s.status === 'complete' || s.status === 'errored') {
return Response.json({ id: instance.id, ...s });
}
} catch {
// status() may not be available in local dev
}
await new Promise(resolve => setTimeout(resolve, 250));
}
return Response.json({ id: instance.id, status: 'timeout' });
}

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

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,48 @@
import type { TransactionEvent } from '@sentry/core';
import { expect, it } from 'vitest';
import { createRunner } from '../../../runner';

// `MyWorkflow` is hand-wrapped in `./workflow` and only re-exported by the
// entry, so the transform's emitted `_INTERNAL_wrapUnlessInstrumented` guard
// must hand the manual wrap back instead of nesting a second wrapper. Nested
// workflow wrappers each run the step through their own client, producing TWO
// identical `step-one` transactions, each individually well-formed, so the
// real check is the count assertion at the end.
//
// Ordering is anchored by a sentinel rather than by waiting: `/trigger`
// responds only after the workflow finished (every step envelope, including a
// duplicate, is flushed before then), and `/sentinel` is requested after that,
// so its transaction arrives a full request/response cycle behind any
// duplicate. Once the sentinel envelope has been matched, everything sent
// before it is known to have been delivered.
it('does not double-instrument an imported, already-wrapped Workflow', async ({ signal }) => {
const runner = createRunner(__dirname)
.unordered()
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
expect(transactionEvent.transaction).toBe('step-one');
expect(transactionEvent.contexts?.trace?.op).toBe('function');
expect(transactionEvent.contexts?.trace?.origin).toBe('auto.faas.cloudflare.workflow');
})
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
// The auto-wrapped default export's own transaction.
expect(transactionEvent.transaction).toBe('GET /trigger');
expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
})
// The sentinel is part of the expected set, so the runner keeps everything
// alive (and keeps receiving envelopes) until it has arrived.
.expect(envelope => {
expect((envelope[1]?.[0]?.[1] as TransactionEvent).transaction).toBe('GET /sentinel');
})
.start(signal);

await runner.makeRequest('get', '/trigger');
await runner.makeRequest('get', '/sentinel');
await runner.completed();

const stepTransactions = runner
.getReceivedEnvelopes()
.filter(envelope => (envelope[1]?.[0]?.[1] as TransactionEvent | undefined)?.transaction === 'step-one');
expect(stepTransactions).toHaveLength(1);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import { defineConfig } from 'vite';

export default defineConfig({
// The Sentry plugin runs first so its build-time transform wraps the worker
// entry and the `MyWorkflow` class before the Cloudflare plugin bundles it.
plugins: [cloudflare(), sentryCloudflareVitePlugin()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as Sentry from '@sentry/cloudflare';
import { WorkflowEntrypoint } from 'cloudflare:workers';
import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers';

interface Env {
SENTRY_DSN: string;
}

class MyWorkflowImpl extends WorkflowEntrypoint<Env> {
async run(_event: WorkflowEvent<unknown>, step: WorkflowStep): Promise<void> {
await step.do('step-one', async () => 'done');
}
}

// Manually instrumented here, in a module separate from the worker entry, which
// only imports and re-exports the wrapped class.
export const MyWorkflow = Sentry.instrumentWorkflowWithSentry(
(env: Env) => ({ dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0 }),
MyWorkflowImpl,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "../../../node_modules/wrangler/config-schema.json",
"name": "cloudflare-vite-autoinstrument-workflow-reexport-instrumented",
// `main` points at the source entry; the Sentry Vite plugin builds from it (so
// the auto-instrument transform runs) and the runner serves the built output.
"main": "index.ts",
"compatibility_date": "2025-06-17",
"compatibility_flags": ["nodejs_compat"],
"workflows": [
{
"name": "my-workflow-reexport",
"binding": "MY_WORKFLOW",
"class_name": "MyWorkflow",
},
],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Agent, callable } from 'agents';

/**
* An Agent declared outside the worker entry, which imports it and exports it again by specifier
* (`import { ImportedAgent } from './imported-agent'; export { ImportedAgent }`). The entry has no
* local class to rename, so the plugin has to re-point the export at a wrapper binding instead.
*/
export class ImportedAgent extends Agent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from ImportedAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'imported' });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@ import { AIChatAgent } from '@cloudflare/ai-chat';
import { Agent, callable, routeAgentRequest } from 'agents';
import { DurableObject } from 'cloudflare:workers';
import { MyBase } from './base';
import { ImportedAgent } from './imported-agent';

export { ImportedAgent };
export { ReExportedAgent } from './reexported-agent';

// The two exports above live in their own modules — see `imported-agent.ts` and
// `reexported-agent.ts`. They cover the shapes an entry that only aggregates
// classes uses, where there is no local declaration for the plugin to rewrite.
//
// NOTE: this file deliberately contains NO `Sentry.*` calls and no import of
// `@sentry/cloudflare`. Everything below is wrapped at build time by
// `sentryCloudflareVitePlugin()`,
// which reads wrangler.jsonc, wraps the default export with `withSentry`, and
// picks a wrapper per class: `instrumentAgentWithSentry` for the three Agents,
// picks a wrapper per class: `instrumentAgentWithSentry` for the five Agents,
// `instrumentDurableObjectWithSentry` for the plain Durable Object.
//
// Options come from `instrument.server.ts` next to this entry.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Agent, callable } from 'agents';

/**
* An Agent the worker entry only ever re-exports (`export { ReExportedAgent } from
* './reexported-agent'`) — it never binds the class locally at all, so the plugin has to import it
* under a private name before it can wrap it.
*/
export class ReExportedAgent extends Agent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from ReExportedAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'reexported' });
}
}
Loading
Loading