Skip to content

Commit 43b8f06

Browse files
committed
feat(cloudflare): Split alarms into multiple traces and link them
1 parent 893539d commit 43b8f06

File tree

10 files changed

+896
-65
lines changed

10 files changed

+896
-65
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
@@ -82,7 +82,11 @@ export function instrumentDurableObjectWithSentry<
8282
}
8383

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

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

packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
11
import type { DurableObjectStorage } from '@cloudflare/workers-types';
2-
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
2+
import { isThenable, 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

9+
type WaitUntil = (promise: Promise<unknown>) => void;
10+
811
/**
912
* Instruments DurableObjectStorage methods with Sentry spans.
1013
*
1114
* Wraps the following async methods:
1215
* - get, put, delete, list (KV API)
16+
* - setAlarm, getAlarm, deleteAlarm (Alarm API)
17+
*
18+
* When setAlarm is called, it also stores the current span context so that when
19+
* the alarm fires later, it can link back to the trace that called setAlarm.
1320
*
1421
* @param storage - The DurableObjectStorage instance to instrument
22+
* @param waitUntil - Optional waitUntil function to defer span context storage
1523
* @returns An instrumented DurableObjectStorage instance
1624
*/
17-
export function instrumentDurableObjectStorage(storage: DurableObjectStorage): DurableObjectStorage {
25+
export function instrumentDurableObjectStorage(
26+
storage: DurableObjectStorage,
27+
waitUntil?: WaitUntil,
28+
): DurableObjectStorage {
1829
return new Proxy(storage, {
1930
get(target, prop, _receiver) {
2031
// Use `target` as the receiver instead of the proxy (`_receiver`).
@@ -46,7 +57,33 @@ export function instrumentDurableObjectStorage(storage: DurableObjectStorage): D
4657
},
4758
},
4859
() => {
49-
return (original as (...args: unknown[]) => unknown).apply(target, args);
60+
const teardown = async (): Promise<void> => {
61+
// When setAlarm is called, store the current span context so that when the alarm
62+
// fires later, it can link back to the trace that called setAlarm.
63+
// We use the original (uninstrumented) storage (target) to avoid creating a span
64+
// for this internal operation. The storage is deferred via waitUntil to not block.
65+
if (methodName === 'setAlarm') {
66+
await storeSpanContext(target, 'alarm');
67+
}
68+
};
69+
70+
const result = (original as (...args: unknown[]) => unknown).apply(target, args);
71+
72+
if (!isThenable(result)) {
73+
waitUntil?.(teardown());
74+
75+
return result;
76+
}
77+
78+
return result.then(
79+
res => {
80+
waitUntil?.(teardown());
81+
return res;
82+
},
83+
e => {
84+
throw e;
85+
},
86+
);
5087
},
5188
);
5289
};

packages/cloudflare/src/utils/instrumentContext.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,14 @@ export function instrumentContext<T extends ContextType>(ctx: T): T {
4141
// If so, wrap the storage with instrumentation
4242
if ('storage' in ctx && ctx.storage) {
4343
const originalStorage = ctx.storage;
44+
const waitUntil = 'waitUntil' in ctx && typeof ctx.waitUntil === 'function' ? ctx.waitUntil.bind(ctx) : undefined;
4445
let instrumentedStorage: DurableObjectStorage | undefined;
4546
descriptors.storage = {
4647
configurable: true,
4748
enumerable: true,
4849
get: () => {
4950
if (!instrumentedStorage) {
50-
instrumentedStorage = instrumentDurableObjectStorage(originalStorage);
51+
instrumentedStorage = instrumentDurableObjectStorage(originalStorage, waitUntil);
5152
}
5253
return instrumentedStorage;
5354
},
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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+
* Errors are silently ignored to prevent internal storage failures from propagating to user code.
36+
*/
37+
export async function storeSpanContext(originalStorage: DurableObjectStorage, methodName: string): Promise<void> {
38+
try {
39+
const activeSpan = getActiveSpan();
40+
if (activeSpan) {
41+
const spanContext = activeSpan.spanContext();
42+
const storedContext: StoredSpanContext = {
43+
traceId: spanContext.traceId,
44+
spanId: spanContext.spanId,
45+
sampled: spanContext.traceFlags === TraceFlags.SAMPLED,
46+
};
47+
await originalStorage.put(getTraceLinkKey(methodName), storedContext);
48+
}
49+
} catch {
50+
// Silently ignore storage errors to prevent internal failures from affecting user code
51+
}
52+
}
53+
54+
/**
55+
* Retrieves a stored span context from Durable Object storage.
56+
*/
57+
export async function getStoredSpanContext(
58+
originalStorage: DurableObjectStorage,
59+
methodName: string,
60+
): Promise<StoredSpanContext | undefined> {
61+
try {
62+
return await originalStorage.get<StoredSpanContext>(getTraceLinkKey(methodName));
63+
} catch {
64+
return undefined;
65+
}
66+
}
67+
68+
/**
69+
* Builds span links from a stored span context.
70+
*/
71+
export function buildSpanLinks(storedContext: StoredSpanContext): SpanLink[] {
72+
return [
73+
{
74+
context: {
75+
traceId: storedContext.traceId,
76+
spanId: storedContext.spanId,
77+
traceFlags: storedContext.sampled ? TraceFlags.SAMPLED : TraceFlags.NONE,
78+
},
79+
attributes: {
80+
'sentry.link.type': 'previous_trace',
81+
},
82+
},
83+
];
84+
}

0 commit comments

Comments
 (0)