diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/index.ts new file mode 100644 index 000000000000..4c8706dfa68d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/index.ts @@ -0,0 +1,57 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + TEST_DURABLE_OBJECT: DurableObjectNamespace; +} + +class SqlDurableObjectBase extends DurableObject { + public constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + } + + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === '/exec') { + this.ctx.storage.sql.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)'); + this.ctx.storage.sql.exec('INSERT INTO users (name) VALUES (?)', 'Alice'); + const cursor = this.ctx.storage.sql.exec('SELECT * FROM users'); + const rows = cursor.toArray(); + + return Response.json({ rows }); + } + + return new Response('OK'); + } +} + +export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + SqlDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/flush-marker') { + Sentry.captureMessage('flush-marker'); + return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } }); + } + + const id = env.TEST_DURABLE_OBJECT.idFromName('test'); + const stub = env.TEST_DURABLE_OBJECT.get(id); + return stub.fetch(request); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts new file mode 100644 index 000000000000..62ba56f01aeb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts @@ -0,0 +1,79 @@ +import type { Envelope } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +const flushMarkerMatcher = (envelope: Envelope): void => { + const [, items] = envelope; + const [itemHeader, itemBody] = items[0] as [{ type: string }, Record]; + + expect(itemHeader.type).toBe('event'); + expect(itemBody.message).toBe('flush-marker'); +}; + +it('instruments SQL exec operations on Durable Object storage', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + const spans = transactionEvent?.spans ?? []; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'GET /exec', + }), + ); + + const sqlSpans = (spans as Array>).filter( + s => s.origin === 'auto.db.cloudflare.durable_object.sql', + ); + + expect(sqlSpans).toHaveLength(3); + expect(sqlSpans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', + op: 'db.query', + origin: 'auto.db.cloudflare.durable_object.sql', + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'exec', + 'db.query.text': 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', + 'cloudflare.durable_object.query.bindings': 0, + }), + }), + expect.objectContaining({ + description: 'INSERT INTO users (name) VALUES (?)', + op: 'db.query', + origin: 'auto.db.cloudflare.durable_object.sql', + data: expect.objectContaining({ + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'exec', + 'db.query.text': 'INSERT INTO users (name) VALUES (?)', + 'cloudflare.durable_object.query.bindings': 1, + }), + }), + expect.objectContaining({ + description: 'SELECT * FROM users', + op: 'db.query', + origin: 'auto.db.cloudflare.durable_object.sql', + data: expect.objectContaining({ + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'exec', + 'db.query.text': 'SELECT * FROM users', + 'cloudflare.durable_object.query.bindings': 0, + }), + }), + ]), + ); + }) + .expect(flushMarkerMatcher) + .unordered() + .start(signal); + + await runner.makeRequest('get', '/exec'); + await runner.makeRequest('get', '/flush-marker'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/wrangler.jsonc new file mode 100644 index 000000000000..8a544e1bdf6b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "worker-name", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "migrations": [ + { + "new_sqlite_classes": ["TestDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "TestDurableObject", + "name": "TEST_DURABLE_OBJECT", + }, + ], + }, + "compatibility_flags": ["nodejs_als"], +} diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts index d109ffcb479c..9413b313b4b0 100644 --- a/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts @@ -1,7 +1,8 @@ -import type { DurableObjectStorage, SyncKvStorage } from '@cloudflare/workers-types'; +import type { DurableObjectStorage, SyncKvStorage, SqlStorage } from '@cloudflare/workers-types'; import { isThenable, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; import { storeSpanContext } from '../utils/traceLinks'; import { instrumentDurableObjectSyncKvStorage } from './instrumentDurableObjectSyncKvStorage'; +import { instrumentSqlStorage } from './instrumentSqlStorage'; const STORAGE_METHODS_TO_INSTRUMENT = ['get', 'put', 'delete', 'list', 'setAlarm', 'getAlarm', 'deleteAlarm'] as const; @@ -40,6 +41,10 @@ export function instrumentDurableObjectStorage( return instrumentDurableObjectSyncKvStorage(original as SyncKvStorage); } + if (prop === 'sql' && original != null && 'databaseSize' in original && 'exec' in original) { + return instrumentSqlStorage(original as SqlStorage); + } + if (typeof original !== 'function') { return original; } diff --git a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts new file mode 100644 index 000000000000..e08ec1759080 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts @@ -0,0 +1,51 @@ +import type { SqlStorage, SqlStorageCursor, SqlStorageValue } from '@cloudflare/workers-types'; +import { _INTERNAL_sanitizeSqlQuery, addBreadcrumb, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; + +/** + * Instruments the Durable Object SqlStorage `exec` method with Sentry spans. + * + * @param sql - The SqlStorage instance to instrument + * @returns An instrumented SqlStorage instance + */ +export function instrumentSqlStorage(sql: SqlStorage): SqlStorage { + return new Proxy(sql, { + get(target, prop, _receiver) { + const original = Reflect.get(target, prop, target); + + if (prop !== 'exec' || typeof original !== 'function') { + return original; + } + + return function (this: unknown, ...args: unknown[]) { + const [query, ...bindings] = args as [string, ...unknown[]]; + const sanitizedQuery = _INTERNAL_sanitizeSqlQuery(query); + + return startSpan( + { + op: 'db.query', + name: sanitizedQuery, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'exec', + 'db.query.text': sanitizedQuery, + 'cloudflare.durable_object.query.bindings': bindings.length, + }, + }, + () => { + const cursor: SqlStorageCursor> = ( + original as (...a: unknown[]) => SqlStorageCursor> + ).apply(target, args); + + addBreadcrumb({ + category: 'query', + message: sanitizedQuery, + }); + + return cursor; + }, + ); + }; + }, + }); +} diff --git a/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts b/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts index 6fabdf613f4b..16d7fd5adb4d 100644 --- a/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts +++ b/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts @@ -300,6 +300,29 @@ describe('instrumentDurableObjectStorage', () => { }); }); + it('instruments sql exec', () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const mockStorage = createMockStorage(); + const instrumented = instrumentDurableObjectStorage(mockStorage); + + instrumented.sql.exec('SELECT 1'); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: 'SELECT ?', + op: 'db.query', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'exec', + 'db.query.text': 'SELECT ?', + 'cloudflare.durable_object.query.bindings': 0, + }, + }, + expect.any(Function), + ); + }); + describe('non-instrumented methods', () => { it('does not instrument deleteAll, sync, transaction', async () => { const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); @@ -312,14 +335,6 @@ describe('instrumentDurableObjectStorage', () => { expect(startSpanSpy).not.toHaveBeenCalled(); }); - - it('does not instrument sql property', () => { - const mockStorage = createMockStorage(); - const instrumented = instrumentDurableObjectStorage(mockStorage); - - // sql is a property, not a method we instrument - expect(instrumented.sql).toBe(mockStorage.sql); - }); }); describe('sync KV instrumentation', () => { diff --git a/packages/cloudflare/test/instrumentSqlStorage.test.ts b/packages/cloudflare/test/instrumentSqlStorage.test.ts new file mode 100644 index 000000000000..1d62f0dc0f49 --- /dev/null +++ b/packages/cloudflare/test/instrumentSqlStorage.test.ts @@ -0,0 +1,178 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import * as sentryCore from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { instrumentSqlStorage } from '../src/instrumentations/instrumentSqlStorage'; + +describe('instrumentSqlStorage', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('instruments exec with summary as span name and sanitized query as db.query.text', () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + instrumented.exec('SELECT * FROM users WHERE id = ?', 42); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + op: 'db.query', + name: 'SELECT * FROM users WHERE id = ?', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'exec', + 'db.query.text': 'SELECT * FROM users WHERE id = ?', + 'cloudflare.durable_object.query.bindings': 1, + }, + }, + expect.any(Function), + ); + }); + + it('sanitizes embedded literals in db.query.text', () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + instrumented.exec("SELECT * FROM users WHERE name = 'Alice' AND age > 30"); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + op: 'db.query', + name: 'SELECT * FROM users WHERE name = ? AND age > ?', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'exec', + 'db.query.text': 'SELECT * FROM users WHERE name = ? AND age > ?', + 'cloudflare.durable_object.query.bindings': 0, + }, + }, + expect.any(Function), + ); + }); + + it('passes bindings through to the original exec', () => { + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + instrumented.exec('SELECT * FROM users WHERE id = ?', 42); + + expect(mockSql.exec).toHaveBeenCalledWith('SELECT * FROM users WHERE id = ?', 42); + }); + + it('tracks binding count in span attributes', () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + instrumented.exec('INSERT INTO users (name, email) VALUES (?, ?)', 'Alice', 'alice@example.com'); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + op: 'db.query', + name: 'INSERT INTO users (name, email) VALUES (?, ?)', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'exec', + 'db.query.text': 'INSERT INTO users (name, email) VALUES (?, ?)', + 'cloudflare.durable_object.query.bindings': 2, + }, + }, + expect.any(Function), + ); + }); + + it('returns the cursor from exec', () => { + const mockCursor = createMockCursor(); + const mockSql = createMockSqlStorage(mockCursor); + const instrumented = instrumentSqlStorage(mockSql); + + const result = instrumented.exec('SELECT 1'); + + expect(result).toBe(mockCursor); + }); + + it('adds a breadcrumb with the sanitized query', () => { + const addBreadcrumbSpy = vi.spyOn(sentryCore, 'addBreadcrumb'); + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + instrumented.exec('SELECT * FROM users'); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith({ + category: 'query', + message: 'SELECT * FROM users', + }); + }); + + it('does not instrument non-exec properties', () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + expect(instrumented.databaseSize).toBe(1024); + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + + it('preserves native getter this binding through the proxy', () => { + class BrandCheckedSql { + #internal = 1024; + get databaseSize() { + return this.#internal; + } + exec = vi.fn().mockReturnValue(createMockCursor()); + } + + const sql = new BrandCheckedSql(); + const instrumented = instrumentSqlStorage(sql as any); + + expect(instrumented.databaseSize).toBe(1024); + }); + + it('propagates errors from exec', () => { + const mockSql = createMockSqlStorage(); + mockSql.exec = vi.fn().mockImplementation(() => { + throw new Error('SQL error'); + }); + const instrumented = instrumentSqlStorage(mockSql); + + expect(() => instrumented.exec('INVALID SQL')).toThrow('SQL error'); + }); + + it('creates a span for each exec call', () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + instrumented.exec('SELECT 1'); + instrumented.exec('SELECT 2'); + + expect(startSpanSpy).toHaveBeenCalledTimes(2); + expect(mockSql.exec).toHaveBeenCalledTimes(2); + }); +}); + +function createMockCursor() { + return { + next: vi.fn(), + toArray: vi.fn().mockReturnValue([]), + one: vi.fn(), + raw: vi.fn(), + columnNames: [], + rowsRead: 0, + rowsWritten: 0, + }; +} + +function createMockSqlStorage(cursor?: ReturnType): any { + return { + exec: vi.fn().mockReturnValue(cursor ?? createMockCursor()), + databaseSize: 1024, + Cursor: class {}, + Statement: class {}, + }; +} diff --git a/packages/core/src/server-exports.ts b/packages/core/src/server-exports.ts index 2ee34bbbdeff..0733e41872db 100644 --- a/packages/core/src/server-exports.ts +++ b/packages/core/src/server-exports.ts @@ -23,7 +23,7 @@ export type { ExpressMiddleware, ExpressErrorMiddleware, } from './integrations/express/types'; -export { instrumentPostgresJsSql } from './integrations/postgresjs'; +export { instrumentPostgresJsSql, _sanitizeSqlQuery as _INTERNAL_sanitizeSqlQuery } from './integrations/postgresjs'; export { patchHttpModuleClient } from './integrations/http/client-patch'; export { getHttpClientSubscriptions } from './integrations/http/client-subscriptions';