Skip to content

Commit a438a0c

Browse files
JPeer264claude
andauthored
feat(cloudflare): Only capture workflow step error on final retry attempt (#21025)
closes #17421 closes [JS-869](https://linear.app/getsentry/issue/JS-869/cloudflare-workflow-option-to-have-only-one-error-on-step-failure) Cloudflare Workflows (wrangler 4.86.0+) now pass step context with `attempt` and `config.retries.limit` to step callbacks. When available, we use this to only capture errors on the final retry attempt, avoiding duplicate error events during retries. For older wrangler versions without step context, the SDK falls back to legacy behavior (capturing errors on every attempt). - Add integration tests for step context behavior - This is specifically for the new behavior - Add e2e test for legacy behavior (wrangler 4.70.0) - This makes sure we won't break older wrangler versions - This test breaks when wrangler is updated, as with a newer wrangler version you get a higher compat version, therefore you get only 1 error instead of 3 - A E2E test was chosen as it seem the `compatibility_date` has no effect with `wrangler`, so I couldn't make an integration test --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 18fbbb7 commit a438a0c

16 files changed

Lines changed: 843 additions & 115 deletions

File tree

dev-packages/cloudflare-integration-tests/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@
1919
"hono": "^4.12.18"
2020
},
2121
"devDependencies": {
22-
"@cloudflare/workers-types": "^4.20250922.0",
22+
"@cloudflare/workers-types": "^4.20260426.0",
2323
"@sentry-internal/test-utils": "10.55.0",
2424
"eslint-plugin-regexp": "^1.15.0",
2525
"vitest": "^3.2.4",
26-
"wrangler": "4.61.0"
26+
"wrangler": "4.86.0"
2727
},
2828
"volta": {
2929
"extends": "../../package.json"

dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ it('Workflow steps create transactions with correct attributes', async ({ signal
2929
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.workflow',
3030
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task',
3131
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
32+
'cloudflare.workflow.attempt': 1,
3233
},
3334
},
3435
}),
@@ -55,6 +56,7 @@ it('Workflow steps create transactions with correct attributes', async ({ signal
5556
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.workflow',
5657
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task',
5758
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
59+
'cloudflare.workflow.attempt': 1,
5860
},
5961
},
6062
}),
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { WorkflowEntrypoint } from 'cloudflare:workers';
3+
import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers';
4+
5+
interface Env {
6+
SENTRY_DSN: string;
7+
STEP_CONTEXT_WORKFLOW: Workflow;
8+
}
9+
10+
interface WorkflowParams {
11+
failCount: number;
12+
captureManual?: boolean;
13+
}
14+
class StepContextTestWorkflowBase extends WorkflowEntrypoint<Env, WorkflowParams> {
15+
async run(event: WorkflowEvent<WorkflowParams>, step: WorkflowStep): Promise<void> {
16+
let remainingFailures = event.payload.failCount;
17+
18+
const result = await step.do(
19+
'failing-step',
20+
{
21+
retries: {
22+
limit: 2,
23+
delay: 100,
24+
},
25+
},
26+
async ctx => {
27+
if (event.payload.captureManual) {
28+
Sentry.captureException(new Error(`Manual capture on attempt ${ctx.attempt}`));
29+
}
30+
31+
if (remainingFailures > 0) {
32+
remainingFailures--;
33+
throw new Error('Intentional failure for retry test');
34+
}
35+
},
36+
);
37+
38+
return result;
39+
}
40+
}
41+
42+
export const StepContextTestWorkflow = Sentry.instrumentWorkflowWithSentry(
43+
(env: Env) => ({
44+
dsn: env.SENTRY_DSN,
45+
}),
46+
StepContextTestWorkflowBase,
47+
);
48+
49+
export default Sentry.withSentry(
50+
(env: Env) => ({
51+
dsn: env.SENTRY_DSN,
52+
}),
53+
{
54+
async fetch(request, env, _ctx) {
55+
const url = new URL(request.url);
56+
57+
if (url.pathname === '/trigger-workflow') {
58+
const failCount = parseInt(url.searchParams.get('failCount') || '0', 10);
59+
const captureManual = url.searchParams.get('captureManual') === 'true';
60+
61+
try {
62+
const instance = await env.STEP_CONTEXT_WORKFLOW.create({
63+
params: { failCount, captureManual },
64+
});
65+
66+
return new Response(JSON.stringify({ id: instance.id }), { headers: { 'Content-Type': 'application/json' } });
67+
} catch (e) {
68+
return new Response(JSON.stringify({ error: 'Failed to create workflow', details: String(e) }), {
69+
status: 500,
70+
headers: { 'Content-Type': 'application/json' },
71+
});
72+
}
73+
}
74+
75+
if (url.pathname === '/flush-marker') {
76+
Sentry.captureMessage('flush-marker');
77+
return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
78+
}
79+
80+
const statusMatch = url.pathname.match(/^\/workflow-status\/(.+)$/);
81+
82+
if (statusMatch) {
83+
const workflowId = statusMatch[1];
84+
85+
try {
86+
const instance = await env.STEP_CONTEXT_WORKFLOW.get(workflowId!);
87+
const status = await instance.status();
88+
89+
return new Response(
90+
JSON.stringify({
91+
id: workflowId,
92+
status,
93+
}),
94+
{ headers: { 'Content-Type': 'application/json' } },
95+
);
96+
} catch (e) {
97+
return new Response(JSON.stringify({ error: 'Failed to get workflow status', details: String(e) }), {
98+
status: 500,
99+
headers: { 'Content-Type': 'application/json' },
100+
});
101+
}
102+
}
103+
104+
return new Response('Step Context Test Worker');
105+
},
106+
} satisfies ExportedHandler<Env>,
107+
);
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { Envelope } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../../runner';
4+
5+
interface TriggerResponse {
6+
id: string;
7+
}
8+
9+
interface StatusResponse {
10+
id: string;
11+
status: { status: string };
12+
}
13+
14+
async function waitForWorkflowStatus(
15+
makeRequest: <T>(method: 'get' | 'post', path: string) => Promise<T | undefined>,
16+
workflowId: string,
17+
): Promise<StatusResponse | undefined> {
18+
for (let i = 0; i < 30; i++) {
19+
const status = await makeRequest<StatusResponse>('get', `/workflow-status/${workflowId}`);
20+
if (status?.status?.status === 'errored' || status?.status?.status === 'complete') {
21+
return status;
22+
}
23+
await new Promise(r => setTimeout(r, 200));
24+
}
25+
return undefined;
26+
}
27+
28+
const flushMarkerMatcher = (envelope: Envelope): void => {
29+
const [, items] = envelope;
30+
const [itemHeader, itemBody] = items[0] as [{ type: string }, Record<string, unknown>];
31+
32+
expect(itemHeader.type).toBe('event');
33+
expect(itemBody.message).toBe('flush-marker');
34+
};
35+
36+
it('With step context, only one error is captured on final retry attempt', async ({ signal }) => {
37+
const runner = createRunner(__dirname)
38+
.expect((envelope: Envelope): void => {
39+
const [, items] = envelope;
40+
const [itemHeader, itemBody] = items[0] as [{ type: string }, Record<string, unknown>];
41+
42+
expect(itemHeader.type).toBe('event');
43+
expect(itemBody.exception).toBeDefined();
44+
45+
const exception = itemBody.exception as { values?: Array<{ value?: string }> };
46+
expect(exception?.values?.[0]?.value).toBe('Intentional failure for retry test');
47+
})
48+
.expect(flushMarkerMatcher)
49+
.start(signal);
50+
51+
const trigger = await runner.makeRequest<TriggerResponse>('get', '/trigger-workflow?failCount=3');
52+
53+
expect(trigger?.id).toBeDefined();
54+
55+
const status = await waitForWorkflowStatus(runner.makeRequest.bind(runner), trigger!.id);
56+
expect(status?.status?.status).toBe('errored');
57+
58+
await runner.makeRequest('get', '/flush-marker');
59+
await runner.completed();
60+
});
61+
62+
it('No error event when step eventually succeeds within retry limit', async ({ signal }) => {
63+
const runner = createRunner(__dirname).expect(flushMarkerMatcher).start(signal);
64+
65+
const trigger = await runner.makeRequest<TriggerResponse>('get', '/trigger-workflow?failCount=1');
66+
expect(trigger?.id).toBeDefined();
67+
68+
const status = await waitForWorkflowStatus(runner.makeRequest.bind(runner), trigger!.id);
69+
expect(status?.status?.status).toBe('complete');
70+
71+
await runner.makeRequest('get', '/flush-marker');
72+
await runner.completed();
73+
});
74+
75+
it('Manually captured exceptions are always sent on every attempt', async ({ signal }) => {
76+
const runner = createRunner(__dirname)
77+
.expectN(3, (envelope: Envelope): void => {
78+
const [, items] = envelope;
79+
const [itemHeader, itemBody] = items[0] as [{ type: string }, Record<string, unknown>];
80+
81+
expect(itemHeader.type).toBe('event');
82+
expect(itemBody.exception).toBeDefined();
83+
84+
const exception = itemBody.exception as { values?: Array<{ value?: string }> };
85+
expect(exception?.values?.[0]?.value).toMatch(/^Manual capture on attempt \d+$/);
86+
})
87+
.expect((envelope: Envelope): void => {
88+
const [, items] = envelope;
89+
const [itemHeader, itemBody] = items[0] as [{ type: string }, Record<string, unknown>];
90+
91+
expect(itemHeader.type).toBe('event');
92+
expect(itemBody.exception).toBeDefined();
93+
94+
const exception = itemBody.exception as { values?: Array<{ value?: string }> };
95+
expect(exception?.values?.[0]?.value).toBe('Intentional failure for retry test');
96+
})
97+
.expect(flushMarkerMatcher)
98+
.unordered()
99+
.start(signal);
100+
101+
const trigger = await runner.makeRequest<TriggerResponse>('get', '/trigger-workflow?failCount=3&captureManual=true');
102+
expect(trigger?.id).toBeDefined();
103+
104+
const status = await waitForWorkflowStatus(runner.makeRequest.bind(runner), trigger!.id);
105+
expect(status?.status?.status).toBe('errored');
106+
107+
await runner.makeRequest('get', '/flush-marker');
108+
await runner.completed();
109+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "workflow-step-context-test",
3+
"compatibility_date": "2026-05-01",
4+
"main": "index.ts",
5+
"compatibility_flags": ["nodejs_compat"],
6+
"workflows": [
7+
{
8+
"name": "step-context-test-workflow",
9+
"binding": "STEP_CONTEXT_WORKFLOW",
10+
"class_name": "StepContextTestWorkflow",
11+
},
12+
],
13+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.wrangler
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "cloudflare-workers-workflow-legacy",
3+
"version": "0.0.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
7+
"build": "wrangler deploy --dry-run",
8+
"typecheck": "tsc --noEmit",
9+
"test:build": "pnpm install && pnpm build",
10+
"test:assert": "pnpm typecheck && pnpm test:dev",
11+
"test:dev": "TEST_ENV=development playwright test"
12+
},
13+
"dependencies": {
14+
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz"
15+
},
16+
"devDependencies": {
17+
"@playwright/test": "~1.56.0",
18+
"@cloudflare/workers-types": "^4.20260303.0",
19+
"@sentry-internal/test-utils": "link:../../../test-utils",
20+
"typescript": "^5.5.2",
21+
"wrangler": "4.70.0"
22+
},
23+
"volta": {
24+
"node": "24.15.0",
25+
"extends": "../../package.json"
26+
}
27+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
const config = getPlaywrightConfig({
4+
startCommand: 'pnpm dev',
5+
port: 8787,
6+
eventProxyFile: 'start-event-proxy.mjs',
7+
});
8+
9+
export default config;
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { WorkflowEntrypoint } from 'cloudflare:workers';
3+
import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers';
4+
5+
interface Env {
6+
E2E_TEST_DSN: string;
7+
RETRY_WORKFLOW: Workflow;
8+
}
9+
10+
interface WorkflowParams {
11+
failCount: number;
12+
}
13+
14+
class RetryTestWorkflowBase extends WorkflowEntrypoint<Env, WorkflowParams> {
15+
async run(event: WorkflowEvent<WorkflowParams>, step: WorkflowStep): Promise<string> {
16+
let remainingFailures = event.payload.failCount;
17+
18+
await step.do(
19+
'failing-step',
20+
{
21+
retries: {
22+
limit: 2,
23+
delay: 100,
24+
},
25+
},
26+
async () => {
27+
if (remainingFailures > 0) {
28+
remainingFailures--;
29+
throw new Error('Intentional failure for retry test');
30+
}
31+
return 'success';
32+
},
33+
);
34+
35+
return 'workflow completed';
36+
}
37+
}
38+
39+
export const RetryTestWorkflow = Sentry.instrumentWorkflowWithSentry(
40+
(env: Env) => ({
41+
dsn: env.E2E_TEST_DSN,
42+
tunnel: 'http://localhost:3031/',
43+
}),
44+
RetryTestWorkflowBase,
45+
);
46+
47+
export default Sentry.withSentry(
48+
(env: Env) => ({
49+
dsn: env.E2E_TEST_DSN,
50+
tunnel: 'http://localhost:3031/',
51+
}),
52+
{
53+
async fetch(request, env, _ctx) {
54+
const url = new URL(request.url);
55+
56+
if (url.pathname === '/flush-marker') {
57+
Sentry.captureMessage('flush-marker');
58+
return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
59+
}
60+
61+
if (url.pathname === '/trigger-workflow') {
62+
const failCount = parseInt(url.searchParams.get('failCount') || '3', 10);
63+
64+
const instance = await env.RETRY_WORKFLOW.create({
65+
params: { failCount },
66+
});
67+
68+
// Poll for workflow completion
69+
for (let i = 0; i < 30; i++) {
70+
try {
71+
const status = await instance.status();
72+
if (status.status === 'complete' || status.status === 'errored') {
73+
return new Response(JSON.stringify({ id: instance.id, status }), {
74+
headers: { 'Content-Type': 'application/json' },
75+
});
76+
}
77+
} catch {
78+
// status() may not be available yet
79+
}
80+
await new Promise(r => setTimeout(r, 500));
81+
}
82+
83+
return new Response(JSON.stringify({ id: instance.id, status: 'timeout' }), {
84+
headers: { 'Content-Type': 'application/json' },
85+
});
86+
}
87+
88+
return new Response('Workflow Legacy Test Worker');
89+
},
90+
} satisfies ExportedHandler<Env>,
91+
);

0 commit comments

Comments
 (0)