diff --git a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts index 96950dbb4f9e..adcf7c689bc1 100644 --- a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts +++ b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts @@ -5,6 +5,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan, } from '@sentry/core'; +import { targetsCloudflareInternalTable } from '../utils/internalSqlQuery'; /** * Instruments the Durable Object SqlStorage `exec` method with Sentry spans. @@ -23,9 +24,14 @@ export function instrumentSqlStorage(sql: SqlStorage): SqlStorage { return function (this: unknown, ...args: unknown[]) { const [query, ...bindings] = args as [string, ...unknown[]]; + const sanitizedQuery = _INTERNAL_sanitizeSqlQuery(query); const querySummary = _INTERNAL_getSqlQuerySummary(sanitizedQuery); + if (targetsCloudflareInternalTable(querySummary)) { + return (original as (...a: unknown[]) => ReturnType).apply(target, args); + } + return startSpan( { op: 'db.query', diff --git a/packages/cloudflare/src/utils/internalSqlQuery.ts b/packages/cloudflare/src/utils/internalSqlQuery.ts new file mode 100644 index 000000000000..af3e7b5638f4 --- /dev/null +++ b/packages/cloudflare/src/utils/internalSqlQuery.ts @@ -0,0 +1,23 @@ +/** + * Cloudflare frameworks that build on Durable Objects (`agents`, `partyserver`, ...) manage their + * own internal SQLite tables, all namespaced with a `cf_` prefix — e.g. `cf_agents_schedules`, + * `cf_agent_state`, `cf_ai_chat_stream_chunks`. Queries against them (schedule polling, chat-stream + * persistence, state bookkeeping) are framework implementation details that otherwise flood traces + * with dozens of zero-signal `db.query` spans per request. The exact set of tables even varies + * between framework versions, so we match the reserved prefix rather than an enumerated list. + * + * User tables never use this prefix, so skipping their spans is safe. + * + * The check operates on the query summary produced by `getSqlQuerySummary` (`{operation} {table} ...`, + * the same value used as the span name), so table targets are already isolated from the rest of the + * query. + */ +export function targetsCloudflareInternalTable(querySummary: string | undefined): boolean { + if (!querySummary) { + return false; + } + + const [, ...tables] = querySummary.split(' '); + + return tables.some(table => table.toLowerCase().startsWith('cf_')); +} diff --git a/packages/cloudflare/test/instrumentSqlStorage.test.ts b/packages/cloudflare/test/instrumentSqlStorage.test.ts index de5ba69f79b0..10b326cde981 100644 --- a/packages/cloudflare/test/instrumentSqlStorage.test.ts +++ b/packages/cloudflare/test/instrumentSqlStorage.test.ts @@ -144,6 +144,31 @@ describe('instrumentSqlStorage', () => { expect(startSpanSpy).toHaveBeenCalledTimes(2); expect(mockSql.exec).toHaveBeenCalledTimes(2); }); + + describe('internal storage queries', () => { + it('does not create a span for Cloudflare-internal queries', () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const mockCursor = createMockCursor(); + const mockSql = createMockSqlStorage(mockCursor); + const instrumented = instrumentSqlStorage(mockSql); + + const result = instrumented.exec('SELECT * FROM cf_agents_state WHERE id = ?', 'foo'); + + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(mockSql.exec).toHaveBeenCalledWith('SELECT * FROM cf_agents_state WHERE id = ?', 'foo'); + expect(result).toBe(mockCursor); + }); + + it('still creates a span for user queries', () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + instrumented.exec('SELECT * FROM users WHERE id = ?', 1); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + }); + }); }); function createMockCursor() { diff --git a/packages/cloudflare/test/utils/internalSqlQuery.test.ts b/packages/cloudflare/test/utils/internalSqlQuery.test.ts new file mode 100644 index 000000000000..4a57774a7824 --- /dev/null +++ b/packages/cloudflare/test/utils/internalSqlQuery.test.ts @@ -0,0 +1,75 @@ +import { _INTERNAL_getSqlQuerySummary } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { targetsCloudflareInternalTable } from '../../src/utils/internalSqlQuery'; + +// Builds the summary the same way `instrumentSqlStorage` does, so the test exercises the real +// operation -> summary -> detection path rather than hand-written summaries. +const summarize = (query: string): string | undefined => _INTERNAL_getSqlQuerySummary(query); + +describe('targetsCloudflareInternalTable', () => { + describe('internal queries (cf_ tables)', () => { + it.each([ + ['SELECT', 'SELECT * FROM cf_agents_state WHERE id = ?'], + ['INSERT', 'INSERT INTO cf_agents_fibers (id, callback) VALUES (?, ?)'], + ['DELETE', 'DELETE FROM cf_agents_schedules WHERE id = ?'], + ['UPDATE', 'UPDATE cf_agent_tool_runs SET output_json = ? WHERE id = ?'], + ['CREATE TABLE', 'CREATE TABLE IF NOT EXISTS cf_agents_workflows (id TEXT PRIMARY KEY NOT NULL)'], + ['ALTER TABLE', 'ALTER TABLE cf_agents_queues ADD COLUMN retry_options TEXT'], + ['DROP TABLE', 'DROP TABLE cf_agents_state'], + ['cf_agent_ prefix', 'SELECT * FROM cf_agent_identity'], + ['cf_ai_ prefix', 'INSERT INTO cf_ai_chat_stream_chunks (id) VALUES (?)'], + ['cf_mcp_ prefix', 'SELECT * FROM cf_mcp_agent_event'], + ['schema version', 'SELECT version FROM cf_schema_version'], + ])('returns true for %s on internal tables', (_label, query) => { + expect(targetsCloudflareInternalTable(summarize(query))).toBe(true); + }); + + it('returns true for an internal JOIN', () => { + const query = ` + SELECT f.fiber_id, f.status + FROM cf_agents_fibers f + LEFT JOIN cf_agents_runs r ON r.id = f.fiber_id + WHERE f.status IN ('pending', 'running') + `; + expect(targetsCloudflareInternalTable(summarize(query))).toBe(true); + }); + + it('returns true when an internal table is joined with a user table', () => { + // `.some()` — any internal table present means the query is framework-driven noise. + expect( + targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state s JOIN users u ON u.id = s.id')), + ).toBe(true); + }); + + it('handles case-insensitive keywords and prefixes', () => { + expect(targetsCloudflareInternalTable(summarize('select * from CF_AGENTS_STATE'))).toBe(true); + }); + }); + + describe('user queries (must be instrumented)', () => { + it.each([ + ['SELECT', 'SELECT * FROM users WHERE id = ?'], + ['INSERT', 'INSERT INTO orders (id, total) VALUES (?, ?)'], + ['UPDATE', 'UPDATE products SET price = ? WHERE id = ?'], + ['DELETE', 'DELETE FROM sessions WHERE expired = 1'], + ['CREATE TABLE', 'CREATE TABLE users (id TEXT PRIMARY KEY)'], + ['table with cf in the middle', 'SELECT * FROM my_cf_table'], + ['table starting with cfg', 'SELECT * FROM cfg_settings'], + ])('returns false for %s on user tables', (_label, query) => { + expect(targetsCloudflareInternalTable(summarize(query))).toBe(false); + }); + }); + + describe('summaries without a resolvable table target (safe default: instrument)', () => { + it.each([ + ['undefined', undefined], + ['empty', ''], + ['no-table SELECT', 'SELECT 1'], + ['PRAGMA', 'PRAGMA foreign_keys = ON'], + ['bare operation', 'BEGIN'], + ])('returns false for %s', (_label, value) => { + const summary = typeof value === 'string' ? summarize(value) : value; + expect(targetsCloudflareInternalTable(summary)).toBe(false); + }); + }); +});