Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
24 changes: 24 additions & 0 deletions packages/cloudflare/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | RegExp>;

/**
* @deprecated Use `enableRpcTracePropagation` instead. This option will be removed in a future major version.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -23,9 +26,17 @@ 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 allowlist = (getClient()?.getOptions() as CloudflareClientOptions | undefined)
?.durableObjectSqlSpanAllowlist;

if (targetsCloudflareInternalTable(querySummary, allowlist)) {
return (original as (...a: unknown[]) => ReturnType<SqlStorage['exec']>).apply(target, args);
}

return startSpan(
{
op: 'db.query',
Expand Down
38 changes: 38 additions & 0 deletions packages/cloudflare/src/utils/internalSqlQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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`,
* `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.
*
* 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,
allowlist?: Array<string | RegExp>,
): boolean {
if (!querySummary) {
return false;
}

const [, ...tables] = querySummary.split(' ');

Comment thread
JPeer264 marked this conversation as resolved.
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);
});
}
39 changes: 39 additions & 0 deletions packages/cloudflare/test/instrumentSqlStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,45 @@ 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);
});

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<typeof sentryCore.getClient>);

const mockSql = createMockSqlStorage();
const instrumented = instrumentSqlStorage(mockSql);

instrumented.exec('SELECT * FROM cf_my_table WHERE id = ?', 1);

expect(startSpanSpy).toHaveBeenCalledTimes(1);
});
});
});

function createMockCursor() {
Expand Down
106 changes: 106 additions & 0 deletions packages/cloudflare/test/utils/internalSqlQuery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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('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],
['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