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..696278d5a7dc 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,78 @@ 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'); + }) + .unordered() + .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 e5f9068fc7a8..fb2c2bc12442 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,106 @@ 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 ?? '') + .filter(Boolean) + .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..efe71733fcf5 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,191 @@ 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', + }, + }); + }); + + test('omits db.query.text when statements lack the .statement property', async () => { + const db = createMockD1Database(); + const instrumentedDb = instrumentD1(db); + + const statementsWithoutQuery = [createMockD1Statement(), createMockD1Statement()]; + + await instrumentedDb.batch(statementsWithoutQuery); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + attributes: { + 'db.system.name': 'cloudflare-d1', + 'db.operation.name': 'batch', + 'db.query.text': undefined, + 'db.operation.batch.size': 2, + 'sentry.origin': 'auto.db.cloudflare.d1', + }, + name: 'D1 batch', + op: 'db.query', + }, + expect.any(Function), + ); + }); + }); + + 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();