Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<SqlStorage['exec']>).apply(target, args);
}

return startSpan(
{
op: 'db.query',
Expand Down
23 changes: 23 additions & 0 deletions packages/cloudflare/src/utils/internalSqlQuery.ts
Original file line number Diff line number Diff line change
@@ -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_'));
}
25 changes: 25 additions & 0 deletions packages/cloudflare/test/instrumentSqlStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
75 changes: 75 additions & 0 deletions packages/cloudflare/test/utils/internalSqlQuery.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading