Skip to content

Commit 0be9894

Browse files
authored
feat(cloudflare): Add Cloudflare D1 instrumentation (#13142)
This PR adds a new method to the cloudflare SDK, `instrumentD1WithSentry`. This method can be used to instrument [Cloudflare D1](https://developers.cloudflare.com/d1/), Cloudflare's serverless SQL database. ```js // env.DB is the D1 DB binding configured in your `wrangler.toml` const db = instrumentD1WithSentry(env.DB); // Now you can use the database as usual await db.prepare('SELECT * FROM table WHERE id = ?').bind(1).run(); ``` The reason this has to be a standalone wrapper method instead of an integration is because the cloudflare d1 instance can be bound to any arbitrary environmental variable as per user config. This is why the snippet above shows `env.DB` being passed into `instrumentD1WithSentry`. `env.DB` can easily be `env.COOL_DB` or `env.HAPPY_DB`. I am planning to ask the cloudflare team to expose some APIs to make this better, but in the meantime this is the best we can do.
1 parent 1320f2d commit 0be9894

File tree

4 files changed

+421
-0
lines changed

4 files changed

+421
-0
lines changed

packages/cloudflare/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,15 @@ Sentry.captureEvent({
136136
],
137137
});
138138
```
139+
140+
## Cloudflare D1 Instrumentation
141+
142+
You can use the `instrumentD1WithSentry` method to instrument [Cloudflare D1](https://developers.cloudflare.com/d1/),
143+
Cloudflare's serverless SQL database with Sentry.
144+
145+
```javascript
146+
// env.DB is the D1 DB binding configured in your `wrangler.toml`
147+
const db = instrumentD1WithSentry(env.DB);
148+
// Now you can use the database as usual
149+
await db.prepare('SELECT * FROM table WHERE id = ?').bind(1).run();
150+
```

packages/cloudflare/src/d1.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import type { D1Database, D1PreparedStatement, D1Response } from '@cloudflare/workers-types';
2+
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, addBreadcrumb, startSpan } from '@sentry/core';
3+
import type { Span, SpanAttributes, StartSpanOptions } from '@sentry/types';
4+
5+
// Patching is based on internal Cloudflare D1 API
6+
// https://github.com/cloudflare/workerd/blob/cd5279e7b305003f1d9c851e73efa9d67e4b68b2/src/cloudflare/internal/d1-api.ts
7+
8+
const patchedStatement = new WeakSet<D1PreparedStatement>();
9+
10+
/**
11+
* Patches the query methods of a Cloudflare D1 prepared statement with Sentry.
12+
*/
13+
function instrumentD1PreparedStatementQueries(statement: D1PreparedStatement, query: string): D1PreparedStatement {
14+
if (patchedStatement.has(statement)) {
15+
return statement;
16+
}
17+
18+
// eslint-disable-next-line @typescript-eslint/unbound-method
19+
statement.first = new Proxy(statement.first, {
20+
apply(target, thisArg, args: Parameters<typeof statement.first>) {
21+
return startSpan(createStartSpanOptions(query, 'first'), async () => {
22+
const res = await Reflect.apply(target, thisArg, args);
23+
createD1Breadcrumb(query, 'first');
24+
return res;
25+
});
26+
},
27+
});
28+
29+
// eslint-disable-next-line @typescript-eslint/unbound-method
30+
statement.run = new Proxy(statement.run, {
31+
apply(target, thisArg, args: Parameters<typeof statement.run>) {
32+
return startSpan(createStartSpanOptions(query, 'run'), async span => {
33+
const d1Response = await Reflect.apply(target, thisArg, args);
34+
applyD1ReturnObjectToSpan(span, d1Response);
35+
createD1Breadcrumb(query, 'run', d1Response);
36+
return d1Response;
37+
});
38+
},
39+
});
40+
41+
// eslint-disable-next-line @typescript-eslint/unbound-method
42+
statement.all = new Proxy(statement.all, {
43+
apply(target, thisArg, args: Parameters<typeof statement.all>) {
44+
return startSpan(createStartSpanOptions(query, 'all'), async span => {
45+
const d1Result = await Reflect.apply(target, thisArg, args);
46+
applyD1ReturnObjectToSpan(span, d1Result);
47+
createD1Breadcrumb(query, 'all', d1Result);
48+
return d1Result;
49+
});
50+
},
51+
});
52+
53+
// eslint-disable-next-line @typescript-eslint/unbound-method
54+
statement.raw = new Proxy(statement.raw, {
55+
apply(target, thisArg, args: Parameters<typeof statement.raw>) {
56+
return startSpan(createStartSpanOptions(query, 'raw'), async () => {
57+
const res = await Reflect.apply(target, thisArg, args);
58+
createD1Breadcrumb(query, 'raw');
59+
return res;
60+
});
61+
},
62+
});
63+
64+
patchedStatement.add(statement);
65+
66+
return statement;
67+
}
68+
69+
/**
70+
* Instruments a Cloudflare D1 prepared statement with Sentry.
71+
*
72+
* This is meant to be used as a top-level call, under the hood it calls `instrumentD1PreparedStatementQueries`
73+
* to patch the query methods. The reason for this abstraction is to ensure that the `bind` method is also patched.
74+
*/
75+
function instrumentD1PreparedStatement(statement: D1PreparedStatement, query: string): D1PreparedStatement {
76+
// statement.bind() returns a new instance of D1PreparedStatement, so we have to patch it as well.
77+
// eslint-disable-next-line @typescript-eslint/unbound-method
78+
statement.bind = new Proxy(statement.bind, {
79+
apply(target, thisArg, args: Parameters<typeof statement.bind>) {
80+
return instrumentD1PreparedStatementQueries(Reflect.apply(target, thisArg, args), query);
81+
},
82+
});
83+
84+
return instrumentD1PreparedStatementQueries(statement, query);
85+
}
86+
87+
/**
88+
* Add D1Response meta information to a span.
89+
*
90+
* See: https://developers.cloudflare.com/d1/build-with-d1/d1-client-api/#return-object
91+
*/
92+
function applyD1ReturnObjectToSpan(span: Span, d1Result: D1Response): void {
93+
if (!d1Result.success) {
94+
span.setStatus({ code: SPAN_STATUS_ERROR });
95+
}
96+
97+
span.setAttributes(getAttributesFromD1Response(d1Result));
98+
}
99+
100+
function getAttributesFromD1Response(d1Result: D1Response): SpanAttributes {
101+
return {
102+
'cloudflare.d1.duration': d1Result.meta.duration,
103+
'cloudflare.d1.rows_read': d1Result.meta.rows_read,
104+
'cloudflare.d1.rows_written': d1Result.meta.rows_written,
105+
};
106+
}
107+
108+
function createD1Breadcrumb(query: string, type: 'first' | 'run' | 'all' | 'raw', d1Result?: D1Response): void {
109+
addBreadcrumb({
110+
category: 'query',
111+
message: query,
112+
data: {
113+
...(d1Result ? getAttributesFromD1Response(d1Result) : {}),
114+
'cloudflare.d1.query_type': type,
115+
},
116+
});
117+
}
118+
119+
function createStartSpanOptions(query: string, type: 'first' | 'run' | 'all' | 'raw'): StartSpanOptions {
120+
return {
121+
op: 'db.query',
122+
name: query,
123+
attributes: {
124+
'cloudflare.d1.query_type': type,
125+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1',
126+
},
127+
};
128+
}
129+
130+
/**
131+
* Instruments Cloudflare D1 bindings with Sentry.
132+
*
133+
* Currently, only prepared statements are instrumented. `db.exec` and `db.batch` are not instrumented.
134+
*
135+
* @example
136+
*
137+
* ```js
138+
* // env.DB is the D1 DB binding configured in your `wrangler.toml`
139+
* const db = instrumentD1WithSentry(env.DB);
140+
* // Now you can use the database as usual
141+
* await db.prepare('SELECT * FROM table WHERE id = ?').bind(1).run();
142+
* ```
143+
*/
144+
export function instrumentD1WithSentry(db: D1Database): D1Database {
145+
// eslint-disable-next-line @typescript-eslint/unbound-method
146+
db.prepare = new Proxy(db.prepare, {
147+
apply(target, thisArg, args: Parameters<typeof db.prepare>) {
148+
const [query] = args;
149+
return instrumentD1PreparedStatement(Reflect.apply(target, thisArg, args), query);
150+
},
151+
});
152+
153+
return db;
154+
}

packages/cloudflare/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,5 @@ export { CloudflareClient } from './client';
9191
export { getDefaultIntegrations } from './sdk';
9292

9393
export { fetchIntegration } from './integrations/fetch';
94+
95+
export { instrumentD1WithSentry } from './d1';

0 commit comments

Comments
 (0)