Skip to content

Commit 8d8c709

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

File tree

2 files changed

+102
-39
lines changed

2 files changed

+102
-39
lines changed

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/wrapMethodWithSentry.test.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,52 @@ describe('wrapMethodWithSentry', () => {
6969
});
7070

7171
describe('basic wrapping', () => {
72-
it('wraps a sync method and returns its result', () => {
72+
it('wraps a sync method and returns its result synchronously (not a Promise)', () => {
7373
const handler = vi.fn().mockReturnValue('sync-result');
7474
const options = {
7575
options: {},
7676
context: createMockContext(),
7777
};
7878

7979
const wrapped = wrapMethodWithSentry(options, handler);
80-
wrapped();
80+
const result = wrapped();
8181

8282
expect(handler).toHaveBeenCalled();
83+
expect(result).not.toBeInstanceOf(Promise);
84+
expect(result).toBe('sync-result');
85+
});
86+
87+
it('wraps a sync method with spanName and returns synchronously (not a Promise)', () => {
88+
const handler = vi.fn().mockReturnValue('sync-result');
89+
const options = {
90+
options: {},
91+
context: createMockContext(),
92+
spanName: 'test-span',
93+
};
94+
95+
const wrapped = wrapMethodWithSentry(options, handler);
96+
const result = wrapped();
97+
98+
expect(handler).toHaveBeenCalled();
99+
expect(result).not.toBeInstanceOf(Promise);
100+
expect(result).toBe('sync-result');
101+
});
102+
103+
it('wraps a sync method with startNewTrace and returns synchronously (not a Promise)', () => {
104+
const handler = vi.fn().mockReturnValue('sync-result');
105+
const options = {
106+
options: {},
107+
context: createMockContext(),
108+
spanName: 'test-span',
109+
startNewTrace: true,
110+
};
111+
112+
const wrapped = wrapMethodWithSentry(options, handler);
113+
const result = wrapped();
114+
115+
expect(handler).toHaveBeenCalled();
116+
expect(result).not.toBeInstanceOf(Promise);
117+
expect(result).toBe('sync-result');
83118
});
84119

85120
it('wraps an async method and returns a promise', async () => {
@@ -90,11 +125,39 @@ describe('wrapMethodWithSentry', () => {
90125
};
91126

92127
const wrapped = wrapMethodWithSentry(options, handler);
93-
await wrapped();
128+
const result = wrapped();
94129

130+
expect(result).toBeInstanceOf(Promise);
131+
await expect(result).resolves.toBe('async-result');
95132
expect(handler).toHaveBeenCalled();
96133
});
97134

135+
it('returns a Promise when linkPreviousTrace is true (even for sync handlers)', async () => {
136+
const handler = vi.fn().mockReturnValue('sync-result');
137+
const mockStorage = {
138+
get: vi.fn().mockResolvedValue(undefined),
139+
put: vi.fn().mockResolvedValue(undefined),
140+
};
141+
const context = {
142+
waitUntil: vi.fn(),
143+
originalStorage: mockStorage,
144+
} as any;
145+
146+
const options = {
147+
options: {},
148+
context,
149+
spanName: 'alarm',
150+
startNewTrace: true,
151+
linkPreviousTrace: true,
152+
};
153+
154+
const wrapped = wrapMethodWithSentry(options, handler);
155+
const result = wrapped();
156+
157+
expect(result).toBeInstanceOf(Promise);
158+
await expect(result).resolves.toBe('sync-result');
159+
});
160+
98161
it('marks handler as instrumented', () => {
99162
const handler = vi.fn();
100163
const options = {

0 commit comments

Comments
 (0)