From a504d0d612cbb23574eb081e5353b779118b8b13 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 17 Jul 2026 22:29:22 +0200 Subject: [PATCH 1/2] fix(cloudflare): Skip spans for Cloudflare-internal Durable Object SQL queries --- packages/cloudflare/src/client.ts | 13 ++++ .../instrumentations/instrumentSqlStorage.ts | 10 +++ .../cloudflare/src/utils/internalSqlQuery.ts | 24 ++++++ .../test/instrumentSqlStorage.test.ts | 44 +++++++++++ .../test/utils/internalSqlQuery.test.ts | 75 +++++++++++++++++++ 5 files changed, 166 insertions(+) create mode 100644 packages/cloudflare/src/utils/internalSqlQuery.ts create mode 100644 packages/cloudflare/test/utils/internalSqlQuery.test.ts diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index b9a2d2614ebf..493b052e47db 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -216,6 +216,19 @@ interface BaseCloudflareOptions { */ enableRpcTracePropagation?: boolean; + /** + * By default, the SDK does not create `db.query` spans for Cloudflare-internal Durable Object SQL + * queries. Cloudflare frameworks built on Durable Objects (`agents`, `partyserver`, …) manage their + * own SQLite tables, all namespaced with a `cf_` prefix (state, schedules, fibers, workflows, MCP + * servers, chat-stream persistence, …). These queries are framework implementation details that + * would otherwise flood traces with dozens of zero-signal spans per request. + * + * Set this to `true` to include these internal spans as well (e.g. for debugging). + * + * @default false + */ + includeCloudflareInternalSpans?: boolean; + /** * @deprecated Use `enableRpcTracePropagation` instead. This option will be removed in a future major version. * diff --git a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts index 96950dbb4f9e..c27637a6d3dd 100644 --- a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts +++ b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts @@ -2,9 +2,12 @@ import type { SqlStorage } from '@cloudflare/workers-types'; import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery, + getClient, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan, } from '@sentry/core'; +import type { CloudflareClientOptions } from '../client'; +import { targetsCloudflareInternalTable } from '../utils/internalSqlQuery'; /** * Instruments the Durable Object SqlStorage `exec` method with Sentry spans. @@ -23,9 +26,16 @@ 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); + const includeInternalSpans = (getClient()?.getOptions() as CloudflareClientOptions | undefined) + ?.includeCloudflareInternalSpans; + if (!includeInternalSpans && 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..7be7b3e81768 --- /dev/null +++ b/packages/cloudflare/src/utils/internalSqlQuery.ts @@ -0,0 +1,24 @@ +/** + * 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 by default is safe. Users can opt back + * in via `includeCloudflareInternalSpans`. + * + * 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..8aff050c6cfa 100644 --- a/packages/cloudflare/test/instrumentSqlStorage.test.ts +++ b/packages/cloudflare/test/instrumentSqlStorage.test.ts @@ -144,8 +144,52 @@ 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 by default', () => { + mockClientOptions({}); + 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 when the internal skip is active', () => { + mockClientOptions({}); + 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); + }); + + it('creates a span for internal queries when includeCloudflareInternalSpans is true', () => { + mockClientOptions({ includeCloudflareInternalSpans: true }); + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + instrumented.exec('SELECT * FROM cf_agents_state'); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + }); + }); }); +function mockClientOptions(options: Record): void { + vi.spyOn(sentryCore, 'getClient').mockReturnValue({ + getOptions: () => options, + } as any); +} + function createMockCursor() { return { next: vi.fn(), 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); + }); + }); +}); From 689e90cde44437891188f2e5c4016c286a829c29 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 17 Jul 2026 22:37:12 +0200 Subject: [PATCH 2/2] ref: Remove the option to enable them again --- packages/cloudflare/src/client.ts | 13 ----------- .../instrumentations/instrumentSqlStorage.ts | 6 +---- .../cloudflare/src/utils/internalSqlQuery.ts | 3 +-- .../test/instrumentSqlStorage.test.ts | 23 ++----------------- 4 files changed, 4 insertions(+), 41 deletions(-) diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 493b052e47db..b9a2d2614ebf 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -216,19 +216,6 @@ interface BaseCloudflareOptions { */ enableRpcTracePropagation?: boolean; - /** - * By default, the SDK does not create `db.query` spans for Cloudflare-internal Durable Object SQL - * queries. Cloudflare frameworks built on Durable Objects (`agents`, `partyserver`, …) manage their - * own SQLite tables, all namespaced with a `cf_` prefix (state, schedules, fibers, workflows, MCP - * servers, chat-stream persistence, …). These queries are framework implementation details that - * would otherwise flood traces with dozens of zero-signal spans per request. - * - * Set this to `true` to include these internal spans as well (e.g. for debugging). - * - * @default false - */ - includeCloudflareInternalSpans?: boolean; - /** * @deprecated Use `enableRpcTracePropagation` instead. This option will be removed in a future major version. * diff --git a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts index c27637a6d3dd..adcf7c689bc1 100644 --- a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts +++ b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts @@ -2,11 +2,9 @@ import type { SqlStorage } from '@cloudflare/workers-types'; import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery, - getClient, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan, } from '@sentry/core'; -import type { CloudflareClientOptions } from '../client'; import { targetsCloudflareInternalTable } from '../utils/internalSqlQuery'; /** @@ -30,9 +28,7 @@ export function instrumentSqlStorage(sql: SqlStorage): SqlStorage { const sanitizedQuery = _INTERNAL_sanitizeSqlQuery(query); const querySummary = _INTERNAL_getSqlQuerySummary(sanitizedQuery); - const includeInternalSpans = (getClient()?.getOptions() as CloudflareClientOptions | undefined) - ?.includeCloudflareInternalSpans; - if (!includeInternalSpans && targetsCloudflareInternalTable(querySummary)) { + if (targetsCloudflareInternalTable(querySummary)) { return (original as (...a: unknown[]) => ReturnType).apply(target, args); } diff --git a/packages/cloudflare/src/utils/internalSqlQuery.ts b/packages/cloudflare/src/utils/internalSqlQuery.ts index 7be7b3e81768..af3e7b5638f4 100644 --- a/packages/cloudflare/src/utils/internalSqlQuery.ts +++ b/packages/cloudflare/src/utils/internalSqlQuery.ts @@ -6,8 +6,7 @@ * 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 by default is safe. Users can opt back - * in via `includeCloudflareInternalSpans`. + * 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 diff --git a/packages/cloudflare/test/instrumentSqlStorage.test.ts b/packages/cloudflare/test/instrumentSqlStorage.test.ts index 8aff050c6cfa..10b326cde981 100644 --- a/packages/cloudflare/test/instrumentSqlStorage.test.ts +++ b/packages/cloudflare/test/instrumentSqlStorage.test.ts @@ -146,8 +146,7 @@ describe('instrumentSqlStorage', () => { }); describe('internal storage queries', () => { - it('does not create a span for Cloudflare-internal queries by default', () => { - mockClientOptions({}); + it('does not create a span for Cloudflare-internal queries', () => { const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); const mockCursor = createMockCursor(); const mockSql = createMockSqlStorage(mockCursor); @@ -160,8 +159,7 @@ describe('instrumentSqlStorage', () => { expect(result).toBe(mockCursor); }); - it('still creates a span for user queries when the internal skip is active', () => { - mockClientOptions({}); + it('still creates a span for user queries', () => { const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); const mockSql = createMockSqlStorage(); const instrumented = instrumentSqlStorage(mockSql); @@ -170,26 +168,9 @@ describe('instrumentSqlStorage', () => { expect(startSpanSpy).toHaveBeenCalledTimes(1); }); - - it('creates a span for internal queries when includeCloudflareInternalSpans is true', () => { - mockClientOptions({ includeCloudflareInternalSpans: true }); - const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); - const mockSql = createMockSqlStorage(); - const instrumented = instrumentSqlStorage(mockSql); - - instrumented.exec('SELECT * FROM cf_agents_state'); - - expect(startSpanSpy).toHaveBeenCalledTimes(1); - }); }); }); -function mockClientOptions(options: Record): void { - vi.spyOn(sentryCore, 'getClient').mockReturnValue({ - getOptions: () => options, - } as any); -} - function createMockCursor() { return { next: vi.fn(),