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,51 @@
import * as Sentry from '@sentry/cloudflare';
import { DurableObject } from 'cloudflare:workers';

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

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

async fetch(): Promise<Response> {
this.ctx.storage.kv.put('test-key', { value: 'hello' });
const val = this.ctx.storage.kv.get('test-key');
const entries = [...this.ctx.storage.kv.list()];
const deleted = this.ctx.storage.kv.delete('test-key');

return Response.json({ get: val, listSize: entries.length, deleted });
}
}

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

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 === '/flush-marker') {
Sentry.captureMessage('flush-marker');
return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
}

const id = env.TEST_DURABLE_OBJECT.idFromName('test');
const stub = env.TEST_DURABLE_OBJECT.get(id);
return stub.fetch(request);
},
} satisfies ExportedHandler<Env>,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { Envelope } from '@sentry/core';
import { expect, it } from 'vitest';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
import { createRunner } from '../../../runner';

const flushMarkerMatcher = (envelope: Envelope): void => {
const [, items] = envelope;
const [itemHeader, itemBody] = items[0] as [{ type: string }, Record<string, unknown>];

expect(itemHeader.type).toBe('event');
expect(itemBody.message).toBe('flush-marker');
};

it('instruments sync KV operations on Durable Object storage', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1];
const spans = transactionEvent?.spans ?? [];

expect(transactionEvent).toEqual(
expect.objectContaining({
type: 'transaction',
transaction: 'GET /',
}),
);

expect(spans).toHaveLength(4);
expect(spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'durable_object_storage_kv_put',
op: 'db',
origin: 'auto.db.cloudflare.durable_object',
data: expect.objectContaining({
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object',
'db.system.name': 'cloudflare-durable-object-sql',
'db.operation.name': 'put',
}),
}),
expect.objectContaining({
description: 'durable_object_storage_kv_get',
op: 'db',
origin: 'auto.db.cloudflare.durable_object',
data: expect.objectContaining({
'db.system.name': 'cloudflare-durable-object-sql',
'db.operation.name': 'get',
}),
}),
expect.objectContaining({
description: 'durable_object_storage_kv_list',
op: 'db',
origin: 'auto.db.cloudflare.durable_object',
data: expect.objectContaining({
'db.system.name': 'cloudflare-durable-object-sql',
'db.operation.name': 'list',
}),
}),
expect.objectContaining({
description: 'durable_object_storage_kv_delete',
op: 'db',
origin: 'auto.db.cloudflare.durable_object',
data: expect.objectContaining({
'db.system.name': 'cloudflare-durable-object-sql',
'db.operation.name': 'delete',
}),
}),
]),
);
})
.expect(flushMarkerMatcher)
.start(signal);

await runner.makeRequest('get', '/');
await runner.makeRequest('get', '/flush-marker');
await runner.completed();
Comment thread
cursor[bot] marked this conversation as resolved.
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "worker-name",
"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"],
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { DurableObjectStorage } from '@cloudflare/workers-types';
import type { DurableObjectStorage, SyncKvStorage } from '@cloudflare/workers-types';
import { isThenable, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
import { storeSpanContext } from '../utils/traceLinks';
import { instrumentDurableObjectSyncKvStorage } from './instrumentDurableObjectSyncKvStorage';

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

Expand Down Expand Up @@ -35,6 +36,10 @@ export function instrumentDurableObjectStorage(
// reference" errors.
const original = Reflect.get(target, prop, target);

if (prop === 'kv' && original != null && typeof original === 'object') {
return instrumentDurableObjectSyncKvStorage(original as SyncKvStorage);
}
Comment on lines +39 to +41
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: Accessing storage.kv repeatedly creates a new proxy object each time instead of reusing a cached one, causing unnecessary performance overhead.
Severity: MEDIUM

Suggested Fix

Use a WeakMap to cache the instrumented proxy for the kv property. On the first access, create the proxy, store it in the map, and return it. On subsequent accesses, return the cached proxy from the WeakMap. This aligns with the existing caching pattern in instrumentEnv.ts and avoids creating multiple proxy objects.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location:
packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts#L39-L41

Potential issue: The instrumentation for Durable Object storage creates a new proxy for
the `kv` property on every access. In scenarios where code makes multiple sequential
calls like `storage.kv.get(...)` and then `storage.kv.put(...)`, a new proxy object is
unnecessarily created for each call. This leads to increased object allocations and
garbage collection pressure, which can degrade performance in serverless environments.
This behavior is inconsistent with the caching pattern (using `WeakMap`) established in
other parts of the codebase, such as in `instrumentEnv.ts`.

Did we get this right? 👍 / 👎 to inform future reviews.


if (typeof original !== 'function') {
return original;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { SyncKvStorage } from '@cloudflare/workers-types';
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';

const SYNC_KV_METHODS_TO_INSTRUMENT = ['get', 'put', 'delete', 'list'] as const;

type SyncKvMethod = (typeof SYNC_KV_METHODS_TO_INSTRUMENT)[number];

export function instrumentDurableObjectSyncKvStorage(syncKv: SyncKvStorage): SyncKvStorage {
return new Proxy(syncKv, {
get(target, prop, _receiver) {
const original = Reflect.get(target, prop, target);

if (typeof original !== 'function') {
return original;
}

const methodName = prop as SyncKvMethod;

if (!SYNC_KV_METHODS_TO_INSTRUMENT.includes(methodName)) {
return (original as (...args: unknown[]) => unknown).bind(target);
}

return function (this: unknown, ...args: unknown[]) {
return startSpan(
{
name: `durable_object_storage_kv_${methodName}`,
op: 'db',
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object',
'db.system.name': 'cloudflare-durable-object-sql',
'db.operation.name': methodName,
},
},
() => {
return (original as (...args: unknown[]) => unknown).apply(target, args);
},
);
};
},
});
}
29 changes: 29 additions & 0 deletions packages/cloudflare/test/instrumentDurableObjectStorage.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
import * as sentryCore from '@sentry/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { instrumentDurableObjectStorage } from '../src/instrumentations/instrumentDurableObjectStorage';
Expand Down Expand Up @@ -304,6 +305,28 @@ describe('instrumentDurableObjectStorage', () => {
});
});

describe('sync KV instrumentation', () => {
it('instruments the kv property with a proxy', () => {
const mockStorage = createMockStorage();
const instrumented = instrumentDurableObjectStorage(mockStorage);

instrumented.kv.get('myKey');

expect(sentryCore.startSpan).toHaveBeenCalledWith(
{
name: 'durable_object_storage_kv_get',
op: 'db',
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object',
'db.system.name': 'cloudflare-durable-object-sql',
'db.operation.name': 'get',
},
},
expect.any(Function),
);
});
});

describe('native getter preservation', () => {
it('preserves native getter `this` binding through the proxy', () => {
// Private fields simulate workerd's native brand check —
Expand Down Expand Up @@ -349,5 +372,11 @@ function createMockStorage(): any {
sql: {
exec: vi.fn(),
},
kv: {
get: vi.fn().mockReturnValue(undefined),
put: vi.fn().mockReturnValue(undefined),
delete: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
},
};
}
Loading
Loading