Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as Sentry from '@sentry/cloudflare';
import { DurableObject } from 'cloudflare:workers';

interface Env {
SENTRY_DSN: string;
TEST_DURABLE_OBJECT: DurableObjectNamespace;
}

class AlarmDurableObjectBase extends DurableObject<Env> {
public constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
}

async setAlarm(): Promise<void> {
await this.ctx.storage.setAlarm(Date.now() + 100);
}

async alarm(): Promise<void> {
await new Promise<void>(resolve => setTimeout(resolve, 10));
}
}

export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
enableRpcTracePropagation: true,
}),
AlarmDurableObjectBase,
);

export default Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
}),
{
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/set-alarm') {
const id = url.searchParams.get('id') || 'default';
const doId = env.TEST_DURABLE_OBJECT.idFromName(id);
const stub = env.TEST_DURABLE_OBJECT.get(doId) as unknown as AlarmDurableObjectBase;
await stub.setAlarm();
return new Response('Alarm scheduled');
}

return new Response('OK');
},
} satisfies ExportedHandler<Env>,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { expect, it } from 'vitest';
import type { TransactionEvent } from '@sentry/core';
import { createRunner } from '../../../runner';

it('alarm links to the trace that scheduled it via sentry.previous_trace', async ({ signal }) => {
let setAlarmTransaction: TransactionEvent | undefined;
let alarmTransaction: TransactionEvent | undefined;
const testId = Date.now().toString();

const runner = createRunner(__dirname)
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
expect(transactionEvent).toEqual(
expect.objectContaining({
transaction: expect.stringContaining('/set-alarm'),
}),
);
})
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
expect(transactionEvent).toEqual(
expect.objectContaining({
transaction: 'setAlarm',
contexts: expect.objectContaining({
trace: expect.objectContaining({
op: 'rpc',
origin: 'auto.faas.cloudflare.durable_object',
}),
}),
}),
);
setAlarmTransaction = transactionEvent;
})
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
expect(transactionEvent).toEqual(
expect.objectContaining({
type: 'transaction',
transaction: 'alarm',
contexts: expect.objectContaining({
trace: expect.objectContaining({
op: 'function',
origin: 'auto.faas.cloudflare.durable_object',
}),
}),
}),
);
alarmTransaction = transactionEvent;
})
.unordered()
.start(signal);

await runner.makeRequest('get', `/set-alarm?id=${testId}`);
await runner.completed();

const traceData = alarmTransaction!.contexts?.trace?.data as Record<string, unknown> | undefined;
const previousTrace = traceData?.['sentry.previous_trace'] as string | undefined;

expect(previousTrace).toBeDefined();
expect(previousTrace).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/);

const [linkedTraceId] = previousTrace!.split('-');
expect(linkedTraceId).toBe(setAlarmTransaction!.contexts?.trace?.trace_id);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "durableobject-alarm-links",
"main": "index.ts",
"compatibility_date": "2025-06-17",
"migrations": [
{
"new_sqlite_classes": ["TestDurableObject"],
"tag": "v1",
},
],
"durable_objects": {
"bindings": [
{
"class_name": "TestDurableObject",
"name": "TEST_DURABLE_OBJECT",
},
],
},
"compatibility_flags": ["nodejs_als"],
}
50 changes: 30 additions & 20 deletions packages/cloudflare/src/wrapMethodWithSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,37 +170,47 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(

const executeSpan = (): unknown => {
return startSpan({ name: methodName, attributes }, span => {
// When linking to previous trace, fetch the stored context and add links asynchronously
// This avoids blocking the response while fetching from storage
// When linking to a previous trace, fetch the stored context in parallel with the
// user's handler and await it before the span ends, so `sentry.previous_trace` lands
// on the serialized transaction. Awaiting it ties the lookup into the handler's
// async lifecycle, so a separate `waitUntil` is not needed.
let linkPromise: Promise<void> | undefined;

if (startNewTrace && storage) {
waitUntil?.(
getStoredSpanContext(storage, methodName).then(storedContext => {
if (storedContext) {
span.addLinks(buildSpanLinks(storedContext));
// TODO: Remove this once EAP can store span links. We currently only set this attribute so that we
// can obtain the previous trace information from the EAP store. Long-term, EAP will handle
// span links and then we should remove this again. Also throwing in a TODO(v11), to remind us
// to check this at v11 time :)
const sampledFlag = storedContext.sampled ? '1' : '0';
span.setAttribute(
'sentry.previous_trace',
`${storedContext.traceId}-${storedContext.spanId}-${sampledFlag}`,
);
}
}),
);
linkPromise = getStoredSpanContext(storage, methodName).then(storedContext => {
if (storedContext) {
span.addLinks(buildSpanLinks(storedContext));
// TODO: Remove this once EAP can store span links. We currently only set this attribute so that we
// can obtain the previous trace information from the EAP store. Long-term, EAP will handle
// span links and then we should remove this again. Also throwing in a TODO(v11), to remind us
// to check this at v11 time :)
const sampledFlag = storedContext.sampled ? '1' : '0';
span.setAttribute(
'sentry.previous_trace',
`${storedContext.traceId}-${storedContext.spanId}-${sampledFlag}`,
);
}
});
}

const awaitLink = async (): Promise<void> => {
if (linkPromise) {
await linkPromise.catch(() => undefined);
}
};

try {
const result = Reflect.apply(target, thisArg, args);

if (isThenable(result)) {
return result.then(
(res: unknown) => {
async (res: unknown) => {
await awaitLink();
waitUntil?.(teardown());
return res;
},
(e: unknown) => {
async (e: unknown) => {
await awaitLink();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sync handlers skip link await

Medium Severity

For startNewTrace handlers that return synchronously or throw synchronously, the stored trace link is never awaited and is no longer registered with waitUntil. The span can end before getStoredSpanContext sets sentry.previous_trace, so alarm-style linking can fail for sync alarm implementations.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ce76d4b. Configure here.

captureException(e, {
mechanism: {
type: 'auto.faas.cloudflare.durable_object',
Expand Down