Skip to content

Commit 91d7244

Browse files
authored
feat(cloudflare): Instrument async KV API (#19404)
closes #19384 closes [JS-1744](https://linear.app/getsentry/issue/JS-1744/cloudflare-instrument-async-kv-api) With that we start to instrument DO objects starting with the Async KV API. Cloudflare is instrumenting these with underlines between: `durable_object_storage_get`, without any more information to it. In the future to make them a little more useful we could store the keys as span attributes on it with `db.cloudflare.durable_object.storage.key` or `db.cloudflare.durable_object.storage.keys`. First we have to add them to our [semantic conventions](https://getsentry.github.io/sentry-conventions/attributes/) though
1 parent b6baf6d commit 91d7244

File tree

8 files changed

+723
-7
lines changed

8 files changed

+723
-7
lines changed

dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,26 @@ class MyDurableObjectBase extends DurableObject<Env> {
2020
}
2121

2222
async fetch(request: Request) {
23-
const { pathname } = new URL(request.url);
24-
switch (pathname) {
23+
const url = new URL(request.url);
24+
switch (url.pathname) {
2525
case '/throwException': {
2626
await this.throwException();
2727
break;
2828
}
29-
case '/ws':
29+
case '/ws': {
3030
const webSocketPair = new WebSocketPair();
3131
const [client, server] = Object.values(webSocketPair);
3232
this.ctx.acceptWebSocket(server);
3333
return new Response(null, { status: 101, webSocket: client });
34+
}
35+
case '/storage/put': {
36+
await this.ctx.storage.put('test-key', 'test-value');
37+
return new Response('Stored');
38+
}
39+
case '/storage/get': {
40+
const value = await this.ctx.storage.get('test-key');
41+
return new Response(`Got: ${value}`);
42+
}
3443
}
3544
return new Response('DO is fine');
3645
}

dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForError, waitForRequest } from '@sentry-internal/test-utils';
2+
import { waitForError, waitForRequest, waitForTransaction } from '@sentry-internal/test-utils';
33
import { SDK_VERSION } from '@sentry/cloudflare';
44
import { WebSocket } from 'ws';
55

@@ -82,3 +82,20 @@ test('sends user-agent header with SDK name and version in envelope requests', a
8282
'user-agent': `sentry.javascript.cloudflare/${SDK_VERSION}`,
8383
});
8484
});
85+
86+
test('Storage operations create spans in Durable Object transactions', async ({ baseURL }) => {
87+
const transactionWaiter = waitForTransaction('cloudflare-workers', event => {
88+
return event.spans?.some(span => span.op === 'db' && span.description === 'durable_object_storage_put') ?? false;
89+
});
90+
91+
const response = await fetch(`${baseURL}/pass-to-object/storage/put`);
92+
expect(response.status).toBe(200);
93+
94+
const transaction = await transactionWaiter;
95+
const putSpan = transaction.spans?.find(span => span.description === 'durable_object_storage_put');
96+
97+
expect(putSpan).toBeDefined();
98+
expect(putSpan?.op).toBe('db');
99+
expect(putSpan?.data?.['db.system.name']).toBe('cloudflare.durable_object.storage');
100+
expect(putSpan?.data?.['db.operation.name']).toBe('put');
101+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { DurableObjectStorage } from '@cloudflare/workers-types';
2+
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
3+
4+
const STORAGE_METHODS_TO_INSTRUMENT = ['get', 'put', 'delete', 'list'] as const;
5+
6+
type StorageMethod = (typeof STORAGE_METHODS_TO_INSTRUMENT)[number];
7+
8+
/**
9+
* Instruments DurableObjectStorage methods with Sentry spans.
10+
*
11+
* Wraps the following async methods:
12+
* - get, put, delete, list (KV API)
13+
*
14+
* @param storage - The DurableObjectStorage instance to instrument
15+
* @returns An instrumented DurableObjectStorage instance
16+
*/
17+
export function instrumentDurableObjectStorage(storage: DurableObjectStorage): DurableObjectStorage {
18+
return new Proxy(storage, {
19+
get(target, prop, receiver) {
20+
const original = Reflect.get(target, prop, receiver);
21+
22+
if (typeof original !== 'function') {
23+
return original;
24+
}
25+
26+
const methodName = prop as string;
27+
if (!STORAGE_METHODS_TO_INSTRUMENT.includes(methodName as StorageMethod)) {
28+
return (original as (...args: unknown[]) => unknown).bind(target);
29+
}
30+
31+
return function (this: unknown, ...args: unknown[]) {
32+
return startSpan(
33+
{
34+
// Use underscore naming to match Cloudflare's native instrumentation (e.g., "durable_object_storage_get")
35+
name: `durable_object_storage_${methodName}`,
36+
op: 'db',
37+
attributes: {
38+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object',
39+
'db.system.name': 'cloudflare.durable_object.storage',
40+
'db.operation.name': methodName,
41+
},
42+
},
43+
() => {
44+
return (original as (...args: unknown[]) => unknown).apply(target, args);
45+
},
46+
);
47+
};
48+
},
49+
});
50+
}

packages/cloudflare/src/utils/instrumentContext.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { type DurableObjectState, type ExecutionContext } from '@cloudflare/workers-types';
1+
import { type DurableObjectState, type DurableObjectStorage, type ExecutionContext } from '@cloudflare/workers-types';
2+
import { instrumentDurableObjectStorage } from '../instrumentations/instrumentDurableObjectStorage';
23

34
type ContextType = ExecutionContext | DurableObjectState;
45
type OverridesStore<T extends ContextType> = Map<keyof T, (...args: unknown[]) => unknown>;
@@ -8,6 +9,8 @@ type OverridesStore<T extends ContextType> = Map<keyof T, (...args: unknown[]) =
89
*
910
* Creates a copy of the context that:
1011
* - Allows overriding of methods (e.g., waitUntil)
12+
* - For DurableObjectState: instruments storage operations (get, put, delete, list, etc.)
13+
* to create Sentry spans automatically
1114
*
1215
* @param ctx - The execution context or DurableObjectState to instrument
1316
* @returns An instrumented copy of the context
@@ -34,6 +37,30 @@ export function instrumentContext<T extends ContextType>(ctx: T): T {
3437
{} as PropertyDescriptorMap,
3538
);
3639

40+
// Check if this is a DurableObjectState context with a storage property
41+
// If so, wrap the storage with instrumentation
42+
if ('storage' in ctx && ctx.storage) {
43+
const originalStorage = ctx.storage;
44+
let instrumentedStorage: DurableObjectStorage | undefined;
45+
descriptors.storage = {
46+
configurable: true,
47+
enumerable: true,
48+
get: () => {
49+
if (!instrumentedStorage) {
50+
instrumentedStorage = instrumentDurableObjectStorage(originalStorage);
51+
}
52+
return instrumentedStorage;
53+
},
54+
};
55+
// Expose the original uninstrumented storage for internal Sentry operations
56+
// This avoids creating spans for internal storage operations
57+
descriptors.originalStorage = {
58+
configurable: true,
59+
enumerable: false,
60+
get: () => originalStorage,
61+
};
62+
}
63+
3764
return Object.create(ctx, descriptors);
3865
}
3966

packages/cloudflare/src/wrapMethodWithSentry.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { DurableObjectStorage } from '@cloudflare/workers-types';
12
import {
23
captureException,
34
flush,
@@ -14,6 +15,11 @@ import type { CloudflareOptions } from './client';
1415
import { isInstrumented, markAsInstrumented } from './instrument';
1516
import { init } from './sdk';
1617

18+
/** Extended DurableObjectState with originalStorage exposed by instrumentContext */
19+
interface InstrumentedDurableObjectState extends DurableObjectState {
20+
originalStorage?: DurableObjectStorage;
21+
}
22+
1723
type MethodWrapperOptions = {
1824
spanName?: string;
1925
spanOp?: string;
@@ -58,13 +64,13 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
5864
// In certain situations, the passed context can become undefined.
5965
// For example, for Astro while prerendering pages at build time.
6066
// see: https://github.com/getsentry/sentry-javascript/issues/13217
61-
const context = wrapperOptions.context as ExecutionContext | undefined;
67+
const context = wrapperOptions.context as InstrumentedDurableObjectState | undefined;
6268

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

6571
const currentClient = scope.getClient();
6672
if (!currentClient) {
67-
const client = init({ ...wrapperOptions.options, ctx: context });
73+
const client = init({ ...wrapperOptions.options, ctx: context as unknown as ExecutionContext | undefined });
6874
scope.setClient(client);
6975
}
7076

packages/cloudflare/test/instrumentContext.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,57 @@ describe('instrumentContext', () => {
4646
const instrumented = instrumentContext(context);
4747
expect(instrumented[s]).toBe(context[s]);
4848
});
49+
50+
describe('DurableObjectState storage instrumentation', () => {
51+
it('instruments storage property', () => {
52+
const mockStorage = createMockStorage();
53+
const context = makeDurableObjectStateMock(mockStorage);
54+
const instrumented = instrumentContext(context);
55+
56+
// The storage property should be instrumented (wrapped)
57+
expect(instrumented.storage).toBeDefined();
58+
// The instrumented storage should not be the same reference
59+
expect(instrumented.storage).not.toBe(mockStorage);
60+
});
61+
62+
it('exposes originalStorage as the uninstrumented storage', () => {
63+
const mockStorage = createMockStorage();
64+
const context = makeDurableObjectStateMock(mockStorage);
65+
const instrumented = instrumentContext(context);
66+
67+
// originalStorage should be the original uninstrumented storage
68+
expect(instrumented.originalStorage).toBe(mockStorage);
69+
});
70+
71+
it('originalStorage is not enumerable', () => {
72+
const mockStorage = createMockStorage();
73+
const context = makeDurableObjectStateMock(mockStorage);
74+
const instrumented = instrumentContext(context);
75+
76+
// originalStorage should not appear in Object.keys
77+
expect(Object.keys(instrumented)).not.toContain('originalStorage');
78+
});
79+
80+
it('returns instrumented storage lazily', () => {
81+
const mockStorage = createMockStorage();
82+
const context = makeDurableObjectStateMock(mockStorage);
83+
const instrumented = instrumentContext(context);
84+
85+
// Access storage twice to ensure memoization
86+
const storage1 = instrumented.storage;
87+
const storage2 = instrumented.storage;
88+
89+
expect(storage1).toBe(storage2);
90+
});
91+
92+
it('handles context without storage property', () => {
93+
const context = makeExecutionContextMock();
94+
const instrumented = instrumentContext(context) as any;
95+
96+
// Should not have originalStorage if no storage property
97+
expect(instrumented.originalStorage).toBeUndefined();
98+
});
99+
});
49100
});
50101

51102
function makeExecutionContextMock<T extends ExecutionContext>() {
@@ -54,3 +105,39 @@ function makeExecutionContextMock<T extends ExecutionContext>() {
54105
passThroughOnException: vi.fn(),
55106
} as unknown as Mocked<T>;
56107
}
108+
109+
function makeDurableObjectStateMock(storage?: any) {
110+
return {
111+
waitUntil: vi.fn(),
112+
blockConcurrencyWhile: vi.fn(),
113+
id: { toString: () => 'test-id', equals: vi.fn(), name: 'test' },
114+
storage: storage || createMockStorage(),
115+
acceptWebSocket: vi.fn(),
116+
getWebSockets: vi.fn().mockReturnValue([]),
117+
setWebSocketAutoResponse: vi.fn(),
118+
getWebSocketAutoResponse: vi.fn(),
119+
getWebSocketAutoResponseTimestamp: vi.fn(),
120+
setHibernatableWebSocketEventTimeout: vi.fn(),
121+
getHibernatableWebSocketEventTimeout: vi.fn(),
122+
getTags: vi.fn().mockReturnValue([]),
123+
abort: vi.fn(),
124+
} as any;
125+
}
126+
127+
function createMockStorage(): any {
128+
return {
129+
get: vi.fn().mockResolvedValue(undefined),
130+
put: vi.fn().mockResolvedValue(undefined),
131+
delete: vi.fn().mockResolvedValue(false),
132+
list: vi.fn().mockResolvedValue(new Map()),
133+
getAlarm: vi.fn().mockResolvedValue(null),
134+
setAlarm: vi.fn().mockResolvedValue(undefined),
135+
deleteAlarm: vi.fn().mockResolvedValue(undefined),
136+
deleteAll: vi.fn().mockResolvedValue(undefined),
137+
sync: vi.fn().mockResolvedValue(undefined),
138+
transaction: vi.fn().mockImplementation(async (cb: () => unknown) => cb()),
139+
sql: {
140+
exec: vi.fn(),
141+
},
142+
};
143+
}

0 commit comments

Comments
 (0)