Skip to content

Commit b72a360

Browse files
authored
feat(cloudflare): Instrument SQL API in sqlite durable objects (#21656)
closes #20832 closes [JS-2445](https://linear.app/getsentry/issue/JS-2445) This instruments `sql.exec()` for an SQLite DurableObject. It adds a span and a breadcrumb for the `exec` method. It was inspired by the postgres integration, that is why the `_INTERNAL_sanitizeSqlQuery` has been now exported from `core`. I wanted to also add the span attribute `db.query.summary`, but the functionality didn't exist and didn't want to make the PR bigger. I'll add another PR for that functionality. This PR can only be merged once getsentry/sentry-conventions#435 has been merged
1 parent a8de444 commit b72a360

9 files changed

Lines changed: 417 additions & 12 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { DurableObject } from 'cloudflare:workers';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
TEST_DURABLE_OBJECT: DurableObjectNamespace;
7+
}
8+
9+
class SqlDurableObjectBase extends DurableObject<Env> {
10+
public constructor(ctx: DurableObjectState, env: Env) {
11+
super(ctx, env);
12+
}
13+
14+
async fetch(request: Request): Promise<Response> {
15+
const url = new URL(request.url);
16+
17+
if (url.pathname === '/exec') {
18+
this.ctx.storage.sql.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)');
19+
this.ctx.storage.sql.exec('INSERT INTO users (name) VALUES (?)', 'Alice');
20+
const cursor = this.ctx.storage.sql.exec('SELECT * FROM users');
21+
const rows = cursor.toArray();
22+
23+
return Response.json({ rows });
24+
}
25+
26+
return new Response('OK');
27+
}
28+
}
29+
30+
export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry(
31+
(env: Env) => ({
32+
dsn: env.SENTRY_DSN,
33+
tracesSampleRate: 1.0,
34+
}),
35+
SqlDurableObjectBase,
36+
);
37+
38+
export default Sentry.withSentry(
39+
(env: Env) => ({
40+
dsn: env.SENTRY_DSN,
41+
tracesSampleRate: 1.0,
42+
}),
43+
{
44+
async fetch(request: Request, env: Env): Promise<Response> {
45+
const url = new URL(request.url);
46+
47+
if (url.pathname === '/flush-marker') {
48+
Sentry.captureMessage('flush-marker');
49+
return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
50+
}
51+
52+
const id = env.TEST_DURABLE_OBJECT.idFromName('test');
53+
const stub = env.TEST_DURABLE_OBJECT.get(id);
54+
return stub.fetch(request);
55+
},
56+
} satisfies ExportedHandler<Env>,
57+
);
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { Envelope } from '@sentry/core';
2+
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
3+
import { expect, it } from 'vitest';
4+
import { createRunner } from '../../../runner';
5+
6+
const flushMarkerMatcher = (envelope: Envelope): void => {
7+
const [, items] = envelope;
8+
const [itemHeader, itemBody] = items[0] as [{ type: string }, Record<string, unknown>];
9+
10+
expect(itemHeader.type).toBe('event');
11+
expect(itemBody.message).toBe('flush-marker');
12+
};
13+
14+
it('instruments SQL exec operations on Durable Object storage', async ({ signal }) => {
15+
const runner = createRunner(__dirname)
16+
.expect(envelope => {
17+
const transactionEvent = envelope[1]?.[0]?.[1];
18+
const spans = transactionEvent?.spans ?? [];
19+
20+
expect(transactionEvent).toEqual(
21+
expect.objectContaining({
22+
type: 'transaction',
23+
transaction: 'GET /exec',
24+
}),
25+
);
26+
27+
const sqlSpans = (spans as Array<Record<string, unknown>>).filter(
28+
s => s.origin === 'auto.db.cloudflare.durable_object.sql',
29+
);
30+
31+
expect(sqlSpans).toHaveLength(3);
32+
expect(sqlSpans).toEqual(
33+
expect.arrayContaining([
34+
expect.objectContaining({
35+
description: 'CREATE TABLE users',
36+
op: 'db.query',
37+
origin: 'auto.db.cloudflare.durable_object.sql',
38+
data: expect.objectContaining({
39+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query',
40+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql',
41+
'db.system.name': 'cloudflare-durable-object-sql',
42+
'db.operation.name': 'exec',
43+
'db.query.text': 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)',
44+
'db.query.summary': 'CREATE TABLE users',
45+
'cloudflare.durable_object.query.bindings': 0,
46+
}),
47+
}),
48+
expect.objectContaining({
49+
description: 'INSERT users',
50+
op: 'db.query',
51+
origin: 'auto.db.cloudflare.durable_object.sql',
52+
data: expect.objectContaining({
53+
'db.system.name': 'cloudflare-durable-object-sql',
54+
'db.operation.name': 'exec',
55+
'db.query.text': 'INSERT INTO users (name) VALUES (?)',
56+
'db.query.summary': 'INSERT users',
57+
'cloudflare.durable_object.query.bindings': 1,
58+
}),
59+
}),
60+
expect.objectContaining({
61+
description: 'SELECT users',
62+
op: 'db.query',
63+
origin: 'auto.db.cloudflare.durable_object.sql',
64+
data: expect.objectContaining({
65+
'db.system.name': 'cloudflare-durable-object-sql',
66+
'db.operation.name': 'exec',
67+
'db.query.text': 'SELECT * FROM users',
68+
'db.query.summary': 'SELECT users',
69+
'cloudflare.durable_object.query.bindings': 0,
70+
}),
71+
}),
72+
]),
73+
);
74+
})
75+
.expect(flushMarkerMatcher)
76+
.unordered()
77+
.start(signal);
78+
79+
await runner.makeRequest('get', '/exec');
80+
await runner.makeRequest('get', '/flush-marker');
81+
await runner.completed();
82+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "worker-name",
3+
"main": "index.ts",
4+
"compatibility_date": "2025-06-17",
5+
"migrations": [
6+
{
7+
"new_sqlite_classes": ["TestDurableObject"],
8+
"tag": "v1",
9+
},
10+
],
11+
"durable_objects": {
12+
"bindings": [
13+
{
14+
"class_name": "TestDurableObject",
15+
"name": "TEST_DURABLE_OBJECT",
16+
},
17+
],
18+
},
19+
"compatibility_flags": ["nodejs_als"],
20+
}

‎dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,17 @@ test('@callable() methods work correctly with Sentry instrumentDurableObjectWith
3434
},
3535
spans: expect.arrayContaining([
3636
expect.objectContaining({
37-
op: 'db',
38-
description: 'durable_object_storage_get',
37+
op: 'db.query',
38+
origin: 'auto.db.cloudflare.durable_object.sql',
39+
description: expect.stringMatching(/^SELECT /),
40+
data: expect.objectContaining({
41+
'db.system.name': 'cloudflare-durable-object-sql',
42+
'db.operation.name': 'exec',
43+
'db.query.summary': expect.any(String),
44+
'db.query.text': expect.any(String),
45+
'sentry.op': 'db.query',
46+
'sentry.origin': 'auto.db.cloudflare.durable_object.sql',
47+
}),
3948
}),
4049
]),
4150
start_timestamp: expect.any(Number),

‎packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import type { DurableObjectStorage, SyncKvStorage } from '@cloudflare/workers-types';
1+
import type { DurableObjectStorage, SyncKvStorage, SqlStorage } from '@cloudflare/workers-types';
22
import { isThenable, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
33
import { storeSpanContext } from '../utils/traceLinks';
44
import { instrumentDurableObjectSyncKvStorage } from './instrumentDurableObjectSyncKvStorage';
5+
import { instrumentSqlStorage } from './instrumentSqlStorage';
56

67
const STORAGE_METHODS_TO_INSTRUMENT = ['get', 'put', 'delete', 'list', 'setAlarm', 'getAlarm', 'deleteAlarm'] as const;
78

@@ -40,6 +41,10 @@ export function instrumentDurableObjectStorage(
4041
return instrumentDurableObjectSyncKvStorage(original as SyncKvStorage);
4142
}
4243

44+
if (prop === 'sql' && original != null && 'databaseSize' in original && 'exec' in original) {
45+
return instrumentSqlStorage(original as SqlStorage);
46+
}
47+
4348
if (typeof original !== 'function') {
4449
return original;
4550
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { SqlStorage } from '@cloudflare/workers-types';
2+
import {
3+
_INTERNAL_getSqlQuerySummary,
4+
_INTERNAL_sanitizeSqlQuery,
5+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
6+
startSpan,
7+
} from '@sentry/core';
8+
9+
/**
10+
* Instruments the Durable Object SqlStorage `exec` method with Sentry spans.
11+
*
12+
* @param sql - The SqlStorage instance to instrument
13+
* @returns An instrumented SqlStorage instance
14+
*/
15+
export function instrumentSqlStorage(sql: SqlStorage): SqlStorage {
16+
return new Proxy(sql, {
17+
get(target, prop, _receiver) {
18+
const original = Reflect.get(target, prop, target);
19+
20+
if (prop !== 'exec' || typeof original !== 'function') {
21+
return original;
22+
}
23+
24+
return function (this: unknown, ...args: unknown[]) {
25+
const [query, ...bindings] = args as [string, ...unknown[]];
26+
const sanitizedQuery = _INTERNAL_sanitizeSqlQuery(query);
27+
const querySummary = _INTERNAL_getSqlQuerySummary(sanitizedQuery);
28+
29+
return startSpan(
30+
{
31+
op: 'db.query',
32+
name: querySummary || sanitizedQuery,
33+
attributes: {
34+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql',
35+
'db.system.name': 'cloudflare-durable-object-sql',
36+
'db.operation.name': 'exec',
37+
'db.query.text': sanitizedQuery,
38+
'db.query.summary': querySummary,
39+
'cloudflare.durable_object.query.bindings': bindings.length,
40+
},
41+
},
42+
() => (original as (...a: unknown[]) => ReturnType<SqlStorage['exec']>).apply(target, args),
43+
);
44+
};
45+
},
46+
});
47+
}

‎packages/cloudflare/test/instrumentDurableObjectStorage.test.ts‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,30 @@ describe('instrumentDurableObjectStorage', () => {
300300
});
301301
});
302302

303+
it('instruments sql exec', () => {
304+
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
305+
const mockStorage = createMockStorage();
306+
const instrumented = instrumentDurableObjectStorage(mockStorage);
307+
308+
instrumented.sql.exec('SELECT 1');
309+
310+
expect(startSpanSpy).toHaveBeenCalledWith(
311+
{
312+
name: 'SELECT',
313+
op: 'db.query',
314+
attributes: {
315+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql',
316+
'db.system.name': 'cloudflare-durable-object-sql',
317+
'db.operation.name': 'exec',
318+
'db.query.text': 'SELECT ?',
319+
'db.query.summary': 'SELECT',
320+
'cloudflare.durable_object.query.bindings': 0,
321+
},
322+
},
323+
expect.any(Function),
324+
);
325+
});
326+
303327
describe('non-instrumented methods', () => {
304328
it('does not instrument deleteAll, sync, transaction', async () => {
305329
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
@@ -312,14 +336,6 @@ describe('instrumentDurableObjectStorage', () => {
312336

313337
expect(startSpanSpy).not.toHaveBeenCalled();
314338
});
315-
316-
it('does not instrument sql property', () => {
317-
const mockStorage = createMockStorage();
318-
const instrumented = instrumentDurableObjectStorage(mockStorage);
319-
320-
// sql is a property, not a method we instrument
321-
expect(instrumented.sql).toBe(mockStorage.sql);
322-
});
323339
});
324340

325341
describe('sync KV instrumentation', () => {

0 commit comments

Comments
 (0)