Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions packages/core/src/integrations/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string> | WebFetchHeaders;

export interface PostgRESTFilterBuilder {
method: string;
headers: Record<string, string>;
headers: PostgRESTHeaders;
url: URL;
schema: string;
body: any;
Expand Down Expand Up @@ -168,19 +175,42 @@ function hasMutationBodyForDescription(rawBody: unknown, plainBody: Record<strin
return getMutationBodyPayloadForTelemetry(rawBody, plainBody) !== undefined;
}

/**
* Reads a header off a PostgREST builder, regardless of whether it holds a plain object or a
* `Headers` instance. Lookup is case-insensitive because `Headers` lower-cases all of its keys.
* @param headers - The request headers
* @param name - The header name to look up
* @returns The header value, or `undefined` if it is not set
*/
export function getHeader(headers: PostgRESTHeaders | undefined, name: string): string | undefined {
if (!headers) {
return undefined;
}

if (typeof (headers as WebFetchHeaders).get === 'function') {
return (headers as WebFetchHeaders).get(name) ?? undefined;
}

const plainHeaders = headers as Record<string, string>;
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, string> = {}): 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';
Expand Down Expand Up @@ -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',
Expand Down
87 changes: 85 additions & 2 deletions packages/core/test/lib/integrations/supabase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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<string, string> = { 'X-Client-Info': 'supabase-js/2.0.0' };
headers: PostgRESTHeaders = headers;
url = requestUrl;
schema = 'public';
body = body;
Expand Down Expand Up @@ -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');
Expand All @@ -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');
});
Expand Down Expand Up @@ -433,4 +467,53 @@ describe('Supabase Integration', () => {
expect(spanOptions.attributes['db.body']).toEqual([{ title: 'Test Todo' }]);
});
});

describe.each([
['plain object headers', (init: Record<string, string>): PostgRESTHeaders => init],
['Headers instance', (init: Record<string, string>): 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<string, unknown> };
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<string, unknown>;
};
expect(spanOptions.name).toMatch(/^upsert\(\.\.\.\)/);
expect(spanOptions.attributes['db.operation']).toBe('upsert');
});
});
});
Loading