Skip to content

Commit 2cfabce

Browse files
authored
feat(core): Migrate Supabase integration to dataCollection (#21085)
Replaces `sendDefaultPii` usage in the Supabase integration with the new `dataCollection` API - Adds a new `sendOperationData` integration option to explicitly control whether PostgREST query filters and mutation body payloads are attached - Falls back to dataCollection.userInfo when sendOperationData is not set, preserving backwards compatibility with legacy `sendDefaultPii: true` via the bridge function | Configuration | Data attached? | |---|---| | `sendOperationData: true` | Yes (explicit opt-in) | | `sendOperationData: false` | No (explicit opt-out) | | `sendOperationData` unset + `dataCollection.userInfo: true` | Yes | | `sendOperationData` unset + `sendDefaultPii: true` (bridged) | Yes | | `sendOperationData` unset + defaults | No | closes #21050
1 parent cb0ac22 commit 2cfabce

2 files changed

Lines changed: 144 additions & 33 deletions

File tree

packages/core/src/integrations/supabase.ts

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ function isInstrumented<T>(fn: T): boolean | undefined {
151151

152152
/**
153153
* Plain-object bodies are copied into `plainBody`; array inserts (and other non-plain shapes) stay only on `rawBody`.
154-
* Returns a payload suitable for span attributes / breadcrumbs when the client has `sendDefaultPii` enabled.
154+
* Returns a payload suitable for span attributes / breadcrumbs when operation data collection is enabled.
155155
*/
156156
function getMutationBodyPayloadForTelemetry(rawBody: unknown, plainBody: Record<string, unknown>): unknown | undefined {
157157
if (Object.keys(plainBody).length > 0) {
@@ -322,7 +322,7 @@ function instrumentSupabaseAuthClient(supabaseClientInstance: SupabaseClientInst
322322
markAsInstrumented(supabaseClientInstance.auth);
323323
}
324324

325-
function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void {
325+
function instrumentSupabaseClientConstructor(SupabaseClient: unknown, _options: { sendOperationData?: boolean }): void {
326326
if (isInstrumented((SupabaseClient as SupabaseClientConstructor).prototype.from)) {
327327
return;
328328
}
@@ -334,7 +334,7 @@ function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void {
334334
const rv = Reflect.apply(target, thisArg, argumentsList);
335335
const PostgRESTQueryBuilder = (rv as PostgRESTQueryBuilder).constructor;
336336

337-
instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder);
337+
instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder, _options);
338338

339339
return rv;
340340
},
@@ -344,7 +344,10 @@ function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void {
344344
markAsInstrumented((SupabaseClient as SupabaseClientConstructor).prototype.from);
345345
}
346346

347-
function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor']): void {
347+
function instrumentPostgRESTFilterBuilder(
348+
PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor'],
349+
_options: { sendOperationData?: boolean },
350+
): void {
348351
if (isInstrumented((PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then)) {
349352
return;
350353
}
@@ -381,7 +384,8 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
381384
}
382385
}
383386

384-
const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii);
387+
const client = getClient();
388+
const shouldSendData = _options.sendOperationData ?? client?.getDataCollectionOptions().userInfo === true;
385389
const bodyPayload = getMutationBodyPayloadForTelemetry(typedThis.body, body);
386390

387391
// Adding operation to the beginning of the description if it's not a `select` operation
@@ -391,7 +395,7 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
391395
operation === 'select'
392396
? ''
393397
: `${operation}${hasMutationBodyForDescription(typedThis.body, body) ? '(...) ' : ''}`;
394-
const queryPart = sendDefaultPii ? queryItems.join(' ') : queryItems.length > 0 ? '[redacted]' : '';
398+
const queryPart = shouldSendData ? queryItems.join(' ') : queryItems.length > 0 ? '[redacted]' : '';
395399
const descriptionMiddle = [mutationPart.trimEnd(), queryPart].filter(Boolean).join(' ');
396400
const description = descriptionMiddle ? `${descriptionMiddle} from(${table})` : `from(${table})`;
397401

@@ -406,11 +410,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
406410
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db',
407411
};
408412

409-
if (queryItems.length && sendDefaultPii) {
413+
if (queryItems.length && shouldSendData) {
410414
attributes['db.query'] = queryItems;
411415
}
412416

413-
if (bodyPayload !== undefined && sendDefaultPii) {
417+
if (bodyPayload !== undefined && shouldSendData) {
414418
attributes['db.body'] = bodyPayload;
415419
}
416420

@@ -440,10 +444,10 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
440444
}
441445

442446
const supabaseContext: Record<string, any> = {};
443-
if (queryItems.length && sendDefaultPii) {
447+
if (queryItems.length && shouldSendData) {
444448
supabaseContext.query = queryItems;
445449
}
446-
if (bodyPayload !== undefined && sendDefaultPii) {
450+
if (bodyPayload !== undefined && shouldSendData) {
447451
supabaseContext.body = bodyPayload;
448452
}
449453

@@ -471,11 +475,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
471475

472476
const data: Record<string, unknown> = {};
473477

474-
if (queryItems.length && sendDefaultPii) {
478+
if (queryItems.length && shouldSendData) {
475479
data.query = queryItems;
476480
}
477481

478-
if (bodyPayload !== undefined && sendDefaultPii) {
482+
if (bodyPayload !== undefined && shouldSendData) {
479483
data.body = bodyPayload;
480484
}
481485

@@ -506,7 +510,10 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
506510
markAsInstrumented((PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then);
507511
}
508512

509-
function instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder: new () => PostgRESTQueryBuilder): void {
513+
function instrumentPostgRESTQueryBuilder(
514+
PostgRESTQueryBuilder: new () => PostgRESTQueryBuilder,
515+
_options: { sendOperationData?: boolean },
516+
): void {
510517
// We need to wrap _all_ operations despite them sharing the same `PostgRESTFilterBuilder`
511518
// constructor, as we don't know which method will be called first, and we don't want to miss any calls.
512519
for (const operation of DB_OPERATIONS_TO_INSTRUMENT) {
@@ -524,7 +531,7 @@ function instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder: new () => PostgR
524531

525532
DEBUG_BUILD && debug.log(`Instrumenting ${operation} operation's PostgRESTFilterBuilder`);
526533

527-
instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder);
534+
instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder, _options);
528535

529536
return rv;
530537
},
@@ -535,29 +542,44 @@ function instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder: new () => PostgR
535542
}
536543
}
537544

538-
export const instrumentSupabaseClient = (supabaseClient: unknown): void => {
545+
export const instrumentSupabaseClient = (
546+
supabaseClient: unknown,
547+
options: { sendOperationData?: boolean } = {},
548+
): void => {
539549
if (!supabaseClient) {
540550
DEBUG_BUILD && debug.warn('Supabase integration was not installed because no Supabase client was provided.');
541551
return;
542552
}
543553
const SupabaseClientConstructor =
544554
supabaseClient.constructor === Function ? supabaseClient : supabaseClient.constructor;
545555

546-
instrumentSupabaseClientConstructor(SupabaseClientConstructor);
556+
instrumentSupabaseClientConstructor(SupabaseClientConstructor, options);
547557
instrumentSupabaseAuthClient(supabaseClient as SupabaseClientInstance);
548558
};
549559

560+
interface SupabaseIntegrationOptions {
561+
supabaseClient: any;
562+
/**
563+
* Whether to attach PostgREST query filters and mutation body payloads
564+
* to Sentry telemetry.
565+
*
566+
* Falls back to `dataCollection.userInfo` when not set.
567+
* @default undefined
568+
*/
569+
sendOperationData?: boolean;
570+
}
571+
550572
const INTEGRATION_NAME = 'Supabase';
551573

552-
const _supabaseIntegration = ((supabaseClient: unknown) => {
574+
const _supabaseIntegration = ((supabaseClient: unknown, options: { sendOperationData?: boolean }) => {
553575
return {
554576
setupOnce() {
555-
instrumentSupabaseClient(supabaseClient);
577+
instrumentSupabaseClient(supabaseClient, options);
556578
},
557579
name: INTEGRATION_NAME,
558580
};
559581
}) satisfies IntegrationFn;
560582

561-
export const supabaseIntegration = defineIntegration((options: { supabaseClient: any }) => {
562-
return _supabaseIntegration(options.supabaseClient);
583+
export const supabaseIntegration = defineIntegration((options: SupabaseIntegrationOptions) => {
584+
return _supabaseIntegration(options.supabaseClient, { sendOperationData: options.sendOperationData });
563585
}) satisfies IntegrationFn;

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

Lines changed: 102 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
translateFiltersIntoMethods,
88
} from '../../../src/integrations/supabase';
99
import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase';
10+
import { resolveDataCollectionOptions } from '../../../src/utils/data-collection/resolveDataCollectionOptions';
1011

1112
const tracingMocks = vi.hoisted(() => ({
1213
startSpan: vi.fn((_opts: unknown, cb: (span: unknown) => unknown) => {
@@ -38,23 +39,23 @@ type CreateMockSupabaseClientOptions = {
3839
method?: string;
3940
url?: URL | string;
4041
body?: unknown;
41-
/** When set, configures the mocked Sentry client `sendDefaultPii`. Omit to leave `getClient` to the test file `beforeEach`. */
42-
sendDefaultPii?: boolean;
42+
/** When set, configures the mocked Sentry client's `dataCollection.userInfo`. Omit to leave `getClient` to the test file `beforeEach`. */
43+
dataCollectionUserInfo?: boolean;
4344
};
4445

4546
const DEFAULT_MOCK_SUPABASE_REST_URL = 'https://example.supabase.co/rest/v1/todos';
4647

47-
/** Shared PATCH + query string + body shape for `sendDefaultPii` tests. */
48+
/** Shared PATCH + query string + body shape for operation data tests. */
4849
const MOCK_SUPABASE_PII_SCENARIO: Pick<CreateMockSupabaseClientOptions, 'method' | 'url' | 'body'> = {
4950
method: 'PATCH',
5051
url: 'https://example.supabase.co/rest/v1/users?email=eq.secret%40example.com&select=id',
5152
body: { full_name: 'Jane Doe', phone: '555-0100' },
5253
};
5354

5455
function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupabaseClientOptions): unknown {
55-
if (options?.sendDefaultPii !== undefined) {
56+
if (options?.dataCollectionUserInfo !== undefined) {
5657
currentScopesMocks.getClient.mockReturnValue({
57-
getOptions: () => ({ sendDefaultPii: options.sendDefaultPii }),
58+
getDataCollectionOptions: () => ({ userInfo: options.dataCollectionUserInfo }),
5859
} as any);
5960
}
6061

@@ -223,7 +224,7 @@ describe('Supabase Integration', () => {
223224
});
224225
});
225226

226-
describe('sendDefaultPii', () => {
227+
describe('operation data collection', () => {
227228
let captureExceptionSpy: ReturnType<typeof vi.spyOn>;
228229
let addBreadcrumbSpy: ReturnType<typeof vi.spyOn>;
229230

@@ -236,10 +237,10 @@ describe('Supabase Integration', () => {
236237
vi.restoreAllMocks();
237238
});
238239

239-
it('omits db.query, db.body, and breadcrumb query/body when sendDefaultPii is false', async () => {
240+
it('omits db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is false', async () => {
240241
const client = createMockSupabaseClient(
241242
{ status: 200 },
242-
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
243+
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
243244
);
244245
instrumentSupabaseClient(client);
245246

@@ -258,8 +259,11 @@ describe('Supabase Integration', () => {
258259
expect(breadcrumb).not.toHaveProperty('data');
259260
});
260261

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 });
262+
it('includes db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is true', async () => {
263+
const client = createMockSupabaseClient(
264+
{ status: 200 },
265+
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
266+
);
263267
instrumentSupabaseClient(client);
264268

265269
await (client as any).from('users').update({}).then();
@@ -286,10 +290,95 @@ describe('Supabase Integration', () => {
286290
);
287291
});
288292

289-
it('omits supabase error context query/body when sendDefaultPii is false', async () => {
293+
it('includes data when sendOperationData option is set, regardless of dataCollection.userInfo', async () => {
294+
const client = createMockSupabaseClient(
295+
{ status: 200 },
296+
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
297+
);
298+
instrumentSupabaseClient(client, { sendOperationData: true });
299+
300+
await (client as any).from('users').update({}).then();
301+
302+
const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
303+
name: string;
304+
attributes: Record<string, unknown>;
305+
};
306+
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
307+
expect(spanOptions.attributes['db.query']).toEqual(
308+
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
309+
);
310+
expect(spanOptions.attributes['db.body']).toEqual(
311+
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
312+
);
313+
});
314+
315+
it('sendOperationData: false takes precedence over dataCollection.userInfo: true', async () => {
316+
const client = createMockSupabaseClient(
317+
{ status: 200 },
318+
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
319+
);
320+
instrumentSupabaseClient(client, { sendOperationData: false });
321+
322+
await (client as any).from('users').update({}).then();
323+
324+
const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
325+
name: string;
326+
attributes: Record<string, unknown>;
327+
};
328+
expect(spanOptions.name).toContain('[redacted]');
329+
expect(spanOptions.attributes['db.query']).toBeUndefined();
330+
expect(spanOptions.attributes['db.body']).toBeUndefined();
331+
});
332+
333+
it('includes data when legacy sendDefaultPii: true is bridged to dataCollection.userInfo', async () => {
334+
const resolved = resolveDataCollectionOptions({ sendDefaultPii: true });
335+
currentScopesMocks.getClient.mockReturnValue({
336+
getDataCollectionOptions: () => resolved,
337+
} as any);
338+
339+
const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
340+
instrumentSupabaseClient(client);
341+
342+
await (client as any).from('users').update({}).then();
343+
344+
const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
345+
name: string;
346+
attributes: Record<string, unknown>;
347+
};
348+
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
349+
expect(spanOptions.attributes['db.query']).toEqual(
350+
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
351+
);
352+
expect(spanOptions.attributes['db.body']).toEqual(
353+
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
354+
);
355+
});
356+
357+
it('redacts data when legacy sendDefaultPii is not set (bridged defaults)', async () => {
358+
const resolved = resolveDataCollectionOptions({ sendDefaultPii: false });
359+
currentScopesMocks.getClient.mockReturnValue({
360+
getDataCollectionOptions: () => resolved,
361+
} as any);
362+
363+
const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
364+
instrumentSupabaseClient(client);
365+
366+
await (client as any).from('users').update({}).then();
367+
368+
const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
369+
name: string;
370+
attributes: Record<string, unknown>;
371+
};
372+
expect(spanOptions.name).toContain('[redacted]');
373+
expect(spanOptions.name).not.toContain('secret');
374+
expect(spanOptions.attributes['db.query']).toBeUndefined();
375+
expect(spanOptions.attributes['db.body']).toBeUndefined();
376+
});
377+
378+
it('omits supabase error context query/body when data collection is off', async () => {
290379
const client = createMockSupabaseClient(
291380
{ status: 400, error: { message: 'Bad request', code: '400' } },
292-
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
381+
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
293382
);
294383
instrumentSupabaseClient(client);
295384

@@ -328,7 +417,7 @@ describe('Supabase Integration', () => {
328417
method: 'POST',
329418
url: 'https://example.supabase.co/rest/v1/todos?columns=',
330419
body: [{ title: 'Test Todo' }],
331-
sendDefaultPii: true,
420+
dataCollectionUserInfo: true,
332421
},
333422
);
334423
instrumentSupabaseClient(client);

0 commit comments

Comments
 (0)