diff --git a/dev-packages/cloudflare-integration-tests/suites/workflows/run-errors/index.ts b/dev-packages/cloudflare-integration-tests/suites/workflows/run-errors/index.ts new file mode 100644 index 000000000000..4f77c798e2e2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/workflows/run-errors/index.ts @@ -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 { + async run(event: WorkflowEvent, step: WorkflowStep): Promise { + 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, + ); + } + + 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; diff --git a/dev-packages/cloudflare-integration-tests/suites/workflows/run-errors/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/workflows/run-errors/instrument.server.ts new file mode 100644 index 000000000000..9de8aec879bb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/workflows/run-errors/instrument.server.ts @@ -0,0 +1,5 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/workflows/run-errors/test.ts b/dev-packages/cloudflare-integration-tests/suites/workflows/run-errors/test.ts new file mode 100644 index 000000000000..6913c1f133b2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/workflows/run-errors/test.ts @@ -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: (method: 'get' | 'post', path: string) => Promise, + workflowId: string, +): Promise { + for (let i = 0; i < 30; i++) { + const status = await makeRequest('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]; + + 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('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('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('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('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(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/workflows/run-errors/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/workflows/run-errors/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/workflows/run-errors/vite.config.mts @@ -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()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/workflows/run-errors/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/workflows/run-errors/wrangler.jsonc new file mode 100644 index 000000000000..c70373ceb803 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/workflows/run-errors/wrangler.jsonc @@ -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", + }, + ], +} diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index 0d31267e8176..44d05d93faa5 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -13,6 +13,7 @@ import { FUNCTION } from '@sentry/conventions/op'; import type { PropagationContext, Scope } from '@sentry/core'; import { captureException, + checkOrSetAlreadyCaught, flush, getCurrentScope, isError, @@ -126,6 +127,8 @@ class WrappedWorkflowStep implements WorkflowStep { | WorkflowStepRollbackOptions | undefined; + let capturedByCallback = false; + const instrumentedCallback = async (...args: unknown[]): Promise => { // Feature detection: Cloudflare Workflows (April 2026+) pass a step context // with `attempt` and `config.retries.limit`. When available, we only capture @@ -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; } @@ -198,15 +202,25 @@ class WrappedWorkflowStep implements WorkflowStep { }); }; + let stepResult: Promise; 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) { + checkOrSetAlreadyCaught(error); + } + throw error; + }); } public async sleep(name: string, duration: WorkflowSleepDuration): Promise { @@ -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)); } diff --git a/packages/cloudflare/test/workflow.test.ts b/packages/cloudflare/test/workflow.test.ts index 3490b51f8272..e55ce9986ba6 100644 --- a/packages/cloudflare/test/workflow.test.ts +++ b/packages/cloudflare/test/workflow.test.ts @@ -721,6 +721,155 @@ describe.skipIf(NODE_MAJOR_VERSION < 20)('workflows', () => { }); }); + describe('errors that fail run()', () => { + const event = { payload: {}, timestamp: new Date(), instanceId: INSTANCE_ID }; + + test('captures an error thrown in run() outside a step', async () => { + class ThrowingWorkflow { + constructor(_ctx: ExecutionContext, _env: unknown) {} + + async run(_event: Readonly>, _step: WorkflowStep): Promise { + throw new TypeError('thrown before any step'); + } + } + + const TestWorkflowInstrumented = instrumentWorkflowWithSentry(() => getSentryOptions(), ThrowingWorkflow as any); + const workflow = new TestWorkflowInstrumented(mockContext, {}) as ThrowingWorkflow; + await expect(workflow.run(event, mockStep)).rejects.toThrow('thrown before any step'); + + const errorEvents = mockTransport.send.mock.calls + .map(call => (call[0] as any)[1] as any[]) + .flatMap(items => items.filter(i => i[0].type === 'event').map(i => i[1])); + + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0]).toMatchObject({ + exception: { + values: [ + expect.objectContaining({ + type: 'TypeError', + value: 'thrown before any step', + mechanism: { type: 'auto.faas.cloudflare.workflow', handled: false }, + }), + ], + }, + }); + }); + + test('captures a step rejection that does not come from the step callback', async () => { + const timedOutStep: WorkflowStep = { + ...mockStep, + do: vi.fn().mockRejectedValue(new Error('Execution timed out after 1000ms')), + }; + + class TimedOutWorkflow { + constructor(_ctx: ExecutionContext, _env: unknown) {} + + async run(_event: Readonly>, step: WorkflowStep): Promise { + await step.do('slow step', async () => 'never called'); + } + } + + const TestWorkflowInstrumented = instrumentWorkflowWithSentry(() => getSentryOptions(), TimedOutWorkflow as any); + const workflow = new TestWorkflowInstrumented(mockContext, {}) as TimedOutWorkflow; + await expect(workflow.run(event, timedOutStep)).rejects.toThrow('Execution timed out after 1000ms'); + + const errorEvents = mockTransport.send.mock.calls + .map(call => (call[0] as any)[1] as any[]) + .flatMap(items => items.filter(i => i[0].type === 'event').map(i => i[1])); + + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0]).toMatchObject({ + exception: { + values: [ + expect.objectContaining({ + value: 'Execution timed out after 1000ms', + mechanism: { type: 'auto.faas.cloudflare.workflow', handled: false }, + }), + ], + }, + }); + }); + + test('captures a step callback error only once when the engine rejects with a copy of it', async () => { + const copyingStep: WorkflowStep = { + ...mockStep, + do: vi.fn().mockImplementation(async (_name: string, callback: (...args: unknown[]) => Promise) => { + try { + return await callback({ attempt: 1, config: { retries: { limit: 0 }, timeout: 60000 } }); + } catch (err) { + throw new Error((err as Error).message); + } + }), + }; + + class FailingStepWorkflow { + constructor(_ctx: ExecutionContext, _env: unknown) {} + + async run(_event: Readonly>, step: WorkflowStep): Promise { + await step.do('failing step', async () => { + throw new Error('thrown inside the step callback'); + }); + } + } + + const TestWorkflowInstrumented = instrumentWorkflowWithSentry( + () => getSentryOptions(), + FailingStepWorkflow as any, + ); + const workflow = new TestWorkflowInstrumented(mockContext, {}) as FailingStepWorkflow; + await expect(workflow.run(event, copyingStep)).rejects.toThrow('thrown inside the step callback'); + + const errorEvents = mockTransport.send.mock.calls + .map(call => (call[0] as any)[1] as any[]) + .flatMap(items => items.filter(i => i[0].type === 'event').map(i => i[1])); + + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0]).toMatchObject({ + exception: { + values: [ + expect.objectContaining({ + value: 'thrown inside the step callback', + mechanism: { type: 'auto.faas.cloudflare.workflow', handled: true }, + }), + ], + }, + }); + }); + + test('captures the last rejection when earlier attempts failed in the callback without a capture', async () => { + const timeoutAfterRetryStep: WorkflowStep = { + ...mockStep, + do: vi.fn().mockImplementation(async (_name: string, callback: (...args: unknown[]) => Promise) => { + await callback({ attempt: 1, config: { retries: { limit: 1 }, timeout: 60000 } }).catch(() => undefined); + throw new Error('Execution timed out after 1000ms'); + }), + }; + + class RetriedWorkflow { + constructor(_ctx: ExecutionContext, _env: unknown) {} + + async run(_event: Readonly>, step: WorkflowStep): Promise { + await step.do('retried step', async () => { + throw new Error('first attempt failed'); + }); + } + } + + const TestWorkflowInstrumented = instrumentWorkflowWithSentry(() => getSentryOptions(), RetriedWorkflow as any); + const workflow = new TestWorkflowInstrumented(mockContext, {}) as RetriedWorkflow; + await expect(workflow.run(event, timeoutAfterRetryStep)).rejects.toThrow('Execution timed out after 1000ms'); + + const errorEvents = mockTransport.send.mock.calls + .map(call => (call[0] as any)[1] as any[]) + .flatMap(items => items.filter(i => i[0].type === 'event').map(i => i[1])); + + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0]).toMatchObject({ + exception: { values: [expect.objectContaining({ value: 'Execution timed out after 1000ms' })] }, + }); + }); + }); + test('emits error per attempt on older Cloudflare without step context', async () => { // Mock that always throws — simulates a step that fails through every retry // Does NOT pass step context (simulates older Cloudflare versions)