Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(cloudflare): Add batch, exec, and withSession D1 instrumentation#21292
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
3ba7b53b51ef8d2a9d622527039eFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -21,16 +21,18 @@ it('instruments D1 prepare().all() automatically via env', async ({ signal }) => | ||
| const runner = createRunner(__dirname) | ||
| .ignore('event') | ||
| .expect((envelope: Envelope) => { | ||
| if (envelopeItemType(envelope) !== 'transaction') return; | ||
| expect(envelopeItemType(envelope)).toBe('transaction'); | ||
| 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: { | ||
| '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,13 +96,48 @@ 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) => { | ||
| expect(envelopeItemType(envelope)).toBe('transaction'); | ||
| 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, | ||
| }) => { | ||
| const runner = createRunner(__dirname) | ||
| .ignore('event') | ||
| .expect((envelope: Envelope) => { | ||
| if (envelopeItemType(envelope) !== 'transaction') return; | ||
| expect(envelopeItemType(envelope)).toBe('transaction'); | ||
| const d1Spans = findD1Spans(envelope); | ||
| const querySpans = d1Spans.filter(s => s.description === 'SELECT * FROM users WHERE id = ?'); | ||
| @@ -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(); | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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) => { | ||
| expect(envelopeItemType(envelope)).toBe('transaction'); | ||
| 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 |
|---|---|---|
| @@ -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<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'); | ||
sentry[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.