Skip to content

Commit e1f993f

Browse files
committed
feat(cloudflare): Add spanLinks within a waitUntil
1 parent 05ad36f commit e1f993f

File tree

3 files changed

+69
-53
lines changed

3 files changed

+69
-53
lines changed

packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ export function instrumentDurableObjectStorage(
7979
throw e;
8080
},
8181
);
82-
8382
},
8483
);
8584
};

packages/cloudflare/src/wrapMethodWithSentry.ts

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
import type { CloudflareOptions } from './client';
1616
import { isInstrumented, markAsInstrumented } from './instrument';
1717
import { init } from './sdk';
18-
import { buildSpanLinks, getStoredSpanContext, type StoredSpanContext, storeSpanContext } from './utils/traceLinks';
18+
import { buildSpanLinks, getStoredSpanContext, storeSpanContext } from './utils/traceLinks';
1919

2020
/** Extended DurableObjectState with originalStorage exposed by instrumentContext */
2121
interface InstrumentedDurableObjectState extends DurableObjectState {
@@ -36,10 +36,7 @@ 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).
39+
* The link is set asynchronously via `span.addLinks()` in a `waitUntil` to avoid blocking.
4340
*
4441
* @default false
4542
*/
@@ -160,19 +157,26 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
160157
}
161158
: {};
162159

163-
const executeSpan = (storedContext?: StoredSpanContext): unknown => {
164-
const links = storedContext ? buildSpanLinks(storedContext) : undefined;
165-
166-
return startSpan({ name: spanName, attributes, links }, span => {
167-
// TODO: Remove this once EAP can store span links. We currently only set this attribute so that we
168-
// can obtain the previous trace information from the EAP store. Long-term, EAP will handle
169-
// span links and then we should remove this again. Also throwing in a TODO(v11), to remind us
170-
// to check this at v11 time :)
171-
if (storedContext) {
172-
const sampledFlag = storedContext.sampled ? '1' : '0';
173-
span.setAttribute(
174-
'sentry.previous_trace',
175-
`${storedContext.traceId}-${storedContext.spanId}-${sampledFlag}`,
160+
const executeSpan = (): unknown => {
161+
return startSpan({ name: spanName, attributes }, span => {
162+
// When linking to previous trace, fetch the stored context and add links asynchronously
163+
// This avoids blocking the response while fetching from storage
164+
if (linkPreviousTrace && storage) {
165+
waitUntil?.(
166+
getStoredSpanContext(storage, methodName).then(storedContext => {
167+
if (storedContext) {
168+
span.addLinks(buildSpanLinks(storedContext));
169+
// TODO: Remove this once EAP can store span links. We currently only set this attribute so that we
170+
// can obtain the previous trace information from the EAP store. Long-term, EAP will handle
171+
// span links and then we should remove this again. Also throwing in a TODO(v11), to remind us
172+
// to check this at v11 time :)
173+
const sampledFlag = storedContext.sampled ? '1' : '0';
174+
span.setAttribute(
175+
'sentry.previous_trace',
176+
`${storedContext.traceId}-${storedContext.spanId}-${sampledFlag}`,
177+
);
178+
}
179+
}),
176180
);
177181
}
178182

@@ -213,17 +217,6 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
213217
});
214218
};
215219

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-
227220
if (startNewTrace) {
228221
return startNewTraceCore(() => executeSpan());
229222
}

packages/cloudflare/test/wrapMethodWithSentry.test.ts

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ function createMockSpan() {
3838
return {
3939
setAttribute: vi.fn(),
4040
setAttributes: vi.fn(),
41+
addLinks: vi.fn(),
4142
spanContext: vi.fn().mockReturnValue({
4243
traceId: 'test-trace-id-12345678901234567890',
4344
spanId: 'test-span-id',
@@ -84,7 +85,7 @@ describe('wrapMethodWithSentry', () => {
8485
expect(result).toBe('sync-result');
8586
});
8687

87-
it('wraps a sync method with spanName and returns synchronously (not a Promise)', () => {
88+
it('wraps a sync method with spanName and preserves sync behavior', () => {
8889
const handler = vi.fn().mockReturnValue('sync-result');
8990
const options = {
9091
options: {},
@@ -100,7 +101,7 @@ describe('wrapMethodWithSentry', () => {
100101
expect(result).toBe('sync-result');
101102
});
102103

103-
it('wraps a sync method with startNewTrace and returns synchronously (not a Promise)', () => {
104+
it('wraps a sync method with startNewTrace and preserves sync behavior', () => {
104105
const handler = vi.fn().mockReturnValue('sync-result');
105106
const options = {
106107
options: {},
@@ -132,14 +133,15 @@ describe('wrapMethodWithSentry', () => {
132133
expect(handler).toHaveBeenCalled();
133134
});
134135

135-
it('returns a Promise when linkPreviousTrace is true (even for sync handlers)', async () => {
136+
it('does not change sync/async behavior when linkPreviousTrace is true (links are set via waitUntil)', () => {
136137
const handler = vi.fn().mockReturnValue('sync-result');
137138
const mockStorage = {
138139
get: vi.fn().mockResolvedValue(undefined),
139140
put: vi.fn().mockResolvedValue(undefined),
140141
};
142+
const waitUntilPromises: Promise<void>[] = [];
141143
const context = {
142-
waitUntil: vi.fn(),
144+
waitUntil: vi.fn((p: Promise<void>) => waitUntilPromises.push(p)),
143145
originalStorage: mockStorage,
144146
} as any;
145147

@@ -154,8 +156,12 @@ describe('wrapMethodWithSentry', () => {
154156
const wrapped = wrapMethodWithSentry(options, handler);
155157
const result = wrapped();
156158

157-
expect(result).toBeInstanceOf(Promise);
158-
await expect(result).resolves.toBe('sync-result');
159+
// linkPreviousTrace does not make the result async - links are set via waitUntil
160+
expect(result).not.toBeInstanceOf(Promise);
161+
expect(result).toBe('sync-result');
162+
163+
// The link fetching happens via waitUntil, not blocking the response
164+
expect(context.waitUntil).toHaveBeenCalled();
159165
});
160166

161167
it('marks handler as instrumented', () => {
@@ -363,8 +369,13 @@ describe('wrapMethodWithSentry', () => {
363369
get: vi.fn().mockResolvedValue(storedContext),
364370
put: vi.fn().mockResolvedValue(undefined),
365371
};
372+
373+
const mockSpan = createMockSpan();
374+
vi.mocked(sentryCore.startSpan).mockImplementation((opts, callback) => callback(mockSpan as any));
375+
376+
const waitUntilPromises: Promise<void>[] = [];
366377
const context = {
367-
waitUntil: vi.fn(),
378+
waitUntil: vi.fn((p: Promise<void>) => waitUntilPromises.push(p)),
368379
originalStorage: mockStorage,
369380
} as any;
370381

@@ -380,20 +391,20 @@ describe('wrapMethodWithSentry', () => {
380391
const wrapped = wrapMethodWithSentry(options, handler);
381392
await wrapped();
382393

383-
// startSpan should be called with links
384-
expect(sentryCore.startSpan).toHaveBeenCalledWith(
385-
expect.objectContaining({
386-
links: expect.arrayContaining([
387-
expect.objectContaining({
388-
context: expect.objectContaining({
389-
traceId: 'previous-trace-id-1234567890123456',
390-
spanId: 'previous-span-id',
391-
}),
392-
attributes: { 'sentry.link.type': 'previous_trace' },
394+
// Wait for waitUntil promises to resolve (setSpanLinks is called via waitUntil)
395+
await Promise.all(waitUntilPromises);
396+
397+
// addLinks should be called on the span with the stored context
398+
expect(mockSpan.addLinks).toHaveBeenCalledWith(
399+
expect.arrayContaining([
400+
expect.objectContaining({
401+
context: expect.objectContaining({
402+
traceId: 'previous-trace-id-1234567890123456',
403+
spanId: 'previous-span-id',
393404
}),
394-
]),
395-
}),
396-
expect.any(Function),
405+
attributes: { 'sentry.link.type': 'previous_trace' },
406+
}),
407+
]),
397408
);
398409
});
399410

@@ -430,13 +441,22 @@ describe('wrapMethodWithSentry', () => {
430441
expect(mockStorage.put).toHaveBeenCalledWith('__SENTRY_TRACE_LINK__alarm', expect.any(Object));
431442
});
432443

433-
it('does not retrieve stored context when linkPreviousTrace is false', async () => {
444+
it('does not store span context when linkPreviousTrace is false', async () => {
445+
vi.mocked(sentryCore.getActiveSpan).mockReturnValue({
446+
spanContext: vi.fn().mockReturnValue({
447+
traceId: 'current-trace-id-123456789012345678',
448+
spanId: 'current-span-id',
449+
}),
450+
} as any);
451+
434452
const mockStorage = {
435453
get: vi.fn().mockResolvedValue(undefined),
436454
put: vi.fn().mockResolvedValue(undefined),
437455
};
456+
457+
const waitUntilPromises: Promise<void>[] = [];
438458
const context = {
439-
waitUntil: vi.fn(),
459+
waitUntil: vi.fn((p: Promise<void>) => waitUntilPromises.push(p)),
440460
originalStorage: mockStorage,
441461
} as any;
442462

@@ -452,7 +472,11 @@ describe('wrapMethodWithSentry', () => {
452472
const wrapped = wrapMethodWithSentry(options, handler);
453473
await wrapped();
454474

455-
expect(mockStorage.get).not.toHaveBeenCalled();
475+
// Wait for all waitUntil promises to resolve
476+
await Promise.all(waitUntilPromises);
477+
478+
// Should NOT store span context when linkPreviousTrace is false
479+
expect(mockStorage.put).not.toHaveBeenCalledWith('__SENTRY_TRACE_LINK__alarm', expect.any(Object));
456480
});
457481
});
458482

0 commit comments

Comments
 (0)