From 73a0b57a5380f190d83e15ed4791789d3018e1cf Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 1 Jun 2026 15:31:08 +0200 Subject: [PATCH 1/3] feat(cloudflare): Auto instrument D1 based on env Deprecate `instrumentD1WithSentry` in favor of automatic D1 instrumentation via `instrumentEnv`. Add `isD1Database` duck-type check and `instrumentD1` with `ensureInstrumented` to prevent double instrumentation. Co-Authored-By: Claude Opus 4.6 --- .../suites/d1/index.ts | 37 ++++++ .../suites/d1/test.ts | 72 +++++++++++ .../suites/d1/wrangler.jsonc | 13 ++ .../instrumentations/worker/instrumentD1.ts | 29 +++-- .../instrumentations/worker/instrumentEnv.ts | 9 +- packages/cloudflare/src/utils/isBinding.ts | 16 ++- .../instrumentations/instrumentEnv.test.ts | 40 ++++++ .../worker/instrumentD1.test.ts | 116 ++++++++++++------ .../cloudflare/test/utils/isBinding.test.ts | 43 ++++++- 9 files changed, 324 insertions(+), 51 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/d1/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/d1/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/d1/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/d1/index.ts b/dev-packages/cloudflare-integration-tests/suites/d1/index.ts new file mode 100644 index 000000000000..5015da7c5f0f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/d1/index.ts @@ -0,0 +1,37 @@ +import type { D1Database } from '@cloudflare/workers-types'; +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + DB: D1Database; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + }), + { + async fetch(request, env, _ctx) { + const url = new URL(request.url); + + if (url.pathname === '/prepare') { + await env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(1).all(); + return new Response('ok'); + } + + if (url.pathname === '/double-instrument') { + const prepareBeforeManual = env.DB.prepare; + const db = Sentry.instrumentD1WithSentry(env.DB); + const prepareAfterManual = db.prepare; + + await db.prepare('SELECT * FROM users WHERE id = ?').bind(1).all(); + + const isSameRef = prepareBeforeManual === prepareAfterManual ? 'true' : 'false'; + return new Response(isSameRef); + } + + return new Response('not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/d1/test.ts new file mode 100644 index 000000000000..ea62b14e0654 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/d1/test.ts @@ -0,0 +1,72 @@ +import type { Envelope } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../runner'; + +function envelopeItemType(envelope: Envelope): string | undefined { + return envelope[1][0]?.[0]?.type as string | undefined; +} + +function envelopeItem(envelope: Envelope): Record { + return envelope[1][0]![1] as Record; +} + +function findD1Spans(envelope: Envelope): Array> { + if (envelopeItemType(envelope) !== 'transaction') return []; + const tx = envelopeItem(envelope); + const spans = (tx.spans as Array>) || []; + return spans.filter(s => (s.op as string) === 'db.query'); +} + +it('instruments D1 prepare().all() automatically via env', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect((envelope: Envelope) => { + if (envelopeItemType(envelope) !== 'transaction') return; + const d1Spans = findD1Spans(envelope); + expect(d1Spans.length).toBeGreaterThanOrEqual(1); + + const querySpan = d1Spans.find(s => s.description === 'SELECT * FROM users WHERE id = ?'); + expect(querySpan).toBeDefined(); + expect(querySpan).toEqual({ + data: { + 'cloudflare.d1.duration': expect.any(Number), + 'cloudflare.d1.query_type': 'all', + 'cloudflare.d1.rows_read': expect.any(Number), + 'cloudflare.d1.rows_written': expect.any(Number), + 'sentry.op': 'db.query', + 'sentry.origin': 'auto.db.cloudflare.d1', + }, + description: 'SELECT * FROM users WHERE id = ?', + op: 'db.query', + origin: 'auto.db.cloudflare.d1', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); + }) + .start(signal); + + await runner.makeRequest('get', '/prepare'); + await runner.completed(); +}); + +it('does not double-instrument when instrumentD1WithSentry is used on top of env instrumentation', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect((envelope: Envelope) => { + if (envelopeItemType(envelope) !== 'transaction') return; + const d1Spans = findD1Spans(envelope); + + const querySpans = d1Spans.filter(s => s.description === 'SELECT * FROM users WHERE id = ?'); + expect(querySpans).toHaveLength(1); + }) + .start(signal); + + const response = await runner.makeRequest('get', '/double-instrument'); + expect(response).toBe('true'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/d1/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/d1/wrangler.jsonc new file mode 100644 index 000000000000..9ebec402e521 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/d1/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "name": "d1-test-worker", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], + "d1_databases": [ + { + "binding": "DB", + "database_name": "test-db", + "database_id": "00000000-0000-0000-0000-000000000000", + }, + ], +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts b/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts index ff81986dc3aa..2571a1e404e2 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts @@ -1,6 +1,8 @@ +/* eslint-disable @typescript-eslint/unbound-method */ import type { D1Database, D1PreparedStatement, D1Response } from '@cloudflare/workers-types'; import type { Span, SpanAttributes, StartSpanOptions } from '@sentry/core'; import { addBreadcrumb, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startSpan } from '@sentry/core'; +import { ensureInstrumented } from '../../instrument'; // Patching is based on internal Cloudflare D1 API // https://github.com/cloudflare/workerd/blob/cd5279e7b305003f1d9c851e73efa9d67e4b68b2/src/cloudflare/internal/d1-api.ts @@ -127,10 +129,25 @@ function createStartSpanOptions(query: string, type: 'first' | 'run' | 'all' | ' }; } +function _instrumentD1(db: D1Database): D1Database { + db.prepare = new Proxy(db.prepare, { + apply(target, thisArg, args: Parameters) { + const [query] = args; + return instrumentD1PreparedStatement(Reflect.apply(target, thisArg, args), query); + }, + }); + + return db; +} + +export function instrumentD1(db: D1Database): D1Database { + return ensureInstrumented(db, _instrumentD1); +} + /** * Instruments Cloudflare D1 bindings with Sentry. * - * Currently, only prepared statements are instrumented. `db.exec` and `db.batch` are not instrumented. + * @deprecated Use `withSentry()` instead, which automatically instruments all D1 bindings via env. * * @example * @@ -142,13 +159,5 @@ function createStartSpanOptions(query: string, type: 'first' | 'run' | 'all' | ' * ``` */ export function instrumentD1WithSentry(db: D1Database): D1Database { - // eslint-disable-next-line @typescript-eslint/unbound-method - db.prepare = new Proxy(db.prepare, { - apply(target, thisArg, args: Parameters) { - const [query] = args; - return instrumentD1PreparedStatement(Reflect.apply(target, thisArg, args), query); - }, - }); - - return db; + return instrumentD1(db); } diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index 3a386f0bb59d..cbf3925a0397 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -1,5 +1,6 @@ import type { CloudflareOptions } from '../../client'; -import { isDurableObjectNamespace, isJSRPC, isQueue } from '../../utils/isBinding'; +import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue } from '../../utils/isBinding'; +import { instrumentD1 } from './instrumentD1'; import { appendRpcMeta } from '../../utils/rpcMeta'; import { getEffectiveRpcPropagation } from '../../utils/rpcOptions'; import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace'; @@ -47,6 +48,12 @@ export function instrumentEnv>(env: Env, opt return cached; } + if (isD1Database(item)) { + const instrumented = instrumentD1(item); + instrumentedBindings.set(item, instrumented); + return instrumented; + } + if (isQueue(item)) { const bindingName = typeof prop === 'string' ? prop : String(prop); const instrumented = instrumentQueueProducer(item, bindingName); diff --git a/packages/cloudflare/src/utils/isBinding.ts b/packages/cloudflare/src/utils/isBinding.ts index 5ced12c78389..9386a58ce88a 100644 --- a/packages/cloudflare/src/utils/isBinding.ts +++ b/packages/cloudflare/src/utils/isBinding.ts @@ -31,7 +31,7 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import type { DurableObjectNamespace, Queue } from '@cloudflare/workers-types'; +import type { D1Database, DurableObjectNamespace, Queue } from '@cloudflare/workers-types'; /** * Checks if a value is a JSRPC proxy (service binding). @@ -67,3 +67,17 @@ export function isDurableObjectNamespace(item: unknown): item is DurableObjectNa export function isQueue(item: unknown): item is Queue { return item != null && isNotJSRPC(item) && typeof item.send === 'function' && typeof item.sendBatch === 'function'; } + +/** + * Duck-type check for D1Database bindings. + * D1Database has `prepare`, `batch`, and `exec` methods. + */ +export function isD1Database(item: unknown): item is D1Database { + return ( + item != null && + isNotJSRPC(item) && + typeof item.prepare === 'function' && + typeof item.batch === 'function' && + typeof item.exec === 'function' + ); +} diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index a324cb1e3678..2c4b38682534 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -17,6 +17,46 @@ describe('instrumentEnv', () => { vi.clearAllMocks(); }); + it('detects and instruments D1Database bindings', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const mockStatement = { + bind: vi.fn(), + first: vi.fn().mockResolvedValue(null), + run: vi.fn().mockResolvedValue({ success: true, meta: { duration: 0, rows_read: 0, rows_written: 0 } }), + all: vi.fn().mockResolvedValue({ success: true, meta: { duration: 0, rows_read: 0, rows_written: 0 } }), + raw: vi.fn().mockResolvedValue([]), + }; + const d1Database = { + prepare: vi.fn().mockReturnValue(mockStatement), + batch: vi.fn().mockResolvedValue([]), + exec: vi.fn().mockResolvedValue({ count: 0, duration: 0 }), + dump: vi.fn(), + }; + const env = { DB: d1Database }; + const instrumented = instrumentEnv(env); + + const db = instrumented.DB as typeof d1Database; + await db.exec('SELECT 1'); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ op: 'db.query', name: 'SELECT 1' }), + expect.any(Function), + ); + }); + + it('caches instrumented D1 bindings across repeated access', () => { + const d1Database = { + prepare: vi.fn(), + batch: vi.fn(), + exec: vi.fn(), + dump: vi.fn(), + }; + const env = { DB: d1Database }; + const instrumented = instrumentEnv(env); + + expect(instrumented.DB).toBe(instrumented.DB); + }); + it('returns primitive values unchanged', () => { const env = { SENTRY_DSN: 'https://key@sentry.io/123', PORT: 8080, DEBUG: true }; const instrumented = instrumentEnv(env); diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts index 376ebef9e322..2b65a0943a07 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts @@ -1,7 +1,7 @@ import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types'; import * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { instrumentD1WithSentry } from '../../../src/instrumentations/worker/instrumentD1'; +import { instrumentD1, instrumentD1WithSentry } from '../../../src/instrumentations/worker/instrumentD1'; const MOCK_FIRST_RETURN_VALUE = { id: 1, name: 'Foo' }; @@ -23,7 +23,40 @@ const MOCK_D1_RESPONSE = { }, }; -describe('instrumentD1WithSentry', () => { +function createMockD1Statement(): D1PreparedStatement { + return { + bind: vi.fn().mockImplementation(createMockD1Statement), + first: vi.fn().mockImplementation(() => Promise.resolve(MOCK_FIRST_RETURN_VALUE)), + run: vi.fn().mockImplementation(() => Promise.resolve(MOCK_D1_RESPONSE)), + all: vi.fn().mockImplementation(() => Promise.resolve(MOCK_D1_RESPONSE)), + raw: vi.fn().mockImplementation(() => Promise.resolve(MOCK_RAW_RETURN_VALUE)), + }; +} + +function createMockD1Database(): D1Database { + return { + prepare: vi.fn().mockImplementation(createMockD1Statement), + dump: vi.fn(), + batch: vi.fn(), + exec: vi.fn(), + }; +} + +describe('instrumentD1WithSentry (deprecated)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('still instruments the database', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + await instrumentedDb.prepare('SELECT 1').first(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('instrumentD1', () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -31,34 +64,15 @@ describe('instrumentD1WithSentry', () => { const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); const addBreadcrumbSpy = vi.spyOn(SentryCore, 'addBreadcrumb'); - function createMockD1Statement(): D1PreparedStatement { - return { - bind: vi.fn().mockImplementation(createMockD1Statement), - first: vi.fn().mockImplementation(() => Promise.resolve(MOCK_FIRST_RETURN_VALUE)), - run: vi.fn().mockImplementation(() => Promise.resolve(MOCK_D1_RESPONSE)), - all: vi.fn().mockImplementation(() => Promise.resolve(MOCK_D1_RESPONSE)), - raw: vi.fn().mockImplementation(() => Promise.resolve(MOCK_RAW_RETURN_VALUE)), - }; - } - - function createMockD1Database(): D1Database { - return { - prepare: vi.fn().mockImplementation(createMockD1Statement), - dump: vi.fn(), - batch: vi.fn(), - exec: vi.fn(), - }; - } - describe('statement.first()', () => { test('does not change return value', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + const instrumentedDb = instrumentD1(createMockD1Database()); const response = await instrumentedDb.prepare('SELECT * FROM users').first(); expect(response).toEqual(MOCK_FIRST_RETURN_VALUE); }); test('instruments with spans', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + const instrumentedDb = instrumentD1(createMockD1Database()); await instrumentedDb.prepare('SELECT * FROM users').first(); expect(startSpanSpy).toHaveBeenCalledTimes(1); @@ -76,7 +90,7 @@ describe('instrumentD1WithSentry', () => { }); test('instruments with breadcrumbs', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + const instrumentedDb = instrumentD1(createMockD1Database()); await instrumentedDb.prepare('SELECT * FROM users').first(); expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); @@ -90,7 +104,7 @@ describe('instrumentD1WithSentry', () => { }); test('works with statement.bind()', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + const instrumentedDb = instrumentD1(createMockD1Database()); await instrumentedDb.prepare('SELECT * FROM users').bind().first(); expect(startSpanSpy).toHaveBeenCalledTimes(1); @@ -100,13 +114,13 @@ describe('instrumentD1WithSentry', () => { describe('statement.run()', () => { test('does not change return value', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + const instrumentedDb = instrumentD1(createMockD1Database()); const response = await instrumentedDb.prepare('INSERT INTO users (name) VALUES (?)').run(); expect(response).toEqual(MOCK_D1_RESPONSE); }); test('instruments with spans', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + const instrumentedDb = instrumentD1(createMockD1Database()); await instrumentedDb.prepare('INSERT INTO users (name) VALUES (?)').run(); expect(startSpanSpy).toHaveBeenCalledTimes(1); @@ -124,7 +138,7 @@ describe('instrumentD1WithSentry', () => { }); test('instruments with breadcrumbs', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + const instrumentedDb = instrumentD1(createMockD1Database()); await instrumentedDb.prepare('INSERT INTO users (name) VALUES (?)').run(); expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); @@ -141,7 +155,7 @@ describe('instrumentD1WithSentry', () => { }); test('works with statement.bind()', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + const instrumentedDb = instrumentD1(createMockD1Database()); await instrumentedDb.prepare('SELECT * FROM users').bind().run(); expect(startSpanSpy).toHaveBeenCalledTimes(1); @@ -151,13 +165,13 @@ describe('instrumentD1WithSentry', () => { describe('statement.all()', () => { test('does not change return value', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); - const response = await instrumentedDb.prepare('INSERT INTO users (name) VALUES (?)').run(); + const instrumentedDb = instrumentD1(createMockD1Database()); + const response = await instrumentedDb.prepare('SELECT * FROM users').all(); expect(response).toEqual(MOCK_D1_RESPONSE); }); test('instruments with spans', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + const instrumentedDb = instrumentD1(createMockD1Database()); await instrumentedDb.prepare('INSERT INTO users (name) VALUES (?)').all(); expect(startSpanSpy).toHaveBeenCalledTimes(1); @@ -175,7 +189,7 @@ describe('instrumentD1WithSentry', () => { }); test('instruments with breadcrumbs', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + const instrumentedDb = instrumentD1(createMockD1Database()); await instrumentedDb.prepare('INSERT INTO users (name) VALUES (?)').all(); expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); @@ -192,7 +206,7 @@ describe('instrumentD1WithSentry', () => { }); test('works with statement.bind()', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + const instrumentedDb = instrumentD1(createMockD1Database()); await instrumentedDb.prepare('SELECT * FROM users').bind().all(); expect(startSpanSpy).toHaveBeenCalledTimes(1); @@ -202,13 +216,13 @@ describe('instrumentD1WithSentry', () => { describe('statement.raw()', () => { test('does not change return value', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + const instrumentedDb = instrumentD1(createMockD1Database()); const response = await instrumentedDb.prepare('SELECT * FROM users').raw(); expect(response).toEqual(MOCK_RAW_RETURN_VALUE); }); test('instruments with spans', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + const instrumentedDb = instrumentD1(createMockD1Database()); await instrumentedDb.prepare('SELECT * FROM users').raw(); expect(startSpanSpy).toHaveBeenCalledTimes(1); @@ -226,7 +240,7 @@ describe('instrumentD1WithSentry', () => { }); test('instruments with breadcrumbs', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + const instrumentedDb = instrumentD1(createMockD1Database()); await instrumentedDb.prepare('SELECT * FROM users').raw(); expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); @@ -240,11 +254,37 @@ describe('instrumentD1WithSentry', () => { }); test('works with statement.bind()', async () => { - const instrumentedDb = instrumentD1WithSentry(createMockD1Database()); + const instrumentedDb = instrumentD1(createMockD1Database()); await instrumentedDb.prepare('SELECT * FROM users').bind().raw(); expect(startSpanSpy).toHaveBeenCalledTimes(1); expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); }); }); + + describe('double instrumentation prevention', () => { + test('does not double-instrument the same database', async () => { + const db = createMockD1Database(); + const first = instrumentD1(db); + const prepareAfterFirst = first.prepare; + + const second = instrumentD1(db); + + expect(first).toBe(second); + expect(second.prepare).toBe(prepareAfterFirst); + }); + + test('does not double-instrument when instrumentD1WithSentry is also used', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const db = createMockD1Database(); + const fromEnv = instrumentD1(db); + const prepareAfterFirst = fromEnv.prepare; + + const fromManual = instrumentD1WithSentry(db); + + expect(fromEnv).toBe(fromManual); + expect(fromManual.prepare).toBe(prepareAfterFirst); + }); + }); }); diff --git a/packages/cloudflare/test/utils/isBinding.test.ts b/packages/cloudflare/test/utils/isBinding.test.ts index 95db6e1ff3e9..28d44fd9936d 100644 --- a/packages/cloudflare/test/utils/isBinding.test.ts +++ b/packages/cloudflare/test/utils/isBinding.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isDurableObjectNamespace, isJSRPC, isQueue } from '../../src/utils/isBinding'; +import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue } from '../../src/utils/isBinding'; describe('isJSRPC', () => { it('returns false for a plain object', () => { @@ -169,3 +169,44 @@ describe('isQueue', () => { expect(isQueue(doNamespace)).toBe(false); }); }); + +describe('isD1Database', () => { + it('returns true for an object with prepare, batch, and exec methods', () => { + const d1 = { + prepare: () => ({}), + batch: async () => [], + exec: async () => ({}), + dump: async () => new ArrayBuffer(0), + }; + expect(isD1Database(d1)).toBe(true); + }); + + it('returns false when prepare is missing', () => { + expect(isD1Database({ batch: async () => [], exec: async () => ({}) })).toBe(false); + }); + + it('returns false when batch is missing', () => { + expect(isD1Database({ prepare: () => ({}), exec: async () => ({}) })).toBe(false); + }); + + it('returns false when exec is missing', () => { + expect(isD1Database({ prepare: () => ({}), batch: async () => [] })).toBe(false); + }); + + it('returns false for null and undefined', () => { + expect(isD1Database(null)).toBe(false); + expect(isD1Database(undefined)).toBe(false); + }); + + it('returns false for a JSRPC proxy', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + expect(isD1Database(jsrpcProxy)).toBe(false); + }); +}); From 59c5ec0621964c55b97036c8aa695ede1b0f507d Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 2 Jun 2026 10:43:49 +0200 Subject: [PATCH 2/3] fixup! feat(cloudflare): Auto instrument D1 based on env --- .../suites/d1/index.ts | 7 ++++ .../suites/d1/test.ts | 42 +++++++++++++++++++ .../instrumentations/instrumentEnv.test.ts | 2 +- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/d1/index.ts b/dev-packages/cloudflare-integration-tests/suites/d1/index.ts index 5015da7c5f0f..16796c6a38cf 100644 --- a/dev-packages/cloudflare-integration-tests/suites/d1/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/d1/index.ts @@ -15,6 +15,8 @@ export default Sentry.withSentry( async fetch(request, env, _ctx) { const url = new URL(request.url); + await env.DB.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)'); + if (url.pathname === '/prepare') { await env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(1).all(); return new Response('ok'); @@ -31,6 +33,11 @@ export default Sentry.withSentry( return new Response(isSameRef); } + if (url.pathname === '/error') { + await env.DB.prepare('SELECT * FROM non_existent_table').all(); + return new Response('ok'); + } + return new Response('not found', { status: 404 }); }, } satisfies ExportedHandler, diff --git a/dev-packages/cloudflare-integration-tests/suites/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/d1/test.ts index ea62b14e0654..1938729ac3f6 100644 --- a/dev-packages/cloudflare-integration-tests/suites/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/d1/test.ts @@ -52,6 +52,48 @@ it('instruments D1 prepare().all() automatically via env', async ({ signal }) => await runner.completed(); }); +it('captures error event when a D1 query references a non-existent table', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('transaction') + .expect((envelope: Envelope) => { + expect(envelopeItemType(envelope)).toBe('event'); + const event = envelopeItem(envelope); + expect(event.level).toBe('error'); + + const values = (event.exception as { values: Array> })?.values; + expect(values).toHaveLength(2); + + expect(values).toEqual([ + { + type: 'Error', + value: 'no such table: non_existent_table: SQLITE_ERROR', + stacktrace: expect.any(Object), + mechanism: { + type: 'auto.http.cloudflare', + handled: false, + source: 'cause', + exception_id: 1, + parent_id: 0, + }, + }, + { + type: 'Error', + value: 'D1_ERROR: no such table: non_existent_table: SQLITE_ERROR', + stacktrace: expect.any(Object), + mechanism: { + type: 'generic', + handled: true, + exception_id: 0, + }, + }, + ]); + }) + .start(signal); + + await runner.makeRequest('get', '/error', { expectError: true }); + await runner.completed(); +}); + it('does not double-instrument when instrumentD1WithSentry is used on top of env instrumentation', async ({ signal, }) => { diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index 2c4b38682534..da462dc584d9 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -36,7 +36,7 @@ describe('instrumentEnv', () => { const instrumented = instrumentEnv(env); const db = instrumented.DB as typeof d1Database; - await db.exec('SELECT 1'); + await db.prepare('SELECT 1').first(); expect(startSpanSpy).toHaveBeenCalledWith( expect.objectContaining({ op: 'db.query', name: 'SELECT 1' }), From c7c19eba1972ce96132fe432f231e100aa32ce33 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 1 Jun 2026 15:31:38 +0200 Subject: [PATCH 3/3] feat(cloudflare): Add batch, exec, and withSession D1 instrumentation Instrument `db.batch()`, `db.exec()`, and `db.withSession()` methods that were previously not covered by D1 instrumentation. Co-Authored-By: Claude Opus 4.6 --- .../suites/d1/index.ts | 22 ++ .../suites/d1/test.ts | 113 +++++++++- .../suites/tracing/d1/test.ts | 25 ++- .../instrumentations/worker/instrumentD1.ts | 86 ++++++- .../worker/instrumentD1.test.ts | 210 ++++++++++++++++-- 5 files changed, 429 insertions(+), 27 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/d1/index.ts b/dev-packages/cloudflare-integration-tests/suites/d1/index.ts index 16796c6a38cf..70735b6d783e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/d1/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/d1/index.ts @@ -22,6 +22,11 @@ export default Sentry.withSentry( return new Response('ok'); } + if (url.pathname === '/exec') { + await env.DB.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)'); + return new Response('ok'); + } + if (url.pathname === '/double-instrument') { const prepareBeforeManual = env.DB.prepare; const db = Sentry.instrumentD1WithSentry(env.DB); @@ -38,6 +43,23 @@ export default Sentry.withSentry( return new Response('ok'); } + if (url.pathname === '/batch') { + await env.DB.batch([ + env.DB.prepare('INSERT INTO users (name) VALUES (?)').bind('Alice'), + env.DB.prepare('INSERT INTO users (name) VALUES (?)').bind('Bob'), + ]); + return new Response('ok'); + } + + if (url.pathname === '/with-session/batch') { + const session = env.DB.withSession(); + await session.batch([ + session.prepare('INSERT INTO users (name) VALUES (?)').bind('Alice'), + session.prepare('INSERT INTO users (name) VALUES (?)').bind('Bob'), + ]); + return new Response('ok'); + } + return new Response('not found', { status: 404 }); }, } satisfies ExportedHandler, diff --git a/dev-packages/cloudflare-integration-tests/suites/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/d1/test.ts index 1938729ac3f6..8776193f0ba1 100644 --- a/dev-packages/cloudflare-integration-tests/suites/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/d1/test.ts @@ -29,8 +29,10 @@ it('instruments D1 prepare().all() automatically via env', async ({ signal }) => expect(querySpan).toBeDefined(); expect(querySpan).toEqual({ data: { + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'all', + 'db.query.text': 'SELECT * FROM users WHERE id = ?', 'cloudflare.d1.duration': expect.any(Number), - 'cloudflare.d1.query_type': 'all', 'cloudflare.d1.rows_read': expect.any(Number), 'cloudflare.d1.rows_written': expect.any(Number), 'sentry.op': 'db.query', @@ -94,6 +96,41 @@ it('captures error event when a D1 query references a non-existent table', async await runner.completed(); }); +it('instruments D1 exec() automatically via env', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect((envelope: Envelope) => { + if (envelopeItemType(envelope) !== 'transaction') return; + const d1Spans = findD1Spans(envelope); + + const execSpan = d1Spans.find( + s => s.description === 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', + ); + expect(execSpan).toBeDefined(); + expect(execSpan).toEqual({ + data: { + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'exec', + 'db.query.text': 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', + 'sentry.op': 'db.query', + 'sentry.origin': 'auto.db.cloudflare.d1', + }, + description: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', + op: 'db.query', + origin: 'auto.db.cloudflare.d1', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); + }) + .start(signal); + + await runner.makeRequest('get', '/exec'); + await runner.completed(); +}); + it('does not double-instrument when instrumentD1WithSentry is used on top of env instrumentation', async ({ signal, }) => { @@ -112,3 +149,77 @@ it('does not double-instrument when instrumentD1WithSentry is used on top of env expect(response).toBe('true'); await runner.completed(); }); + +it('instruments D1 withSession().batch() identically to db.batch()', async ({ signal }) => { + let directBatchSpan: Record | undefined; + let sessionBatchSpan: Record | undefined; + + const runner = createRunner(__dirname) + .ignore('event') + .expect((envelope: Envelope) => { + expect(envelopeItem(envelope).transaction).toBe('GET /batch'); + + directBatchSpan = findD1Spans(envelope).find(s => s.description === 'D1 batch'); + }) + .expect((envelope: Envelope) => { + expect(envelopeItem(envelope).transaction).toBe('GET /with-session/batch'); + + sessionBatchSpan = findD1Spans(envelope).find(s => s.description === 'D1 batch'); + }) + .start(signal); + + await runner.makeRequest('get', '/batch'); + await runner.makeRequest('get', '/with-session/batch'); + await runner.completed(); + + expect(directBatchSpan).toBeDefined(); + expect(sessionBatchSpan).toBeDefined(); + + const normalize = (span: Record): Record => { + const { + span_id: _spanId, + parent_span_id: _parentSpanId, + start_timestamp: _start, + timestamp: _end, + trace_id: _traceId, + ...rest + } = span; + return rest; + }; + + expect(normalize(sessionBatchSpan!)).toEqual(normalize(directBatchSpan!)); +}); + +it('instruments D1 batch() automatically via env', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect((envelope: Envelope) => { + if (envelopeItemType(envelope) !== 'transaction') return; + const d1Spans = findD1Spans(envelope); + + const batchSpan = d1Spans.find(s => s.description === 'D1 batch'); + expect(batchSpan).toBeDefined(); + expect(batchSpan).toEqual({ + data: { + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'batch', + 'db.query.text': 'INSERT INTO users (name) VALUES (?)\nINSERT INTO users (name) VALUES (?)', + 'db.operation.batch.size': 2, + 'sentry.op': 'db.query', + 'sentry.origin': 'auto.db.cloudflare.d1', + }, + description: 'D1 batch', + op: 'db.query', + origin: 'auto.db.cloudflare.d1', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); + }) + .start(signal); + + await runner.makeRequest('get', '/batch'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts index e921b23ce1a2..9755b9e4dd9f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts @@ -15,7 +15,26 @@ it('D1 database queries create spans with correct attributes', async ({ signal } data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', - 'cloudflare.d1.query_type': 'run', + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'exec', + 'db.query.text': 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', + }, + description: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', + op: 'db.query', + origin: 'auto.db.cloudflare.d1', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }, + { + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'run', + 'db.query.text': 'INSERT INTO users (name) VALUES (?)', 'cloudflare.d1.duration': expect.any(Number), 'cloudflare.d1.rows_read': expect.any(Number), 'cloudflare.d1.rows_written': expect.any(Number), @@ -44,7 +63,9 @@ it('D1 database queries create spans with correct attributes', async ({ signal } data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', - 'cloudflare.d1.query_type': 'first', + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'first', + 'db.query.text': 'SELECT * FROM users WHERE name = ?', }, description: 'SELECT * FROM users WHERE name = ?', op: 'db.query', diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts b/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts index 2571a1e404e2..e180df77ed3f 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/unbound-method */ -import type { D1Database, D1PreparedStatement, D1Response } from '@cloudflare/workers-types'; +import type { D1Database, D1DatabaseSession, D1PreparedStatement, D1Response } from '@cloudflare/workers-types'; import type { Span, SpanAttributes, StartSpanOptions } from '@sentry/core'; import { addBreadcrumb, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startSpan } from '@sentry/core'; import { ensureInstrumented } from '../../instrument'; @@ -107,35 +107,105 @@ function getAttributesFromD1Response(d1Result: D1Response): SpanAttributes { }; } -function createD1Breadcrumb(query: string, type: 'first' | 'run' | 'all' | 'raw', d1Result?: D1Response): void { +type D1QueryType = 'first' | 'run' | 'all' | 'raw' | 'batch' | 'exec'; + +function createD1Breadcrumb(query: string, type: D1QueryType, d1Result?: D1Response): void { addBreadcrumb({ category: 'query', message: query, data: { ...(d1Result ? getAttributesFromD1Response(d1Result) : {}), - 'cloudflare.d1.query_type': type, + 'db.operation.name': type, }, }); } -function createStartSpanOptions(query: string, type: 'first' | 'run' | 'all' | 'raw'): StartSpanOptions { +function createStartSpanOptions(query: string, type: D1QueryType): StartSpanOptions { return { op: 'db.query', name: query, attributes: { - 'cloudflare.d1.query_type': type, + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': type, + 'db.query.text': query, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', }, }; } -function _instrumentD1(db: D1Database): D1Database { - db.prepare = new Proxy(db.prepare, { - apply(target, thisArg, args: Parameters) { +function instrumentPrepare( + prepare: D1Database['prepare'] | D1DatabaseSession['prepare'], +): D1Database['prepare'] | D1DatabaseSession['prepare'] { + return new Proxy(prepare, { + apply(target, thisArg, args: Parameters) { const [query] = args; return instrumentD1PreparedStatement(Reflect.apply(target, thisArg, args), query); }, }); +} + +function instrumentBatch( + batch: D1Database['batch'] | D1DatabaseSession['batch'], +): D1Database['batch'] | D1DatabaseSession['batch'] { + return new Proxy(batch, { + apply(target, thisArg, args: Parameters) { + const statements = args[0]; + // D1PreparedStatement exposes a `statement` property at runtime, but it's not in @cloudflare/workers-types. + // https://github.com/cloudflare/workerd/blob/dc12d7650b4f5d4f9ba6a47aa45fad769cdf8db4/src/cloudflare/internal/d1-api.ts#L210 + const queryText = statements + .map(statement => (statement as unknown as { statement?: string }).statement ?? '') + .join('\n'); + + return startSpan( + { + op: 'db.query', + name: 'D1 batch', + attributes: { + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'batch', + 'db.query.text': queryText || undefined, + 'db.operation.batch.size': statements.length, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', + }, + }, + async () => { + const res = await Reflect.apply(target, thisArg, args); + createD1Breadcrumb('D1 batch', 'batch'); + return res; + }, + ); + }, + }); +} + +function instrumentD1Session(session: D1DatabaseSession): D1DatabaseSession { + session.prepare = instrumentPrepare(session.prepare); + session.batch = instrumentBatch(session.batch); + return session; +} + +function _instrumentD1(db: D1Database): D1Database { + db.prepare = instrumentPrepare(db.prepare); + db.batch = instrumentBatch(db.batch); + + db.exec = new Proxy(db.exec, { + apply(target, thisArg, args: Parameters) { + const [query] = args; + return startSpan(createStartSpanOptions(query, 'exec'), async () => { + const res = await Reflect.apply(target, thisArg, args); + createD1Breadcrumb(query, 'exec'); + return res; + }); + }, + }); + + if ('withSession' in db && typeof db.withSession === 'function') { + db.withSession = new Proxy(db.withSession, { + apply(target, thisArg, args: [unknown]) { + return instrumentD1Session(Reflect.apply(target, thisArg, args) as D1DatabaseSession); + }, + }); + } return db; } diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts index 2b65a0943a07..3e9e36bf181e 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts @@ -1,4 +1,4 @@ -import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types'; +import type { D1Database, D1DatabaseSession, D1PreparedStatement } from '@cloudflare/workers-types'; import * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { instrumentD1, instrumentD1WithSentry } from '../../../src/instrumentations/worker/instrumentD1'; @@ -23,23 +23,33 @@ const MOCK_D1_RESPONSE = { }, }; -function createMockD1Statement(): D1PreparedStatement { +function createMockD1Statement(query?: string): D1PreparedStatement { return { - bind: vi.fn().mockImplementation(createMockD1Statement), + statement: query, + bind: vi.fn().mockImplementation(() => createMockD1Statement(query)), first: vi.fn().mockImplementation(() => Promise.resolve(MOCK_FIRST_RETURN_VALUE)), run: vi.fn().mockImplementation(() => Promise.resolve(MOCK_D1_RESPONSE)), all: vi.fn().mockImplementation(() => Promise.resolve(MOCK_D1_RESPONSE)), raw: vi.fn().mockImplementation(() => Promise.resolve(MOCK_RAW_RETURN_VALUE)), - }; + } as unknown as D1PreparedStatement; } function createMockD1Database(): D1Database { return { - prepare: vi.fn().mockImplementation(createMockD1Statement), + prepare: vi.fn().mockImplementation((query: string) => createMockD1Statement(query)), dump: vi.fn(), - batch: vi.fn(), - exec: vi.fn(), - }; + batch: vi.fn().mockResolvedValue([MOCK_D1_RESPONSE]), + exec: vi.fn().mockResolvedValue({ count: 1, duration: 0.5 }), + withSession: vi.fn().mockImplementation(() => createMockD1Session()), + } as unknown as D1Database; +} + +function createMockD1Session(): D1DatabaseSession { + return { + prepare: vi.fn().mockImplementation((query: string) => createMockD1Statement(query)), + batch: vi.fn().mockResolvedValue([MOCK_D1_RESPONSE]), + getBookmark: vi.fn().mockReturnValue(null), + } as unknown as D1DatabaseSession; } describe('instrumentD1WithSentry (deprecated)', () => { @@ -79,7 +89,9 @@ describe('instrumentD1', () => { expect(startSpanSpy).toHaveBeenLastCalledWith( { attributes: { - 'cloudflare.d1.query_type': 'first', + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'first', + 'db.query.text': 'SELECT * FROM users', 'sentry.origin': 'auto.db.cloudflare.d1', }, name: 'SELECT * FROM users', @@ -98,7 +110,7 @@ describe('instrumentD1', () => { category: 'query', message: 'SELECT * FROM users', data: { - 'cloudflare.d1.query_type': 'first', + 'db.operation.name': 'first', }, }); }); @@ -127,7 +139,9 @@ describe('instrumentD1', () => { expect(startSpanSpy).toHaveBeenLastCalledWith( { attributes: { - 'cloudflare.d1.query_type': 'run', + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'run', + 'db.query.text': 'INSERT INTO users (name) VALUES (?)', 'sentry.origin': 'auto.db.cloudflare.d1', }, name: 'INSERT INTO users (name) VALUES (?)', @@ -146,7 +160,7 @@ describe('instrumentD1', () => { category: 'query', message: 'INSERT INTO users (name) VALUES (?)', data: { - 'cloudflare.d1.query_type': 'run', + 'db.operation.name': 'run', 'cloudflare.d1.duration': 1, 'cloudflare.d1.rows_read': 3, 'cloudflare.d1.rows_written': 4, @@ -178,7 +192,9 @@ describe('instrumentD1', () => { expect(startSpanSpy).toHaveBeenLastCalledWith( { attributes: { - 'cloudflare.d1.query_type': 'all', + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'all', + 'db.query.text': 'INSERT INTO users (name) VALUES (?)', 'sentry.origin': 'auto.db.cloudflare.d1', }, name: 'INSERT INTO users (name) VALUES (?)', @@ -197,7 +213,7 @@ describe('instrumentD1', () => { category: 'query', message: 'INSERT INTO users (name) VALUES (?)', data: { - 'cloudflare.d1.query_type': 'all', + 'db.operation.name': 'all', 'cloudflare.d1.duration': 1, 'cloudflare.d1.rows_read': 3, 'cloudflare.d1.rows_written': 4, @@ -229,7 +245,9 @@ describe('instrumentD1', () => { expect(startSpanSpy).toHaveBeenLastCalledWith( { attributes: { - 'cloudflare.d1.query_type': 'raw', + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'raw', + 'db.query.text': 'SELECT * FROM users', 'sentry.origin': 'auto.db.cloudflare.d1', }, name: 'SELECT * FROM users', @@ -248,7 +266,7 @@ describe('instrumentD1', () => { category: 'query', message: 'SELECT * FROM users', data: { - 'cloudflare.d1.query_type': 'raw', + 'db.operation.name': 'raw', }, }); }); @@ -262,6 +280,166 @@ describe('instrumentD1', () => { }); }); + describe('db.batch()', () => { + test('does not change return value', async () => { + const instrumentedDb = instrumentD1(createMockD1Database()); + const response = await instrumentedDb.batch([instrumentedDb.prepare('SELECT 1')]); + expect(response).toEqual([MOCK_D1_RESPONSE]); + }); + + test('instruments with spans', async () => { + const instrumentedDb = instrumentD1(createMockD1Database()); + await instrumentedDb.batch([instrumentedDb.prepare('SELECT 1'), instrumentedDb.prepare('SELECT 2')]); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + attributes: { + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'batch', + 'db.query.text': 'SELECT 1\nSELECT 2', + 'db.operation.batch.size': 2, + 'sentry.origin': 'auto.db.cloudflare.d1', + }, + name: 'D1 batch', + op: 'db.query', + }, + expect.any(Function), + ); + }); + + test('instruments with breadcrumbs', async () => { + const instrumentedDb = instrumentD1(createMockD1Database()); + await instrumentedDb.batch([instrumentedDb.prepare('SELECT 1')]); + + expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); + expect(addBreadcrumbSpy).toHaveBeenLastCalledWith({ + category: 'query', + message: 'D1 batch', + data: { + 'db.operation.name': 'batch', + }, + }); + }); + }); + + describe('db.exec()', () => { + test('does not change return value', async () => { + const instrumentedDb = instrumentD1(createMockD1Database()); + const response = await instrumentedDb.exec('CREATE TABLE users (id INTEGER PRIMARY KEY)'); + expect(response).toEqual({ count: 1, duration: 0.5 }); + }); + + test('instruments with spans', async () => { + const instrumentedDb = instrumentD1(createMockD1Database()); + await instrumentedDb.exec('CREATE TABLE users (id INTEGER PRIMARY KEY)'); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + attributes: { + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'exec', + 'db.query.text': 'CREATE TABLE users (id INTEGER PRIMARY KEY)', + 'sentry.origin': 'auto.db.cloudflare.d1', + }, + name: 'CREATE TABLE users (id INTEGER PRIMARY KEY)', + op: 'db.query', + }, + expect.any(Function), + ); + }); + + test('instruments with breadcrumbs', async () => { + const instrumentedDb = instrumentD1(createMockD1Database()); + await instrumentedDb.exec('CREATE TABLE users (id INTEGER PRIMARY KEY)'); + + expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); + expect(addBreadcrumbSpy).toHaveBeenLastCalledWith({ + category: 'query', + message: 'CREATE TABLE users (id INTEGER PRIMARY KEY)', + data: { + 'db.operation.name': 'exec', + }, + }); + }); + }); + + describe('db.withSession()', () => { + test('instruments session.prepare with spans', async () => { + const instrumentedDb = instrumentD1(createMockD1Database()); + const session = (instrumentedDb as unknown as { withSession: () => D1DatabaseSession }).withSession(); + await session.prepare('SELECT * FROM users').first(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + attributes: { + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'first', + 'db.query.text': 'SELECT * FROM users', + 'sentry.origin': 'auto.db.cloudflare.d1', + }, + name: 'SELECT * FROM users', + op: 'db.query', + }, + expect.any(Function), + ); + }); + + test('instruments session.prepare with breadcrumbs', async () => { + const instrumentedDb = instrumentD1(createMockD1Database()); + const session = (instrumentedDb as unknown as { withSession: () => D1DatabaseSession }).withSession(); + await session.prepare('SELECT * FROM users').first(); + + expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); + expect(addBreadcrumbSpy).toHaveBeenLastCalledWith({ + category: 'query', + message: 'SELECT * FROM users', + data: { + 'db.operation.name': 'first', + }, + }); + }); + + test('instruments session.batch with spans', async () => { + const instrumentedDb = instrumentD1(createMockD1Database()); + const session = (instrumentedDb as unknown as { withSession: () => D1DatabaseSession }).withSession(); + await session.batch([session.prepare('SELECT 1'), session.prepare('SELECT 2')]); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + attributes: { + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'batch', + 'db.query.text': 'SELECT 1\nSELECT 2', + 'db.operation.batch.size': 2, + 'sentry.origin': 'auto.db.cloudflare.d1', + }, + name: 'D1 batch', + op: 'db.query', + }, + expect.any(Function), + ); + }); + + test('instruments session.batch with breadcrumbs', async () => { + const instrumentedDb = instrumentD1(createMockD1Database()); + const session = (instrumentedDb as unknown as { withSession: () => D1DatabaseSession }).withSession(); + await session.batch([session.prepare('SELECT 1')]); + + expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); + expect(addBreadcrumbSpy).toHaveBeenLastCalledWith({ + category: 'query', + message: 'D1 batch', + data: { + 'db.operation.name': 'batch', + }, + }); + }); + }); + describe('double instrumentation prevention', () => { test('does not double-instrument the same database', async () => { const db = createMockD1Database();