diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index 64a75359bcbd..605228376354 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -11,6 +11,7 @@ import { defineIntegration } from '../integration'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; import { setHttpStatus, SPAN_STATUS_ERROR, SPAN_STATUS_OK, startSpan } from '../tracing'; import type { IntegrationFn } from '../types/integration'; +import type { WebFetchHeaders } from '../types/webfetchapi'; import { debug } from '../utils/debug-logger'; import { isObjectLike, isPlainObject } from '../utils/is'; import { addExceptionMechanism } from '../utils/misc'; @@ -84,9 +85,15 @@ export interface PostgRESTQueryBuilder { [key: string]: PostgRESTQueryOperationFn; } +/** + * `postgrest-js` stores the request headers as a plain object up to v1.19.x and as a `Headers` + * instance from v2.74.0 on (shipped with `supabase-js` 2.74.0), so we have to handle both shapes. + */ +export type PostgRESTHeaders = Record | WebFetchHeaders; + export interface PostgRESTFilterBuilder { method: string; - headers: Record; + headers: PostgRESTHeaders; url: URL; schema: string; body: any; @@ -168,19 +175,42 @@ function hasMutationBodyForDescription(rawBody: unknown, plainBody: Record; + const lowerCaseName = name.toLowerCase(); + const key = Object.keys(plainHeaders).find(headerName => headerName.toLowerCase() === lowerCaseName); + + return key !== undefined ? plainHeaders[key] : undefined; +} + /** * Extracts the database operation type from the HTTP method and headers * @param method - The HTTP method of the request * @param headers - The request headers * @returns The database operation type ('select', 'insert', 'upsert', 'update', or 'delete') */ -export function extractOperation(method: string, headers: Record = {}): string { +export function extractOperation(method: string, headers: PostgRESTHeaders = {}): string { switch (method) { case 'GET': { return 'select'; } case 'POST': { - if (headers['Prefer']?.includes('resolution=')) { + if (getHeader(headers, 'Prefer')?.includes('resolution=')) { return 'upsert'; } else { return 'insert'; @@ -404,7 +434,7 @@ function instrumentPostgRESTFilterBuilder( 'db.table': table, 'db.schema': typedThis.schema, 'db.url': typedThis.url.origin, - 'db.sdk': typedThis.headers['X-Client-Info'], + 'db.sdk': getHeader(typedThis.headers, 'X-Client-Info'), 'db.system': 'postgresql', 'db.operation': operation, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', diff --git a/packages/core/test/lib/integrations/supabase.test.ts b/packages/core/test/lib/integrations/supabase.test.ts index fef69305a6fd..d776b8a85c83 100644 --- a/packages/core/test/lib/integrations/supabase.test.ts +++ b/packages/core/test/lib/integrations/supabase.test.ts @@ -3,10 +3,15 @@ import * as breadcrumbModule from '../../../src/breadcrumbs'; import * as exportsModule from '../../../src/exports'; import { extractOperation, + getHeader, instrumentSupabaseClient, translateFiltersIntoMethods, } from '../../../src/integrations/supabase'; -import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase'; +import type { + PostgRESTHeaders, + PostgRESTQueryBuilder, + SupabaseClientInstance, +} from '../../../src/integrations/supabase'; import { resolveDataCollectionOptions } from '../../../src/utils/data-collection/resolveDataCollectionOptions'; const tracingMocks = vi.hoisted(() => ({ @@ -39,6 +44,8 @@ type CreateMockSupabaseClientOptions = { method?: string; url?: URL | string; body?: unknown; + /** Defaults to the plain-object shape used by `postgrest-js` v1. Pass a `Headers` instance to emulate v2. */ + headers?: PostgRESTHeaders; /** When set, configures the mocked Sentry client's `dataCollection.databaseQueryData`. Omit to leave `getClient` to the test file `beforeEach`. */ dataCollectionDatabaseQueryData?: boolean; }; @@ -67,10 +74,11 @@ function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupa : new URL(options.url) : new URL(DEFAULT_MOCK_SUPABASE_REST_URL); const body = options?.body; + const headers = options?.headers ?? { 'X-Client-Info': 'supabase-js/2.0.0' }; class MockPostgRESTFilterBuilder { method = method; - headers: Record = { 'X-Client-Info': 'supabase-js/2.0.0' }; + headers: PostgRESTHeaders = headers; url = requestUrl; schema = 'public'; body = body; @@ -116,6 +124,28 @@ describe('Supabase Integration', () => { currentScopesMocks.getClient.mockReturnValue(undefined); }); + describe('getHeader', () => { + it('reads a header off a plain object', () => { + expect(getHeader({ 'X-Client-Info': 'supabase-js/2.0.0' }, 'X-Client-Info')).toBe('supabase-js/2.0.0'); + }); + + it('reads a header off a Headers instance', () => { + expect(getHeader(new Headers({ 'X-Client-Info': 'supabase-js/2.112.0' }), 'X-Client-Info')).toBe( + 'supabase-js/2.112.0', + ); + }); + + it('looks up plain object headers case-insensitively', () => { + expect(getHeader({ prefer: 'resolution=merge-duplicates' }, 'Prefer')).toBe('resolution=merge-duplicates'); + }); + + it('returns undefined for unset headers', () => { + expect(getHeader({ Prefer: 'count=exact' }, 'X-Client-Info')).toBeUndefined(); + expect(getHeader(new Headers({ Prefer: 'count=exact' }), 'X-Client-Info')).toBeUndefined(); + expect(getHeader(undefined, 'X-Client-Info')).toBeUndefined(); + }); + }); + describe('extractOperation', () => { it('returns select for GET', () => { expect(extractOperation('GET')).toBe('select'); @@ -129,6 +159,10 @@ describe('Supabase Integration', () => { expect(extractOperation('POST', { Prefer: 'resolution=merge-duplicates' })).toBe('upsert'); }); + it('returns upsert for POST with resolution header on a Headers instance', () => { + expect(extractOperation('POST', new Headers({ Prefer: 'resolution=merge-duplicates' }))).toBe('upsert'); + }); + it('returns update for PATCH', () => { expect(extractOperation('PATCH')).toBe('update'); }); @@ -433,4 +467,53 @@ describe('Supabase Integration', () => { expect(spanOptions.attributes['db.body']).toEqual([{ title: 'Test Todo' }]); }); }); + + describe.each([ + ['plain object headers', (init: Record): PostgRESTHeaders => init], + ['Headers instance', (init: Record): PostgRESTHeaders => new Headers(init)], + ])('%s', (_name, createHeaders) => { + beforeEach(() => { + vi.spyOn(breadcrumbModule, 'addBreadcrumb').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sets db.sdk from X-Client-Info', async () => { + tracingMocks.startSpan.mockClear(); + const client = createMockSupabaseClient( + { status: 200 }, + { headers: createHeaders({ 'X-Client-Info': 'supabase-js/2.112.0' }) }, + ); + instrumentSupabaseClient(client); + + await (client as any).from('todos').select().then(); + + const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as { attributes: Record }; + expect(spanOptions.attributes['db.sdk']).toBe('supabase-js/2.112.0'); + }); + + it('detects upsert from the Prefer header', async () => { + tracingMocks.startSpan.mockClear(); + const client = createMockSupabaseClient( + { status: 200 }, + { + method: 'POST', + body: { title: 'Test Todo' }, + headers: createHeaders({ Prefer: 'resolution=merge-duplicates' }), + }, + ); + instrumentSupabaseClient(client); + + await (client as any).from('todos').upsert({}).then(); + + const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as { + name: string; + attributes: Record; + }; + expect(spanOptions.name).toMatch(/^upsert\(\.\.\.\)/); + expect(spanOptions.attributes['db.operation']).toBe('upsert'); + }); + }); });