Skip to content

Commit ce3a761

Browse files
committed
feat(cloudflare): Split alarms into multiple traces and link them
1 parent ac42063 commit ce3a761

File tree

9 files changed

+706
-56
lines changed

9 files changed

+706
-56
lines changed

dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ class MyDurableObjectBase extends DurableObject<Env> {
1919
throw new Error('Should be recorded in Sentry.');
2020
}
2121

22+
async alarm(): Promise<void> {
23+
const action = await this.ctx.storage.get<string>('alarm-action');
24+
if (action === 'throw') {
25+
throw new Error('Alarm error captured by Sentry');
26+
}
27+
}
28+
2229
async fetch(request: Request) {
2330
const url = new URL(request.url);
2431
switch (url.pathname) {
@@ -32,6 +39,12 @@ class MyDurableObjectBase extends DurableObject<Env> {
3239
this.ctx.acceptWebSocket(server);
3340
return new Response(null, { status: 101, webSocket: client });
3441
}
42+
case '/setAlarm': {
43+
const action = url.searchParams.get('action') || 'succeed';
44+
await this.ctx.storage.put('alarm-action', action);
45+
await this.ctx.storage.setAlarm(Date.now() + 500);
46+
return new Response('Alarm set');
47+
}
3548
case '/storage/put': {
3649
await this.ctx.storage.put('test-key', 'test-value');
3750
return new Response('Stored');

dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,47 @@ test('Storage operations create spans in Durable Object transactions', async ({
9999
expect(putSpan?.data?.['db.system.name']).toBe('cloudflare.durable_object.storage');
100100
expect(putSpan?.data?.['db.operation.name']).toBe('put');
101101
});
102+
103+
test.describe('Alarm instrumentation', () => {
104+
test.describe.configure({ mode: 'serial' });
105+
106+
test('captures error from alarm handler', async ({ baseURL }) => {
107+
const errorWaiter = waitForError('cloudflare-workers', event => {
108+
return event.exception?.values?.[0]?.value === 'Alarm error captured by Sentry';
109+
});
110+
111+
const response = await fetch(`${baseURL}/pass-to-object/setAlarm?action=throw`);
112+
expect(response.status).toBe(200);
113+
114+
const event = await errorWaiter;
115+
expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object');
116+
});
117+
118+
test('creates a transaction for alarm with new trace linked to setAlarm', async ({ baseURL }) => {
119+
const setAlarmTransactionWaiter = waitForTransaction('cloudflare-workers', event => {
120+
return event.spans?.some(span => span.description?.includes('storage_setAlarm')) ?? false;
121+
});
122+
123+
const alarmTransactionWaiter = waitForTransaction('cloudflare-workers', event => {
124+
return event.transaction === 'alarm' && event.contexts?.trace?.op === 'function';
125+
});
126+
127+
const response = await fetch(`${baseURL}/pass-to-object/setAlarm`);
128+
expect(response.status).toBe(200);
129+
130+
const setAlarmTransaction = await setAlarmTransactionWaiter;
131+
const alarmTransaction = await alarmTransactionWaiter;
132+
133+
// Alarm creates a transaction with correct attributes
134+
expect(alarmTransaction.contexts?.trace?.op).toBe('function');
135+
expect(alarmTransaction.contexts?.trace?.origin).toBe('auto.faas.cloudflare.durable_object');
136+
137+
// Alarm starts a new trace (different trace ID from the request that called setAlarm)
138+
expect(alarmTransaction.contexts?.trace?.trace_id).not.toBe(setAlarmTransaction.contexts?.trace?.trace_id);
139+
140+
// Alarm links to the trace that called setAlarm via sentry.previous_trace attribute
141+
const previousTrace = alarmTransaction.contexts?.trace?.data?.['sentry.previous_trace'];
142+
expect(previousTrace).toBeDefined();
143+
expect(previousTrace).toContain(setAlarmTransaction.contexts?.trace?.trace_id);
144+
});
145+
});

packages/cloudflare/src/durableobject.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ export function instrumentDurableObjectWithSentry<
8080
}
8181

8282
if (obj.alarm && typeof obj.alarm === 'function') {
83-
obj.alarm = wrapMethodWithSentry({ options, context, spanName: 'alarm' }, obj.alarm);
83+
// Alarms are independent invocations, so we start a new trace and link to the previous alarm
84+
obj.alarm = wrapMethodWithSentry(
85+
{ options, context, spanName: 'alarm', spanOp: 'function', startNewTrace: true, linkPreviousTrace: true },
86+
obj.alarm,
87+
);
8488
}
8589

8690
if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') {

packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { DurableObjectStorage } from '@cloudflare/workers-types';
22
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
3+
import { storeSpanContext } from '../utils/traceLinks';
34

4-
const STORAGE_METHODS_TO_INSTRUMENT = ['get', 'put', 'delete', 'list'] as const;
5+
const STORAGE_METHODS_TO_INSTRUMENT = ['get', 'put', 'delete', 'list', 'setAlarm', 'getAlarm', 'deleteAlarm'] as const;
56

67
type StorageMethod = (typeof STORAGE_METHODS_TO_INSTRUMENT)[number];
78

@@ -10,6 +11,10 @@ type StorageMethod = (typeof STORAGE_METHODS_TO_INSTRUMENT)[number];
1011
*
1112
* Wraps the following async methods:
1213
* - get, put, delete, list (KV API)
14+
* - setAlarm, getAlarm, deleteAlarm (Alarm API)
15+
*
16+
* When setAlarm is called, it also stores the current span context so that when
17+
* the alarm fires later, it can link back to the trace that called setAlarm.
1318
*
1419
* @param storage - The DurableObjectStorage instance to instrument
1520
* @returns An instrumented DurableObjectStorage instance
@@ -40,8 +45,16 @@ export function instrumentDurableObjectStorage(storage: DurableObjectStorage): D
4045
'db.operation.name': methodName,
4146
},
4247
},
43-
() => {
44-
return (original as (...args: unknown[]) => unknown).apply(target, args);
48+
async () => {
49+
const result = await (original as (...args: unknown[]) => Promise<unknown>).apply(target, args);
50+
// When setAlarm is called, store the current span context so that when the alarm
51+
// fires later, it can link back to the trace that called setAlarm.
52+
// We use the original (uninstrumented) storage (target) to avoid creating a span
53+
// for this internal operation.
54+
if (methodName === 'setAlarm') {
55+
await storeSpanContext(target, 'alarm');
56+
}
57+
return result;
4558
},
4659
);
4760
};
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { DurableObjectStorage } from '@cloudflare/workers-types';
2+
import { TraceFlags } from '@opentelemetry/api';
3+
import { getActiveSpan } from '@sentry/core';
4+
5+
/** Storage key prefix for the span context that links consecutive method invocations */
6+
const SENTRY_TRACE_LINK_KEY_PREFIX = '__SENTRY_TRACE_LINK__';
7+
8+
/** Stored span context for creating span links */
9+
export interface StoredSpanContext {
10+
traceId: string;
11+
spanId: string;
12+
sampled: boolean;
13+
}
14+
15+
/** Span link structure for connecting traces */
16+
export interface SpanLink {
17+
context: {
18+
traceId: string;
19+
spanId: string;
20+
traceFlags: number;
21+
};
22+
attributes?: Record<string, string>;
23+
}
24+
25+
/**
26+
* Gets the storage key for a specific method's trace link.
27+
*/
28+
export function getTraceLinkKey(methodName: string): string {
29+
return `${SENTRY_TRACE_LINK_KEY_PREFIX}${methodName}`;
30+
}
31+
32+
/**
33+
* Stores the current span context in Durable Object storage for trace linking.
34+
* Uses the original uninstrumented storage to avoid creating spans for internal operations.
35+
*/
36+
export async function storeSpanContext(originalStorage: DurableObjectStorage, methodName: string): Promise<void> {
37+
const activeSpan = getActiveSpan();
38+
if (activeSpan) {
39+
const spanContext = activeSpan.spanContext();
40+
const storedContext: StoredSpanContext = {
41+
traceId: spanContext.traceId,
42+
spanId: spanContext.spanId,
43+
sampled: spanContext.traceFlags === TraceFlags.SAMPLED,
44+
};
45+
await originalStorage.put(getTraceLinkKey(methodName), storedContext);
46+
}
47+
}
48+
49+
/**
50+
* Retrieves a stored span context from Durable Object storage.
51+
*/
52+
export async function getStoredSpanContext(
53+
originalStorage: DurableObjectStorage,
54+
methodName: string,
55+
): Promise<StoredSpanContext | undefined> {
56+
try {
57+
return await originalStorage.get<StoredSpanContext>(getTraceLinkKey(methodName));
58+
} catch {
59+
return undefined;
60+
}
61+
}
62+
63+
/**
64+
* Builds span links from a stored span context.
65+
*/
66+
export function buildSpanLinks(storedContext: StoredSpanContext): SpanLink[] {
67+
return [
68+
{
69+
context: {
70+
traceId: storedContext.traceId,
71+
spanId: storedContext.spanId,
72+
traceFlags: storedContext.sampled ? TraceFlags.SAMPLED : TraceFlags.NONE,
73+
},
74+
attributes: {
75+
'sentry.link.type': 'previous_trace',
76+
},
77+
},
78+
];
79+
}

0 commit comments

Comments
 (0)