Skip to content

Commit 326fbc9

Browse files
authored
fix(cloudflare): Skip spans for Cloudflare-internal Durable Object SQL queries (#22376)
`agents` generates a lot of internal spans, which are mainly noise. The `workers-sdk` actually proofs that the summary with a `cf_` prefix are internal. So these are now filtered by default. I thought about having [an option](689e90c) to enable them, but I don't think this adds a lot of value. <img width="1051" height="991" alt="Screenshot 2026-07-17 at 22 38 30" src="https://github.com/user-attachments/assets/565af8c4-3330-4dc3-91be-f772f16e6d93" /> In case users have `cf_` prefixed spans in their app they can use: `durableObjectSqlSpanAllowlist: []` to allow them, this would also be helpful if you want to get cf internal spans activated.
1 parent 8e20a26 commit 326fbc9

7 files changed

Lines changed: 233 additions & 12 deletions

File tree

.size-limit.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,7 @@ module.exports = [
480480
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
481481
gzip: false,
482482
brotli: false,
483-
limit: '445 KiB',
483+
limit: '448 KiB',
484484
disablePlugins: ['@size-limit/webpack'],
485485
webpack: false,
486486
modifyEsbuildConfig: function (config) {

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

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,20 @@ test('@callable() methods work correctly with Sentry instrumentDurableObjectWith
3434
},
3535
spans: expect.arrayContaining([
3636
expect.objectContaining({
37-
op: 'db.query',
38-
origin: 'auto.db.cloudflare.durable_object.sql',
39-
description: expect.stringMatching(/^SELECT /),
40-
data: expect.objectContaining({
41-
'db.system.name': 'cloudflare-durable-object-sql',
42-
'db.operation.name': 'exec',
43-
'db.query.summary': expect.any(String),
44-
'db.query.text': expect.any(String),
45-
'sentry.op': 'db.query',
46-
'sentry.origin': 'auto.db.cloudflare.durable_object.sql',
47-
}),
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}/),
4851
}),
4952
]),
5053
start_timestamp: expect.any(Number),

packages/cloudflare/src/client.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,30 @@ interface BaseCloudflareOptions {
216216
*/
217217
enableRpcTracePropagation?: boolean;
218218

219+
/**
220+
* Table names that should stay instrumented even though they match the reserved `cf_` prefix used
221+
* by Durable Object frameworks (`agents`, `partyserver`, ...) for their internal SQLite tables.
222+
*
223+
* By default, `exec` queries against `cf_`-prefixed tables are treated as framework noise and no
224+
* `db.query` span is created for them. If one of your own tables happens to use this prefix, add it
225+
* here to opt it back into instrumentation. Entries are matched against each table name in the
226+
* query summary — strings must match exactly, while regular expressions give you prefix/pattern
227+
* matching.
228+
*
229+
* @default []
230+
* @example
231+
* ```ts
232+
* export default Sentry.withSentry(
233+
* (env) => ({
234+
* dsn: env.SENTRY_DSN,
235+
* durableObjectSqlSpanAllowlist: ['cf_my_table', /^cf_reports_/],
236+
* }),
237+
* handler,
238+
* );
239+
* ```
240+
*/
241+
durableObjectSqlSpanAllowlist?: Array<string | RegExp>;
242+
219243
/**
220244
* @deprecated Use `enableRpcTracePropagation` instead. This option will be removed in a future major version.
221245
*

packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import type { SqlStorage } from '@cloudflare/workers-types';
22
import {
33
_INTERNAL_getSqlQuerySummary,
44
_INTERNAL_sanitizeSqlQuery,
5+
getClient,
56
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
67
startSpan,
78
} from '@sentry/core';
9+
import type { CloudflareClientOptions } from '../client';
10+
import { targetsCloudflareInternalTable } from '../utils/internalSqlQuery';
811

912
/**
1013
* Instruments the Durable Object SqlStorage `exec` method with Sentry spans.
@@ -23,9 +26,17 @@ export function instrumentSqlStorage(sql: SqlStorage): SqlStorage {
2326

2427
return function (this: unknown, ...args: unknown[]) {
2528
const [query, ...bindings] = args as [string, ...unknown[]];
29+
2630
const sanitizedQuery = _INTERNAL_sanitizeSqlQuery(query);
2731
const querySummary = _INTERNAL_getSqlQuerySummary(sanitizedQuery);
2832

33+
const allowlist = (getClient()?.getOptions() as CloudflareClientOptions | undefined)
34+
?.durableObjectSqlSpanAllowlist;
35+
36+
if (targetsCloudflareInternalTable(querySummary, allowlist)) {
37+
return (original as (...a: unknown[]) => ReturnType<SqlStorage['exec']>).apply(target, args);
38+
}
39+
2940
return startSpan(
3041
{
3142
op: 'db.query',
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { stringMatchesSomePattern } from '@sentry/core';
2+
3+
/**
4+
* Cloudflare frameworks that build on Durable Objects (`agents`, `partyserver`, ...) manage their
5+
* own internal SQLite tables, all namespaced with a `cf_` prefix — e.g. `cf_agents_schedules`,
6+
* `cf_agent_state`, `cf_ai_chat_stream_chunks`. Queries against them (schedule polling, chat-stream
7+
* persistence, state bookkeeping) are framework implementation details that otherwise flood traces
8+
* with dozens of zero-signal `db.query` spans per request. The exact set of tables even varies
9+
* between framework versions, so we match the reserved prefix rather than an enumerated list.
10+
*
11+
* The `cf_` prefix is a reserved convention for framework-managed tables, so user tables should not
12+
* use it. In case a user table does collide with the prefix, the `durableObjectSqlSpanAllowlist`
13+
* option lets them opt those tables back into instrumentation.
14+
*
15+
* The check operates on the query summary produced by `getSqlQuerySummary` (`{operation} {table} ...`,
16+
* the same value used as the span name), so table targets are already isolated from the rest of the
17+
* query.
18+
*/
19+
export function targetsCloudflareInternalTable(
20+
querySummary: string | undefined,
21+
allowlist?: Array<string | RegExp>,
22+
): boolean {
23+
if (!querySummary) {
24+
return false;
25+
}
26+
27+
const [, ...tables] = querySummary.split(' ');
28+
29+
return tables.some(table => {
30+
if (!table.toLowerCase().startsWith('cf_')) {
31+
return false;
32+
}
33+
34+
// A table on the allowlist is treated as a user table and stays instrumented, even though it
35+
// matches the reserved prefix.
36+
return !allowlist?.length || !stringMatchesSomePattern(table, allowlist, true);
37+
});
38+
}

packages/cloudflare/test/instrumentSqlStorage.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,45 @@ describe('instrumentSqlStorage', () => {
144144
expect(startSpanSpy).toHaveBeenCalledTimes(2);
145145
expect(mockSql.exec).toHaveBeenCalledTimes(2);
146146
});
147+
148+
describe('internal storage queries', () => {
149+
it('does not create a span for Cloudflare-internal queries', () => {
150+
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
151+
const mockCursor = createMockCursor();
152+
const mockSql = createMockSqlStorage(mockCursor);
153+
const instrumented = instrumentSqlStorage(mockSql);
154+
155+
const result = instrumented.exec('SELECT * FROM cf_agents_state WHERE id = ?', 'foo');
156+
157+
expect(startSpanSpy).not.toHaveBeenCalled();
158+
expect(mockSql.exec).toHaveBeenCalledWith('SELECT * FROM cf_agents_state WHERE id = ?', 'foo');
159+
expect(result).toBe(mockCursor);
160+
});
161+
162+
it('still creates a span for user queries', () => {
163+
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
164+
const mockSql = createMockSqlStorage();
165+
const instrumented = instrumentSqlStorage(mockSql);
166+
167+
instrumented.exec('SELECT * FROM users WHERE id = ?', 1);
168+
169+
expect(startSpanSpy).toHaveBeenCalledTimes(1);
170+
});
171+
172+
it('creates a span for a cf_ table on the durableObjectSqlSpanAllowlist', () => {
173+
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
174+
vi.spyOn(sentryCore, 'getClient').mockReturnValue({
175+
getOptions: () => ({ durableObjectSqlSpanAllowlist: ['cf_my_table'] }),
176+
} as unknown as ReturnType<typeof sentryCore.getClient>);
177+
178+
const mockSql = createMockSqlStorage();
179+
const instrumented = instrumentSqlStorage(mockSql);
180+
181+
instrumented.exec('SELECT * FROM cf_my_table WHERE id = ?', 1);
182+
183+
expect(startSpanSpy).toHaveBeenCalledTimes(1);
184+
});
185+
});
147186
});
148187

149188
function createMockCursor() {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { _INTERNAL_getSqlQuerySummary } from '@sentry/core';
2+
import { describe, expect, it } from 'vitest';
3+
import { targetsCloudflareInternalTable } from '../../src/utils/internalSqlQuery';
4+
5+
// Builds the summary the same way `instrumentSqlStorage` does, so the test exercises the real
6+
// operation -> summary -> detection path rather than hand-written summaries.
7+
const summarize = (query: string): string | undefined => _INTERNAL_getSqlQuerySummary(query);
8+
9+
describe('targetsCloudflareInternalTable', () => {
10+
describe('internal queries (cf_ tables)', () => {
11+
it.each([
12+
['SELECT', 'SELECT * FROM cf_agents_state WHERE id = ?'],
13+
['INSERT', 'INSERT INTO cf_agents_fibers (id, callback) VALUES (?, ?)'],
14+
['DELETE', 'DELETE FROM cf_agents_schedules WHERE id = ?'],
15+
['UPDATE', 'UPDATE cf_agent_tool_runs SET output_json = ? WHERE id = ?'],
16+
['CREATE TABLE', 'CREATE TABLE IF NOT EXISTS cf_agents_workflows (id TEXT PRIMARY KEY NOT NULL)'],
17+
['ALTER TABLE', 'ALTER TABLE cf_agents_queues ADD COLUMN retry_options TEXT'],
18+
['DROP TABLE', 'DROP TABLE cf_agents_state'],
19+
['cf_agent_ prefix', 'SELECT * FROM cf_agent_identity'],
20+
['cf_ai_ prefix', 'INSERT INTO cf_ai_chat_stream_chunks (id) VALUES (?)'],
21+
['cf_mcp_ prefix', 'SELECT * FROM cf_mcp_agent_event'],
22+
['schema version', 'SELECT version FROM cf_schema_version'],
23+
])('returns true for %s on internal tables', (_label, query) => {
24+
expect(targetsCloudflareInternalTable(summarize(query))).toBe(true);
25+
});
26+
27+
it('returns true for an internal JOIN', () => {
28+
const query = `
29+
SELECT f.fiber_id, f.status
30+
FROM cf_agents_fibers f
31+
LEFT JOIN cf_agents_runs r ON r.id = f.fiber_id
32+
WHERE f.status IN ('pending', 'running')
33+
`;
34+
expect(targetsCloudflareInternalTable(summarize(query))).toBe(true);
35+
});
36+
37+
it('returns true when an internal table is joined with a user table', () => {
38+
// `.some()` — any internal table present means the query is framework-driven noise.
39+
expect(
40+
targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state s JOIN users u ON u.id = s.id')),
41+
).toBe(true);
42+
});
43+
44+
it('handles case-insensitive keywords and prefixes', () => {
45+
expect(targetsCloudflareInternalTable(summarize('select * from CF_AGENTS_STATE'))).toBe(true);
46+
});
47+
});
48+
49+
describe('user queries (must be instrumented)', () => {
50+
it.each([
51+
['SELECT', 'SELECT * FROM users WHERE id = ?'],
52+
['INSERT', 'INSERT INTO orders (id, total) VALUES (?, ?)'],
53+
['UPDATE', 'UPDATE products SET price = ? WHERE id = ?'],
54+
['DELETE', 'DELETE FROM sessions WHERE expired = 1'],
55+
['CREATE TABLE', 'CREATE TABLE users (id TEXT PRIMARY KEY)'],
56+
['table with cf in the middle', 'SELECT * FROM my_cf_table'],
57+
['table starting with cfg', 'SELECT * FROM cfg_settings'],
58+
])('returns false for %s on user tables', (_label, query) => {
59+
expect(targetsCloudflareInternalTable(summarize(query))).toBe(false);
60+
});
61+
});
62+
63+
describe('allowlist (opt a cf_ table back into instrumentation)', () => {
64+
it('returns false for an allowlisted table matched by exact string', () => {
65+
expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_my_table'), ['cf_my_table'])).toBe(false);
66+
});
67+
68+
it('returns false for an allowlisted table matched by regex', () => {
69+
expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_reports_daily'), [/^cf_reports_/])).toBe(false);
70+
});
71+
72+
it('requires an exact match for string entries', () => {
73+
// Substring matches must not opt a table back in, otherwise `cf_` would allowlist everything.
74+
expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), ['cf_agents'])).toBe(true);
75+
});
76+
77+
it('still skips genuine internal tables that are not allowlisted', () => {
78+
expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), ['cf_my_table'])).toBe(true);
79+
});
80+
81+
it('still skips when an internal table is joined with an allowlisted table', () => {
82+
expect(
83+
targetsCloudflareInternalTable(summarize('SELECT * FROM cf_my_table t JOIN cf_agents_state s ON s.id = t.id'), [
84+
'cf_my_table',
85+
]),
86+
).toBe(true);
87+
});
88+
89+
it('ignores an empty allowlist', () => {
90+
expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), [])).toBe(true);
91+
});
92+
});
93+
94+
describe('summaries without a resolvable table target (safe default: instrument)', () => {
95+
it.each([
96+
['undefined', undefined],
97+
['empty', ''],
98+
['no-table SELECT', 'SELECT 1'],
99+
['PRAGMA', 'PRAGMA foreign_keys = ON'],
100+
['bare operation', 'BEGIN'],
101+
])('returns false for %s', (_label, value) => {
102+
const summary = typeof value === 'string' ? summarize(value) : value;
103+
expect(targetsCloudflareInternalTable(summary)).toBe(false);
104+
});
105+
});
106+
});

0 commit comments

Comments
 (0)