Skip to content

Commit 27cff01

Browse files
committed
fix(supabase): Consider sendDefaultPii for supabase integration
1 parent e2d35a2 commit 27cff01

2 files changed

Lines changed: 197 additions & 59 deletions

File tree

packages/core/src/integrations/supabase.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
/* eslint-disable @typescript-eslint/no-explicit-any */
55
/* eslint-disable max-lines */
66
import { addBreadcrumb } from '../breadcrumbs';
7+
import { getClient } from '../currentScopes';
78
import { DEBUG_BUILD } from '../debug-build';
89
import { captureException } from '../exports';
910
import { defineIntegration } from '../integration';
@@ -361,12 +362,15 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
361362
}
362363
}
363364

365+
const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii);
366+
364367
// Adding operation to the beginning of the description if it's not a `select` operation
365368
// For example, it can be an `insert` or `update` operation but the query can be `select(...)`
366369
// For `select` operations, we don't need repeat it in the description
367-
const description = `${operation === 'select' ? '' : `${operation}${body ? '(...) ' : ''}`}${queryItems.join(
368-
' ',
369-
)} from(${table})`;
370+
const mutationPart = operation === 'select' ? '' : `${operation}${Object.keys(body).length ? '(...) ' : ''}`;
371+
const queryPart = sendDefaultPii ? queryItems.join(' ') : queryItems.length > 0 ? '[redacted]' : '';
372+
const descriptionMiddle = [mutationPart.trimEnd(), queryPart].filter(Boolean).join(' ');
373+
const description = descriptionMiddle ? `${descriptionMiddle} from(${table})` : `from(${table})`;
370374

371375
const attributes: Record<string, any> = {
372376
'db.table': table,
@@ -379,11 +383,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
379383
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db',
380384
};
381385

382-
if (queryItems.length) {
386+
if (queryItems.length && sendDefaultPii) {
383387
attributes['db.query'] = queryItems;
384388
}
385389

386-
if (Object.keys(body).length) {
390+
if (Object.keys(body).length && sendDefaultPii) {
387391
attributes['db.body'] = body;
388392
}
389393

@@ -413,10 +417,10 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
413417
}
414418

415419
const supabaseContext: Record<string, any> = {};
416-
if (queryItems.length) {
420+
if (queryItems.length && sendDefaultPii) {
417421
supabaseContext.query = queryItems;
418422
}
419-
if (Object.keys(body).length) {
423+
if (Object.keys(body).length && sendDefaultPii) {
420424
supabaseContext.body = body;
421425
}
422426

@@ -444,11 +448,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
444448

445449
const data: Record<string, unknown> = {};
446450

447-
if (queryItems.length) {
451+
if (queryItems.length && sendDefaultPii) {
448452
data.query = queryItems;
449453
}
450454

451-
if (Object.keys(body).length) {
455+
if (Object.keys(body).length && sendDefaultPii) {
452456
data.body = body;
453457
}
454458

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

Lines changed: 184 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,113 @@ import {
88
} from '../../../src/integrations/supabase';
99
import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase';
1010

11-
// Mock tracing to avoid needing full SDK setup
12-
vi.mock('../../../src/tracing', () => ({
13-
startSpan: (_opts: any, cb: (span: any) => any) => {
11+
const tracingMocks = vi.hoisted(() => ({
12+
startSpan: vi.fn((_opts: unknown, cb: (span: unknown) => unknown) => {
1413
const mockSpan = {
1514
setStatus: vi.fn(),
1615
end: vi.fn(),
1716
};
1817
return cb(mockSpan);
19-
},
18+
}),
19+
}));
20+
21+
const currentScopesMocks = vi.hoisted(() => ({
22+
getClient: vi.fn(),
23+
}));
24+
25+
// Mock tracing to avoid needing full SDK setup
26+
vi.mock('../../../src/tracing', () => ({
27+
startSpan: tracingMocks.startSpan,
2028
setHttpStatus: vi.fn(),
2129
SPAN_STATUS_OK: 1,
2230
SPAN_STATUS_ERROR: 2,
2331
}));
2432

33+
vi.mock('../../../src/currentScopes', () => ({
34+
getClient: currentScopesMocks.getClient,
35+
}));
36+
37+
type CreateMockSupabaseClientOptions = {
38+
method?: string;
39+
url?: URL | string;
40+
body?: unknown;
41+
/** When set, configures the mocked Sentry client `sendDefaultPii`. Omit to leave `getClient` to the test file `beforeEach`. */
42+
sendDefaultPii?: boolean;
43+
};
44+
45+
const DEFAULT_MOCK_SUPABASE_REST_URL = 'https://example.supabase.co/rest/v1/todos';
46+
47+
/** Shared PATCH + query string + body shape for `sendDefaultPii` tests. */
48+
const MOCK_SUPABASE_PII_SCENARIO: Pick<CreateMockSupabaseClientOptions, 'method' | 'url' | 'body'> = {
49+
method: 'PATCH',
50+
url: 'https://example.supabase.co/rest/v1/users?email=eq.secret%40example.com&select=id',
51+
body: { full_name: 'Jane Doe', phone: '555-0100' },
52+
};
53+
54+
function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupabaseClientOptions): unknown {
55+
if (options?.sendDefaultPii !== undefined) {
56+
currentScopesMocks.getClient.mockReturnValue({
57+
getOptions: () => ({ sendDefaultPii: options.sendDefaultPii }),
58+
} as any);
59+
}
60+
61+
const method = options?.method ?? 'GET';
62+
const requestUrl =
63+
options?.url !== undefined
64+
? options.url instanceof URL
65+
? options.url
66+
: new URL(options.url)
67+
: new URL(DEFAULT_MOCK_SUPABASE_REST_URL);
68+
const body = options?.body;
69+
70+
class MockPostgRESTFilterBuilder {
71+
method = method;
72+
headers: Record<string, string> = { 'X-Client-Info': 'supabase-js/2.0.0' };
73+
url = requestUrl;
74+
schema = 'public';
75+
body = body;
76+
77+
then(onfulfilled?: (value: any) => any, onrejected?: (reason: any) => any): Promise<any> {
78+
return Promise.resolve(resolveWith).then(onfulfilled, onrejected);
79+
}
80+
}
81+
82+
class MockPostgRESTQueryBuilder {
83+
select() {
84+
return new MockPostgRESTFilterBuilder();
85+
}
86+
insert() {
87+
return new MockPostgRESTFilterBuilder();
88+
}
89+
upsert() {
90+
return new MockPostgRESTFilterBuilder();
91+
}
92+
update() {
93+
return new MockPostgRESTFilterBuilder();
94+
}
95+
delete() {
96+
return new MockPostgRESTFilterBuilder();
97+
}
98+
}
99+
100+
class MockSupabaseClient {
101+
auth = {
102+
admin: {} as any,
103+
} as SupabaseClientInstance['auth'];
104+
105+
from(_table: string): PostgRESTQueryBuilder {
106+
return new MockPostgRESTQueryBuilder() as unknown as PostgRESTQueryBuilder;
107+
}
108+
}
109+
110+
return new MockSupabaseClient();
111+
}
112+
25113
describe('Supabase Integration', () => {
114+
beforeEach(() => {
115+
currentScopesMocks.getClient.mockReturnValue(undefined);
116+
});
117+
26118
describe('extractOperation', () => {
27119
it('returns select for GET', () => {
28120
expect(extractOperation('GET')).toBe('select');
@@ -72,52 +164,6 @@ describe('Supabase Integration', () => {
72164
vi.restoreAllMocks();
73165
});
74166

75-
function createMockSupabaseClient(resolveWith: unknown): unknown {
76-
// Create a PostgRESTFilterBuilder-like class
77-
class MockPostgRESTFilterBuilder {
78-
method = 'GET';
79-
headers: Record<string, string> = { 'X-Client-Info': 'supabase-js/2.0.0' };
80-
url = new URL('https://example.supabase.co/rest/v1/todos');
81-
schema = 'public';
82-
body = undefined;
83-
84-
then(onfulfilled?: (value: any) => any, onrejected?: (reason: any) => any): Promise<any> {
85-
return Promise.resolve(resolveWith).then(onfulfilled, onrejected);
86-
}
87-
}
88-
89-
class MockPostgRESTQueryBuilder {
90-
select() {
91-
return new MockPostgRESTFilterBuilder();
92-
}
93-
insert() {
94-
return new MockPostgRESTFilterBuilder();
95-
}
96-
upsert() {
97-
return new MockPostgRESTFilterBuilder();
98-
}
99-
update() {
100-
return new MockPostgRESTFilterBuilder();
101-
}
102-
delete() {
103-
return new MockPostgRESTFilterBuilder();
104-
}
105-
}
106-
107-
// Create a mock SupabaseClient constructor
108-
class MockSupabaseClient {
109-
auth = {
110-
admin: {} as any,
111-
} as SupabaseClientInstance['auth'];
112-
113-
from(_table: string): PostgRESTQueryBuilder {
114-
return new MockPostgRESTQueryBuilder() as unknown as PostgRESTQueryBuilder;
115-
}
116-
}
117-
118-
return new MockSupabaseClient();
119-
}
120-
121167
it('handles undefined response without throwing', async () => {
122168
const client = createMockSupabaseClient(undefined);
123169
instrumentSupabaseClient(client);
@@ -176,4 +222,92 @@ describe('Supabase Integration', () => {
176222
expect(captureExceptionSpy).toHaveBeenCalled();
177223
});
178224
});
225+
226+
describe('sendDefaultPii', () => {
227+
let captureExceptionSpy: ReturnType<typeof vi.spyOn>;
228+
let addBreadcrumbSpy: ReturnType<typeof vi.spyOn>;
229+
230+
beforeEach(() => {
231+
captureExceptionSpy = vi.spyOn(exportsModule, 'captureException').mockImplementation(() => '');
232+
addBreadcrumbSpy = vi.spyOn(breadcrumbModule, 'addBreadcrumb').mockImplementation(() => {});
233+
});
234+
235+
afterEach(() => {
236+
vi.restoreAllMocks();
237+
});
238+
239+
it('omits db.query, db.body, and breadcrumb query/body when sendDefaultPii is false', async () => {
240+
const client = createMockSupabaseClient(
241+
{ status: 200 },
242+
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
243+
);
244+
instrumentSupabaseClient(client);
245+
246+
await (client as any).from('users').update({}).then();
247+
248+
const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
249+
name: string;
250+
attributes: Record<string, unknown>;
251+
};
252+
expect(spanOptions.name).toContain('[redacted]');
253+
expect(spanOptions.name).not.toContain('secret');
254+
expect(spanOptions.attributes['db.query']).toBeUndefined();
255+
expect(spanOptions.attributes['db.body']).toBeUndefined();
256+
257+
const breadcrumb = addBreadcrumbSpy.mock.calls[0]![0] as { data?: unknown };
258+
expect(breadcrumb).not.toHaveProperty('data');
259+
});
260+
261+
it('includes db.query, db.body, and breadcrumb query/body when sendDefaultPii is true', async () => {
262+
const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: true });
263+
instrumentSupabaseClient(client);
264+
265+
await (client as any).from('users').update({}).then();
266+
267+
const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
268+
name: string;
269+
attributes: Record<string, unknown>;
270+
};
271+
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
272+
expect(spanOptions.attributes['db.query']).toEqual(
273+
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
274+
);
275+
expect(spanOptions.attributes['db.body']).toEqual(
276+
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
277+
);
278+
279+
expect(addBreadcrumbSpy).toHaveBeenCalledWith(
280+
expect.objectContaining({
281+
data: expect.objectContaining({
282+
query: expect.any(Array),
283+
body: expect.objectContaining({ full_name: 'Jane Doe' }),
284+
}),
285+
}),
286+
);
287+
});
288+
289+
it('omits supabase error context query/body when sendDefaultPii is false', async () => {
290+
const client = createMockSupabaseClient(
291+
{ status: 400, error: { message: 'Bad request', code: '400' } },
292+
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
293+
);
294+
instrumentSupabaseClient(client);
295+
296+
await (client as any).from('users').update({}).then();
297+
298+
expect(captureExceptionSpy).toHaveBeenCalled();
299+
const scopeCallback = captureExceptionSpy.mock.calls[0]![1] as (scope: {
300+
addEventProcessor: (fn: (e: unknown) => unknown) => void;
301+
setContext: (key: string, ctx: Record<string, unknown>) => void;
302+
}) => unknown;
303+
const contexts: Record<string, Record<string, unknown>> = {};
304+
scopeCallback({
305+
addEventProcessor: () => {},
306+
setContext(key: string, ctx: Record<string, unknown>) {
307+
contexts[key] = ctx;
308+
},
309+
} as any);
310+
expect(contexts.supabase).toEqual({});
311+
});
312+
});
179313
});

0 commit comments

Comments
 (0)