From 784d2d21d87ab7bafcb65259b23f667fa8e607f5 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 25 Aug 2026 17:42:12 +0200 Subject: [PATCH] feat(core)!: Emit low cardinality supabase db span names With span streaming, supabase query spans are named `{db.operation.name} {db.collection.name}` (`update users`) instead of the PostgREST description. The description embeds the translated filter list, which carries user values (`eq(email, secret@example.com)`), so it cannot be a span name. The filters stay on the `db.query` attribute, subject to the existing data collection settings. `auth` spans are unaffected: they have no collection to pair the operation with, so the templates would reduce them to `postgresql`, which says less than the name they already carry. `traceLifecycle: 'static'` keeps the existing names. Refs #23523 Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/src/integrations/supabase.ts | 8 ++++- .../test/lib/integrations/supabase.test.ts | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index 1f405ed4b098..15ade5a410ce 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -11,6 +11,7 @@ import { captureException } from '../exports'; import { defineIntegration } from '../integration'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; import { setHttpStatus, SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '../tracing'; +import { hasSpanStreamingEnabled } from '../tracing/spans/hasSpanStreamingEnabled'; import { startSpan } from '../tracing/trace'; import type { IntegrationFn } from '../types/integration'; import type { WebFetchHeaders } from '../types/webfetchapi'; @@ -433,6 +434,11 @@ function instrumentPostgRESTFilterBuilder( const descriptionMiddle = [mutationPart.trimEnd(), queryPart].filter(Boolean).join(' '); const description = descriptionMiddle ? `${descriptionMiddle} from(${table})` : `from(${table})`; + // With span streaming, span names have to be low cardinality — the description carries the + // filter list, which can hold user values — so `{db.operation.name} {db.collection.name}` is + // used instead. The filters are still reported via the `db.query` attribute. + const streamedName = client && hasSpanStreamingEnabled(client) ? `${operation} ${table}` : undefined; + const attributes: Record = { 'db.table': table, 'db.schema': typedThis.schema, @@ -454,7 +460,7 @@ function instrumentPostgRESTFilterBuilder( return startSpan( { - name: description, + name: streamedName ?? description, attributes, }, span => { diff --git a/packages/core/test/lib/integrations/supabase.test.ts b/packages/core/test/lib/integrations/supabase.test.ts index d9a47452e282..175e6caee6d8 100644 --- a/packages/core/test/lib/integrations/supabase.test.ts +++ b/packages/core/test/lib/integrations/supabase.test.ts @@ -51,6 +51,8 @@ type CreateMockSupabaseClientOptions = { headers?: PostgRESTHeaders; /** When set, configures the mocked Sentry client's `dataCollection.databaseQueryData`. Omit to leave `getClient` to the test file `beforeEach`. */ dataCollectionDatabaseQueryData?: boolean; + /** Defaults to `'static'`, so span names keep the full description. */ + traceLifecycle?: 'static' | 'stream'; }; const DEFAULT_MOCK_SUPABASE_REST_URL = 'https://example.supabase.co/rest/v1/todos'; @@ -66,6 +68,7 @@ function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupa if (options?.dataCollectionDatabaseQueryData !== undefined) { currentScopesMocks.getClient.mockReturnValue({ getDataCollectionOptions: () => ({ databaseQueryData: options.dataCollectionDatabaseQueryData }), + getOptions: () => ({ traceLifecycle: options.traceLifecycle ?? 'static' }), } as any); } @@ -371,6 +374,7 @@ describe('Supabase Integration', () => { const resolved = resolveDataCollectionOptions({}); currentScopesMocks.getClient.mockReturnValue({ getDataCollectionOptions: () => resolved, + getOptions: () => ({ traceLifecycle: 'static' }), } as any); const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO }); @@ -391,10 +395,36 @@ describe('Supabase Integration', () => { ); }); + it('names the span from the conventions instead of the description with span streaming enabled', async () => { + const resolved = resolveDataCollectionOptions({ dataCollection: { databaseQueryData: true } }); + currentScopesMocks.getClient.mockReturnValue({ + getDataCollectionOptions: () => resolved, + getOptions: () => ({ traceLifecycle: 'stream' }), + } as any); + + const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO }); + instrumentSupabaseClient(client); + + await (client as any).from('users').update({}).then(); + + const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as { + name: string; + attributes: Record; + }; + // `{db.operation.name} {db.collection.name}` — the description, which carries the filters, is + // not used as the name. + expect(spanOptions.name).toBe('update users'); + // the filters are still reported, just not as the name + expect(spanOptions.attributes['db.query']).toEqual( + expect.arrayContaining([expect.stringContaining('secret@example.com')]), + ); + }); + it('redacts data when dataCollection.databaseQueryData is false', async () => { const resolved = resolveDataCollectionOptions({ dataCollection: { databaseQueryData: false } }); currentScopesMocks.getClient.mockReturnValue({ getDataCollectionOptions: () => resolved, + getOptions: () => ({ traceLifecycle: 'static' }), } as any); const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });