Skip to content
Open
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,62 @@
import * as Sentry from '@sentry/cloudflare';
import { DurableObject, WorkflowEntrypoint } from 'cloudflare:workers';
import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers';

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

class CounterImpl extends DurableObject<Env> {
async fetch(): Promise<Response> {
const current = ((await this.ctx.storage.get<number>('count')) ?? 0) + 1;
await this.ctx.storage.put('count', current);
return Response.json({ count: current });
}
}

// The Durable Object is wrapped by hand. The transform must recognize the
// existing `instrumentDurableObjectWithSentry` call — matched by the DO-kind
// wrapper method, not the workflow one — and leave it untouched (no double-wrap).
export const Counter = Sentry.instrumentDurableObjectWithSentry(
(env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }),
CounterImpl,
);

// The Workflow is a plain inline export — the transform must auto-wrap it with
// `instrumentWorkflowWithSentry` even though its DO sibling is already wrapped.
export class MyWorkflow extends WorkflowEntrypoint<Env> {
async run(_event: WorkflowEvent<unknown>, step: WorkflowStep): Promise<void> {
await step.do('step-one', async () => 'Step one completed');
}
}

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 stub.fetch(new Request('https://do/increment'));
}

if (url.pathname === '/workflow/trigger') {
const instance = await env.MY_WORKFLOW.create();
for (let i = 0; i < 15; 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(r => setTimeout(r, 500));
}
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,6 @@
import { defineCloudflareOptions } from '@sentry/cloudflare';

export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { TransactionEvent } from '@sentry/core';
import { expect, it } from 'vitest';
import { createRunner } from '../../../runner';

// A fetch-invoked Durable Object emits an `http.server` transaction whose only
// children are the two `auto.db.cloudflare.durable_object` storage spans
// (`get` + `put`) — present here because the class was manually wrapped.
function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void {
expect(transactionEvent).toEqual(
expect.objectContaining({
contexts: expect.objectContaining({
trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }),
}),
}),
);
expect(transactionEvent.spans).toHaveLength(2);
expect(transactionEvent.spans).toEqual([
expect.objectContaining({
op: 'db',
description: 'durable_object_storage_get',
origin: 'auto.db.cloudflare.durable_object',
}),
expect.objectContaining({
op: 'db',
description: 'durable_object_storage_put',
origin: 'auto.db.cloudflare.durable_object',
}),
]);
}

// A workflow step runs in its own invocation and reports a `function.step.do` /
// `auto.faas.cloudflare.workflow` transaction named after the step — present
// only because the transform auto-wrapped the Workflow class.
function expectWorkflowStepTransaction(transactionEvent: TransactionEvent): void {
expect(transactionEvent.transaction).toBe('step-one');
expect(transactionEvent.contexts?.trace?.op).toBe('function.step.do');
expect(transactionEvent.contexts?.trace?.origin).toBe('auto.faas.cloudflare.workflow');
}

// The main worker transaction for `/increment` just forwards to the DO, so it
// carries no child spans. The empty-spans assertion keeps it disjoint from the
// DO and workflow transactions regardless of arrival order.
function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void {
expect(transactionEvent).toEqual(
expect.objectContaining({
contexts: expect.objectContaining({
trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }),
}),
}),
);
expect(transactionEvent.spans).toHaveLength(0);
}

// The Durable Object is wrapped by hand with `instrumentDurableObjectWithSentry`
// while the Workflow sibling is a plain inline export. The transform must match
// the manual wrap by its DO-kind method and skip it (no double-wrap) while still
// auto-wrapping the Workflow with `instrumentWorkflowWithSentry`. We therefore
// expect a storage-bearing DO transaction (manual wrap) and a `step-one`
// transaction (auto wrap), plus the child-less main worker transaction.
it('leaves a manually wrapped Durable Object untouched and still auto-wraps a Workflow sibling', async ({ signal }) => {
const runner = createRunner(__dirname)
.unordered()
.expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
.expect(envelope => expectWorkflowStepTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
.expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
.start(signal);

await runner.makeRequest('get', '/increment');
await runner.makeRequest('get', '/workflow/trigger');
await runner.completed();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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 skips the manually
// wrapped `Counter` Durable Object and auto-wraps the `MyWorkflow` Workflow
// before the Cloudflare plugin bundles it.
plugins: [
cloudflare(),
sentryCloudflareVitePlugin({
_experimental: {
autoInstrumentation: true,
},
}),
],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"$schema": "../../../node_modules/wrangler/config-schema.json",
"name": "cloudflare-vite-autoinstrument-durableobject-workflow-manual-mixed",
// `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_als"],
"durable_objects": {
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }],
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }],
"workflows": [
{
"name": "my-workflow",
"binding": "MY_WORKFLOW",
"class_name": "MyWorkflow",
},
],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { DurableObject, WorkflowEntrypoint } from 'cloudflare:workers';
import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers';

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

// Both classes are declared plain and exported through a single specifier list
// (`export { Counter, MyWorkflow }`) rather than inline. The transform must
// handle the specifier form for each kind: rename each local class and rebind
// the exported name to the kind-specific wrapper.
class Counter extends DurableObject<Env> {
async fetch(): Promise<Response> {
const current = ((await this.ctx.storage.get<number>('count')) ?? 0) + 1;
await this.ctx.storage.put('count', current);
return Response.json({ count: current });
}
}

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

export { Counter, MyWorkflow };

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 stub.fetch(new Request('https://do/increment'));
}

if (url.pathname === '/workflow/trigger') {
const instance = await env.MY_WORKFLOW.create();
for (let i = 0; i < 15; 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(r => setTimeout(r, 500));
}
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,6 @@
import { defineCloudflareOptions } from '@sentry/cloudflare';

export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { TransactionEvent } from '@sentry/core';
import { expect, it } from 'vitest';
import { createRunner } from '../../../runner';

// A fetch-invoked Durable Object emits an `http.server` transaction whose only
// children are the two `auto.db.cloudflare.durable_object` storage spans
// (`get` + `put`) — present only when the class was auto-instrumented.
function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void {
expect(transactionEvent).toEqual(
expect.objectContaining({
contexts: expect.objectContaining({
trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }),
}),
}),
);
expect(transactionEvent.spans).toHaveLength(2);
expect(transactionEvent.spans).toEqual([
expect.objectContaining({
op: 'db',
description: 'durable_object_storage_get',
origin: 'auto.db.cloudflare.durable_object',
}),
expect.objectContaining({
op: 'db',
description: 'durable_object_storage_put',
origin: 'auto.db.cloudflare.durable_object',
}),
]);
}

// A workflow step runs in its own invocation and reports a `function.step.do` /
// `auto.faas.cloudflare.workflow` transaction named after the step — present
// only when the Workflow class was wrapped with `instrumentWorkflowWithSentry`.
function expectWorkflowStepTransaction(transactionEvent: TransactionEvent): void {
expect(transactionEvent.transaction).toBe('step-one');
expect(transactionEvent.contexts?.trace?.op).toBe('function.step.do');
expect(transactionEvent.contexts?.trace?.origin).toBe('auto.faas.cloudflare.workflow');
}

// The main worker transaction for `/increment` just forwards to the DO, so it
// carries no child spans. The empty-spans assertion keeps it disjoint from the
// DO and workflow transactions regardless of arrival order.
function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void {
expect(transactionEvent).toEqual(
expect.objectContaining({
contexts: expect.objectContaining({
trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }),
}),
}),
);
expect(transactionEvent.spans).toHaveLength(0);
}

// A Durable Object and a Workflow are both exported through a single specifier
// list (`export { Counter, MyWorkflow }`) instead of inline `export class`. The
// transform renames each local class and rebinds the exported name to the
// kind-specific wrapper, so the DO storage spans and the `step-one` workflow
// transaction only arrive if the specifier form was handled for both kinds.
it('auto-instruments a Durable Object and a Workflow exported via a specifier list', async ({ signal }) => {
const runner = createRunner(__dirname)
.unordered()
.expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
.expect(envelope => expectWorkflowStepTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
.expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
.start(signal);

await runner.makeRequest('get', '/increment');
await runner.makeRequest('get', '/workflow/trigger');
await runner.completed();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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 both specifier-exported classes before the Cloudflare plugin
// bundles it.
plugins: [
cloudflare(),
sentryCloudflareVitePlugin({
_experimental: {
autoInstrumentation: true,
},
}),
],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"$schema": "../../../node_modules/wrangler/config-schema.json",
"name": "cloudflare-vite-autoinstrument-durableobject-workflow-specifier",
// `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_als"],
"durable_objects": {
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }],
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }],
"workflows": [
{
"name": "my-workflow",
"binding": "MY_WORKFLOW",
"class_name": "MyWorkflow",
},
],
}
Loading
Loading