Skip to content

Commit 05ad36f

Browse files
committed
fixup! feat(cloudflare): Split alarms into multiple traces and link them
1 parent 35d0286 commit 05ad36f

File tree

5 files changed

+201
-53
lines changed

5 files changed

+201
-53
lines changed

packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
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';
33
import { storeSpanContext } from '../utils/traceLinks';
44

55
const STORAGE_METHODS_TO_INSTRUMENT = ['get', 'put', 'delete', 'list', 'setAlarm', 'getAlarm', 'deleteAlarm'] as const;
66

77
type StorageMethod = (typeof STORAGE_METHODS_TO_INSTRUMENT)[number];
88

9+
type WaitUntil = (promise: Promise<unknown>) => void;
10+
911
/**
1012
* Instruments DurableObjectStorage methods with Sentry spans.
1113
*
@@ -17,9 +19,13 @@ type StorageMethod = (typeof STORAGE_METHODS_TO_INSTRUMENT)[number];
1719
* the alarm fires later, it can link back to the trace that called setAlarm.
1820
*
1921
* @param storage - The DurableObjectStorage instance to instrument
22+
* @param waitUntil - Optional waitUntil function to defer span context storage
2023
* @returns An instrumented DurableObjectStorage instance
2124
*/
22-
export function instrumentDurableObjectStorage(storage: DurableObjectStorage): DurableObjectStorage {
25+
export function instrumentDurableObjectStorage(
26+
storage: DurableObjectStorage,
27+
waitUntil?: WaitUntil,
28+
): DurableObjectStorage {
2329
return new Proxy(storage, {
2430
get(target, prop, receiver) {
2531
const original = Reflect.get(target, prop, receiver);
@@ -45,16 +51,35 @@ export function instrumentDurableObjectStorage(storage: DurableObjectStorage): D
4551
'db.operation.name': methodName,
4652
},
4753
},
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');
54+
() => {
55+
const teardown = async (): Promise<void> => {
56+
// When setAlarm is called, store the current span context so that when the alarm
57+
// fires later, it can link back to the trace that called setAlarm.
58+
// We use the original (uninstrumented) storage (target) to avoid creating a span
59+
// for this internal operation. The storage is deferred via waitUntil to not block.
60+
if (methodName === 'setAlarm') {
61+
await storeSpanContext(target, 'alarm');
62+
}
63+
};
64+
65+
const result = (original as (...args: unknown[]) => unknown).apply(target, args);
66+
67+
if (!isThenable(result)) {
68+
waitUntil?.(teardown());
69+
70+
return result;
5671
}
57-
return result;
72+
73+
return result.then(
74+
res => {
75+
waitUntil?.(teardown());
76+
return res;
77+
},
78+
e => {
79+
throw e;
80+
},
81+
);
82+
5883
},
5984
);
6085
};

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
},

packages/cloudflare/src/wrapMethodWithSentry.ts

Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,16 @@ type MethodWrapperOptions = {
3636
/**
3737
* If true, stores the current span context and links to the previous invocation's span.
3838
* Requires `startNewTrace` to be true. Uses Durable Object storage to persist the link.
39+
*
40+
* WARNING: Enabling this option causes the wrapped method to always return a Promise,
41+
* even if the original method was synchronous. Only use this for methods that are
42+
* inherently async (e.g., Cloudflare's `alarm()` handler).
43+
*
3944
* @default false
4045
*/
4146
linkPreviousTrace?: boolean;
4247
};
4348

44-
type SpanLink = ReturnType<typeof buildSpanLinks>[number];
45-
4649
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4750
export type UncheckedMethod = (...args: any[]) => any;
4851
type OriginalMethod = UncheckedMethod;
@@ -80,7 +83,7 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
8083
const currentClient = getClient();
8184
const sentryWithScope = startNewTrace ? withIsolationScope : currentClient ? withScope : withIsolationScope;
8285

83-
const wrappedFunction = async (scope: Scope): Promise<unknown> => {
86+
const wrappedFunction = (scope: Scope): unknown | Promise<unknown> => {
8487
// In certain situations, the passed context can become undefined.
8588
// For example, for Astro while prerendering pages at build time.
8689
// see: https://github.com/getsentry/sentry-javascript/issues/13217
@@ -100,21 +103,13 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
100103
}
101104
}
102105

103-
let links: SpanLink[] | undefined;
104-
let storedContext: StoredSpanContext | undefined;
105106
const methodName = wrapperOptions.spanName || 'unknown';
106107

107-
if (linkPreviousTrace && storage) {
108-
storedContext = await getStoredSpanContext(storage, methodName);
109-
if (storedContext) {
110-
links = buildSpanLinks(storedContext);
111-
}
112-
}
113-
114-
const storeContextIfNeeded = async (): Promise<void> => {
108+
const teardown = async (): Promise<void> => {
115109
if (linkPreviousTrace && storage) {
116110
await storeSpanContext(storage, methodName);
117111
}
112+
await flush(2000);
118113
};
119114

120115
if (!wrapperOptions.spanName) {
@@ -126,26 +121,23 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
126121

127122
if (isThenable(result)) {
128123
return result.then(
129-
async (res: unknown) => {
130-
await storeContextIfNeeded();
131-
waitUntil?.(flush(2000));
124+
(res: unknown) => {
125+
waitUntil?.(teardown());
132126
return res;
133127
},
134-
async (e: unknown) => {
128+
(e: unknown) => {
135129
captureException(e, {
136130
mechanism: {
137131
type: 'auto.faas.cloudflare.durable_object',
138132
handled: false,
139133
},
140134
});
141-
await storeContextIfNeeded();
142-
waitUntil?.(flush(2000));
135+
waitUntil?.(teardown());
143136
throw e;
144137
},
145138
);
146139
} else {
147-
await storeContextIfNeeded();
148-
waitUntil?.(flush(2000));
140+
waitUntil?.(teardown());
149141
return result;
150142
}
151143
} catch (e) {
@@ -155,8 +147,7 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
155147
handled: false,
156148
},
157149
});
158-
await storeContextIfNeeded();
159-
waitUntil?.(flush(2000));
150+
waitUntil?.(teardown());
160151
throw e;
161152
}
162153
}
@@ -169,8 +160,10 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
169160
}
170161
: {};
171162

172-
const executeSpan = (): unknown => {
173-
return startSpan({ name: spanName, attributes, links }, async span => {
163+
const executeSpan = (storedContext?: StoredSpanContext): unknown => {
164+
const links = storedContext ? buildSpanLinks(storedContext) : undefined;
165+
166+
return startSpan({ name: spanName, attributes, links }, span => {
174167
// TODO: Remove this once EAP can store span links. We currently only set this attribute so that we
175168
// can obtain the previous trace information from the EAP store. Long-term, EAP will handle
176169
// span links and then we should remove this again. Also throwing in a TODO(v11), to remind us
@@ -188,26 +181,23 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
188181

189182
if (isThenable(result)) {
190183
return result.then(
191-
async (res: unknown) => {
192-
await storeContextIfNeeded();
193-
waitUntil?.(flush(2000));
184+
(res: unknown) => {
185+
waitUntil?.(teardown());
194186
return res;
195187
},
196-
async (e: unknown) => {
188+
(e: unknown) => {
197189
captureException(e, {
198190
mechanism: {
199191
type: 'auto.faas.cloudflare.durable_object',
200192
handled: false,
201193
},
202194
});
203-
await storeContextIfNeeded();
204-
waitUntil?.(flush(2000));
195+
waitUntil?.(teardown());
205196
throw e;
206197
},
207198
);
208199
} else {
209-
await storeContextIfNeeded();
210-
waitUntil?.(flush(2000));
200+
waitUntil?.(teardown());
211201
return result;
212202
}
213203
} catch (e) {
@@ -217,15 +207,25 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
217207
handled: false,
218208
},
219209
});
220-
await storeContextIfNeeded();
221-
waitUntil?.(flush(2000));
210+
waitUntil?.(teardown());
222211
throw e;
223212
}
224213
});
225214
};
226215

216+
// When linking to previous trace, we need to fetch the stored context first
217+
// We chain this with the span execution to avoid making the outer function async
218+
if (linkPreviousTrace && storage) {
219+
const storedContextPromise = getStoredSpanContext(storage, methodName);
220+
221+
if (startNewTrace) {
222+
return storedContextPromise.then(storedContext => startNewTraceCore(() => executeSpan(storedContext)));
223+
}
224+
return storedContextPromise.then(storedContext => executeSpan(storedContext));
225+
}
226+
227227
if (startNewTrace) {
228-
return startNewTraceCore(executeSpan);
228+
return startNewTraceCore(() => executeSpan());
229229
}
230230

231231
return executeSpan();

packages/cloudflare/test/instrumentDurableObjectStorage.test.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,15 +178,74 @@ describe('instrumentDurableObjectStorage', () => {
178178
);
179179
});
180180

181-
it('stores span context when setAlarm is called', async () => {
181+
it('stores span context when setAlarm is called (async)', async () => {
182182
const mockStorage = createMockStorage();
183-
const instrumented = instrumentDurableObjectStorage(mockStorage);
183+
const waitUntil = vi.fn();
184+
const instrumented = instrumentDurableObjectStorage(mockStorage, waitUntil);
184185

185186
await instrumented.setAlarm(Date.now() + 1000);
186187

188+
expect(waitUntil).toHaveBeenCalledTimes(1);
187189
expect(traceLinks.storeSpanContext).toHaveBeenCalledWith(mockStorage, 'alarm');
188190
});
189191

192+
it('calls teardown after promise resolves (async case)', async () => {
193+
const callOrder: string[] = [];
194+
let resolveStorage: () => void;
195+
const storagePromise = new Promise<void>(resolve => {
196+
resolveStorage = resolve;
197+
});
198+
199+
const mockStorage = createMockStorage();
200+
mockStorage.setAlarm = vi.fn().mockImplementation(() => {
201+
callOrder.push('setAlarm started');
202+
return storagePromise.then(() => {
203+
callOrder.push('setAlarm resolved');
204+
});
205+
});
206+
207+
const waitUntil = vi.fn().mockImplementation(() => {
208+
callOrder.push('waitUntil called');
209+
});
210+
211+
const instrumented = instrumentDurableObjectStorage(mockStorage, waitUntil);
212+
const resultPromise = instrumented.setAlarm(Date.now() + 1000);
213+
214+
// Before resolving, waitUntil should not have been called yet
215+
expect(waitUntil).not.toHaveBeenCalled();
216+
expect(callOrder).toEqual(['setAlarm started']);
217+
218+
// Resolve the storage promise
219+
resolveStorage!();
220+
await resultPromise;
221+
222+
// After resolving, waitUntil should have been called
223+
expect(waitUntil).toHaveBeenCalledTimes(1);
224+
expect(callOrder).toEqual(['setAlarm started', 'setAlarm resolved', 'waitUntil called']);
225+
});
226+
227+
it('calls teardown immediately for sync results', () => {
228+
const callOrder: string[] = [];
229+
230+
const mockStorage = createMockStorage();
231+
// Make setAlarm return a sync value (not a promise)
232+
mockStorage.setAlarm = vi.fn().mockImplementation(() => {
233+
callOrder.push('setAlarm executed');
234+
return undefined; // sync return
235+
});
236+
237+
const waitUntil = vi.fn().mockImplementation(() => {
238+
callOrder.push('waitUntil called');
239+
});
240+
241+
const instrumented = instrumentDurableObjectStorage(mockStorage, waitUntil);
242+
instrumented.setAlarm(Date.now() + 1000);
243+
244+
// For sync results, waitUntil should be called immediately after
245+
expect(waitUntil).toHaveBeenCalledTimes(1);
246+
expect(callOrder).toEqual(['setAlarm executed', 'waitUntil called']);
247+
});
248+
190249
it('instruments getAlarm', async () => {
191250
const mockStorage = createMockStorage();
192251
const instrumented = instrumentDurableObjectStorage(mockStorage);

0 commit comments

Comments
 (0)