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,105 @@
import * as Sentry from '@sentry/cloudflare';
import { WorkflowEntrypoint } from 'cloudflare:workers';
import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers';
import { NonRetryableError } from 'cloudflare:workflows';

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

interface WorkflowParams {
mode: string;
}

export class RunErrorsTestWorkflow extends WorkflowEntrypoint<Env, WorkflowParams> {
async run(event: WorkflowEvent<WorkflowParams>, step: WorkflowStep): Promise<void> {
const { mode } = event.payload;

if (mode === 'throw-before-step') {
throw new TypeError('Thrown before any step');
}

if (mode === 'non-retryable-before-step') {
throw new NonRetryableError('Non-retryable before any step');
}

if (mode === 'throw-after-step') {
await step.do('successful-step', async () => 'done');
throw new Error('Thrown after a successful step');
}

if (mode === 'wait-for-event-timeout') {
await step.waitForEvent('wait-for-event', { type: 'never-sent', timeout: '1 second' });
}

if (mode === 'step-timeout') {
await step.do('slow-step', { timeout: '1 second', retries: { limit: 0, delay: 100 } }, async () => {
await new Promise(resolve => setTimeout(resolve, 5000));
});
}

if (mode === 'non-serializable') {
await step.do(
'non-serializable-step',
{ retries: { limit: 0, delay: 100 } },
(async () => () => 'function') as () => Promise<never>,
);
}

if (mode === 'throw-in-step') {
await step.do('failing-step', { retries: { limit: 0, delay: 100 } }, async () => {
throw new Error('Thrown inside the step callback');
});
}

if (mode === 'caught-step-error') {
try {
await step.do('failing-step', { retries: { limit: 0, delay: 100 } }, async () => {
throw new Error('Thrown inside the step callback');
});
} catch {
// The workflow goes on after the failed step
}
}

if (mode === 'rethrow-new-error') {
try {
await step.do('failing-step', { retries: { limit: 0, delay: 100 } }, async () => {
throw new Error('Thrown inside the step callback');
});
} catch {
throw new Error('Thrown by run() after the failed step');
}
}
}
}

export default {
async fetch(request, env, _ctx) {
const url = new URL(request.url);

if (url.pathname === '/trigger-workflow') {
const instance = await env.RUN_ERRORS_WORKFLOW.create({
params: { mode: url.searchParams.get('mode') || '' },
});

return Response.json({ id: instance.id });
}

if (url.pathname === '/flush-marker') {
Sentry.captureMessage('flush-marker');
return Response.json({ ok: true });
}

const statusMatch = url.pathname.match(/^\/workflow-status\/(.+)$/);

if (statusMatch) {
const instance = await env.RUN_ERRORS_WORKFLOW.get(statusMatch[1]!);

return Response.json({ id: statusMatch[1], status: await instance.status() });
}

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

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

interface TriggerResponse {
id: string;
}

interface StatusResponse {
id: string;
status: { status: string };
}

interface SentException {
type?: string;
value?: string;
mechanism?: { handled?: boolean; type?: string };
}

async function waitForWorkflowStatus(
makeRequest: <T>(method: 'get' | 'post', path: string) => Promise<T | undefined>,
workflowId: string,
): Promise<StatusResponse | undefined> {
for (let i = 0; i < 30; i++) {
const status = await makeRequest<StatusResponse>('get', `/workflow-status/${workflowId}`);
if (status?.status?.status === 'errored' || status?.status?.status === 'complete') {
return status;
}
await new Promise(r => setTimeout(r, 200));
}
return undefined;
}

// The flush marker is sent after the instance has ended and must arrive directly after the expected
// error event. A second error event for the same failure would arrive in its place and fail the test.
const flushMarkerMatcher = (envelope: Envelope): void => {
const [, items] = envelope;
const [itemHeader, itemBody] = items[0] as [{ type: string }, Record<string, unknown>];

expect(itemHeader.type).toBe('event');
expect(itemBody.message).toBe('flush-marker');
};

it.for([
{ mode: 'throw-before-step', type: 'TypeError', value: 'Thrown before any step' },
{ mode: 'non-retryable-before-step', type: 'NonRetryableError', value: 'Non-retryable before any step' },
{ mode: 'throw-after-step', type: 'Error', value: 'Thrown after a successful step' },
{ mode: 'wait-for-event-timeout', type: 'Error', value: expect.stringContaining('timed out') },
{ mode: 'step-timeout', type: 'Error', value: expect.stringContaining('timed out') },
{ mode: 'non-serializable', type: 'Error', value: expect.stringContaining('not serialisable') },
])(
'captures one unhandled error when run() fails outside a step callback ($mode)',
async ({ mode, type, value }, { signal }) => {
const runner = createRunner(__dirname)
.expect((envelope: Envelope): void => {
const [, items] = envelope;
const [itemHeader, itemBody] = items[0] as [{ type: string }, { exception?: { values?: SentException[] } }];

expect(itemHeader.type).toBe('event');
expect(itemBody.exception?.values?.[0]).toMatchObject({
type,
value,
mechanism: { handled: false, type: 'auto.faas.cloudflare.workflow' },
});
})
.expect(flushMarkerMatcher)
.start(signal);

const trigger = await runner.makeRequest<TriggerResponse>('get', `/trigger-workflow?mode=${mode}`);
expect(trigger?.id).toBeDefined();

const status = await waitForWorkflowStatus(runner.makeRequest.bind(runner), trigger!.id);
expect(status?.status?.status).toBe('errored');

await runner.makeRequest('get', '/flush-marker');
await runner.completed();
},
);

it('captures an error thrown inside a step callback only one time', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect((envelope: Envelope): void => {
const [, items] = envelope;
const [itemHeader, itemBody] = items[0] as [{ type: string }, { exception?: { values?: SentException[] } }];

expect(itemHeader.type).toBe('event');
expect(itemBody.exception?.values?.[0]).toMatchObject({
type: 'Error',
value: 'Thrown inside the step callback',
mechanism: { handled: true, type: 'auto.faas.cloudflare.workflow' },
});
})
.expect(flushMarkerMatcher)
.start(signal);

const trigger = await runner.makeRequest<TriggerResponse>('get', '/trigger-workflow?mode=throw-in-step');
expect(trigger?.id).toBeDefined();

const status = await waitForWorkflowStatus(runner.makeRequest.bind(runner), trigger!.id);
expect(status?.status?.status).toBe('errored');

await runner.makeRequest('get', '/flush-marker');
await runner.completed();
});

it('captures no second error when run() catches a failed step', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect((envelope: Envelope): void => {
const [, items] = envelope;
const [itemHeader, itemBody] = items[0] as [{ type: string }, { exception?: { values?: SentException[] } }];

expect(itemHeader.type).toBe('event');
expect(itemBody.exception?.values?.[0]?.value).toBe('Thrown inside the step callback');
})
.expect(flushMarkerMatcher)
.start(signal);

const trigger = await runner.makeRequest<TriggerResponse>('get', '/trigger-workflow?mode=caught-step-error');
expect(trigger?.id).toBeDefined();

const status = await waitForWorkflowStatus(runner.makeRequest.bind(runner), trigger!.id);
expect(status?.status?.status).toBe('complete');

await runner.makeRequest('get', '/flush-marker');
await runner.completed();
});

it('captures the step error and the new error when run() replaces a failed step error', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect((envelope: Envelope): void => {
const [, items] = envelope;
const [itemHeader, itemBody] = items[0] as [{ type: string }, { exception?: { values?: SentException[] } }];

expect(itemHeader.type).toBe('event');
expect(itemBody.exception?.values?.[0]).toMatchObject({
value: 'Thrown inside the step callback',
mechanism: { handled: true, type: 'auto.faas.cloudflare.workflow' },
});
})
.expect((envelope: Envelope): void => {
const [, items] = envelope;
const [itemHeader, itemBody] = items[0] as [{ type: string }, { exception?: { values?: SentException[] } }];

expect(itemHeader.type).toBe('event');
expect(itemBody.exception?.values?.[0]).toMatchObject({
value: 'Thrown by run() after the failed step',
mechanism: { handled: false, type: 'auto.faas.cloudflare.workflow' },
});
})
.expect(flushMarkerMatcher)
.start(signal);

const trigger = await runner.makeRequest<TriggerResponse>('get', '/trigger-workflow?mode=rethrow-new-error');
expect(trigger?.id).toBeDefined();

const status = await waitForWorkflowStatus(runner.makeRequest.bind(runner), trigger!.id);
expect(status?.status?.status).toBe('errored');

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

export default defineConfig({
plugins: [cloudflare(), sentryCloudflareVitePlugin()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "workflow-run-errors-test",
"compatibility_date": "2026-05-01",
"main": "index.ts",
"compatibility_flags": ["nodejs_compat"],
"workflows": [
{
"name": "run-errors-test-workflow",
"binding": "RUN_ERRORS_WORKFLOW",
"class_name": "RunErrorsTestWorkflow",
},
],
}
25 changes: 21 additions & 4 deletions packages/cloudflare/src/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { FUNCTION } from '@sentry/conventions/op';
import type { PropagationContext, Scope } from '@sentry/core';
import {
captureException,
checkOrSetAlreadyCaught,
flush,
getCurrentScope,
isError,
Expand Down Expand Up @@ -126,6 +127,8 @@ class WrappedWorkflowStep implements WorkflowStep {
| WorkflowStepRollbackOptions<T>
| undefined;

let capturedByCallback = false;

const instrumentedCallback = async (...args: unknown[]): Promise<T> => {
// Feature detection: Cloudflare Workflows (April 2026+) pass a step context
// with `attempt` and `config.retries.limit`. When available, we only capture
Expand Down Expand Up @@ -176,6 +179,7 @@ class WrappedWorkflowStep implements WorkflowStep {
} catch (error) {
if (isFinalAttempt || isNonRetryableError(error)) {
captureException(error, { mechanism: { handled: true, type: 'auto.faas.cloudflare.workflow' } });
capturedByCallback = true;
}
throw error;
}
Expand All @@ -198,15 +202,25 @@ class WrappedWorkflowStep implements WorkflowStep {
});
};

let stepResult: Promise<T>;
if (config) {
return rollbackOptions
stepResult = rollbackOptions
? this._step.do(name, config, instrumentedCallback, rollbackOptions)
: this._step.do(name, config, instrumentedCallback);
} else {
stepResult = rollbackOptions
? this._step.do(name, instrumentedCallback, rollbackOptions)
: this._step.do(name, instrumentedCallback);
}

return rollbackOptions
? this._step.do(name, instrumentedCallback, rollbackOptions)
: this._step.do(name, instrumentedCallback);
return stepResult.catch(error => {
// The engine rejects with a copy of the error the callback threw, so the copy has to be marked
// as captured too. Otherwise `run` would report the same failure a second time.
if (capturedByCallback) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this capturedByCallback thing if we pass it through checkOrSetAlreadyCaught anyhow...?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah wait, I think I am confused by the code. we ignore the return value here and just use this to mark this as captured, right? maybe worth it to adjust the comment a bit, e.g. "we use checkOrSetAlreadyCaught to ensure the error is marked as already sent" or something like this

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll clarify

checkOrSetAlreadyCaught(error);
}
throw error;
});
}

public async sleep(name: string, duration: WorkflowSleepDuration): Promise<void> {
Expand Down Expand Up @@ -291,6 +305,9 @@ export function instrumentWorkflowWithSentry<
event,
new WrappedWorkflowStep(event.instanceId, options, step, waitUntil, isolationScope),
);
} catch (error) {
captureException(error, { mechanism: { handled: false, type: 'auto.faas.cloudflare.workflow' } });
throw error;
} finally {
waitUntil(flushAndDispose(client));
}
Expand Down
Loading
Loading