Skip to content

Commit 7c82cec

Browse files
committed
feat(v10/cloudflare): Filter framework-internal Durable Object storage spans
Backport of: #22710
1 parent c19156c commit 7c82cec

7 files changed

Lines changed: 357 additions & 19 deletions

File tree

dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ test('@callable() methods work correctly with Sentry instrumentDurableObjectWith
99
);
1010
});
1111

12+
// The greet() call goes over the websocket, so its storage spans land in a webSocketMessage
13+
// transaction. Filter for the one carrying our put span — control messages produce their own
14+
// webSocketMessage transactions without storage spans.
15+
const storageTransactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => {
16+
return (
17+
transactionEvent.transaction === 'webSocketMessage' &&
18+
(transactionEvent.spans ?? []).some(span => span.description === 'durable_object_storage_put')
19+
);
20+
});
21+
1222
await page.goto(baseURL!);
1323

1424
await expect(page.getByText('Connected')).toBeVisible();
@@ -32,24 +42,7 @@ test('@callable() methods work correctly with Sentry instrumentDurableObjectWith
3242
culture: { timezone: expect.any(String) },
3343
runtime: { name: 'cloudflare' },
3444
},
35-
spans: expect.arrayContaining([
36-
expect.objectContaining({
37-
data: {
38-
'db.operation.name': 'get',
39-
'db.system.name': 'cloudflare.durable_object.storage',
40-
'sentry.op': 'db',
41-
'sentry.origin': 'auto.db.cloudflare.durable_object',
42-
},
43-
description: 'durable_object_storage_get',
44-
op: 'db',
45-
origin: 'auto.db.cloudflare.durable_object',
46-
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
47-
span_id: expect.stringMatching(/[a-f0-9]{16}/),
48-
start_timestamp: expect.any(Number),
49-
timestamp: expect.any(Number),
50-
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
51-
}),
52-
]),
45+
spans: [],
5346
start_timestamp: expect.any(Number),
5447
timestamp: expect.any(Number),
5548
transaction: 'GET /agents/my-agent/user-123',
@@ -72,4 +65,65 @@ test('@callable() methods work correctly with Sentry instrumentDurableObjectWith
7265
packages: expect.any(Array),
7366
},
7467
});
68+
69+
// greet() touches 6 storage keys: 2 user ops + 3 framework-internal keys (cf_, __ps_, /) that
70+
// must be filtered + 1 allowlisted cf_ key. Spans carry no key attribute, so filtering can only
71+
// be verified by count — exactly these 3 storage spans (in execution order) should survive, and
72+
// any framework-internal span leaking through shows up as an extra entry here.
73+
const storageTransaction = await storageTransactionPromise;
74+
75+
const storageSpans = (storageTransaction.spans ?? []).filter(
76+
span => span.origin === 'auto.db.cloudflare.durable_object',
77+
);
78+
79+
expect(storageSpans).toEqual([
80+
expect.objectContaining({
81+
data: {
82+
'db.operation.name': 'put',
83+
'db.system.name': 'cloudflare.durable_object.storage',
84+
'sentry.op': 'db',
85+
'sentry.origin': 'auto.db.cloudflare.durable_object',
86+
},
87+
description: 'durable_object_storage_put',
88+
op: 'db',
89+
origin: 'auto.db.cloudflare.durable_object',
90+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
91+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
92+
start_timestamp: expect.any(Number),
93+
timestamp: expect.any(Number),
94+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
95+
}),
96+
expect.objectContaining({
97+
data: {
98+
'db.operation.name': 'get',
99+
'db.system.name': 'cloudflare.durable_object.storage',
100+
'sentry.op': 'db',
101+
'sentry.origin': 'auto.db.cloudflare.durable_object',
102+
},
103+
description: 'durable_object_storage_get',
104+
op: 'db',
105+
origin: 'auto.db.cloudflare.durable_object',
106+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
107+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
108+
start_timestamp: expect.any(Number),
109+
timestamp: expect.any(Number),
110+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
111+
}),
112+
expect.objectContaining({
113+
data: {
114+
'db.operation.name': 'get',
115+
'db.system.name': 'cloudflare.durable_object.storage',
116+
'sentry.op': 'db',
117+
'sentry.origin': 'auto.db.cloudflare.durable_object',
118+
},
119+
description: 'durable_object_storage_get',
120+
op: 'db',
121+
origin: 'auto.db.cloudflare.durable_object',
122+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
123+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
124+
start_timestamp: expect.any(Number),
125+
timestamp: expect.any(Number),
126+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
127+
}),
128+
]);
75129
});

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ import { routeAgentRequest, Agent, callable } from 'agents';
44
class MyBaseAgent extends Agent {
55
@callable()
66
async greet(name: string): Promise<string> {
7+
// User keys — instrumented, spans expected
8+
await this.ctx.storage.put('test', 'any value');
9+
await this.ctx.storage.get('test');
10+
11+
// Framework-internal keys (agents/partyserver/MCP OAuth conventions) — filtered, no spans expected
12+
await this.ctx.storage.put('cf_e2e_internal', 'bookkeeping');
13+
await this.ctx.storage.get('__ps_name');
14+
await this.ctx.storage.get('/oauth/client/token');
15+
16+
// Allowlisted cf_ key — span expected
17+
await this.ctx.storage.get('cf_user_key');
18+
719
return `Hello, ${name}!`;
820
}
921
}
@@ -14,6 +26,7 @@ export const MyAgent = Sentry.instrumentDurableObjectWithSentry(
1426
tunnel: `http://localhost:3031/`,
1527
tracesSampleRate: 1,
1628
enableRpcTracePropagation: true,
29+
durableObjectStorageSpanAllowlist: ['cf_user_key'],
1730
}),
1831
MyBaseAgent,
1932
);

packages/cloudflare/src/client.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,31 @@ interface BaseCloudflareOptions {
240240
*/
241241
durableObjectSqlSpanAllowlist?: Array<string | RegExp>;
242242

243+
/**
244+
* KV keys that should stay instrumented even though they match a reserved prefix used by Durable
245+
* Object frameworks (`agents`, `partyserver`, ...) for their internal storage entries.
246+
*
247+
* By default, KV reads/writes (`get`, `put`, `delete`, `list`) of `cf_`- or `__ps_`-prefixed keys
248+
* are treated as framework noise and no `durable_object_storage_*` span is created for them,
249+
* mirroring how `cf_`-prefixed SQL tables are handled (see {@link durableObjectSqlSpanAllowlist}).
250+
* If one of your own keys happens to use such a prefix, add it here to opt it back into
251+
* instrumentation. Strings must match exactly, while regular expressions give you prefix/pattern
252+
* matching.
253+
*
254+
* @default []
255+
* @example
256+
* ```ts
257+
* export default Sentry.withSentry(
258+
* (env) => ({
259+
* dsn: env.SENTRY_DSN,
260+
* durableObjectStorageSpanAllowlist: ['cf_my_key', /^cf_reports_/],
261+
* }),
262+
* handler,
263+
* );
264+
* ```
265+
*/
266+
durableObjectStorageSpanAllowlist?: Array<string | RegExp>;
267+
243268
/**
244269
* @deprecated Use `enableRpcTracePropagation` instead. This option will be removed in a future major version.
245270
*

packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { DurableObjectStorage, SyncKvStorage, SqlStorage } from '@cloudflare/workers-types';
2-
import { isThenable, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
2+
import { getClient, isThenable, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
3+
import type { CloudflareClientOptions } from '../client';
4+
import { getStorageKeys, targetsCloudflareInternalKey } from '../utils/internalStorageKey';
35
import { storeSpanContext } from '../utils/traceLinks';
46
import { instrumentDurableObjectSyncKvStorage } from './instrumentDurableObjectSyncKvStorage';
57
import { instrumentSqlStorage } from './instrumentSqlStorage';
@@ -55,6 +57,15 @@ export function instrumentDurableObjectStorage(
5557
}
5658

5759
return function (this: unknown, ...args: unknown[]) {
60+
// KV entries managed by the DO framework itself (agents/partyserver state) are bookkeeping
61+
// rather than user work — skip the span, mirroring how `cf_` SQL tables are treated.
62+
const allowlist = (getClient()?.getOptions() as CloudflareClientOptions | undefined)
63+
?.durableObjectStorageSpanAllowlist;
64+
const keys = getStorageKeys(methodName, args);
65+
if (keys && keys.length > 0 && keys.every(key => targetsCloudflareInternalKey(key, allowlist))) {
66+
return (original as (...a: unknown[]) => unknown).apply(target, args);
67+
}
68+
5869
return startSpan(
5970
{
6071
// Use underscore naming to match Cloudflare's native instrumentation (e.g., "durable_object_storage_get")
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { stringMatchesSomePattern } from '@sentry/core';
2+
3+
/**
4+
* Cloudflare frameworks that build on Durable Objects (`agents`, `partyserver`, ...) also manage
5+
* their own internal KV entries alongside their internal SQLite tables, namespaced with a reserved
6+
* prefix — e.g. `cf_agents_state`, `cf_agents_mcp_servers`, `__ps_name`. Reads/writes of these
7+
* (message persistence, MCP connection bookkeeping, name hydration) are framework implementation
8+
* details that otherwise flood traces with dozens of zero-signal `durable_object_storage_*` spans
9+
* per request, so we match the reserved prefixes rather than an enumerated list. This mirrors the
10+
* `cf_` convention used for internal SQL tables (see `targetsCloudflareInternalTable`).
11+
*
12+
* The prefixes are a reserved convention for framework-managed entries, so user keys should not use
13+
* them. In case a user key does collide, the `durableObjectStorageSpanAllowlist` option lets them
14+
* opt those keys back into instrumentation.
15+
*/
16+
export function targetsCloudflareInternalKey(key: string | undefined, allowlist?: Array<string | RegExp>): boolean {
17+
if (!key) {
18+
return false;
19+
}
20+
21+
// Framework-managed KV namespaces:
22+
// - `cf_` — agents / ai-chat internal state (mirrors the internal SQL table convention)
23+
// - `__ps_` — partyserver internals (e.g. `__ps_name`)
24+
// - `/` — MCP OAuth client state (`/<clientName>/<serverId>/{token,client_info,state,...}`),
25+
// read on every MCP tool call. User keys on an Agent rarely use a leading slash; if one does,
26+
// the allowlist opts it back in.
27+
const isFrameworkKey = key.startsWith('cf_') || key.startsWith('__ps_') || key.startsWith('/');
28+
if (!isFrameworkKey) {
29+
return false;
30+
}
31+
32+
// A key on the allowlist is treated as a user key and stays instrumented, even though it matches
33+
// a reserved prefix.
34+
return !allowlist?.length || !stringMatchesSomePattern(key, allowlist, true);
35+
}
36+
37+
/**
38+
* Extracts the KV keys a Durable Object storage call targets, so the caller can decide whether the
39+
* operation only touches framework-internal entries. Returns `undefined` when the keys can't be
40+
* determined from the arguments (e.g. `list()` without a prefix), in which case the call is treated
41+
* as user work and stays instrumented.
42+
*/
43+
export function getStorageKeys(methodName: string, args: unknown[]): string[] | undefined {
44+
const [first] = args;
45+
46+
if (methodName === 'get' || methodName === 'delete') {
47+
// get(key) / get(keys[]) / delete(key) / delete(keys[])
48+
if (typeof first === 'string') {
49+
return [first];
50+
}
51+
if (Array.isArray(first)) {
52+
return first.filter((k): k is string => typeof k === 'string');
53+
}
54+
return undefined;
55+
}
56+
57+
if (methodName === 'put') {
58+
// put(key, value) or put({ key: value, ... })
59+
if (typeof first === 'string') {
60+
return [first];
61+
}
62+
if (first && typeof first === 'object' && !Array.isArray(first)) {
63+
return Object.keys(first);
64+
}
65+
return undefined;
66+
}
67+
68+
if (methodName === 'list') {
69+
// list({ prefix })
70+
const prefix = first && typeof first === 'object' ? (first as { prefix?: unknown }).prefix : undefined;
71+
return typeof prefix === 'string' ? [prefix] : undefined;
72+
}
73+
74+
return undefined;
75+
}

packages/cloudflare/test/instrumentDurableObjectStorage.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,80 @@ describe('instrumentDurableObjectStorage', () => {
324324
);
325325
});
326326

327+
describe('framework-internal KV keys', () => {
328+
it('does not create a span for a cf_-prefixed get', async () => {
329+
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
330+
const instrumented = instrumentDurableObjectStorage(createMockStorage());
331+
332+
await instrumented.get('cf_agents_state');
333+
334+
expect(startSpanSpy).not.toHaveBeenCalled();
335+
});
336+
337+
it('does not create a span for a __ps_-prefixed get', async () => {
338+
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
339+
const instrumented = instrumentDurableObjectStorage(createMockStorage());
340+
341+
await instrumented.get('__ps_name');
342+
343+
expect(startSpanSpy).not.toHaveBeenCalled();
344+
});
345+
346+
it('does not create a span for a cf_-prefixed put with object entries', async () => {
347+
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
348+
const instrumented = instrumentDurableObjectStorage(createMockStorage());
349+
350+
await instrumented.put({ cf_agents_a: 1, cf_agents_b: 2 });
351+
352+
expect(startSpanSpy).not.toHaveBeenCalled();
353+
});
354+
355+
it('does not create a span for a cf_-prefixed delete with an array of keys', async () => {
356+
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
357+
const instrumented = instrumentDurableObjectStorage(createMockStorage());
358+
359+
await instrumented.delete(['cf_agents_a', 'cf_agents_b']);
360+
361+
expect(startSpanSpy).not.toHaveBeenCalled();
362+
});
363+
364+
it('does not create a span for a list with a cf_ prefix', async () => {
365+
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
366+
const instrumented = instrumentDurableObjectStorage(createMockStorage());
367+
368+
await instrumented.list({ prefix: 'cf_agents_' });
369+
370+
expect(startSpanSpy).not.toHaveBeenCalled();
371+
});
372+
373+
it('still creates a span when a batch mixes framework and user keys', async () => {
374+
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
375+
const instrumented = instrumentDurableObjectStorage(createMockStorage());
376+
377+
await instrumented.get(['cf_agents_state', 'myKey']);
378+
379+
expect(startSpanSpy).toHaveBeenCalled();
380+
});
381+
382+
it('still creates a span for a list without a prefix', async () => {
383+
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
384+
const instrumented = instrumentDurableObjectStorage(createMockStorage());
385+
386+
await instrumented.list();
387+
388+
expect(startSpanSpy).toHaveBeenCalled();
389+
});
390+
391+
it('still creates a span for a user key', async () => {
392+
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
393+
const instrumented = instrumentDurableObjectStorage(createMockStorage());
394+
395+
await instrumented.get('myKey');
396+
397+
expect(startSpanSpy).toHaveBeenCalled();
398+
});
399+
});
400+
327401
describe('non-instrumented methods', () => {
328402
it('does not instrument deleteAll, sync, transaction', async () => {
329403
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');

0 commit comments

Comments
 (0)