From aecd1b2cc99af120721c4a172ccc4a2f32dd721e Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 22 Jul 2025 11:45:48 +0200 Subject: [PATCH 1/7] fix(cloudflare): Allow non uuid workflow instance IDs --- packages/cloudflare/src/workflows.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index 022f0040893a..e5aa56ad90e0 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -24,14 +24,17 @@ import { init } from './sdk'; const UUID_REGEX = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i; -function propagationContextFromInstanceId(instanceId: string): PropagationContext { - // Validate and normalize traceId - should be a valid UUID with or without hyphens - if (!UUID_REGEX.test(instanceId)) { - throw new Error("Invalid 'instanceId' for workflow: Sentry requires random UUIDs for instanceId."); - } +async function hashStringToUuid(input: string): Promise { + const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(input)); + return Array.from(new Uint8Array(buf)) + // We only need the first 16 bytes for the 32 characters + .slice(0, 16) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} - // Remove hyphens to get UUID without hyphens - const traceId = instanceId.replace(/-/g, ''); +async function propagationContextFromInstanceId(instanceId: string): Promise { + const traceId = UUID_REGEX.test(instanceId) ? instanceId.replace(/-/g, '') : await hashStringToUuid(instanceId); // Derive sampleRand from last 4 characters of the random UUID // @@ -60,7 +63,7 @@ async function workflowStepWithSentry( addCloudResourceContext(isolationScope); return withScope(async scope => { - const propagationContext = propagationContextFromInstanceId(instanceId); + const propagationContext = await propagationContextFromInstanceId(instanceId); scope.setPropagationContext(propagationContext); // eslint-disable-next-line no-return-await From ba751cac6f2ac07194b030305c29202be4dbc6b7 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 22 Jul 2025 11:56:38 +0200 Subject: [PATCH 2/7] Lint --- packages/cloudflare/src/workflows.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index e5aa56ad90e0..de51d1c768c0 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -26,11 +26,13 @@ const UUID_REGEX = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f async function hashStringToUuid(input: string): Promise { const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(input)); - return Array.from(new Uint8Array(buf)) - // We only need the first 16 bytes for the 32 characters - .slice(0, 16) - .map(b => b.toString(16).padStart(2, '0')) - .join(''); + return ( + Array.from(new Uint8Array(buf)) + // We only need the first 16 bytes for the 32 characters + .slice(0, 16) + .map(b => b.toString(16).padStart(2, '0')) + .join('') + ); } async function propagationContextFromInstanceId(instanceId: string): Promise { From 244cd47e5cb7d11d6d96c6bb7ae6d5d8285394f3 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 22 Jul 2025 12:52:52 +0200 Subject: [PATCH 3/7] add test --- packages/cloudflare/test/workflow.test.ts | 65 +++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/cloudflare/test/workflow.test.ts b/packages/cloudflare/test/workflow.test.ts index 03eee5191eb2..93f5faced228 100644 --- a/packages/cloudflare/test/workflow.test.ts +++ b/packages/cloudflare/test/workflow.test.ts @@ -133,6 +133,71 @@ describe('workflows', () => { ]); }); + test('Calls expected functions with non-uuid instance id', async () => { + class BasicTestWorkflow { + constructor(_ctx: ExecutionContext, _env: unknown) {} + + async run(_event: Readonly>, step: WorkflowStep): Promise { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const files = await step.do('first step', async () => { + return { files: ['doc_7392_rev3.pdf', 'report_x29_final.pdf'] }; + }); + } + } + + const TestWorkflowInstrumented = instrumentWorkflowWithSentry(getSentryOptions, BasicTestWorkflow as any); + const workflow = new TestWorkflowInstrumented(mockContext, {}) as BasicTestWorkflow; + const event = { payload: {}, timestamp: new Date(), instanceId: 'ae0ee067' }; + await workflow.run(event, mockStep); + + expect(mockStep.do).toHaveBeenCalledTimes(1); + expect(mockStep.do).toHaveBeenCalledWith('first step', expect.any(Function)); + expect(mockContext.waitUntil).toHaveBeenCalledTimes(1); + expect(mockContext.waitUntil).toHaveBeenCalledWith(expect.any(Promise)); + expect(mockTransport.send).toHaveBeenCalledTimes(1); + expect(mockTransport.send).toHaveBeenCalledWith([ + expect.objectContaining({ + trace: expect.objectContaining({ + transaction: 'first step', + trace_id: '0d2b6d1743ce6d53af4f5ee416ad5d1b', + sample_rand: '0.3636987869077592', + }), + }), + [ + [ + { + type: 'transaction', + }, + expect.objectContaining({ + event_id: expect.any(String), + contexts: { + trace: { + parent_span_id: undefined, + span_id: expect.any(String), + trace_id: '0d2b6d1743ce6d53af4f5ee416ad5d1b', + data: { + 'sentry.origin': 'auto.faas.cloudflare.workflow', + 'sentry.op': 'function.step.do', + 'sentry.source': 'task', + 'sentry.sample_rate': 1, + }, + op: 'function.step.do', + status: 'ok', + origin: 'auto.faas.cloudflare.workflow', + }, + cloud_resource: { 'cloud.provider': 'cloudflare' }, + runtime: { name: 'cloudflare' }, + }, + type: 'transaction', + transaction_info: { source: 'task' }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ], + ], + ]); + }); + class ErrorTestWorkflow { count = 0; constructor(_ctx: ExecutionContext, _env: unknown) {} From 0d27a048042c2af02fee202c6aca967b13355e2b Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 22 Jul 2025 13:52:00 +0200 Subject: [PATCH 4/7] =?UTF-8?q?Lint=20=F0=9F=A4=A6=F0=9F=8F=BB=E2=80=8D?= =?UTF-8?q?=E2=99=82=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cloudflare/test/workflow.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cloudflare/test/workflow.test.ts b/packages/cloudflare/test/workflow.test.ts index 93f5faced228..d1901c9c1e7a 100644 --- a/packages/cloudflare/test/workflow.test.ts +++ b/packages/cloudflare/test/workflow.test.ts @@ -133,7 +133,7 @@ describe('workflows', () => { ]); }); - test('Calls expected functions with non-uuid instance id', async () => { + test('Calls expected functions with non-uuid instance id', async () => { class BasicTestWorkflow { constructor(_ctx: ExecutionContext, _env: unknown) {} From f16fca88aae1920ea078197204e5663ad04d8cb5 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 22 Jul 2025 14:30:08 +0200 Subject: [PATCH 5/7] Skip workflow test on older node versions --- packages/cloudflare/test/workflow.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cloudflare/test/workflow.test.ts b/packages/cloudflare/test/workflow.test.ts index d1901c9c1e7a..28e2368edf84 100644 --- a/packages/cloudflare/test/workflow.test.ts +++ b/packages/cloudflare/test/workflow.test.ts @@ -3,6 +3,12 @@ import type { WorkflowEvent, WorkflowStep, WorkflowStepConfig } from 'cloudflare import { beforeEach, describe, expect, test, vi } from 'vitest'; import { instrumentWorkflowWithSentry } from '../src/workflows'; +const NODE_MAJOR_VERSION = parseInt(process.versions.node.split('.')[0]!); + +if (NODE_MAJOR_VERSION < 20) { + process.exit(0); // Skip tests for Node.js versions below 20 +} + const mockStep: WorkflowStep = { do: vi .fn() From 59a116667e96e8337c7240c84b7ecf9903bfd882 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 23 Jul 2025 14:38:52 +0200 Subject: [PATCH 6/7] PR review --- packages/cloudflare/src/workflows.ts | 11 ++++++++--- packages/cloudflare/test/workflow.test.ts | 9 ++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index de51d1c768c0..7ca31d00bbd5 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -24,8 +24,11 @@ import { init } from './sdk'; const UUID_REGEX = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i; -async function hashStringToUuid(input: string): Promise { - const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(input)); +/** + * Hashes a string to a UUID using SHA-1. + */ +export async function deterministicTraceIdFromInstanceId(instanceId: string): Promise { + const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(instanceId)); return ( Array.from(new Uint8Array(buf)) // We only need the first 16 bytes for the 32 characters @@ -36,7 +39,9 @@ async function hashStringToUuid(input: string): Promise { } async function propagationContextFromInstanceId(instanceId: string): Promise { - const traceId = UUID_REGEX.test(instanceId) ? instanceId.replace(/-/g, '') : await hashStringToUuid(instanceId); + const traceId = UUID_REGEX.test(instanceId) + ? instanceId.replace(/-/g, '') + : await deterministicTraceIdFromInstanceId(instanceId); // Derive sampleRand from last 4 characters of the random UUID // diff --git a/packages/cloudflare/test/workflow.test.ts b/packages/cloudflare/test/workflow.test.ts index 28e2368edf84..229fe5f838fb 100644 --- a/packages/cloudflare/test/workflow.test.ts +++ b/packages/cloudflare/test/workflow.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/unbound-method */ import type { WorkflowEvent, WorkflowStep, WorkflowStepConfig } from 'cloudflare:workers'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { instrumentWorkflowWithSentry } from '../src/workflows'; +import { deterministicTraceIdFromInstanceId, instrumentWorkflowWithSentry } from '../src/workflows'; const NODE_MAJOR_VERSION = parseInt(process.versions.node.split('.')[0]!); @@ -74,6 +74,13 @@ describe('workflows', () => { vi.clearAllMocks(); }); + test('hashStringToUuid hashes a string to a UUID for Sentry trace ID', async () => { + const UUID_WITHOUT_HYPHENS_REGEX = /^[0-9a-f]{32}$/i; + expect(await deterministicTraceIdFromInstanceId('s')).toMatch(UUID_WITHOUT_HYPHENS_REGEX); + expect(await deterministicTraceIdFromInstanceId('test-string')).toMatch(UUID_WITHOUT_HYPHENS_REGEX); + expect(await deterministicTraceIdFromInstanceId(INSTANCE_ID)).toMatch(UUID_WITHOUT_HYPHENS_REGEX); + }); + test('Calls expected functions', async () => { class BasicTestWorkflow { constructor(_ctx: ExecutionContext, _env: unknown) {} From acd3ecf02a75c27587b4e73a59c1bf83ec32de05 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 23 Jul 2025 15:04:21 +0200 Subject: [PATCH 7/7] Skip older node versions --- packages/cloudflare/test/workflow.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/cloudflare/test/workflow.test.ts b/packages/cloudflare/test/workflow.test.ts index 229fe5f838fb..c403023fb525 100644 --- a/packages/cloudflare/test/workflow.test.ts +++ b/packages/cloudflare/test/workflow.test.ts @@ -5,10 +5,6 @@ import { deterministicTraceIdFromInstanceId, instrumentWorkflowWithSentry } from const NODE_MAJOR_VERSION = parseInt(process.versions.node.split('.')[0]!); -if (NODE_MAJOR_VERSION < 20) { - process.exit(0); // Skip tests for Node.js versions below 20 -} - const mockStep: WorkflowStep = { do: vi .fn() @@ -69,7 +65,7 @@ const INSTANCE_ID = 'ae0ee067-61b3-4852-9219-5d62282270f0'; const SAMPLE_RAND = '0.44116884107728693'; const TRACE_ID = INSTANCE_ID.replace(/-/g, ''); -describe('workflows', () => { +describe.skipIf(NODE_MAJOR_VERSION < 20)('workflows', () => { beforeEach(() => { vi.clearAllMocks(); });