diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js b/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js index 0f600426009f..30928883d2d8 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js @@ -9,6 +9,7 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [Sentry.browserTracingIntegration(), Sentry.supabaseIntegration({ supabaseClient })], tracesSampleRate: 1.0, + sendDefaultPii: true, }); // Simulate database operations diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts index cfb66b372420..40ce1462fcfd 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts @@ -57,37 +57,16 @@ test('Sends client-side Supabase db-operation spans and breadcrumbs to Sentry', const transactionEvent = await pageloadTransactionPromise; - expect(transactionEvent.spans).toContainEqual( - expect.objectContaining({ - description: 'select(*) filter(order, asc) from(todos)', - op: 'db', - data: expect.objectContaining({ - 'db.operation': 'select', - 'db.query': ['select(*)', 'filter(order, asc)'], - 'db.system': 'postgresql', - 'sentry.op': 'db', - 'sentry.origin': 'auto.db.supabase', - }), - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.db.supabase', - }), - ); - - expect(transactionEvent.spans).toContainEqual({ + // Client uses default sendDefaultPii: false — URL filters and bodies are not attached to spans/breadcrumbs. + const redactedSelectSpan = expect.objectContaining({ + description: '[redacted] from(todos)', + op: 'db', data: expect.objectContaining({ 'db.operation': 'select', - 'db.query': ['select(*)', 'filter(order, asc)'], 'db.system': 'postgresql', 'sentry.op': 'db', 'sentry.origin': 'auto.db.supabase', }), - description: 'select(*) filter(order, asc) from(todos)', - op: 'db', parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), @@ -97,20 +76,26 @@ test('Sends client-side Supabase db-operation spans and breadcrumbs to Sentry', origin: 'auto.db.supabase', }); + expect(transactionEvent.spans).toContainEqual(redactedSelectSpan); + + const selectSpan = transactionEvent.spans?.find( + (s: { description?: string }) => s.description === '[redacted] from(todos)', + ); + expect(selectSpan).toBeDefined(); + expect(selectSpan!.data).not.toHaveProperty('db.query'); + expect(transactionEvent.breadcrumbs).toContainEqual({ timestamp: expect.any(Number), type: 'supabase', category: 'db.select', - message: 'select(*) filter(order, asc) from(todos)', - data: expect.any(Object), + message: '[redacted] from(todos)', }); expect(transactionEvent.breadcrumbs).toContainEqual({ timestamp: expect.any(Number), type: 'supabase', category: 'db.insert', - message: 'insert(...) select(*) from(todos)', - data: expect.any(Object), + message: 'insert(...) [redacted] from(todos)', }); }); diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index dac7530b46f0..96a1c93069d3 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -4,6 +4,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable max-lines */ import { addBreadcrumb } from '../breadcrumbs'; +import { getClient } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import { captureException } from '../exports'; import { defineIntegration } from '../integration'; @@ -361,12 +362,15 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte } } + const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii); + // Adding operation to the beginning of the description if it's not a `select` operation // For example, it can be an `insert` or `update` operation but the query can be `select(...)` // For `select` operations, we don't need repeat it in the description - const description = `${operation === 'select' ? '' : `${operation}${body ? '(...) ' : ''}`}${queryItems.join( - ' ', - )} from(${table})`; + const mutationPart = operation === 'select' ? '' : `${operation}${Object.keys(body).length ? '(...) ' : ''}`; + const queryPart = sendDefaultPii ? queryItems.join(' ') : queryItems.length > 0 ? '[redacted]' : ''; + const descriptionMiddle = [mutationPart.trimEnd(), queryPart].filter(Boolean).join(' '); + const description = descriptionMiddle ? `${descriptionMiddle} from(${table})` : `from(${table})`; const attributes: Record = { 'db.table': table, @@ -379,11 +383,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', }; - if (queryItems.length) { + if (queryItems.length && sendDefaultPii) { attributes['db.query'] = queryItems; } - if (Object.keys(body).length) { + if (Object.keys(body).length && sendDefaultPii) { attributes['db.body'] = body; } @@ -413,10 +417,10 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte } const supabaseContext: Record = {}; - if (queryItems.length) { + if (queryItems.length && sendDefaultPii) { supabaseContext.query = queryItems; } - if (Object.keys(body).length) { + if (Object.keys(body).length && sendDefaultPii) { supabaseContext.body = body; } @@ -444,11 +448,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte const data: Record = {}; - if (queryItems.length) { + if (queryItems.length && sendDefaultPii) { data.query = queryItems; } - if (Object.keys(body).length) { + if (Object.keys(body).length && sendDefaultPii) { data.body = body; } diff --git a/packages/core/test/lib/integrations/supabase.test.ts b/packages/core/test/lib/integrations/supabase.test.ts index 519dda4f06a0..520aacde9fd8 100644 --- a/packages/core/test/lib/integrations/supabase.test.ts +++ b/packages/core/test/lib/integrations/supabase.test.ts @@ -8,21 +8,113 @@ import { } from '../../../src/integrations/supabase'; import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase'; -// Mock tracing to avoid needing full SDK setup -vi.mock('../../../src/tracing', () => ({ - startSpan: (_opts: any, cb: (span: any) => any) => { +const tracingMocks = vi.hoisted(() => ({ + startSpan: vi.fn((_opts: unknown, cb: (span: unknown) => unknown) => { const mockSpan = { setStatus: vi.fn(), end: vi.fn(), }; return cb(mockSpan); - }, + }), +})); + +const currentScopesMocks = vi.hoisted(() => ({ + getClient: vi.fn(), +})); + +// Mock tracing to avoid needing full SDK setup +vi.mock('../../../src/tracing', () => ({ + startSpan: tracingMocks.startSpan, setHttpStatus: vi.fn(), SPAN_STATUS_OK: 1, SPAN_STATUS_ERROR: 2, })); +vi.mock('../../../src/currentScopes', () => ({ + getClient: currentScopesMocks.getClient, +})); + +type CreateMockSupabaseClientOptions = { + method?: string; + url?: URL | string; + body?: unknown; + /** When set, configures the mocked Sentry client `sendDefaultPii`. Omit to leave `getClient` to the test file `beforeEach`. */ + sendDefaultPii?: boolean; +}; + +const DEFAULT_MOCK_SUPABASE_REST_URL = 'https://example.supabase.co/rest/v1/todos'; + +/** Shared PATCH + query string + body shape for `sendDefaultPii` tests. */ +const MOCK_SUPABASE_PII_SCENARIO: Pick = { + method: 'PATCH', + url: 'https://example.supabase.co/rest/v1/users?email=eq.secret%40example.com&select=id', + body: { full_name: 'Jane Doe', phone: '555-0100' }, +}; + +function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupabaseClientOptions): unknown { + if (options?.sendDefaultPii !== undefined) { + currentScopesMocks.getClient.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: options.sendDefaultPii }), + } as any); + } + + const method = options?.method ?? 'GET'; + const requestUrl = + options?.url !== undefined + ? options.url instanceof URL + ? options.url + : new URL(options.url) + : new URL(DEFAULT_MOCK_SUPABASE_REST_URL); + const body = options?.body; + + class MockPostgRESTFilterBuilder { + method = method; + headers: Record = { 'X-Client-Info': 'supabase-js/2.0.0' }; + url = requestUrl; + schema = 'public'; + body = body; + + then(onfulfilled?: (value: any) => any, onrejected?: (reason: any) => any): Promise { + return Promise.resolve(resolveWith).then(onfulfilled, onrejected); + } + } + + class MockPostgRESTQueryBuilder { + select() { + return new MockPostgRESTFilterBuilder(); + } + insert() { + return new MockPostgRESTFilterBuilder(); + } + upsert() { + return new MockPostgRESTFilterBuilder(); + } + update() { + return new MockPostgRESTFilterBuilder(); + } + delete() { + return new MockPostgRESTFilterBuilder(); + } + } + + class MockSupabaseClient { + auth = { + admin: {} as any, + } as SupabaseClientInstance['auth']; + + from(_table: string): PostgRESTQueryBuilder { + return new MockPostgRESTQueryBuilder() as unknown as PostgRESTQueryBuilder; + } + } + + return new MockSupabaseClient(); +} + describe('Supabase Integration', () => { + beforeEach(() => { + currentScopesMocks.getClient.mockReturnValue(undefined); + }); + describe('extractOperation', () => { it('returns select for GET', () => { expect(extractOperation('GET')).toBe('select'); @@ -72,52 +164,6 @@ describe('Supabase Integration', () => { vi.restoreAllMocks(); }); - function createMockSupabaseClient(resolveWith: unknown): unknown { - // Create a PostgRESTFilterBuilder-like class - class MockPostgRESTFilterBuilder { - method = 'GET'; - headers: Record = { 'X-Client-Info': 'supabase-js/2.0.0' }; - url = new URL('https://example.supabase.co/rest/v1/todos'); - schema = 'public'; - body = undefined; - - then(onfulfilled?: (value: any) => any, onrejected?: (reason: any) => any): Promise { - return Promise.resolve(resolveWith).then(onfulfilled, onrejected); - } - } - - class MockPostgRESTQueryBuilder { - select() { - return new MockPostgRESTFilterBuilder(); - } - insert() { - return new MockPostgRESTFilterBuilder(); - } - upsert() { - return new MockPostgRESTFilterBuilder(); - } - update() { - return new MockPostgRESTFilterBuilder(); - } - delete() { - return new MockPostgRESTFilterBuilder(); - } - } - - // Create a mock SupabaseClient constructor - class MockSupabaseClient { - auth = { - admin: {} as any, - } as SupabaseClientInstance['auth']; - - from(_table: string): PostgRESTQueryBuilder { - return new MockPostgRESTQueryBuilder() as unknown as PostgRESTQueryBuilder; - } - } - - return new MockSupabaseClient(); - } - it('handles undefined response without throwing', async () => { const client = createMockSupabaseClient(undefined); instrumentSupabaseClient(client); @@ -176,4 +222,92 @@ describe('Supabase Integration', () => { expect(captureExceptionSpy).toHaveBeenCalled(); }); }); + + describe('sendDefaultPii', () => { + let captureExceptionSpy: ReturnType; + let addBreadcrumbSpy: ReturnType; + + beforeEach(() => { + captureExceptionSpy = vi.spyOn(exportsModule, 'captureException').mockImplementation(() => ''); + addBreadcrumbSpy = vi.spyOn(breadcrumbModule, 'addBreadcrumb').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('omits db.query, db.body, and breadcrumb query/body when sendDefaultPii is false', async () => { + const client = createMockSupabaseClient( + { status: 200 }, + { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false }, + ); + instrumentSupabaseClient(client); + + await (client as any).from('users').update({}).then(); + + const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as { + name: string; + attributes: Record; + }; + expect(spanOptions.name).toContain('[redacted]'); + expect(spanOptions.name).not.toContain('secret'); + expect(spanOptions.attributes['db.query']).toBeUndefined(); + expect(spanOptions.attributes['db.body']).toBeUndefined(); + + const breadcrumb = addBreadcrumbSpy.mock.calls[0]![0] as { data?: unknown }; + expect(breadcrumb).not.toHaveProperty('data'); + }); + + it('includes db.query, db.body, and breadcrumb query/body when sendDefaultPii is true', async () => { + const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: true }); + instrumentSupabaseClient(client); + + await (client as any).from('users').update({}).then(); + + const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as { + name: string; + attributes: Record; + }; + expect(spanOptions.name).toContain('eq(email, secret@example.com)'); + expect(spanOptions.attributes['db.query']).toEqual( + expect.arrayContaining([expect.stringContaining('secret@example.com')]), + ); + expect(spanOptions.attributes['db.body']).toEqual( + expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }), + ); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + query: expect.any(Array), + body: expect.objectContaining({ full_name: 'Jane Doe' }), + }), + }), + ); + }); + + it('omits supabase error context query/body when sendDefaultPii is false', async () => { + const client = createMockSupabaseClient( + { status: 400, error: { message: 'Bad request', code: '400' } }, + { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false }, + ); + instrumentSupabaseClient(client); + + await (client as any).from('users').update({}).then(); + + expect(captureExceptionSpy).toHaveBeenCalled(); + const scopeCallback = captureExceptionSpy.mock.calls[0]![1] as (scope: { + addEventProcessor: (fn: (e: unknown) => unknown) => void; + setContext: (key: string, ctx: Record) => void; + }) => unknown; + const contexts: Record> = {}; + scopeCallback({ + addEventProcessor: () => {}, + setContext(key: string, ctx: Record) { + contexts[key] = ctx; + }, + } as any); + expect(contexts.supabase).toEqual({}); + }); + }); });