diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index 022f0040893a..7ca31d00bbd5 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -24,14 +24,24 @@ 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."); - } +/** + * 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 + .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 deterministicTraceIdFromInstanceId(instanceId); // Derive sampleRand from last 4 characters of the random UUID // @@ -60,7 +70,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 diff --git a/packages/cloudflare/test/workflow.test.ts b/packages/cloudflare/test/workflow.test.ts index 03eee5191eb2..c403023fb525 100644 --- a/packages/cloudflare/test/workflow.test.ts +++ b/packages/cloudflare/test/workflow.test.ts @@ -1,7 +1,9 @@ /* 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]!); const mockStep: WorkflowStep = { do: vi @@ -63,11 +65,18 @@ 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(); }); + 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) {} @@ -133,6 +142,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) {}