From 0158a68bfbe2d0d03fa30b49b47ea9b7ec89f1b5 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 25 Aug 2026 17:40:21 +0200 Subject: [PATCH 1/3] feat(cloudflare)!: Emit low cardinality D1 db span names With span streaming, D1 query spans are named after their `db.query.summary` (`SELECT users`) instead of the full SQL statement, and report that summary as a new `db.query.summary` attribute. The statement is sanitized before it is summarized, so a string literal containing `from`/`join` cannot leak a value into the name. D1 exposes no collection, namespace or server to pair the operation with, so a statement that cannot be summarized falls back to `db.system.name`. `batch` spans keep their name and get no summary, since their `db.query.text` is several statements joined together. `traceLifecycle: 'static'` keeps the existing names. Refs #23523 Co-Authored-By: Claude Opus 5 (1M context) --- .../suites/d1/test.ts | 2 + .../suites/tracing/d1/test.ts | 3 + .../instrumentations/worker/instrumentD1.ts | 23 +++++++- .../worker/instrumentD1.test.ts | 59 ++++++++++++++++++- 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/d1/test.ts index 97d708ba25a1..3f4912bc17f5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/d1/test.ts @@ -32,6 +32,7 @@ it('instruments D1 prepare().all() automatically via env', async ({ signal }) => 'db.system.name': 'cloudflare-d1', 'db.operation.name': 'all', 'db.query.text': 'SELECT * FROM users WHERE id = ?', + 'db.query.summary': 'SELECT users', 'cloudflare.d1.duration': expect.any(Number), 'cloudflare.d1.rows_read': expect.any(Number), 'cloudflare.d1.rows_written': expect.any(Number), @@ -113,6 +114,7 @@ it('instruments D1 exec() automatically via env', async ({ signal }) => { 'db.system.name': 'cloudflare-d1', 'db.operation.name': 'exec', 'db.query.text': 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', + 'db.query.summary': 'CREATE TABLE users', 'sentry.op': 'db.query', 'sentry.origin': 'auto.db.cloudflare.d1', }, 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 76d9b470c7c0..8f50d5de2d5c 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts @@ -18,6 +18,7 @@ it('D1 database queries create spans with correct attributes', async ({ signal } 'db.system.name': 'cloudflare-d1', 'db.operation.name': 'exec', 'db.query.text': 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', + 'db.query.summary': 'CREATE TABLE users', }, description: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', op: 'db.query', @@ -36,6 +37,7 @@ it('D1 database queries create spans with correct attributes', async ({ signal } 'db.system.name': 'cloudflare-d1', 'db.operation.name': 'run', 'db.query.text': 'INSERT INTO users (name) VALUES (?)', + 'db.query.summary': 'INSERT users', 'cloudflare.d1.duration': expect.any(Number), 'cloudflare.d1.rows_read': expect.any(Number), 'cloudflare.d1.rows_written': expect.any(Number), @@ -68,6 +70,7 @@ it('D1 database queries create spans with correct attributes', async ({ signal } 'db.system.name': 'cloudflare-d1', 'db.operation.name': 'first', 'db.query.text': 'SELECT * FROM users WHERE name = ?', + 'db.query.summary': 'SELECT users', }, 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 17f03bacee29..35dafd846d97 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts @@ -1,7 +1,16 @@ /* eslint-disable @typescript-eslint/unbound-method */ 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 { + _INTERNAL_getSqlQuerySummary, + _INTERNAL_sanitizeSqlQuery, + addBreadcrumb, + getClient, + hasSpanStreamingEnabled, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + startSpan, +} from '@sentry/core'; import { ensureInstrumented } from '../../instrument'; // Patching is based on internal Cloudflare D1 API @@ -121,13 +130,23 @@ function createD1Breadcrumb(query: string, type: D1QueryType, d1Result?: D1Respo } function createStartSpanOptions(query: string, type: D1QueryType): StartSpanOptions { + const client = getClient(); + // The statement is sanitized before it is summarized, so that a string literal containing + // `from`/`join` can't leak a value into the summary. + const querySummary = query ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(query)) : undefined; + // With span streaming, span names have to be low cardinality, so `{db.query.summary}` is used + // instead of the full statement, falling back to `{db.system.name}` when there is no statement to + // summarize — D1 exposes no collection, namespace or server to pair the operation with. + const streamedName = client && hasSpanStreamingEnabled(client) ? querySummary || 'cloudflare-d1' : undefined; + return { op: 'db.query', - name: query, + name: streamedName ?? query, attributes: { 'db.system.name': 'cloudflare-d1', 'db.operation.name': type, 'db.query.text': query, + 'db.query.summary': querySummary, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', }, }; diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts index ca745c7ba352..a3a9da56860b 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts @@ -1,6 +1,6 @@ import type { D1Database, D1DatabaseSession, D1PreparedStatement } from '@cloudflare/workers-types'; import * as SentryCore from '@sentry/core'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { instrumentD1 } from '../../../src/instrumentations/worker/instrumentD1'; const MOCK_FIRST_RETURN_VALUE = { id: 1, name: 'Foo' }; @@ -78,6 +78,7 @@ describe('instrumentD1', () => { 'db.system.name': 'cloudflare-d1', 'db.operation.name': 'first', 'db.query.text': 'SELECT * FROM users', + 'db.query.summary': 'SELECT users', 'sentry.origin': 'auto.db.cloudflare.d1', }, name: 'SELECT * FROM users', @@ -108,6 +109,57 @@ describe('instrumentD1', () => { expect(startSpanSpy).toHaveBeenCalledTimes(1); expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); }); + + describe('with span streaming enabled', () => { + let getClientSpy: ReturnType; + + beforeEach(() => { + getClientSpy = vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'stream' }), + } as unknown as ReturnType); + }); + + afterEach(() => { + getClientSpy.mockRestore(); + }); + + test('names the span after the query summary', async () => { + const instrumentedDb = instrumentD1(createMockD1Database()); + await instrumentedDb.prepare('SELECT * FROM users').first(); + + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + name: 'SELECT users', + // the statement is still reported, just not as the name + attributes: expect.objectContaining({ 'db.query.text': 'SELECT * FROM users' }), + }), + expect.any(Function), + ); + }); + + test('sanitizes the statement before summarizing it, so literals cannot leak into the name', async () => { + const instrumentedDb = instrumentD1(createMockD1Database()); + // The `from ` inside the string literal would otherwise be read as a table reference. + await instrumentedDb.prepare("SELECT * FROM items WHERE note LIKE '%shipped from warehouse7%'").first(); + + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ name: 'SELECT items' }), + expect.any(Function), + ); + }); + + test('falls back to the db system name when no summary can be derived', async () => { + const instrumentedDb = instrumentD1(createMockD1Database()); + await instrumentedDb.prepare('').first(); + + // D1 exposes no collection, namespace or server, so `{db.system.name}` is the last template + // that can be filled before the static fallback. + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ name: 'cloudflare-d1' }), + expect.any(Function), + ); + }); + }); }); describe('statement.run()', () => { @@ -128,6 +180,7 @@ describe('instrumentD1', () => { 'db.system.name': 'cloudflare-d1', 'db.operation.name': 'run', 'db.query.text': 'INSERT INTO users (name) VALUES (?)', + 'db.query.summary': 'INSERT users', 'sentry.origin': 'auto.db.cloudflare.d1', }, name: 'INSERT INTO users (name) VALUES (?)', @@ -181,6 +234,7 @@ describe('instrumentD1', () => { 'db.system.name': 'cloudflare-d1', 'db.operation.name': 'all', 'db.query.text': 'INSERT INTO users (name) VALUES (?)', + 'db.query.summary': 'INSERT users', 'sentry.origin': 'auto.db.cloudflare.d1', }, name: 'INSERT INTO users (name) VALUES (?)', @@ -234,6 +288,7 @@ describe('instrumentD1', () => { 'db.system.name': 'cloudflare-d1', 'db.operation.name': 'raw', 'db.query.text': 'SELECT * FROM users', + 'db.query.summary': 'SELECT users', 'sentry.origin': 'auto.db.cloudflare.d1', }, name: 'SELECT * FROM users', @@ -352,6 +407,7 @@ describe('instrumentD1', () => { 'db.system.name': 'cloudflare-d1', 'db.operation.name': 'exec', 'db.query.text': 'CREATE TABLE users (id INTEGER PRIMARY KEY)', + 'db.query.summary': 'CREATE TABLE users', 'sentry.origin': 'auto.db.cloudflare.d1', }, name: 'CREATE TABLE users (id INTEGER PRIMARY KEY)', @@ -389,6 +445,7 @@ describe('instrumentD1', () => { 'db.system.name': 'cloudflare-d1', 'db.operation.name': 'first', 'db.query.text': 'SELECT * FROM users', + 'db.query.summary': 'SELECT users', 'sentry.origin': 'auto.db.cloudflare.d1', }, name: 'SELECT * FROM users', From d1d70746454e9692d7563170fb1a9b0de8bd2d98 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 27 Aug 2026 14:51:04 +0200 Subject: [PATCH 2/3] add streaming tests and deslop --- .../suites/tracing/d1/index.ts | 3 +- .../suites/tracing/d1/test.ts | 132 +++++++++++++++++- .../instrumentations/worker/instrumentD1.ts | 12 +- 3 files changed, 136 insertions(+), 11 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts index d7664d602fe3..5962c54cc0c1 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts @@ -2,13 +2,14 @@ import * as Sentry from '@sentry/cloudflare'; interface Env { SENTRY_DSN: string; + STREAMED?: string; DB: D1Database; } export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', + traceLifecycle: env.STREAMED === 'true' ? 'stream' : 'static', tracesSampleRate: 1.0, }), { 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 8f50d5de2d5c..0cddf4ff34cf 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts @@ -1,5 +1,18 @@ -import { expect, it } from 'vitest'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, +} from '@sentry/core'; +import { + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRACE_LIFECYCLE, +} from '@sentry/conventions/attributes'; import { createRunner } from '../../../runner'; it('D1 database queries create spans with correct attributes', async ({ signal }) => { @@ -92,3 +105,118 @@ it('D1 database queries create spans with correct attributes', async ({ signal } await runner.makeRequest('get', '/query'); await runner.completed(); }); + +describe('with span streaming enabled', () => { + function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { + const spanItem = envelope[1].find(item => item[0].type === 'span'); + expect(spanItem).toBeDefined(); + return spanItem![1] as SerializedStreamedSpanContainer; + } + + /** The `db.query` spans of an envelope, paired with the segment span they belong to. */ + function getD1Spans(envelope: Envelope): { + segmentSpan: SerializedStreamedSpanContainer['items'][number]; + d1Spans: SerializedStreamedSpanContainer['items']; + } { + const items = getSpanContainer(envelope).items; + const segmentSpan = items.find(item => item.is_segment); + expect(segmentSpan).toBeDefined(); + + return { + segmentSpan: segmentSpan!, + d1Spans: items.filter(item => item.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value === 'db.query'), + }; + } + + function commonAttributes( + segmentSpan: SerializedStreamedSpanContainer['items'][number], + ): SerializedStreamedSpanContainer['items'][number]['attributes'] { + return { + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan.span_id }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentSpan.name }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'db.query' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.d1' }, + 'db.system.name': { type: 'string', value: 'cloudflare-d1' }, + }; + } + + function commonSpanProps(segmentSpan: SerializedStreamedSpanContainer['items'][number]): Record { + return { + is_segment: false, + parent_span_id: segmentSpan.span_id, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: segmentSpan.trace_id, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }; + } + + // `cloudflare.d1.duration` is only an integer when the query happens to take a whole + // number of milliseconds, so the type can't be pinned down. + const NUMBER_ATTRIBUTE = { type: expect.stringMatching(/^(?:integer|double)$/), value: expect.any(Number) }; + + it('names D1 query spans after their query summary', async ({ signal }) => { + const runner = createRunner(__dirname) + .withWranglerArgs('--var', 'STREAMED:true') + .expect(envelope => { + const { segmentSpan, d1Spans } = getD1Spans(envelope); + expect(segmentSpan.name).toBe('GET /init'); + + expect(d1Spans).toEqual([ + { + name: 'CREATE TABLE users', + attributes: { + ...commonAttributes(segmentSpan), + 'db.operation.name': { type: 'string', value: 'exec' }, + 'db.query.text': { + type: 'string', + value: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', + }, + 'db.query.summary': { type: 'string', value: 'CREATE TABLE users' }, + }, + ...commonSpanProps(segmentSpan), + }, + { + name: 'INSERT users', + attributes: { + ...commonAttributes(segmentSpan), + 'db.operation.name': { type: 'string', value: 'run' }, + 'db.query.text': { type: 'string', value: 'INSERT INTO users (name) VALUES (?)' }, + 'db.query.summary': { type: 'string', value: 'INSERT users' }, + 'cloudflare.d1.duration': NUMBER_ATTRIBUTE, + 'cloudflare.d1.rows_read': NUMBER_ATTRIBUTE, + 'cloudflare.d1.rows_written': NUMBER_ATTRIBUTE, + }, + ...commonSpanProps(segmentSpan), + }, + ]); + }) + .expect(envelope => { + const { segmentSpan, d1Spans } = getD1Spans(envelope); + expect(segmentSpan.name).toBe('GET /query'); + + expect(d1Spans).toEqual([ + { + name: 'SELECT users', + attributes: { + ...commonAttributes(segmentSpan), + 'db.operation.name': { type: 'string', value: 'first' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM users WHERE name = ?' }, + 'db.query.summary': { type: 'string', value: 'SELECT users' }, + }, + ...commonSpanProps(segmentSpan), + }, + ]); + }) + .start(signal); + + await runner.makeRequest('get', '/init'); + await runner.makeRequest('get', '/query'); + await runner.completed(); + }); +}); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts b/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts index 35dafd846d97..da0a8ebb23aa 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts @@ -130,18 +130,14 @@ function createD1Breadcrumb(query: string, type: D1QueryType, d1Result?: D1Respo } function createStartSpanOptions(query: string, type: D1QueryType): StartSpanOptions { - const client = getClient(); - // The statement is sanitized before it is summarized, so that a string literal containing - // `from`/`join` can't leak a value into the summary. const querySummary = query ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(query)) : undefined; - // With span streaming, span names have to be low cardinality, so `{db.query.summary}` is used - // instead of the full statement, falling back to `{db.system.name}` when there is no statement to - // summarize — D1 exposes no collection, namespace or server to pair the operation with. - const streamedName = client && hasSpanStreamingEnabled(client) ? querySummary || 'cloudflare-d1' : undefined; + + const client = getClient(); + const name = client && hasSpanStreamingEnabled(client) ? querySummary || 'cloudflare-d1' : query; return { op: 'db.query', - name: streamedName ?? query, + name, attributes: { 'db.system.name': 'cloudflare-d1', 'db.operation.name': type, From 76bb68992a1f26c8fcc2ddb43d9924461c693339 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 27 Aug 2026 15:56:53 +0200 Subject: [PATCH 3/3] fix test --- .../suites/tracing/d1/test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 0cddf4ff34cf..96a5628da921 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts @@ -165,7 +165,10 @@ describe('with span streaming enabled', () => { .withWranglerArgs('--var', 'STREAMED:true') .expect(envelope => { const { segmentSpan, d1Spans } = getD1Spans(envelope); - expect(segmentSpan.name).toBe('GET /init'); + // With span streaming, the server span name is low cardinality, so the request the + // envelope belongs to is only identifiable through `url.path`. + expect(segmentSpan.name).toBe('GET'); + expect(segmentSpan.attributes['url.path']).toEqual({ type: 'string', value: '/init' }); expect(d1Spans).toEqual([ { @@ -198,7 +201,8 @@ describe('with span streaming enabled', () => { }) .expect(envelope => { const { segmentSpan, d1Spans } = getD1Spans(envelope); - expect(segmentSpan.name).toBe('GET /query'); + expect(segmentSpan.name).toBe('GET'); + expect(segmentSpan.attributes['url.path']).toEqual({ type: 'string', value: '/query' }); expect(d1Spans).toEqual([ {