Skip to content

Commit fefe0d2

Browse files
msonnbclaude
andauthored
fix(core): Read Supabase PostgREST headers from Headers instances (#23234)
## What Read `headers` as plain object or Headers Web API object in Supabase integration to support all supabase-js versions. Also read the plain object case-insensitively, matching the Headers API. ## Why `postgrest-js` changed `PostgrestBuilder.headers` from a plain object to a `Headers` instance in v2.74.0 (original PR: supabase/postgrest-js#619). The Supabase integration read `headers['Prefer']` and `headers['X-Client-Info']`, which returns `undefined` on a `Headers` instance, so every upsert was reported as an insert and the `db.sdk` span attribute was dropped. Bugbot correctly identified this in #23108 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 48d065c commit fefe0d2

2 files changed

Lines changed: 119 additions & 6 deletions

File tree

packages/core/src/integrations/supabase.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { defineIntegration } from '../integration';
1111
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes';
1212
import { setHttpStatus, SPAN_STATUS_ERROR, SPAN_STATUS_OK, startSpan } from '../tracing';
1313
import type { IntegrationFn } from '../types/integration';
14+
import type { WebFetchHeaders } from '../types/webfetchapi';
1415
import { debug } from '../utils/debug-logger';
1516
import { isObjectLike, isPlainObject } from '../utils/is';
1617
import { addExceptionMechanism } from '../utils/misc';
@@ -84,9 +85,15 @@ export interface PostgRESTQueryBuilder {
8485
[key: string]: PostgRESTQueryOperationFn;
8586
}
8687

88+
/**
89+
* `postgrest-js` stores the request headers as a plain object up to v1.19.x and as a `Headers`
90+
* instance from v2.74.0 on (shipped with `supabase-js` 2.74.0), so we have to handle both shapes.
91+
*/
92+
export type PostgRESTHeaders = Record<string, string> | WebFetchHeaders;
93+
8794
export interface PostgRESTFilterBuilder {
8895
method: string;
89-
headers: Record<string, string>;
96+
headers: PostgRESTHeaders;
9097
url: URL;
9198
schema: string;
9299
body: any;
@@ -168,19 +175,42 @@ function hasMutationBodyForDescription(rawBody: unknown, plainBody: Record<strin
168175
return getMutationBodyPayloadForTelemetry(rawBody, plainBody) !== undefined;
169176
}
170177

178+
/**
179+
* Reads a header off a PostgREST builder, regardless of whether it holds a plain object or a
180+
* `Headers` instance. Lookup is case-insensitive because `Headers` lower-cases all of its keys.
181+
* @param headers - The request headers
182+
* @param name - The header name to look up
183+
* @returns The header value, or `undefined` if it is not set
184+
*/
185+
export function getHeader(headers: PostgRESTHeaders | undefined, name: string): string | undefined {
186+
if (!headers) {
187+
return undefined;
188+
}
189+
190+
if (typeof (headers as WebFetchHeaders).get === 'function') {
191+
return (headers as WebFetchHeaders).get(name) ?? undefined;
192+
}
193+
194+
const plainHeaders = headers as Record<string, string>;
195+
const lowerCaseName = name.toLowerCase();
196+
const key = Object.keys(plainHeaders).find(headerName => headerName.toLowerCase() === lowerCaseName);
197+
198+
return key !== undefined ? plainHeaders[key] : undefined;
199+
}
200+
171201
/**
172202
* Extracts the database operation type from the HTTP method and headers
173203
* @param method - The HTTP method of the request
174204
* @param headers - The request headers
175205
* @returns The database operation type ('select', 'insert', 'upsert', 'update', or 'delete')
176206
*/
177-
export function extractOperation(method: string, headers: Record<string, string> = {}): string {
207+
export function extractOperation(method: string, headers: PostgRESTHeaders = {}): string {
178208
switch (method) {
179209
case 'GET': {
180210
return 'select';
181211
}
182212
case 'POST': {
183-
if (headers['Prefer']?.includes('resolution=')) {
213+
if (getHeader(headers, 'Prefer')?.includes('resolution=')) {
184214
return 'upsert';
185215
} else {
186216
return 'insert';
@@ -404,7 +434,7 @@ function instrumentPostgRESTFilterBuilder(
404434
'db.table': table,
405435
'db.schema': typedThis.schema,
406436
'db.url': typedThis.url.origin,
407-
'db.sdk': typedThis.headers['X-Client-Info'],
437+
'db.sdk': getHeader(typedThis.headers, 'X-Client-Info'),
408438
'db.system': 'postgresql',
409439
'db.operation': operation,
410440
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase',

packages/core/test/lib/integrations/supabase.test.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@ import * as breadcrumbModule from '../../../src/breadcrumbs';
33
import * as exportsModule from '../../../src/exports';
44
import {
55
extractOperation,
6+
getHeader,
67
instrumentSupabaseClient,
78
translateFiltersIntoMethods,
89
} from '../../../src/integrations/supabase';
9-
import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase';
10+
import type {
11+
PostgRESTHeaders,
12+
PostgRESTQueryBuilder,
13+
SupabaseClientInstance,
14+
} from '../../../src/integrations/supabase';
1015
import { resolveDataCollectionOptions } from '../../../src/utils/data-collection/resolveDataCollectionOptions';
1116

1217
const tracingMocks = vi.hoisted(() => ({
@@ -39,6 +44,8 @@ type CreateMockSupabaseClientOptions = {
3944
method?: string;
4045
url?: URL | string;
4146
body?: unknown;
47+
/** Defaults to the plain-object shape used by `postgrest-js` v1. Pass a `Headers` instance to emulate v2. */
48+
headers?: PostgRESTHeaders;
4249
/** When set, configures the mocked Sentry client's `dataCollection.databaseQueryData`. Omit to leave `getClient` to the test file `beforeEach`. */
4350
dataCollectionDatabaseQueryData?: boolean;
4451
};
@@ -67,10 +74,11 @@ function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupa
6774
: new URL(options.url)
6875
: new URL(DEFAULT_MOCK_SUPABASE_REST_URL);
6976
const body = options?.body;
77+
const headers = options?.headers ?? { 'X-Client-Info': 'supabase-js/2.0.0' };
7078

7179
class MockPostgRESTFilterBuilder {
7280
method = method;
73-
headers: Record<string, string> = { 'X-Client-Info': 'supabase-js/2.0.0' };
81+
headers: PostgRESTHeaders = headers;
7482
url = requestUrl;
7583
schema = 'public';
7684
body = body;
@@ -116,6 +124,28 @@ describe('Supabase Integration', () => {
116124
currentScopesMocks.getClient.mockReturnValue(undefined);
117125
});
118126

127+
describe('getHeader', () => {
128+
it('reads a header off a plain object', () => {
129+
expect(getHeader({ 'X-Client-Info': 'supabase-js/2.0.0' }, 'X-Client-Info')).toBe('supabase-js/2.0.0');
130+
});
131+
132+
it('reads a header off a Headers instance', () => {
133+
expect(getHeader(new Headers({ 'X-Client-Info': 'supabase-js/2.112.0' }), 'X-Client-Info')).toBe(
134+
'supabase-js/2.112.0',
135+
);
136+
});
137+
138+
it('looks up plain object headers case-insensitively', () => {
139+
expect(getHeader({ prefer: 'resolution=merge-duplicates' }, 'Prefer')).toBe('resolution=merge-duplicates');
140+
});
141+
142+
it('returns undefined for unset headers', () => {
143+
expect(getHeader({ Prefer: 'count=exact' }, 'X-Client-Info')).toBeUndefined();
144+
expect(getHeader(new Headers({ Prefer: 'count=exact' }), 'X-Client-Info')).toBeUndefined();
145+
expect(getHeader(undefined, 'X-Client-Info')).toBeUndefined();
146+
});
147+
});
148+
119149
describe('extractOperation', () => {
120150
it('returns select for GET', () => {
121151
expect(extractOperation('GET')).toBe('select');
@@ -129,6 +159,10 @@ describe('Supabase Integration', () => {
129159
expect(extractOperation('POST', { Prefer: 'resolution=merge-duplicates' })).toBe('upsert');
130160
});
131161

162+
it('returns upsert for POST with resolution header on a Headers instance', () => {
163+
expect(extractOperation('POST', new Headers({ Prefer: 'resolution=merge-duplicates' }))).toBe('upsert');
164+
});
165+
132166
it('returns update for PATCH', () => {
133167
expect(extractOperation('PATCH')).toBe('update');
134168
});
@@ -433,4 +467,53 @@ describe('Supabase Integration', () => {
433467
expect(spanOptions.attributes['db.body']).toEqual([{ title: 'Test Todo' }]);
434468
});
435469
});
470+
471+
describe.each([
472+
['plain object headers', (init: Record<string, string>): PostgRESTHeaders => init],
473+
['Headers instance', (init: Record<string, string>): PostgRESTHeaders => new Headers(init)],
474+
])('%s', (_name, createHeaders) => {
475+
beforeEach(() => {
476+
vi.spyOn(breadcrumbModule, 'addBreadcrumb').mockImplementation(() => {});
477+
});
478+
479+
afterEach(() => {
480+
vi.restoreAllMocks();
481+
});
482+
483+
it('sets db.sdk from X-Client-Info', async () => {
484+
tracingMocks.startSpan.mockClear();
485+
const client = createMockSupabaseClient(
486+
{ status: 200 },
487+
{ headers: createHeaders({ 'X-Client-Info': 'supabase-js/2.112.0' }) },
488+
);
489+
instrumentSupabaseClient(client);
490+
491+
await (client as any).from('todos').select().then();
492+
493+
const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as { attributes: Record<string, unknown> };
494+
expect(spanOptions.attributes['db.sdk']).toBe('supabase-js/2.112.0');
495+
});
496+
497+
it('detects upsert from the Prefer header', async () => {
498+
tracingMocks.startSpan.mockClear();
499+
const client = createMockSupabaseClient(
500+
{ status: 200 },
501+
{
502+
method: 'POST',
503+
body: { title: 'Test Todo' },
504+
headers: createHeaders({ Prefer: 'resolution=merge-duplicates' }),
505+
},
506+
);
507+
instrumentSupabaseClient(client);
508+
509+
await (client as any).from('todos').upsert({}).then();
510+
511+
const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
512+
name: string;
513+
attributes: Record<string, unknown>;
514+
};
515+
expect(spanOptions.name).toMatch(/^upsert\(\.\.\.\)/);
516+
expect(spanOptions.attributes['db.operation']).toBe('upsert');
517+
});
518+
});
436519
});

0 commit comments

Comments
 (0)