Skip to content

Commit de79611

Browse files
committed
fixup! fix(cloudflare): Filter CREATE INDEX spans on cf_-prefixed tables
1 parent ab84884 commit de79611

2 files changed

Lines changed: 131 additions & 153 deletions

File tree

packages/cloudflare/test/instrumentSqlStorage.test.ts

Lines changed: 116 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -159,31 +159,129 @@ describe('instrumentSqlStorage', () => {
159159
expect(result).toBe(mockCursor);
160160
});
161161

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);
162+
describe('internal tables (cf_ prefix) are skipped', () => {
163+
it.each([
164+
['SELECT', 'SELECT * FROM cf_agents_state WHERE id = ?'],
165+
['INSERT', 'INSERT INTO cf_agents_fibers (id, callback) VALUES (?, ?)'],
166+
['DELETE', 'DELETE FROM cf_agents_schedules WHERE id = ?'],
167+
['UPDATE', 'UPDATE cf_agent_tool_runs SET output_json = ? WHERE id = ?'],
168+
['CREATE TABLE', 'CREATE TABLE IF NOT EXISTS cf_agents_workflows (id TEXT PRIMARY KEY NOT NULL)'],
169+
['ALTER TABLE', 'ALTER TABLE cf_agents_queues ADD COLUMN retry_options TEXT'],
170+
['DROP TABLE', 'DROP TABLE cf_agents_state'],
171+
['cf_agent_ prefix', 'SELECT * FROM cf_agent_identity'],
172+
['cf_ai_ prefix', 'INSERT INTO cf_ai_chat_stream_chunks (id) VALUES (?)'],
173+
['cf_mcp_ prefix', 'SELECT * FROM cf_mcp_agent_event'],
174+
['schema version', 'SELECT version FROM cf_schema_version'],
175+
// SQLite upsert forms used by the agents framework for state/schedule/MCP persistence
176+
['INSERT OR REPLACE', 'INSERT OR REPLACE INTO cf_agents_state (id, state) VALUES (?, ?)'],
177+
[
178+
'INSERT OR REPLACE with column list',
179+
`INSERT OR REPLACE INTO cf_agents_mcp_servers ( id, name, server_url, client_id, auth_url,
180+
callback_url, server_options )
181+
VALUES ( ?, ?, ?, ?, ?, ?, ? )`,
182+
],
183+
['INSERT OR IGNORE', 'INSERT OR IGNORE INTO cf_agents_sub_agents (class, name) VALUES (?, ?)'],
184+
['REPLACE INTO', 'REPLACE INTO cf_agents_queues (id, payload) VALUES (?, ?)'],
185+
['UPDATE OR REPLACE', 'UPDATE OR REPLACE cf_agents_state SET state = ? WHERE id = ?'],
186+
// The summary of a CREATE INDEX carries the index name, not the indexed table — the cf_
187+
// target only exists in the ON clause of the full statement.
188+
[
189+
'CREATE INDEX (framework statement)',
190+
`create index if not exists idx_ai_chat_agent_tool_request_id
191+
on cf_ai_chat_agent_tool_runs(request_id)`,
192+
],
193+
['CREATE INDEX (uppercase)', 'CREATE INDEX idx_agents_state_id ON cf_agents_state (id)'],
194+
['CREATE UNIQUE INDEX', 'CREATE UNIQUE INDEX idx_agents_state_id ON cf_agents_state (id)'],
195+
[
196+
'CREATE INDEX (without IF NOT EXISTS)',
197+
'CREATE INDEX idx_chunks_stream ON cf_ai_chat_stream_chunks (stream_id)',
198+
],
199+
[
200+
'JOIN between internal tables',
201+
`SELECT f.fiber_id, f.status
202+
FROM cf_agents_fibers f
203+
LEFT JOIN cf_agents_runs r ON r.id = f.fiber_id
204+
WHERE f.status IN ('pending', 'running')`,
205+
],
206+
// `.some()` — any internal table present means the query is framework-driven noise.
207+
['JOIN with a user table', 'SELECT * FROM cf_agents_state s JOIN users u ON u.id = s.id'],
208+
['lowercase keywords and prefix', 'select * from CF_AGENTS_STATE'],
209+
])('skips %s', (_label, query) => {
210+
expect(execCreatesSpan(query)).toBe(false);
211+
});
212+
});
166213

167-
instrumented.exec('SELECT * FROM users WHERE id = ?', 1);
214+
describe('user queries stay instrumented', () => {
215+
it.each([
216+
['SELECT', 'SELECT * FROM users WHERE id = ?'],
217+
['INSERT', 'INSERT INTO orders (id, total) VALUES (?, ?)'],
218+
['UPDATE', 'UPDATE products SET price = ? WHERE id = ?'],
219+
['DELETE', 'DELETE FROM sessions WHERE expired = 1'],
220+
['CREATE TABLE', 'CREATE TABLE users (id TEXT PRIMARY KEY)'],
221+
['CREATE INDEX', 'CREATE INDEX idx_name ON users (name)'],
222+
['table with cf in the middle', 'SELECT * FROM my_cf_table'],
223+
['table starting with cfg', 'SELECT * FROM cfg_settings'],
224+
['INSERT OR REPLACE', 'INSERT OR REPLACE INTO users (id, name) VALUES (?, ?)'],
225+
['REPLACE INTO', 'REPLACE INTO sessions (id, token) VALUES (?, ?)'],
226+
['UPDATE OR IGNORE', 'UPDATE OR IGNORE products SET price = ? WHERE id = ?'],
227+
// No resolvable table target — safe default is to instrument.
228+
['no-table SELECT', 'SELECT 1'],
229+
['PRAGMA', 'PRAGMA foreign_keys = ON'],
230+
['bare operation', 'BEGIN'],
231+
['empty query', ''],
232+
])('instruments %s', (_label, query) => {
233+
expect(execCreatesSpan(query)).toBe(true);
234+
});
235+
});
168236

169-
expect(startSpanSpy).toHaveBeenCalledTimes(1);
237+
describe('durableObjectSqlSpanAllowlist (opt a cf_ table back into instrumentation)', () => {
238+
it.each([
239+
['exact string', 'SELECT * FROM cf_my_table', ['cf_my_table']],
240+
['regex', 'SELECT * FROM cf_reports_daily', [/^cf_reports_/]],
241+
['upsert target', 'INSERT OR REPLACE INTO cf_my_table (id) VALUES (?)', ['cf_my_table']],
242+
['CREATE INDEX target', 'CREATE INDEX idx_mine ON cf_my_table (id)', ['cf_my_table']],
243+
])('instruments an allowlisted table matched by %s', (_label, query, allowlist) => {
244+
expect(execCreatesSpan(query, allowlist)).toBe(true);
245+
});
246+
247+
it.each([
248+
// Substring matches must not opt a table back in, otherwise `cf_` would allowlist everything.
249+
['a string entry only matches exactly', 'SELECT * FROM cf_agents_state', ['cf_agents']],
250+
['a non-matching entry leaves internal tables skipped', 'SELECT * FROM cf_agents_state', ['cf_my_table']],
251+
[
252+
'an internal table joined with an allowlisted table is still skipped',
253+
'SELECT * FROM cf_my_table t JOIN cf_agents_state s ON s.id = t.id',
254+
['cf_my_table'],
255+
],
256+
['an empty allowlist is ignored', 'SELECT * FROM cf_agents_state', []],
257+
])('%s', (_label, query, allowlist) => {
258+
expect(execCreatesSpan(query, allowlist)).toBe(false);
259+
});
170260
});
261+
});
262+
});
171263

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>);
264+
/**
265+
* Runs a query through the real `instrumentSqlStorage` proxy and reports whether it produced a
266+
* `db.query` span, so the filtering matrix exercises the actual code path rather than a
267+
* reimplementation of it.
268+
*/
269+
function execCreatesSpan(query: string, allowlist?: Array<string | RegExp>): boolean {
270+
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
177271

178-
const mockSql = createMockSqlStorage();
179-
const instrumented = instrumentSqlStorage(mockSql);
272+
if (allowlist) {
273+
vi.spyOn(sentryCore, 'getClient').mockReturnValue({
274+
getOptions: () => ({ durableObjectSqlSpanAllowlist: allowlist }),
275+
} as unknown as ReturnType<typeof sentryCore.getClient>);
276+
}
180277

181-
instrumented.exec('SELECT * FROM cf_my_table WHERE id = ?', 1);
278+
const mockSql = createMockSqlStorage();
279+
instrumentSqlStorage(mockSql).exec(query);
182280

183-
expect(startSpanSpy).toHaveBeenCalledTimes(1);
184-
});
185-
});
186-
});
281+
expect(mockSql.exec).toHaveBeenCalledWith(query);
282+
283+
return startSpanSpy.mock.calls.length > 0;
284+
}
187285

188286
function createMockCursor() {
189287
return {
Lines changed: 15 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,145 +1,25 @@
1-
import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery } from '@sentry/core';
21
import { describe, expect, it } from 'vitest';
32
import { targetsCloudflareInternalTable } from '../../src/utils/internalSqlQuery';
43

5-
// Runs the same sanitize -> summarize -> filter pipeline as `instrumentSqlStorage`, so the tests
6-
// exercise the real detection path rather than hand-written summaries.
7-
const check = (query: string, allowlist?: Array<string | RegExp>): boolean => {
8-
const sanitized = _INTERNAL_sanitizeSqlQuery(query);
9-
return targetsCloudflareInternalTable(_INTERNAL_getSqlQuerySummary(sanitized), allowlist, sanitized);
10-
};
11-
4+
// Behavioural coverage of the filter lives in `instrumentSqlStorage.test.ts`, which drives real
5+
// queries through the instrumented `exec`. What remains here are the signature-level contracts that
6+
// call path cannot reach: an absent summary, and an absent `queryText`.
127
describe('targetsCloudflareInternalTable', () => {
13-
describe('internal queries (cf_ tables)', () => {
14-
it.each([
15-
['SELECT', 'SELECT * FROM cf_agents_state WHERE id = ?'],
16-
['INSERT', 'INSERT INTO cf_agents_fibers (id, callback) VALUES (?, ?)'],
17-
['DELETE', 'DELETE FROM cf_agents_schedules WHERE id = ?'],
18-
['UPDATE', 'UPDATE cf_agent_tool_runs SET output_json = ? WHERE id = ?'],
19-
['CREATE TABLE', 'CREATE TABLE IF NOT EXISTS cf_agents_workflows (id TEXT PRIMARY KEY NOT NULL)'],
20-
['ALTER TABLE', 'ALTER TABLE cf_agents_queues ADD COLUMN retry_options TEXT'],
21-
['DROP TABLE', 'DROP TABLE cf_agents_state'],
22-
['cf_agent_ prefix', 'SELECT * FROM cf_agent_identity'],
23-
['cf_ai_ prefix', 'INSERT INTO cf_ai_chat_stream_chunks (id) VALUES (?)'],
24-
['cf_mcp_ prefix', 'SELECT * FROM cf_mcp_agent_event'],
25-
['schema version', 'SELECT version FROM cf_schema_version'],
26-
// SQLite upsert forms used by the agents framework for state/schedule/MCP persistence
27-
['INSERT OR REPLACE', 'INSERT OR REPLACE INTO cf_agents_state (id, state) VALUES (?, ?)'],
28-
[
29-
'INSERT OR REPLACE with column list',
30-
`INSERT OR REPLACE INTO cf_agents_mcp_servers ( id, name, server_url, client_id, auth_url,
31-
callback_url, server_options )
32-
VALUES ( ?, ?, ?, ?, ?, ?, ? )`,
33-
],
34-
['INSERT OR IGNORE', 'INSERT OR IGNORE INTO cf_agents_sub_agents (class, name) VALUES (?, ?)'],
35-
['REPLACE INTO', 'REPLACE INTO cf_agents_queues (id, payload) VALUES (?, ?)'],
36-
['UPDATE OR REPLACE', 'UPDATE OR REPLACE cf_agents_state SET state = ? WHERE id = ?'],
37-
])('returns true for %s on internal tables', (_label, query) => {
38-
expect(check(query)).toBe(true);
39-
});
40-
41-
// The summary of a CREATE INDEX carries the index name, not the indexed table — the cf_
42-
// target only exists in the ON clause of the full statement.
43-
it.each([
44-
[
45-
'framework statement',
46-
`create index if not exists idx_ai_chat_agent_tool_request_id
47-
on cf_ai_chat_agent_tool_runs(request_id)`,
48-
],
49-
['uppercase', 'CREATE INDEX idx_agents_state_id ON cf_agents_state (id)'],
50-
['UNIQUE', 'CREATE UNIQUE INDEX idx_agents_state_id ON cf_agents_state (id)'],
51-
['without IF NOT EXISTS', 'CREATE INDEX idx_chunks_stream ON cf_ai_chat_stream_chunks (stream_id)'],
52-
])('returns true for CREATE INDEX (%s) on an internal table', (_label, query) => {
53-
expect(check(query)).toBe(true);
54-
});
55-
56-
it('returns true for an internal JOIN', () => {
57-
const query = `
58-
SELECT f.fiber_id, f.status
59-
FROM cf_agents_fibers f
60-
LEFT JOIN cf_agents_runs r ON r.id = f.fiber_id
61-
WHERE f.status IN ('pending', 'running')
62-
`;
63-
expect(check(query)).toBe(true);
64-
});
65-
66-
it('returns true when an internal table is joined with a user table', () => {
67-
// `.some()` — any internal table present means the query is framework-driven noise.
68-
expect(check('SELECT * FROM cf_agents_state s JOIN users u ON u.id = s.id')).toBe(true);
69-
});
70-
71-
it('handles case-insensitive keywords and prefixes', () => {
72-
expect(check('select * from CF_AGENTS_STATE')).toBe(true);
73-
});
8+
it.each([
9+
['undefined', undefined],
10+
['empty', ''],
11+
])('returns false for a %s summary', (_label, summary) => {
12+
expect(targetsCloudflareInternalTable(summary)).toBe(false);
7413
});
7514

76-
describe('user queries (must be instrumented)', () => {
77-
it.each([
78-
['SELECT', 'SELECT * FROM users WHERE id = ?'],
79-
['INSERT', 'INSERT INTO orders (id, total) VALUES (?, ?)'],
80-
['UPDATE', 'UPDATE products SET price = ? WHERE id = ?'],
81-
['DELETE', 'DELETE FROM sessions WHERE expired = 1'],
82-
['CREATE TABLE', 'CREATE TABLE users (id TEXT PRIMARY KEY)'],
83-
['CREATE INDEX', 'CREATE INDEX idx_name ON users (name)'],
84-
['table with cf in the middle', 'SELECT * FROM my_cf_table'],
85-
['table starting with cfg', 'SELECT * FROM cfg_settings'],
86-
['INSERT OR REPLACE', 'INSERT OR REPLACE INTO users (id, name) VALUES (?, ?)'],
87-
['REPLACE INTO', 'REPLACE INTO sessions (id, token) VALUES (?, ?)'],
88-
['UPDATE OR IGNORE', 'UPDATE OR IGNORE products SET price = ? WHERE id = ?'],
89-
])('returns false for %s on user tables', (_label, query) => {
90-
expect(check(query)).toBe(false);
91-
});
15+
it('falls back to the summary when no queryText is passed', () => {
16+
expect(targetsCloudflareInternalTable('SELECT cf_agents_state')).toBe(true);
17+
expect(targetsCloudflareInternalTable('SELECT users')).toBe(false);
9218
});
9319

94-
describe('allowlist (opt a cf_ table back into instrumentation)', () => {
95-
it('returns false for an allowlisted table matched by exact string', () => {
96-
expect(check('SELECT * FROM cf_my_table', ['cf_my_table'])).toBe(false);
97-
});
98-
99-
it('returns false for an allowlisted table matched by regex', () => {
100-
expect(check('SELECT * FROM cf_reports_daily', [/^cf_reports_/])).toBe(false);
101-
});
102-
103-
it('returns false for an allowlisted table targeted by an upsert', () => {
104-
expect(check('INSERT OR REPLACE INTO cf_my_table (id) VALUES (?)', ['cf_my_table'])).toBe(false);
105-
});
106-
107-
it('returns false for CREATE INDEX on an allowlisted table', () => {
108-
expect(check('CREATE INDEX idx_mine ON cf_my_table (id)', ['cf_my_table'])).toBe(false);
109-
});
110-
111-
it('requires an exact match for string entries', () => {
112-
// Substring matches must not opt a table back in, otherwise `cf_` would allowlist everything.
113-
expect(check('SELECT * FROM cf_agents_state', ['cf_agents'])).toBe(true);
114-
});
115-
116-
it('still skips genuine internal tables that are not allowlisted', () => {
117-
expect(check('SELECT * FROM cf_agents_state', ['cf_my_table'])).toBe(true);
118-
});
119-
120-
it('still skips when an internal table is joined with an allowlisted table', () => {
121-
expect(check('SELECT * FROM cf_my_table t JOIN cf_agents_state s ON s.id = t.id', ['cf_my_table'])).toBe(true);
122-
});
123-
124-
it('ignores an empty allowlist', () => {
125-
expect(check('SELECT * FROM cf_agents_state', [])).toBe(true);
126-
});
127-
});
128-
129-
describe('summaries without a resolvable table target (safe default: instrument)', () => {
130-
it.each([
131-
['no-table SELECT', 'SELECT 1'],
132-
['PRAGMA', 'PRAGMA foreign_keys = ON'],
133-
['bare operation', 'BEGIN'],
134-
])('returns false for %s', (_label, query) => {
135-
expect(check(query)).toBe(false);
136-
});
137-
138-
it.each([
139-
['undefined', undefined],
140-
['empty', ''],
141-
])('returns false for a %s summary', (_label, summary) => {
142-
expect(targetsCloudflareInternalTable(summary)).toBe(false);
143-
});
20+
// Without queryText a CREATE INDEX summary carries the index name, so the cf_ table in the ON
21+
// clause is invisible and the query is instrumented — the caller must pass queryText to filter it.
22+
it('cannot resolve a CREATE INDEX target from the summary alone', () => {
23+
expect(targetsCloudflareInternalTable('CREATE INDEX idx_agents_state_id')).toBe(false);
14424
});
14525
});

0 commit comments

Comments
 (0)