Skip to content
Draft
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
Expand Up @@ -8,11 +8,10 @@
"SENTRY_DSN": "https://username@domain/123",
"SENTRY_ENVIRONMENT": "qa",
"SENTRY_TRACES_SAMPLE_RATE": "1.0",
"SENTRY_TUNNEL": "http://localhost:3031/"
"SENTRY_TUNNEL": "http://localhost:3031/",
},
"assets": {
"binding": "ASSETS",
"directory": "./dist"
}
"directory": "./dist",
},
}

Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,40 @@ class MyDurableObjectBase extends DurableObject<Env> {
throw new Error('Should be recorded in Sentry.');
}

async alarm(): Promise<void> {
const action = await this.ctx.storage.get<string>('alarm-action');
if (action === 'throw') {
throw new Error('Alarm error captured by Sentry');
}
}

async fetch(request: Request) {
const { pathname } = new URL(request.url);
switch (pathname) {
const url = new URL(request.url);
switch (url.pathname) {
case '/throwException': {
await this.throwException();
break;
}
case '/ws':
case '/ws': {
const webSocketPair = new WebSocketPair();
const [client, server] = Object.values(webSocketPair);
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
case '/setAlarm': {
const action = url.searchParams.get('action') || 'succeed';
await this.ctx.storage.put('alarm-action', action);
await this.ctx.storage.setAlarm(Date.now() + 500);
return new Response('Alarm set');
}
case '/storage/put': {
await this.ctx.storage.put('test-key', 'test-value');
return new Response('Stored');
}
case '/storage/get': {
const value = await this.ctx.storage.get('test-key');
return new Response(`Got: ${value}`);
}
}
return new Response('DO is fine');
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForRequest } from '@sentry-internal/test-utils';
import { waitForError, waitForRequest, waitForTransaction } from '@sentry-internal/test-utils';
import { SDK_VERSION } from '@sentry/cloudflare';
import { WebSocket } from 'ws';

Expand Down Expand Up @@ -82,3 +82,47 @@ test('sends user-agent header with SDK name and version in envelope requests', a
'user-agent': `sentry.javascript.cloudflare/${SDK_VERSION}`,
});
});

test.describe('Alarm instrumentation', () => {
test.describe.configure({ mode: 'serial' });

test('captures error from alarm handler', async ({ baseURL }) => {
const errorWaiter = waitForError('cloudflare-workers', event => {
return event.exception?.values?.[0]?.value === 'Alarm error captured by Sentry';
});

const response = await fetch(`${baseURL}/pass-to-object/setAlarm?action=throw`);
expect(response.status).toBe(200);

const event = await errorWaiter;
expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object');
});

test('creates a transaction for alarm with new trace linked to setAlarm', async ({ baseURL }) => {
const setAlarmTransactionWaiter = waitForTransaction('cloudflare-workers', event => {
return event.spans?.some(span => span.description?.includes('storage.setAlarm')) ?? false;
});

const alarmTransactionWaiter = waitForTransaction('cloudflare-workers', event => {
return event.transaction === 'alarm' && event.contexts?.trace?.op === 'function';
});

const response = await fetch(`${baseURL}/pass-to-object/setAlarm`);
expect(response.status).toBe(200);

const setAlarmTransaction = await setAlarmTransactionWaiter;
const alarmTransaction = await alarmTransactionWaiter;

// Alarm creates a transaction with correct attributes
expect(alarmTransaction.contexts?.trace?.op).toBe('function');
expect(alarmTransaction.contexts?.trace?.origin).toBe('auto.faas.cloudflare.durable_object');

// Alarm starts a new trace (different trace ID from the request that called setAlarm)
expect(alarmTransaction.contexts?.trace?.trace_id).not.toBe(setAlarmTransaction.contexts?.trace?.trace_id);

// Alarm links to the trace that called setAlarm via sentry.previous_trace attribute
const previousTrace = alarmTransaction.contexts?.trace?.data?.['sentry.previous_trace'];
expect(previousTrace).toBeDefined();
expect(previousTrace).toContain(setAlarmTransaction.contexts?.trace?.trace_id);
});
});
161 changes: 10 additions & 151 deletions packages/cloudflare/src/durableobject.ts
Original file line number Diff line number Diff line change
@@ -1,159 +1,14 @@
/* eslint-disable @typescript-eslint/unbound-method */
import {
captureException,
flush,
getClient,
isThenable,
type Scope,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
withIsolationScope,
withScope,
} from '@sentry/core';
import { captureException } from '@sentry/core';
import type { DurableObject } from 'cloudflare:workers';
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
import type { CloudflareOptions } from './client';
import { isInstrumented, markAsInstrumented } from './instrument';
import { getFinalOptions } from './options';
import { wrapRequestHandler } from './request';
import { init } from './sdk';
import { copyExecutionContext } from './utils/copyExecutionContext';

type MethodWrapperOptions = {
spanName?: string;
spanOp?: string;
options: CloudflareOptions;
context: ExecutionContext | DurableObjectState;
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type UncheckedMethod = (...args: any[]) => any;
type OriginalMethod = UncheckedMethod;

function wrapMethodWithSentry<T extends OriginalMethod>(
wrapperOptions: MethodWrapperOptions,
handler: T,
callback?: (...args: Parameters<T>) => void,
noMark?: true,
): T {
if (isInstrumented(handler)) {
return handler;
}

if (!noMark) {
markAsInstrumented(handler);
}

return new Proxy(handler, {
apply(target, thisArg, args: Parameters<T>) {
const currentClient = getClient();
// if a client is already set, use withScope, otherwise use withIsolationScope
const sentryWithScope = currentClient ? withScope : withIsolationScope;

const wrappedFunction = (scope: Scope): unknown => {
// In certain situations, the passed context can become undefined.
// For example, for Astro while prerendering pages at build time.
// see: https://github.com/getsentry/sentry-javascript/issues/13217
const context = wrapperOptions.context as ExecutionContext | undefined;

const waitUntil = context?.waitUntil?.bind?.(context);

const currentClient = scope.getClient();
if (!currentClient) {
const client = init({ ...wrapperOptions.options, ctx: context });
scope.setClient(client);
}

if (!wrapperOptions.spanName) {
try {
if (callback) {
callback(...args);
}
const result = Reflect.apply(target, thisArg, args);

if (isThenable(result)) {
return result.then(
(res: unknown) => {
waitUntil?.(flush(2000));
return res;
},
(e: unknown) => {
captureException(e, {
mechanism: {
type: 'auto.faas.cloudflare.durable_object',
handled: false,
},
});
waitUntil?.(flush(2000));
throw e;
},
);
} else {
waitUntil?.(flush(2000));
return result;
}
} catch (e) {
captureException(e, {
mechanism: {
type: 'auto.faas.cloudflare.durable_object',
handled: false,
},
});
waitUntil?.(flush(2000));
throw e;
}
}

const attributes = wrapperOptions.spanOp
? {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: wrapperOptions.spanOp,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.durable_object',
}
: {};

return startSpan({ name: wrapperOptions.spanName, attributes }, () => {
try {
const result = Reflect.apply(target, thisArg, args);

if (isThenable(result)) {
return result.then(
(res: unknown) => {
waitUntil?.(flush(2000));
return res;
},
(e: unknown) => {
captureException(e, {
mechanism: {
type: 'auto.faas.cloudflare.durable_object',
handled: false,
},
});
waitUntil?.(flush(2000));
throw e;
},
);
} else {
waitUntil?.(flush(2000));
return result;
}
} catch (e) {
captureException(e, {
mechanism: {
type: 'auto.faas.cloudflare.durable_object',
handled: false,
},
});
waitUntil?.(flush(2000));
throw e;
}
});
};

return sentryWithScope(wrappedFunction);
},
});
}
import { instrumentContext } from './utils/instrumentContext';
import type { UncheckedMethod } from './wrapMethodWithSentry';
import { wrapMethodWithSentry } from './wrapMethodWithSentry';

/**
* Instruments a Durable Object class to capture errors and performance data.
Expand Down Expand Up @@ -196,7 +51,7 @@ export function instrumentDurableObjectWithSentry<
return new Proxy(DurableObjectClass, {
construct(target, [ctx, env]) {
setAsyncLocalStorageAsyncContextStrategy();
const context = copyExecutionContext(ctx);
const context = instrumentContext(ctx);

const options = getFinalOptions(optionsCallback(env), env);

Expand Down Expand Up @@ -225,7 +80,11 @@ export function instrumentDurableObjectWithSentry<
}

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

if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') {
Expand Down
12 changes: 6 additions & 6 deletions packages/cloudflare/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { getFinalOptions } from './options';
import { wrapRequestHandler } from './request';
import { addCloudResourceContext } from './scope-utils';
import { init } from './sdk';
import { copyExecutionContext } from './utils/copyExecutionContext';
import { instrumentContext } from './utils/instrumentContext';

/**
* Wrapper for Cloudflare handlers.
Expand Down Expand Up @@ -46,7 +46,7 @@ export function withSentry<
handler.fetch = new Proxy(handler.fetch, {
apply(target, thisArg, args: Parameters<ExportedHandlerFetchHandler<Env, CfHostMetadata>>) {
const [request, env, ctx] = args;
const context = copyExecutionContext(ctx);
const context = instrumentContext(ctx);
args[2] = context;

const options = getFinalOptions(optionsCallback(env), env);
Expand Down Expand Up @@ -82,7 +82,7 @@ export function withSentry<
handler.scheduled = new Proxy(handler.scheduled, {
apply(target, thisArg, args: Parameters<ExportedHandlerScheduledHandler<Env>>) {
const [event, env, ctx] = args;
const context = copyExecutionContext(ctx);
const context = instrumentContext(ctx);
args[2] = context;

return withIsolationScope(isolationScope => {
Expand Down Expand Up @@ -128,7 +128,7 @@ export function withSentry<
handler.email = new Proxy(handler.email, {
apply(target, thisArg, args: Parameters<EmailExportedHandler<Env>>) {
const [emailMessage, env, ctx] = args;
const context = copyExecutionContext(ctx);
const context = instrumentContext(ctx);
args[2] = context;

return withIsolationScope(isolationScope => {
Expand Down Expand Up @@ -172,7 +172,7 @@ export function withSentry<
handler.queue = new Proxy(handler.queue, {
apply(target, thisArg, args: Parameters<ExportedHandlerQueueHandler<Env, QueueHandlerMessage>>) {
const [batch, env, ctx] = args;
const context = copyExecutionContext(ctx);
const context = instrumentContext(ctx);
args[2] = context;

return withIsolationScope(isolationScope => {
Expand Down Expand Up @@ -224,7 +224,7 @@ export function withSentry<
handler.tail = new Proxy(handler.tail, {
apply(target, thisArg, args: Parameters<ExportedHandlerTailHandler<Env>>) {
const [, env, ctx] = args;
const context = copyExecutionContext(ctx);
const context = instrumentContext(ctx);
args[2] = context;

return withIsolationScope(async isolationScope => {
Expand Down
Loading
Loading