Skip to content

Commit b6f5f32

Browse files
authored
fix(v9/cloudflare) Allow non UUID workflow instance IDs (#17135)
- A backport to v9 of #17121 - Closes #17074 @StephenHaney unless you're a very early adopter of v10, this PR is probably the one you care about!
1 parent a5259dc commit b6f5f32

File tree

2 files changed

+94
-10
lines changed

2 files changed

+94
-10
lines changed

packages/cloudflare/src/workflows.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,24 @@ import { init } from './sdk';
2424

2525
const UUID_REGEX = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
2626

27-
function propagationContextFromInstanceId(instanceId: string): PropagationContext {
28-
// Validate and normalize traceId - should be a valid UUID with or without hyphens
29-
if (!UUID_REGEX.test(instanceId)) {
30-
throw new Error("Invalid 'instanceId' for workflow: Sentry requires random UUIDs for instanceId.");
31-
}
27+
/**
28+
* Hashes a string to a UUID using SHA-1.
29+
*/
30+
export async function deterministicTraceIdFromInstanceId(instanceId: string): Promise<string> {
31+
const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(instanceId));
32+
return (
33+
Array.from(new Uint8Array(buf))
34+
// We only need the first 16 bytes for the 32 characters
35+
.slice(0, 16)
36+
.map(b => b.toString(16).padStart(2, '0'))
37+
.join('')
38+
);
39+
}
3240

33-
// Remove hyphens to get UUID without hyphens
34-
const traceId = instanceId.replace(/-/g, '');
41+
async function propagationContextFromInstanceId(instanceId: string): Promise<PropagationContext> {
42+
const traceId = UUID_REGEX.test(instanceId)
43+
? instanceId.replace(/-/g, '')
44+
: await deterministicTraceIdFromInstanceId(instanceId);
3545

3646
// Derive sampleRand from last 4 characters of the random UUID
3747
//
@@ -60,7 +70,7 @@ async function workflowStepWithSentry<V>(
6070
addCloudResourceContext(isolationScope);
6171

6272
return withScope(async scope => {
63-
const propagationContext = propagationContextFromInstanceId(instanceId);
73+
const propagationContext = await propagationContextFromInstanceId(instanceId);
6474
scope.setPropagationContext(propagationContext);
6575

6676
// eslint-disable-next-line no-return-await

packages/cloudflare/test/workflow.test.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
/* eslint-disable @typescript-eslint/unbound-method */
22
import type { WorkflowEvent, WorkflowStep, WorkflowStepConfig } from 'cloudflare:workers';
33
import { beforeEach, describe, expect, test, vi } from 'vitest';
4-
import { instrumentWorkflowWithSentry } from '../src/workflows';
4+
import { deterministicTraceIdFromInstanceId, instrumentWorkflowWithSentry } from '../src/workflows';
5+
6+
const NODE_MAJOR_VERSION = parseInt(process.versions.node.split('.')[0]!);
57

68
const mockStep: WorkflowStep = {
79
do: vi
@@ -63,11 +65,18 @@ const INSTANCE_ID = 'ae0ee067-61b3-4852-9219-5d62282270f0';
6365
const SAMPLE_RAND = '0.44116884107728693';
6466
const TRACE_ID = INSTANCE_ID.replace(/-/g, '');
6567

66-
describe('workflows', () => {
68+
describe.skipIf(NODE_MAJOR_VERSION < 20)('workflows', () => {
6769
beforeEach(() => {
6870
vi.clearAllMocks();
6971
});
7072

73+
test('hashStringToUuid hashes a string to a UUID for Sentry trace ID', async () => {
74+
const UUID_WITHOUT_HYPHENS_REGEX = /^[0-9a-f]{32}$/i;
75+
expect(await deterministicTraceIdFromInstanceId('s')).toMatch(UUID_WITHOUT_HYPHENS_REGEX);
76+
expect(await deterministicTraceIdFromInstanceId('test-string')).toMatch(UUID_WITHOUT_HYPHENS_REGEX);
77+
expect(await deterministicTraceIdFromInstanceId(INSTANCE_ID)).toMatch(UUID_WITHOUT_HYPHENS_REGEX);
78+
});
79+
7180
test('Calls expected functions', async () => {
7281
class BasicTestWorkflow {
7382
constructor(_ctx: ExecutionContext, _env: unknown) {}
@@ -133,6 +142,71 @@ describe('workflows', () => {
133142
]);
134143
});
135144

145+
test('Calls expected functions with non-uuid instance id', async () => {
146+
class BasicTestWorkflow {
147+
constructor(_ctx: ExecutionContext, _env: unknown) {}
148+
149+
async run(_event: Readonly<WorkflowEvent<Params>>, step: WorkflowStep): Promise<void> {
150+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
151+
const files = await step.do('first step', async () => {
152+
return { files: ['doc_7392_rev3.pdf', 'report_x29_final.pdf'] };
153+
});
154+
}
155+
}
156+
157+
const TestWorkflowInstrumented = instrumentWorkflowWithSentry(getSentryOptions, BasicTestWorkflow as any);
158+
const workflow = new TestWorkflowInstrumented(mockContext, {}) as BasicTestWorkflow;
159+
const event = { payload: {}, timestamp: new Date(), instanceId: 'ae0ee067' };
160+
await workflow.run(event, mockStep);
161+
162+
expect(mockStep.do).toHaveBeenCalledTimes(1);
163+
expect(mockStep.do).toHaveBeenCalledWith('first step', expect.any(Function));
164+
expect(mockContext.waitUntil).toHaveBeenCalledTimes(1);
165+
expect(mockContext.waitUntil).toHaveBeenCalledWith(expect.any(Promise));
166+
expect(mockTransport.send).toHaveBeenCalledTimes(1);
167+
expect(mockTransport.send).toHaveBeenCalledWith([
168+
expect.objectContaining({
169+
trace: expect.objectContaining({
170+
transaction: 'first step',
171+
trace_id: '0d2b6d1743ce6d53af4f5ee416ad5d1b',
172+
sample_rand: '0.3636987869077592',
173+
}),
174+
}),
175+
[
176+
[
177+
{
178+
type: 'transaction',
179+
},
180+
expect.objectContaining({
181+
event_id: expect.any(String),
182+
contexts: {
183+
trace: {
184+
parent_span_id: undefined,
185+
span_id: expect.any(String),
186+
trace_id: '0d2b6d1743ce6d53af4f5ee416ad5d1b',
187+
data: {
188+
'sentry.origin': 'auto.faas.cloudflare.workflow',
189+
'sentry.op': 'function.step.do',
190+
'sentry.source': 'task',
191+
'sentry.sample_rate': 1,
192+
},
193+
op: 'function.step.do',
194+
status: 'ok',
195+
origin: 'auto.faas.cloudflare.workflow',
196+
},
197+
cloud_resource: { 'cloud.provider': 'cloudflare' },
198+
runtime: { name: 'cloudflare' },
199+
},
200+
type: 'transaction',
201+
transaction_info: { source: 'task' },
202+
start_timestamp: expect.any(Number),
203+
timestamp: expect.any(Number),
204+
}),
205+
],
206+
],
207+
]);
208+
});
209+
136210
class ErrorTestWorkflow {
137211
count = 0;
138212
constructor(_ctx: ExecutionContext, _env: unknown) {}

0 commit comments

Comments
 (0)