From a504d0d612cbb23574eb081e5353b779118b8b13 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 17 Jul 2026 22:29:22 +0200 Subject: [PATCH 1/3] 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/3] 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(), From c0aa40eba456553106775baba75a531b66bbb235 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 20 Jul 2026 10:01:27 +0200 Subject: [PATCH 3/3] fixup! ref: Remove the option to enable them again --- .size-limit.js | 2 +- .../cloudflare-agent/tests/callable.test.ts | 25 ++++++++------- packages/cloudflare/src/client.ts | 24 ++++++++++++++ .../instrumentations/instrumentSqlStorage.ts | 7 ++++- .../cloudflare/src/utils/internalSqlQuery.ts | 21 +++++++++++-- .../test/instrumentSqlStorage.test.ts | 14 +++++++++ .../test/utils/internalSqlQuery.test.ts | 31 +++++++++++++++++++ 7 files changed, 108 insertions(+), 16 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index a2b9a8649be9..2dac16a7922c 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -480,7 +480,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '445 KiB', + limit: '448 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts index 45ea0ec5ac96..bdd5bd22b8c0 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts @@ -34,17 +34,20 @@ test('@callable() methods work correctly with Sentry instrumentDurableObjectWith }, spans: expect.arrayContaining([ expect.objectContaining({ - op: 'db.query', - origin: 'auto.db.cloudflare.durable_object.sql', - description: expect.stringMatching(/^SELECT /), - data: expect.objectContaining({ - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'exec', - 'db.query.summary': expect.any(String), - 'db.query.text': expect.any(String), - 'sentry.op': 'db.query', - 'sentry.origin': 'auto.db.cloudflare.durable_object.sql', - }), + data: { + 'db.operation.name': 'get', + 'db.system.name': 'cloudflare.durable_object.storage', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.cloudflare.durable_object', + }, + description: 'durable_object_storage_get', + op: 'db', + origin: 'auto.db.cloudflare.durable_object', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), }), ]), start_timestamp: expect.any(Number), diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index b9a2d2614ebf..087c1ad720d9 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -216,6 +216,30 @@ interface BaseCloudflareOptions { */ enableRpcTracePropagation?: boolean; + /** + * Table names that should stay instrumented even though they match the reserved `cf_` prefix used + * by Durable Object frameworks (`agents`, `partyserver`, ...) for their internal SQLite tables. + * + * By default, `exec` queries against `cf_`-prefixed tables are treated as framework noise and no + * `db.query` span is created for them. If one of your own tables happens to use this prefix, add it + * here to opt it back into instrumentation. Entries are matched against each table name in the + * query summary — strings must match exactly, while regular expressions give you prefix/pattern + * matching. + * + * @default [] + * @example + * ```ts + * export default Sentry.withSentry( + * (env) => ({ + * dsn: env.SENTRY_DSN, + * durableObjectSqlSpanAllowlist: ['cf_my_table', /^cf_reports_/], + * }), + * handler, + * ); + * ``` + */ + durableObjectSqlSpanAllowlist?: Array; + /** * @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 adcf7c689bc1..739a0a7ef14a 100644 --- a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts +++ b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts @@ -2,9 +2,11 @@ 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'; /** @@ -28,7 +30,10 @@ export function instrumentSqlStorage(sql: SqlStorage): SqlStorage { const sanitizedQuery = _INTERNAL_sanitizeSqlQuery(query); const querySummary = _INTERNAL_getSqlQuerySummary(sanitizedQuery); - if (targetsCloudflareInternalTable(querySummary)) { + const allowlist = (getClient()?.getOptions() as CloudflareClientOptions | undefined) + ?.durableObjectSqlSpanAllowlist; + + if (targetsCloudflareInternalTable(querySummary, allowlist)) { 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 af3e7b5638f4..977a5a9c05a1 100644 --- a/packages/cloudflare/src/utils/internalSqlQuery.ts +++ b/packages/cloudflare/src/utils/internalSqlQuery.ts @@ -1,3 +1,5 @@ +import { stringMatchesSomePattern } from '@sentry/core'; + /** * 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`, @@ -6,18 +8,31 @@ * 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 `cf_` prefix is a reserved convention for framework-managed tables, so user tables should not + * use it. In case a user table does collide with the prefix, the `durableObjectSqlSpanAllowlist` + * option lets them opt those tables back into instrumentation. * * 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 { +export function targetsCloudflareInternalTable( + querySummary: string | undefined, + allowlist?: Array, +): boolean { if (!querySummary) { return false; } const [, ...tables] = querySummary.split(' '); - return tables.some(table => table.toLowerCase().startsWith('cf_')); + return tables.some(table => { + if (!table.toLowerCase().startsWith('cf_')) { + return false; + } + + // A table on the allowlist is treated as a user table and stays instrumented, even though it + // matches the reserved prefix. + return !allowlist?.length || !stringMatchesSomePattern(table, allowlist, true); + }); } diff --git a/packages/cloudflare/test/instrumentSqlStorage.test.ts b/packages/cloudflare/test/instrumentSqlStorage.test.ts index 10b326cde981..e9fdb9f5d2ff 100644 --- a/packages/cloudflare/test/instrumentSqlStorage.test.ts +++ b/packages/cloudflare/test/instrumentSqlStorage.test.ts @@ -168,6 +168,20 @@ describe('instrumentSqlStorage', () => { expect(startSpanSpy).toHaveBeenCalledTimes(1); }); + + it('creates a span for a cf_ table on the durableObjectSqlSpanAllowlist', () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + vi.spyOn(sentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ durableObjectSqlSpanAllowlist: ['cf_my_table'] }), + } as unknown as ReturnType); + + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + instrumented.exec('SELECT * FROM cf_my_table WHERE id = ?', 1); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/packages/cloudflare/test/utils/internalSqlQuery.test.ts b/packages/cloudflare/test/utils/internalSqlQuery.test.ts index 4a57774a7824..7d119eb8a98f 100644 --- a/packages/cloudflare/test/utils/internalSqlQuery.test.ts +++ b/packages/cloudflare/test/utils/internalSqlQuery.test.ts @@ -60,6 +60,37 @@ describe('targetsCloudflareInternalTable', () => { }); }); + describe('allowlist (opt a cf_ table back into instrumentation)', () => { + it('returns false for an allowlisted table matched by exact string', () => { + expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_my_table'), ['cf_my_table'])).toBe(false); + }); + + it('returns false for an allowlisted table matched by regex', () => { + expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_reports_daily'), [/^cf_reports_/])).toBe(false); + }); + + it('requires an exact match for string entries', () => { + // Substring matches must not opt a table back in, otherwise `cf_` would allowlist everything. + expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), ['cf_agents'])).toBe(true); + }); + + it('still skips genuine internal tables that are not allowlisted', () => { + expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), ['cf_my_table'])).toBe(true); + }); + + it('still skips when an internal table is joined with an allowlisted table', () => { + expect( + targetsCloudflareInternalTable(summarize('SELECT * FROM cf_my_table t JOIN cf_agents_state s ON s.id = t.id'), [ + 'cf_my_table', + ]), + ).toBe(true); + }); + + it('ignores an empty allowlist', () => { + expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), [])).toBe(true); + }); + }); + describe('summaries without a resolvable table target (safe default: instrument)', () => { it.each([ ['undefined', undefined],