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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions dev-packages/cloudflare-integration-tests/suites/d1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<Env>,
Expand Down
114 changes: 113 additions & 1 deletion dev-packages/cloudflare-integration-tests/suites/d1/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: this would silently pass the test right? seems a bit weird. I think we should just hard fail if we don't have a transaction here, no?

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,
}) => {
Expand All @@ -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<string, unknown> | undefined;
let sessionBatchSpan: Record<string, unknown> | 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();
Comment thread
cursor[bot] marked this conversation as resolved.

expect(directBatchSpan).toBeDefined();
expect(sessionBatchSpan).toBeDefined();

const normalize = (span: Record<string, unknown>): Record<string, unknown> => {
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();
});
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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',
Expand Down
87 changes: 79 additions & 8 deletions packages/cloudflare/src/instrumentations/worker/instrumentD1.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<typeof db.prepare>) {
function instrumentPrepare(
prepare: D1Database['prepare'] | D1DatabaseSession['prepare'],
): D1Database['prepare'] | D1DatabaseSession['prepare'] {
return new Proxy(prepare, {
apply(target, thisArg, args: Parameters<typeof prepare>) {
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<typeof batch>) {
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');
Comment thread
sentry[bot] marked this conversation as resolved.

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<typeof db.exec>) {
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;
}
Expand Down
Loading
Loading