diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/index.ts new file mode 100644 index 000000000000..175283d26c39 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/index.ts @@ -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 { + public constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + } + + async fetch(): Promise { + 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 { + 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, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts new file mode 100644 index 000000000000..c37ec1a64c69 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts @@ -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]; + + 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(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/wrangler.jsonc new file mode 100644 index 000000000000..8a544e1bdf6b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/wrangler.jsonc @@ -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"], +} diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts index 5c7e02085eae..d830d8efb6bd 100644 --- a/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts @@ -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; @@ -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); + } + if (typeof original !== 'function') { return original; } diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectSyncKvStorage.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectSyncKvStorage.ts new file mode 100644 index 000000000000..7df91332eeaf --- /dev/null +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectSyncKvStorage.ts @@ -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); + }, + ); + }; + }, + }); +} diff --git a/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts b/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts index d023f9565df7..37491bb0139b 100644 --- a/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts +++ b/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts @@ -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'; @@ -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 — @@ -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([]), + }, }; } diff --git a/packages/cloudflare/test/instrumentDurableObjectSyncKvStorage.test.ts b/packages/cloudflare/test/instrumentDurableObjectSyncKvStorage.test.ts new file mode 100644 index 000000000000..fa98c94a12a2 --- /dev/null +++ b/packages/cloudflare/test/instrumentDurableObjectSyncKvStorage.test.ts @@ -0,0 +1,200 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import * as sentryCore from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { instrumentDurableObjectSyncKvStorage } from '../src/instrumentations/instrumentDurableObjectSyncKvStorage'; + +vi.mock('@sentry/core', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + startSpan: vi.fn((opts, callback) => callback()), + }; +}); + +describe('instrumentDurableObjectSyncKvStorage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('get', () => { + it('instruments get with single key', () => { + const mockKv = createMockSyncKv(); + const instrumented = instrumentDurableObjectSyncKvStorage(mockKv); + + instrumented.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), + ); + }); + + it('returns the value from the underlying storage', () => { + const mockKv = createMockSyncKv(); + mockKv.get = vi.fn().mockReturnValue('storedValue'); + const instrumented = instrumentDurableObjectSyncKvStorage(mockKv); + + const result = instrumented.get('myKey'); + + expect(result).toBe('storedValue'); + }); + + it('returns undefined for missing keys', () => { + const mockKv = createMockSyncKv(); + mockKv.get = vi.fn().mockReturnValue(undefined); + const instrumented = instrumentDurableObjectSyncKvStorage(mockKv); + + const result = instrumented.get('missing'); + + expect(result).toBeUndefined(); + }); + }); + + describe('put', () => { + it('instruments put', () => { + const mockKv = createMockSyncKv(); + const instrumented = instrumentDurableObjectSyncKvStorage(mockKv); + + instrumented.put('myKey', 'myValue'); + + expect(sentryCore.startSpan).toHaveBeenCalledWith( + { + name: 'durable_object_storage_kv_put', + op: 'db', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'put', + }, + }, + expect.any(Function), + ); + }); + + it('calls the underlying put with correct args', () => { + const mockKv = createMockSyncKv(); + const instrumented = instrumentDurableObjectSyncKvStorage(mockKv); + + instrumented.put('myKey', { nested: true }); + + expect(mockKv.put).toHaveBeenCalledWith('myKey', { nested: true }); + }); + }); + + describe('delete', () => { + it('instruments delete', () => { + const mockKv = createMockSyncKv(); + const instrumented = instrumentDurableObjectSyncKvStorage(mockKv); + + instrumented.delete('myKey'); + + expect(sentryCore.startSpan).toHaveBeenCalledWith( + { + name: 'durable_object_storage_kv_delete', + op: 'db', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'delete', + }, + }, + expect.any(Function), + ); + }); + + it('returns boolean from underlying delete', () => { + const mockKv = createMockSyncKv(); + mockKv.delete = vi.fn().mockReturnValue(true); + const instrumented = instrumentDurableObjectSyncKvStorage(mockKv); + + const result = instrumented.delete('myKey'); + + expect(result).toBe(true); + }); + }); + + describe('list', () => { + it('instruments list', () => { + const mockKv = createMockSyncKv(); + const instrumented = instrumentDurableObjectSyncKvStorage(mockKv); + + instrumented.list(); + + expect(sentryCore.startSpan).toHaveBeenCalledWith( + { + name: 'durable_object_storage_kv_list', + op: 'db', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'list', + }, + }, + expect.any(Function), + ); + }); + + it('passes options through to underlying list', () => { + const mockKv = createMockSyncKv(); + const instrumented = instrumentDurableObjectSyncKvStorage(mockKv); + + instrumented.list({ prefix: 'user:', limit: 10 }); + + expect(mockKv.list).toHaveBeenCalledWith({ prefix: 'user:', limit: 10 }); + }); + + it('returns the iterable from underlying list', () => { + const entries: [string, string][] = [ + ['key1', 'val1'], + ['key2', 'val2'], + ]; + const mockKv = createMockSyncKv(); + mockKv.list = vi.fn().mockReturnValue(entries); + const instrumented = instrumentDurableObjectSyncKvStorage(mockKv); + + const result = instrumented.list(); + + expect(result).toBe(entries); + }); + }); + + describe('non-instrumented properties', () => { + it('passes through unknown properties without instrumentation', () => { + const mockKv = createMockSyncKv(); + (mockKv as any).customProp = 'custom-value'; + const instrumented = instrumentDurableObjectSyncKvStorage(mockKv); + + expect((instrumented as any).customProp).toBe('custom-value'); + expect(sentryCore.startSpan).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('propagates errors from sync KV operations', () => { + const mockKv = createMockSyncKv(); + mockKv.get = vi.fn().mockImplementation(() => { + throw new Error('Storage error'); + }); + const instrumented = instrumentDurableObjectSyncKvStorage(mockKv); + + expect(() => instrumented.get('myKey')).toThrow('Storage error'); + }); + }); +}); + +function createMockSyncKv(): any { + return { + get: vi.fn().mockReturnValue(undefined), + put: vi.fn().mockReturnValue(undefined), + delete: vi.fn().mockReturnValue(false), + list: vi.fn().mockReturnValue([]), + }; +}