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 79c9861008f7..6b28f1ee0e9f 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 @@ -1,5 +1,10 @@ import { expect, test } from '@playwright/test'; -import { collectStreamedSpansUntilSegment, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { + collectStreamedSpansUntilSegment, + getSpanOp, + waitForEnvelopeItem, + waitForStreamedSpan, +} from '@sentry-internal/test-utils'; type StreamedSpan = Awaited>; @@ -65,7 +70,21 @@ test('Sends client-side Supabase db-operation spans to Sentry', async ({ page }) ); }); - const insertSpanPromise = waitForStreamedSpan('supabase-nextjs', span => span.name === 'insert todos'); + let insertSpanSent = false; + waitForStreamedSpan('supabase-nextjs', span => { + if (span.name === 'insert todos') { + insertSpanSent = true; + } + return false; + }).catch(() => {}); + + const clientReportPromise = waitForEnvelopeItem('supabase-nextjs', ([header, payload]) => { + if (header.type !== 'client_report') { + return false; + } + const { discarded_events } = payload as { discarded_events: Array<{ reason: string; category: string }> }; + return discarded_events.some(e => e.reason === 'no_parent_span' && e.category === 'span'); + }); await page.goto('/'); @@ -78,15 +97,21 @@ test('Sends client-side Supabase db-operation spans to Sentry', async ({ page }) // Wait for login to complete await page.waitForSelector('button:has-text("Add")'); + const [pageloadSpan, selectSpan] = await Promise.all([pageloadSpanPromise, selectSpanPromise]); + // Add a new todo entry - await page.locator('input[id=new-task-text]').fill('test'); + const task = `test-${Date.now()}`; + await page.locator('input[id=new-task-text]').fill(task); await page.locator('button[id=add-task]').click(); + await page.getByText(task).waitFor(); + + // Hiding the page flushes the pending client report. + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' }); + document.dispatchEvent(new Event('visibilitychange')); + }); - const [pageloadSpan, selectSpan, insertSpan] = await Promise.all([ - pageloadSpanPromise, - selectSpanPromise, - insertSpanPromise, - ]); + await clientReportPromise; // Client-side database query data is collected by default. expectDbSpan(selectSpan, 'select todos', { @@ -94,13 +119,10 @@ test('Sends client-side Supabase db-operation spans to Sentry', async ({ page }) 'db.query': { value: ['select(*)', 'filter(order, asc)'], type: 'array' }, }); expect(selectSpan.trace_id).toBe(pageloadSpan.trace_id); + expect(selectSpan.parent_span_id).toBe(pageloadSpan.span_id); - // The insert is triggered long after the pageload span has ended, so it is streamed on its own - // rather than as a child of the pageload span. - expectDbSpan(insertSpan, 'insert todos', { - 'db.operation.name': { value: 'insert', type: 'string' }, - 'db.query': { value: ['select(*)'], type: 'array' }, - }); + // The insert runs after the pageload span has ended, so it has no parent and no span is sent. + expect(insertSpanSent).toBe(false); }); test('Sends server-side Supabase db-operation spans to Sentry', async ({ baseURL }) => { diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index 187659aa6345..16b6f95588b7 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -295,6 +295,7 @@ function instrumentAuthOperation(operation: AuthOperationFn, isAdmin = false): A return startSpanManual( { name, + onlyIfParent: true, attributes: { [SENTRY_ORIGIN]: 'auto.db.supabase', [SENTRY_OP]: DB, @@ -479,6 +480,7 @@ function instrumentPostgRESTFilterBuilder( return startSpanManual( { name, + onlyIfParent: true, attributes, }, span => { diff --git a/packages/core/test/lib/integrations/supabase.test.ts b/packages/core/test/lib/integrations/supabase.test.ts index cae6111f7e4d..dcb0233abdb0 100644 --- a/packages/core/test/lib/integrations/supabase.test.ts +++ b/packages/core/test/lib/integrations/supabase.test.ts @@ -645,4 +645,42 @@ describe('Supabase Integration', () => { expect(spanOptions.attributes['db.operation.name']).toBe('upsert'); }); }); + + describe('onlyIfParent', () => { + beforeEach(() => { + vi.spyOn(exportsModule, 'captureException').mockImplementation(() => ''); + vi.spyOn(breadcrumbModule, 'addBreadcrumb').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts the PostgREST span with onlyIfParent so it is dropped without an active parent', async () => { + tracingMocks.startSpanManual.mockClear(); + const client = createMockSupabaseClient({ status: 200, data: [] }); + instrumentSupabaseClient(client); + + await (client as any).from('todos').select('*').then(); + + expect(tracingMocks.startSpanManual).toHaveBeenCalledWith( + expect.objectContaining({ onlyIfParent: true }), + expect.any(Function), + ); + }); + + it('starts the auth span with onlyIfParent so it is dropped without an active parent', async () => { + tracingMocks.startSpanManual.mockClear(); + const client = createMockSupabaseClient(undefined) as any; + client.auth.signInWithPassword = vi.fn(() => Promise.resolve({ data: { user: {} }, error: null })); + instrumentSupabaseClient(client); + + await client.auth.signInWithPassword({ email: 'a@b.c', password: 'pw' }); + + expect(tracingMocks.startSpanManual).toHaveBeenCalledWith( + expect.objectContaining({ onlyIfParent: true }), + expect.any(Function), + ); + }); + }); });