Skip to content

Commit 3ee3d15

Browse files
JPeer264andreiborza
authored andcommitted
feat(v10/cloudflare): Auto-instrument Workflow classes
Backport of: #22442
1 parent 40e1d33 commit 3ee3d15

26 files changed

Lines changed: 892 additions & 14 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { DurableObject, WorkflowEntrypoint } from 'cloudflare:workers';
3+
import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers';
4+
5+
interface Env {
6+
SENTRY_DSN: string;
7+
COUNTER: DurableObjectNamespace;
8+
MY_WORKFLOW: Workflow;
9+
}
10+
11+
class CounterImpl extends DurableObject<Env> {
12+
async fetch(): Promise<Response> {
13+
const current = ((await this.ctx.storage.get<number>('count')) ?? 0) + 1;
14+
await this.ctx.storage.put('count', current);
15+
return Response.json({ count: current });
16+
}
17+
}
18+
19+
// The Durable Object is wrapped by hand. The transform must recognize the
20+
// existing `instrumentDurableObjectWithSentry` call — matched by the DO-kind
21+
// wrapper method, not the workflow one — and leave it untouched (no double-wrap).
22+
export const Counter = Sentry.instrumentDurableObjectWithSentry(
23+
(env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }),
24+
CounterImpl,
25+
);
26+
27+
// The Workflow is a plain inline export — the transform must auto-wrap it with
28+
// `instrumentWorkflowWithSentry` even though its DO sibling is already wrapped.
29+
export class MyWorkflow extends WorkflowEntrypoint<Env> {
30+
async run(_event: WorkflowEvent<unknown>, step: WorkflowStep): Promise<void> {
31+
await step.do('step-one', async () => 'Step one completed');
32+
}
33+
}
34+
35+
export default {
36+
async fetch(request: Request, env: Env): Promise<Response> {
37+
const url = new URL(request.url);
38+
39+
if (url.pathname === '/increment') {
40+
const stub = env.COUNTER.get(env.COUNTER.idFromName('e2e'));
41+
return stub.fetch(new Request('https://do/increment'));
42+
}
43+
44+
if (url.pathname === '/workflow/trigger') {
45+
const instance = await env.MY_WORKFLOW.create();
46+
for (let i = 0; i < 15; i++) {
47+
try {
48+
const s = await instance.status();
49+
if (s.status === 'complete' || s.status === 'errored') {
50+
return Response.json({ id: instance.id, ...s });
51+
}
52+
} catch {
53+
// status() may not be available in local dev
54+
}
55+
await new Promise(r => setTimeout(r, 500));
56+
}
57+
return Response.json({ id: instance.id, status: 'timeout' });
58+
}
59+
60+
return new Response('Not found', { status: 404 });
61+
},
62+
} satisfies ExportedHandler<Env>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defineCloudflareOptions } from '@sentry/cloudflare';
2+
3+
export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
4+
dsn: env.SENTRY_DSN,
5+
tracesSampleRate: 1.0,
6+
}));
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { TransactionEvent } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../../runner';
4+
5+
// A fetch-invoked Durable Object emits an `http.server` transaction whose only
6+
// children are the two `auto.db.cloudflare.durable_object` storage spans
7+
// (`get` + `put`) — present here because the class was manually wrapped.
8+
function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void {
9+
expect(transactionEvent).toEqual(
10+
expect.objectContaining({
11+
contexts: expect.objectContaining({
12+
trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }),
13+
}),
14+
}),
15+
);
16+
expect(transactionEvent.spans).toHaveLength(2);
17+
expect(transactionEvent.spans).toEqual([
18+
expect.objectContaining({
19+
op: 'db',
20+
description: 'durable_object_storage_get',
21+
origin: 'auto.db.cloudflare.durable_object',
22+
}),
23+
expect.objectContaining({
24+
op: 'db',
25+
description: 'durable_object_storage_put',
26+
origin: 'auto.db.cloudflare.durable_object',
27+
}),
28+
]);
29+
}
30+
31+
// A workflow step runs in its own invocation and reports a `function.step.do` /
32+
// `auto.faas.cloudflare.workflow` transaction named after the step — present
33+
// only because the transform auto-wrapped the Workflow class.
34+
function expectWorkflowStepTransaction(transactionEvent: TransactionEvent): void {
35+
expect(transactionEvent.transaction).toBe('step-one');
36+
expect(transactionEvent.contexts?.trace?.op).toBe('function.step.do');
37+
expect(transactionEvent.contexts?.trace?.origin).toBe('auto.faas.cloudflare.workflow');
38+
}
39+
40+
// The main worker transaction for `/increment` just forwards to the DO, so it
41+
// carries no child spans. The empty-spans assertion keeps it disjoint from the
42+
// DO and workflow transactions regardless of arrival order.
43+
function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void {
44+
expect(transactionEvent).toEqual(
45+
expect.objectContaining({
46+
contexts: expect.objectContaining({
47+
trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }),
48+
}),
49+
}),
50+
);
51+
expect(transactionEvent.spans).toHaveLength(0);
52+
}
53+
54+
// The Durable Object is wrapped by hand with `instrumentDurableObjectWithSentry`
55+
// while the Workflow sibling is a plain inline export. The transform must match
56+
// the manual wrap by its DO-kind method and skip it (no double-wrap) while still
57+
// auto-wrapping the Workflow with `instrumentWorkflowWithSentry`. We therefore
58+
// expect a storage-bearing DO transaction (manual wrap) and a `step-one`
59+
// transaction (auto wrap), plus the child-less main worker transaction.
60+
it('leaves a manually wrapped Durable Object untouched and still auto-wraps a Workflow sibling', async ({ signal }) => {
61+
const runner = createRunner(__dirname)
62+
.unordered()
63+
.expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
64+
.expect(envelope => expectWorkflowStepTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
65+
.expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
66+
.start(signal);
67+
68+
await runner.makeRequest('get', '/increment');
69+
await runner.makeRequest('get', '/workflow/trigger');
70+
await runner.completed();
71+
});
Lines changed: 17 additions & 0 deletions
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 skips the manually
7+
// wrapped `Counter` Durable Object and auto-wraps the `MyWorkflow` Workflow
8+
// before the Cloudflare plugin bundles it.
9+
plugins: [
10+
cloudflare(),
11+
sentryCloudflareVitePlugin({
12+
_experimental: {
13+
autoInstrumentation: true,
14+
},
15+
}),
16+
],
17+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"$schema": "../../../node_modules/wrangler/config-schema.json",
3+
"name": "cloudflare-vite-autoinstrument-durableobject-workflow-manual-mixed",
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_als"],
9+
"durable_objects": {
10+
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }],
11+
},
12+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }],
13+
"workflows": [
14+
{
15+
"name": "my-workflow",
16+
"binding": "MY_WORKFLOW",
17+
"class_name": "MyWorkflow",
18+
},
19+
],
20+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { DurableObject, WorkflowEntrypoint } from 'cloudflare:workers';
2+
import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
COUNTER: DurableObjectNamespace;
7+
MY_WORKFLOW: Workflow;
8+
}
9+
10+
// Both classes are declared plain and exported through a single specifier list
11+
// (`export { Counter, MyWorkflow }`) rather than inline. The transform must
12+
// handle the specifier form for each kind: rename each local class and rebind
13+
// the exported name to the kind-specific wrapper.
14+
class Counter extends DurableObject<Env> {
15+
async fetch(): Promise<Response> {
16+
const current = ((await this.ctx.storage.get<number>('count')) ?? 0) + 1;
17+
await this.ctx.storage.put('count', current);
18+
return Response.json({ count: current });
19+
}
20+
}
21+
22+
class MyWorkflow extends WorkflowEntrypoint<Env> {
23+
async run(_event: WorkflowEvent<unknown>, step: WorkflowStep): Promise<void> {
24+
await step.do('step-one', async () => 'Step one completed');
25+
}
26+
}
27+
28+
export { Counter, MyWorkflow };
29+
30+
export default {
31+
async fetch(request: Request, env: Env): Promise<Response> {
32+
const url = new URL(request.url);
33+
34+
if (url.pathname === '/increment') {
35+
const stub = env.COUNTER.get(env.COUNTER.idFromName('e2e'));
36+
return stub.fetch(new Request('https://do/increment'));
37+
}
38+
39+
if (url.pathname === '/workflow/trigger') {
40+
const instance = await env.MY_WORKFLOW.create();
41+
for (let i = 0; i < 15; i++) {
42+
try {
43+
const s = await instance.status();
44+
if (s.status === 'complete' || s.status === 'errored') {
45+
return Response.json({ id: instance.id, ...s });
46+
}
47+
} catch {
48+
// status() may not be available in local dev
49+
}
50+
await new Promise(r => setTimeout(r, 500));
51+
}
52+
return Response.json({ id: instance.id, status: 'timeout' });
53+
}
54+
55+
return new Response('Not found', { status: 404 });
56+
},
57+
} satisfies ExportedHandler<Env>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defineCloudflareOptions } from '@sentry/cloudflare';
2+
3+
export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
4+
dsn: env.SENTRY_DSN,
5+
tracesSampleRate: 1.0,
6+
}));
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { TransactionEvent } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../../runner';
4+
5+
// A fetch-invoked Durable Object emits an `http.server` transaction whose only
6+
// children are the two `auto.db.cloudflare.durable_object` storage spans
7+
// (`get` + `put`) — present only when the class was auto-instrumented.
8+
function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void {
9+
expect(transactionEvent).toEqual(
10+
expect.objectContaining({
11+
contexts: expect.objectContaining({
12+
trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }),
13+
}),
14+
}),
15+
);
16+
expect(transactionEvent.spans).toHaveLength(2);
17+
expect(transactionEvent.spans).toEqual([
18+
expect.objectContaining({
19+
op: 'db',
20+
description: 'durable_object_storage_get',
21+
origin: 'auto.db.cloudflare.durable_object',
22+
}),
23+
expect.objectContaining({
24+
op: 'db',
25+
description: 'durable_object_storage_put',
26+
origin: 'auto.db.cloudflare.durable_object',
27+
}),
28+
]);
29+
}
30+
31+
// A workflow step runs in its own invocation and reports a `function.step.do` /
32+
// `auto.faas.cloudflare.workflow` transaction named after the step — present
33+
// only when the Workflow class was wrapped with `instrumentWorkflowWithSentry`.
34+
function expectWorkflowStepTransaction(transactionEvent: TransactionEvent): void {
35+
expect(transactionEvent.transaction).toBe('step-one');
36+
expect(transactionEvent.contexts?.trace?.op).toBe('function.step.do');
37+
expect(transactionEvent.contexts?.trace?.origin).toBe('auto.faas.cloudflare.workflow');
38+
}
39+
40+
// The main worker transaction for `/increment` just forwards to the DO, so it
41+
// carries no child spans. The empty-spans assertion keeps it disjoint from the
42+
// DO and workflow transactions regardless of arrival order.
43+
function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void {
44+
expect(transactionEvent).toEqual(
45+
expect.objectContaining({
46+
contexts: expect.objectContaining({
47+
trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }),
48+
}),
49+
}),
50+
);
51+
expect(transactionEvent.spans).toHaveLength(0);
52+
}
53+
54+
// A Durable Object and a Workflow are both exported through a single specifier
55+
// list (`export { Counter, MyWorkflow }`) instead of inline `export class`. The
56+
// transform renames each local class and rebinds the exported name to the
57+
// kind-specific wrapper, so the DO storage spans and the `step-one` workflow
58+
// transaction only arrive if the specifier form was handled for both kinds.
59+
it('auto-instruments a Durable Object and a Workflow exported via a specifier list', async ({ signal }) => {
60+
const runner = createRunner(__dirname)
61+
.unordered()
62+
.expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
63+
.expect(envelope => expectWorkflowStepTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
64+
.expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
65+
.start(signal);
66+
67+
await runner.makeRequest('get', '/increment');
68+
await runner.makeRequest('get', '/workflow/trigger');
69+
await runner.completed();
70+
});
Lines changed: 17 additions & 0 deletions
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 both specifier-exported classes before the Cloudflare plugin
8+
// bundles it.
9+
plugins: [
10+
cloudflare(),
11+
sentryCloudflareVitePlugin({
12+
_experimental: {
13+
autoInstrumentation: true,
14+
},
15+
}),
16+
],
17+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"$schema": "../../../node_modules/wrangler/config-schema.json",
3+
"name": "cloudflare-vite-autoinstrument-durableobject-workflow-specifier",
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_als"],
9+
"durable_objects": {
10+
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }],
11+
},
12+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }],
13+
"workflows": [
14+
{
15+
"name": "my-workflow",
16+
"binding": "MY_WORKFLOW",
17+
"class_name": "MyWorkflow",
18+
},
19+
],
20+
}

0 commit comments

Comments
 (0)