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
62 changes: 42 additions & 20 deletions packages/core/src/integrations/supabase.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,7 +151,7 @@ function isInstrumented<T>(fn: T): boolean | undefined {

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

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

instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder);
instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder, _options);

return rv;
},
Expand All@@ -344,7 +344,10 @@ function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void {
markAsInstrumented((SupabaseClient as SupabaseClientConstructor).prototype.from);
}

function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor']): void {
function instrumentPostgRESTFilterBuilder(
PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor'],
_options: { sendOperationData?: boolean },
): void {
Comment thread
chargome marked this conversation as resolved.
if (isInstrumented((PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then)) {
return;
}
Expand DownExpand Up@@ -381,7 +384,8 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}
}

const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii);
const client = getClient();
const shouldSendData = _options.sendOperationData ?? client?.getDataCollectionOptions().userInfo === true;
const bodyPayload = getMutationBodyPayloadForTelemetry(typedThis.body, body);

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

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
attributes['db.query'] = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
attributes['db.body'] = bodyPayload;
}

Expand DownExpand Up@@ -440,10 +444,10 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}

const supabaseContext: Record<string, any> = {};
if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
supabaseContext.query = queryItems;
}
if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
supabaseContext.body = bodyPayload;
}

Expand DownExpand Up@@ -471,11 +475,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
data.query = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
data.body = bodyPayload;
}

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

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

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

instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder);
instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder, _options);

return rv;
},
Expand All@@ -535,29 +542,44 @@ function instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder: new () => PostgR
}
}

export const instrumentSupabaseClient = (supabaseClient: unknown): void => {
export const instrumentSupabaseClient = (
supabaseClient: unknown,
options: { sendOperationData?: boolean } = {},
): void => {
if (!supabaseClient) {
DEBUG_BUILD && debug.warn('Supabase integration was not installed because no Supabase client was provided.');
return;
}
const SupabaseClientConstructor =
supabaseClient.constructor === Function ? supabaseClient : supabaseClient.constructor;

instrumentSupabaseClientConstructor(SupabaseClientConstructor);
instrumentSupabaseClientConstructor(SupabaseClientConstructor, options);
instrumentSupabaseAuthClient(supabaseClient as SupabaseClientInstance);
};

interface SupabaseIntegrationOptions {
supabaseClient: any;
/**
* Whether to attach PostgREST query filters and mutation body payloads
* to Sentry telemetry.
*
* Falls back to `dataCollection.userInfo` when not set.
* @default undefined
*/
sendOperationData?: boolean;
}

const INTEGRATION_NAME = 'Supabase';

const _supabaseIntegration = ((supabaseClient: unknown) => {
const _supabaseIntegration = ((supabaseClient: unknown, options: { sendOperationData?: boolean }) => {
return {
setupOnce() {
instrumentSupabaseClient(supabaseClient);
instrumentSupabaseClient(supabaseClient, options);
},
name: INTEGRATION_NAME,
};
}) satisfies IntegrationFn;

export const supabaseIntegration = defineIntegration((options: { supabaseClient: any }) => {
return _supabaseIntegration(options.supabaseClient);
export const supabaseIntegration = defineIntegration((options: SupabaseIntegrationOptions) => {
return _supabaseIntegration(options.supabaseClient, { sendOperationData: options.sendOperationData });
}) satisfies IntegrationFn;
115 changes: 102 additions & 13 deletions packages/core/test/lib/integrations/supabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import {
translateFiltersIntoMethods,
} from '../../../src/integrations/supabase';
import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase';
import { resolveDataCollectionOptions } from '../../../src/utils/data-collection/resolveDataCollectionOptions';

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

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

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

function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupabaseClientOptions): unknown {
if (options?.sendDefaultPii !== undefined) {
if (options?.dataCollectionUserInfo !== undefined) {
currentScopesMocks.getClient.mockReturnValue({
getOptions: () => ({ sendDefaultPii: options.sendDefaultPii }),
getDataCollectionOptions: () => ({ userInfo: options.dataCollectionUserInfo }),
} as any);
}

Expand DownExpand Up@@ -223,7 +224,7 @@ describe('Supabase Integration', () => {
});
});

describe('sendDefaultPii', () => {
describe('operation data collection', () => {
let captureExceptionSpy: ReturnType<typeof vi.spyOn>;
let addBreadcrumbSpy: ReturnType<typeof vi.spyOn>;

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

it('omits db.query, db.body, and breadcrumb query/body when sendDefaultPii is false', async () => {
it('omits db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is false', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

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

it('includes db.query, db.body, and breadcrumb query/body when sendDefaultPii is true', async () => {
const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: true });
it('includes db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client);

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

it('omits supabase error context query/body when sendDefaultPii is false', async () => {
it('includes data when sendOperationData option is set, regardless of dataCollection.userInfo', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client, { sendOperationData: true });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('sendOperationData: false takes precedence over dataCollection.userInfo: true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client, { sendOperationData: false });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});
Comment thread
chargome marked this conversation as resolved.

it('includes data when legacy sendDefaultPii: true is bridged to dataCollection.userInfo', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: true });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('redacts data when legacy sendDefaultPii is not set (bridged defaults)', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: false });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.name).not.toContain('secret');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});

it('omits supabase error context query/body when data collection is off', async () => {
const client = createMockSupabaseClient(
{ status: 400, error: { message: 'Bad request', code: '400' } },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

Expand DownExpand Up@@ -328,7 +417,7 @@ describe('Supabase Integration', () => {
method: 'POST',
url: 'https://example.supabase.co/rest/v1/todos?columns=',
body: [{ title: 'Test Todo' }],
sendDefaultPii: true,
dataCollectionUserInfo: true,
},
);
instrumentSupabaseClient(client);
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
62 changes: 42 additions & 20 deletions packages/core/src/integrations/supabase.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,7 +151,7 @@ function isInstrumented<T>(fn: T): boolean | undefined {

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

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

instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder);
instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder, _options);

return rv;
},
Expand All@@ -344,7 +344,10 @@ function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void {
markAsInstrumented((SupabaseClient as SupabaseClientConstructor).prototype.from);
}

function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor']): void {
function instrumentPostgRESTFilterBuilder(
PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor'],
_options: { sendOperationData?: boolean },
): void {
Comment thread
chargome marked this conversation as resolved.
if (isInstrumented((PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then)) {
return;
}
Expand DownExpand Up@@ -381,7 +384,8 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}
}

const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii);
const client = getClient();
const shouldSendData = _options.sendOperationData ?? client?.getDataCollectionOptions().userInfo === true;
const bodyPayload = getMutationBodyPayloadForTelemetry(typedThis.body, body);

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

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
attributes['db.query'] = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
attributes['db.body'] = bodyPayload;
}

Expand DownExpand Up@@ -440,10 +444,10 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}

const supabaseContext: Record<string, any> = {};
if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
supabaseContext.query = queryItems;
}
if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
supabaseContext.body = bodyPayload;
}

Expand DownExpand Up@@ -471,11 +475,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
data.query = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
data.body = bodyPayload;
}

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

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

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

instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder);
instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder, _options);

return rv;
},
Expand All@@ -535,29 +542,44 @@ function instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder: new () => PostgR
}
}

export const instrumentSupabaseClient = (supabaseClient: unknown): void => {
export const instrumentSupabaseClient = (
supabaseClient: unknown,
options: { sendOperationData?: boolean } = {},
): void => {
if (!supabaseClient) {
DEBUG_BUILD && debug.warn('Supabase integration was not installed because no Supabase client was provided.');
return;
}
const SupabaseClientConstructor =
supabaseClient.constructor === Function ? supabaseClient : supabaseClient.constructor;

instrumentSupabaseClientConstructor(SupabaseClientConstructor);
instrumentSupabaseClientConstructor(SupabaseClientConstructor, options);
instrumentSupabaseAuthClient(supabaseClient as SupabaseClientInstance);
};

interface SupabaseIntegrationOptions {
supabaseClient: any;
/**
* Whether to attach PostgREST query filters and mutation body payloads
* to Sentry telemetry.
*
* Falls back to `dataCollection.userInfo` when not set.
* @default undefined
*/
sendOperationData?: boolean;
}

const INTEGRATION_NAME = 'Supabase';

const _supabaseIntegration = ((supabaseClient: unknown) => {
const _supabaseIntegration = ((supabaseClient: unknown, options: { sendOperationData?: boolean }) => {
return {
setupOnce() {
instrumentSupabaseClient(supabaseClient);
instrumentSupabaseClient(supabaseClient, options);
},
name: INTEGRATION_NAME,
};
}) satisfies IntegrationFn;

export const supabaseIntegration = defineIntegration((options: { supabaseClient: any }) => {
return _supabaseIntegration(options.supabaseClient);
export const supabaseIntegration = defineIntegration((options: SupabaseIntegrationOptions) => {
return _supabaseIntegration(options.supabaseClient, { sendOperationData: options.sendOperationData });
}) satisfies IntegrationFn;
115 changes: 102 additions & 13 deletions packages/core/test/lib/integrations/supabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import {
translateFiltersIntoMethods,
} from '../../../src/integrations/supabase';
import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase';
import { resolveDataCollectionOptions } from '../../../src/utils/data-collection/resolveDataCollectionOptions';

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

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

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

function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupabaseClientOptions): unknown {
if (options?.sendDefaultPii !== undefined) {
if (options?.dataCollectionUserInfo !== undefined) {
currentScopesMocks.getClient.mockReturnValue({
getOptions: () => ({ sendDefaultPii: options.sendDefaultPii }),
getDataCollectionOptions: () => ({ userInfo: options.dataCollectionUserInfo }),
} as any);
}

Expand DownExpand Up@@ -223,7 +224,7 @@ describe('Supabase Integration', () => {
});
});

describe('sendDefaultPii', () => {
describe('operation data collection', () => {
let captureExceptionSpy: ReturnType<typeof vi.spyOn>;
let addBreadcrumbSpy: ReturnType<typeof vi.spyOn>;

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

it('omits db.query, db.body, and breadcrumb query/body when sendDefaultPii is false', async () => {
it('omits db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is false', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

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

it('includes db.query, db.body, and breadcrumb query/body when sendDefaultPii is true', async () => {
const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: true });
it('includes db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client);

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

it('omits supabase error context query/body when sendDefaultPii is false', async () => {
it('includes data when sendOperationData option is set, regardless of dataCollection.userInfo', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client, { sendOperationData: true });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('sendOperationData: false takes precedence over dataCollection.userInfo: true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client, { sendOperationData: false });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});
Comment thread
chargome marked this conversation as resolved.

it('includes data when legacy sendDefaultPii: true is bridged to dataCollection.userInfo', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: true });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('redacts data when legacy sendDefaultPii is not set (bridged defaults)', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: false });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.name).not.toContain('secret');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});

it('omits supabase error context query/body when data collection is off', async () => {
const client = createMockSupabaseClient(
{ status: 400, error: { message: 'Bad request', code: '400' } },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

Expand DownExpand Up@@ -328,7 +417,7 @@ describe('Supabase Integration', () => {
method: 'POST',
url: 'https://example.supabase.co/rest/v1/todos?columns=',
body: [{ title: 'Test Todo' }],
sendDefaultPii: true,
dataCollectionUserInfo: true,
},
);
instrumentSupabaseClient(client);
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
62 changes: 42 additions & 20 deletions packages/core/src/integrations/supabase.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,7 +151,7 @@ function isInstrumented<T>(fn: T): boolean | undefined {

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

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

instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder);
instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder, _options);

return rv;
},
Expand All@@ -344,7 +344,10 @@ function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void {
markAsInstrumented((SupabaseClient as SupabaseClientConstructor).prototype.from);
}

function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor']): void {
function instrumentPostgRESTFilterBuilder(
PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor'],
_options: { sendOperationData?: boolean },
): void {
Comment thread
chargome marked this conversation as resolved.
if (isInstrumented((PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then)) {
return;
}
Expand DownExpand Up@@ -381,7 +384,8 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}
}

const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii);
const client = getClient();
const shouldSendData = _options.sendOperationData ?? client?.getDataCollectionOptions().userInfo === true;
const bodyPayload = getMutationBodyPayloadForTelemetry(typedThis.body, body);

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

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
attributes['db.query'] = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
attributes['db.body'] = bodyPayload;
}

Expand DownExpand Up@@ -440,10 +444,10 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}

const supabaseContext: Record<string, any> = {};
if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
supabaseContext.query = queryItems;
}
if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
supabaseContext.body = bodyPayload;
}

Expand DownExpand Up@@ -471,11 +475,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
data.query = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
data.body = bodyPayload;
}

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

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

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

instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder);
instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder, _options);

return rv;
},
Expand All@@ -535,29 +542,44 @@ function instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder: new () => PostgR
}
}

export const instrumentSupabaseClient = (supabaseClient: unknown): void => {
export const instrumentSupabaseClient = (
supabaseClient: unknown,
options: { sendOperationData?: boolean } = {},
): void => {
if (!supabaseClient) {
DEBUG_BUILD && debug.warn('Supabase integration was not installed because no Supabase client was provided.');
return;
}
const SupabaseClientConstructor =
supabaseClient.constructor === Function ? supabaseClient : supabaseClient.constructor;

instrumentSupabaseClientConstructor(SupabaseClientConstructor);
instrumentSupabaseClientConstructor(SupabaseClientConstructor, options);
instrumentSupabaseAuthClient(supabaseClient as SupabaseClientInstance);
};

interface SupabaseIntegrationOptions {
supabaseClient: any;
/**
* Whether to attach PostgREST query filters and mutation body payloads
* to Sentry telemetry.
*
* Falls back to `dataCollection.userInfo` when not set.
* @default undefined
*/
sendOperationData?: boolean;
}

const INTEGRATION_NAME = 'Supabase';

const _supabaseIntegration = ((supabaseClient: unknown) => {
const _supabaseIntegration = ((supabaseClient: unknown, options: { sendOperationData?: boolean }) => {
return {
setupOnce() {
instrumentSupabaseClient(supabaseClient);
instrumentSupabaseClient(supabaseClient, options);
},
name: INTEGRATION_NAME,
};
}) satisfies IntegrationFn;

export const supabaseIntegration = defineIntegration((options: { supabaseClient: any }) => {
return _supabaseIntegration(options.supabaseClient);
export const supabaseIntegration = defineIntegration((options: SupabaseIntegrationOptions) => {
return _supabaseIntegration(options.supabaseClient, { sendOperationData: options.sendOperationData });
}) satisfies IntegrationFn;
115 changes: 102 additions & 13 deletions packages/core/test/lib/integrations/supabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import {
translateFiltersIntoMethods,
} from '../../../src/integrations/supabase';
import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase';
import { resolveDataCollectionOptions } from '../../../src/utils/data-collection/resolveDataCollectionOptions';

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

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

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

function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupabaseClientOptions): unknown {
if (options?.sendDefaultPii !== undefined) {
if (options?.dataCollectionUserInfo !== undefined) {
currentScopesMocks.getClient.mockReturnValue({
getOptions: () => ({ sendDefaultPii: options.sendDefaultPii }),
getDataCollectionOptions: () => ({ userInfo: options.dataCollectionUserInfo }),
} as any);
}

Expand DownExpand Up@@ -223,7 +224,7 @@ describe('Supabase Integration', () => {
});
});

describe('sendDefaultPii', () => {
describe('operation data collection', () => {
let captureExceptionSpy: ReturnType<typeof vi.spyOn>;
let addBreadcrumbSpy: ReturnType<typeof vi.spyOn>;

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

it('omits db.query, db.body, and breadcrumb query/body when sendDefaultPii is false', async () => {
it('omits db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is false', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

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

it('includes db.query, db.body, and breadcrumb query/body when sendDefaultPii is true', async () => {
const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: true });
it('includes db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client);

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

it('omits supabase error context query/body when sendDefaultPii is false', async () => {
it('includes data when sendOperationData option is set, regardless of dataCollection.userInfo', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client, { sendOperationData: true });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('sendOperationData: false takes precedence over dataCollection.userInfo: true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client, { sendOperationData: false });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});
Comment thread
chargome marked this conversation as resolved.

it('includes data when legacy sendDefaultPii: true is bridged to dataCollection.userInfo', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: true });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('redacts data when legacy sendDefaultPii is not set (bridged defaults)', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: false });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.name).not.toContain('secret');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});

it('omits supabase error context query/body when data collection is off', async () => {
const client = createMockSupabaseClient(
{ status: 400, error: { message: 'Bad request', code: '400' } },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

Expand DownExpand Up@@ -328,7 +417,7 @@ describe('Supabase Integration', () => {
method: 'POST',
url: 'https://example.supabase.co/rest/v1/todos?columns=',
body: [{ title: 'Test Todo' }],
sendDefaultPii: true,
dataCollectionUserInfo: true,
},
);
instrumentSupabaseClient(client);
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
62 changes: 42 additions & 20 deletions packages/core/src/integrations/supabase.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,7 +151,7 @@ function isInstrumented<T>(fn: T): boolean | undefined {

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

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

instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder);
instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder, _options);

return rv;
},
Expand All@@ -344,7 +344,10 @@ function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void {
markAsInstrumented((SupabaseClient as SupabaseClientConstructor).prototype.from);
}

function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor']): void {
function instrumentPostgRESTFilterBuilder(
PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor'],
_options: { sendOperationData?: boolean },
): void {
Comment thread
chargome marked this conversation as resolved.
if (isInstrumented((PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then)) {
return;
}
Expand DownExpand Up@@ -381,7 +384,8 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}
}

const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii);
const client = getClient();
const shouldSendData = _options.sendOperationData ?? client?.getDataCollectionOptions().userInfo === true;
const bodyPayload = getMutationBodyPayloadForTelemetry(typedThis.body, body);

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

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
attributes['db.query'] = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
attributes['db.body'] = bodyPayload;
}

Expand DownExpand Up@@ -440,10 +444,10 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}

const supabaseContext: Record<string, any> = {};
if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
supabaseContext.query = queryItems;
}
if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
supabaseContext.body = bodyPayload;
}

Expand DownExpand Up@@ -471,11 +475,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
data.query = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
data.body = bodyPayload;
}

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

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

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

instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder);
instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder, _options);

return rv;
},
Expand All@@ -535,29 +542,44 @@ function instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder: new () => PostgR
}
}

export const instrumentSupabaseClient = (supabaseClient: unknown): void => {
export const instrumentSupabaseClient = (
supabaseClient: unknown,
options: { sendOperationData?: boolean } = {},
): void => {
if (!supabaseClient) {
DEBUG_BUILD && debug.warn('Supabase integration was not installed because no Supabase client was provided.');
return;
}
const SupabaseClientConstructor =
supabaseClient.constructor === Function ? supabaseClient : supabaseClient.constructor;

instrumentSupabaseClientConstructor(SupabaseClientConstructor);
instrumentSupabaseClientConstructor(SupabaseClientConstructor, options);
instrumentSupabaseAuthClient(supabaseClient as SupabaseClientInstance);
};

interface SupabaseIntegrationOptions {
supabaseClient: any;
/**
* Whether to attach PostgREST query filters and mutation body payloads
* to Sentry telemetry.
*
* Falls back to `dataCollection.userInfo` when not set.
* @default undefined
*/
sendOperationData?: boolean;
}

const INTEGRATION_NAME = 'Supabase';

const _supabaseIntegration = ((supabaseClient: unknown) => {
const _supabaseIntegration = ((supabaseClient: unknown, options: { sendOperationData?: boolean }) => {
return {
setupOnce() {
instrumentSupabaseClient(supabaseClient);
instrumentSupabaseClient(supabaseClient, options);
},
name: INTEGRATION_NAME,
};
}) satisfies IntegrationFn;

export const supabaseIntegration = defineIntegration((options: { supabaseClient: any }) => {
return _supabaseIntegration(options.supabaseClient);
export const supabaseIntegration = defineIntegration((options: SupabaseIntegrationOptions) => {
return _supabaseIntegration(options.supabaseClient, { sendOperationData: options.sendOperationData });
}) satisfies IntegrationFn;
115 changes: 102 additions & 13 deletions packages/core/test/lib/integrations/supabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import {
translateFiltersIntoMethods,
} from '../../../src/integrations/supabase';
import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase';
import { resolveDataCollectionOptions } from '../../../src/utils/data-collection/resolveDataCollectionOptions';

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

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

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

function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupabaseClientOptions): unknown {
if (options?.sendDefaultPii !== undefined) {
if (options?.dataCollectionUserInfo !== undefined) {
currentScopesMocks.getClient.mockReturnValue({
getOptions: () => ({ sendDefaultPii: options.sendDefaultPii }),
getDataCollectionOptions: () => ({ userInfo: options.dataCollectionUserInfo }),
} as any);
}

Expand DownExpand Up@@ -223,7 +224,7 @@ describe('Supabase Integration', () => {
});
});

describe('sendDefaultPii', () => {
describe('operation data collection', () => {
let captureExceptionSpy: ReturnType<typeof vi.spyOn>;
let addBreadcrumbSpy: ReturnType<typeof vi.spyOn>;

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

it('omits db.query, db.body, and breadcrumb query/body when sendDefaultPii is false', async () => {
it('omits db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is false', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

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

it('includes db.query, db.body, and breadcrumb query/body when sendDefaultPii is true', async () => {
const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: true });
it('includes db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client);

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

it('omits supabase error context query/body when sendDefaultPii is false', async () => {
it('includes data when sendOperationData option is set, regardless of dataCollection.userInfo', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client, { sendOperationData: true });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('sendOperationData: false takes precedence over dataCollection.userInfo: true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client, { sendOperationData: false });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});
Comment thread
chargome marked this conversation as resolved.

it('includes data when legacy sendDefaultPii: true is bridged to dataCollection.userInfo', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: true });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('redacts data when legacy sendDefaultPii is not set (bridged defaults)', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: false });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.name).not.toContain('secret');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});

it('omits supabase error context query/body when data collection is off', async () => {
const client = createMockSupabaseClient(
{ status: 400, error: { message: 'Bad request', code: '400' } },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

Expand DownExpand Up@@ -328,7 +417,7 @@ describe('Supabase Integration', () => {
method: 'POST',
url: 'https://example.supabase.co/rest/v1/todos?columns=',
body: [{ title: 'Test Todo' }],
sendDefaultPii: true,
dataCollectionUserInfo: true,
},
);
instrumentSupabaseClient(client);
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
62 changes: 42 additions & 20 deletions packages/core/src/integrations/supabase.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,7 +151,7 @@ function isInstrumented<T>(fn: T): boolean | undefined {

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

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

instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder);
instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder, _options);

return rv;
},
Expand All@@ -344,7 +344,10 @@ function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void {
markAsInstrumented((SupabaseClient as SupabaseClientConstructor).prototype.from);
}

function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor']): void {
function instrumentPostgRESTFilterBuilder(
PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor'],
_options: { sendOperationData?: boolean },
): void {
Comment thread
chargome marked this conversation as resolved.
if (isInstrumented((PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then)) {
return;
}
Expand DownExpand Up@@ -381,7 +384,8 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}
}

const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii);
const client = getClient();
const shouldSendData = _options.sendOperationData ?? client?.getDataCollectionOptions().userInfo === true;
const bodyPayload = getMutationBodyPayloadForTelemetry(typedThis.body, body);

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

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
attributes['db.query'] = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
attributes['db.body'] = bodyPayload;
}

Expand DownExpand Up@@ -440,10 +444,10 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}

const supabaseContext: Record<string, any> = {};
if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
supabaseContext.query = queryItems;
}
if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
supabaseContext.body = bodyPayload;
}

Expand DownExpand Up@@ -471,11 +475,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
data.query = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
data.body = bodyPayload;
}

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

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

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

instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder);
instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder, _options);

return rv;
},
Expand All@@ -535,29 +542,44 @@ function instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder: new () => PostgR
}
}

export const instrumentSupabaseClient = (supabaseClient: unknown): void => {
export const instrumentSupabaseClient = (
supabaseClient: unknown,
options: { sendOperationData?: boolean } = {},
): void => {
if (!supabaseClient) {
DEBUG_BUILD && debug.warn('Supabase integration was not installed because no Supabase client was provided.');
return;
}
const SupabaseClientConstructor =
supabaseClient.constructor === Function ? supabaseClient : supabaseClient.constructor;

instrumentSupabaseClientConstructor(SupabaseClientConstructor);
instrumentSupabaseClientConstructor(SupabaseClientConstructor, options);
instrumentSupabaseAuthClient(supabaseClient as SupabaseClientInstance);
};

interface SupabaseIntegrationOptions {
supabaseClient: any;
/**
* Whether to attach PostgREST query filters and mutation body payloads
* to Sentry telemetry.
*
* Falls back to `dataCollection.userInfo` when not set.
* @default undefined
*/
sendOperationData?: boolean;
}

const INTEGRATION_NAME = 'Supabase';

const _supabaseIntegration = ((supabaseClient: unknown) => {
const _supabaseIntegration = ((supabaseClient: unknown, options: { sendOperationData?: boolean }) => {
return {
setupOnce() {
instrumentSupabaseClient(supabaseClient);
instrumentSupabaseClient(supabaseClient, options);
},
name: INTEGRATION_NAME,
};
}) satisfies IntegrationFn;

export const supabaseIntegration = defineIntegration((options: { supabaseClient: any }) => {
return _supabaseIntegration(options.supabaseClient);
export const supabaseIntegration = defineIntegration((options: SupabaseIntegrationOptions) => {
return _supabaseIntegration(options.supabaseClient, { sendOperationData: options.sendOperationData });
}) satisfies IntegrationFn;
115 changes: 102 additions & 13 deletions packages/core/test/lib/integrations/supabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import {
translateFiltersIntoMethods,
} from '../../../src/integrations/supabase';
import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase';
import { resolveDataCollectionOptions } from '../../../src/utils/data-collection/resolveDataCollectionOptions';

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

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

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

function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupabaseClientOptions): unknown {
if (options?.sendDefaultPii !== undefined) {
if (options?.dataCollectionUserInfo !== undefined) {
currentScopesMocks.getClient.mockReturnValue({
getOptions: () => ({ sendDefaultPii: options.sendDefaultPii }),
getDataCollectionOptions: () => ({ userInfo: options.dataCollectionUserInfo }),
} as any);
}

Expand DownExpand Up@@ -223,7 +224,7 @@ describe('Supabase Integration', () => {
});
});

describe('sendDefaultPii', () => {
describe('operation data collection', () => {
let captureExceptionSpy: ReturnType<typeof vi.spyOn>;
let addBreadcrumbSpy: ReturnType<typeof vi.spyOn>;

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

it('omits db.query, db.body, and breadcrumb query/body when sendDefaultPii is false', async () => {
it('omits db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is false', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

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

it('includes db.query, db.body, and breadcrumb query/body when sendDefaultPii is true', async () => {
const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: true });
it('includes db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client);

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

it('omits supabase error context query/body when sendDefaultPii is false', async () => {
it('includes data when sendOperationData option is set, regardless of dataCollection.userInfo', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client, { sendOperationData: true });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('sendOperationData: false takes precedence over dataCollection.userInfo: true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client, { sendOperationData: false });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});
Comment thread
chargome marked this conversation as resolved.

it('includes data when legacy sendDefaultPii: true is bridged to dataCollection.userInfo', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: true });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('redacts data when legacy sendDefaultPii is not set (bridged defaults)', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: false });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.name).not.toContain('secret');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});

it('omits supabase error context query/body when data collection is off', async () => {
const client = createMockSupabaseClient(
{ status: 400, error: { message: 'Bad request', code: '400' } },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

Expand DownExpand Up@@ -328,7 +417,7 @@ describe('Supabase Integration', () => {
method: 'POST',
url: 'https://example.supabase.co/rest/v1/todos?columns=',
body: [{ title: 'Test Todo' }],
sendDefaultPii: true,
dataCollectionUserInfo: true,
},
);
instrumentSupabaseClient(client);
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
62 changes: 42 additions & 20 deletions packages/core/src/integrations/supabase.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,7 +151,7 @@ function isInstrumented<T>(fn: T): boolean | undefined {

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

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

instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder);
instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder, _options);

return rv;
},
Expand All@@ -344,7 +344,10 @@ function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void {
markAsInstrumented((SupabaseClient as SupabaseClientConstructor).prototype.from);
}

function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor']): void {
function instrumentPostgRESTFilterBuilder(
PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor'],
_options: { sendOperationData?: boolean },
): void {
Comment thread
chargome marked this conversation as resolved.
if (isInstrumented((PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then)) {
return;
}
Expand DownExpand Up@@ -381,7 +384,8 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}
}

const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii);
const client = getClient();
const shouldSendData = _options.sendOperationData ?? client?.getDataCollectionOptions().userInfo === true;
const bodyPayload = getMutationBodyPayloadForTelemetry(typedThis.body, body);

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

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
attributes['db.query'] = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
attributes['db.body'] = bodyPayload;
}

Expand DownExpand Up@@ -440,10 +444,10 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}

const supabaseContext: Record<string, any> = {};
if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
supabaseContext.query = queryItems;
}
if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
supabaseContext.body = bodyPayload;
}

Expand DownExpand Up@@ -471,11 +475,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
data.query = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
data.body = bodyPayload;
}

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

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

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

instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder);
instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder, _options);

return rv;
},
Expand All@@ -535,29 +542,44 @@ function instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder: new () => PostgR
}
}

export const instrumentSupabaseClient = (supabaseClient: unknown): void => {
export const instrumentSupabaseClient = (
supabaseClient: unknown,
options: { sendOperationData?: boolean } = {},
): void => {
if (!supabaseClient) {
DEBUG_BUILD && debug.warn('Supabase integration was not installed because no Supabase client was provided.');
return;
}
const SupabaseClientConstructor =
supabaseClient.constructor === Function ? supabaseClient : supabaseClient.constructor;

instrumentSupabaseClientConstructor(SupabaseClientConstructor);
instrumentSupabaseClientConstructor(SupabaseClientConstructor, options);
instrumentSupabaseAuthClient(supabaseClient as SupabaseClientInstance);
};

interface SupabaseIntegrationOptions {
supabaseClient: any;
/**
* Whether to attach PostgREST query filters and mutation body payloads
* to Sentry telemetry.
*
* Falls back to `dataCollection.userInfo` when not set.
* @default undefined
*/
sendOperationData?: boolean;
}

const INTEGRATION_NAME = 'Supabase';

const _supabaseIntegration = ((supabaseClient: unknown) => {
const _supabaseIntegration = ((supabaseClient: unknown, options: { sendOperationData?: boolean }) => {
return {
setupOnce() {
instrumentSupabaseClient(supabaseClient);
instrumentSupabaseClient(supabaseClient, options);
},
name: INTEGRATION_NAME,
};
}) satisfies IntegrationFn;

export const supabaseIntegration = defineIntegration((options: { supabaseClient: any }) => {
return _supabaseIntegration(options.supabaseClient);
export const supabaseIntegration = defineIntegration((options: SupabaseIntegrationOptions) => {
return _supabaseIntegration(options.supabaseClient, { sendOperationData: options.sendOperationData });
}) satisfies IntegrationFn;
115 changes: 102 additions & 13 deletions packages/core/test/lib/integrations/supabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import {
translateFiltersIntoMethods,
} from '../../../src/integrations/supabase';
import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase';
import { resolveDataCollectionOptions } from '../../../src/utils/data-collection/resolveDataCollectionOptions';

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

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

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

function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupabaseClientOptions): unknown {
if (options?.sendDefaultPii !== undefined) {
if (options?.dataCollectionUserInfo !== undefined) {
currentScopesMocks.getClient.mockReturnValue({
getOptions: () => ({ sendDefaultPii: options.sendDefaultPii }),
getDataCollectionOptions: () => ({ userInfo: options.dataCollectionUserInfo }),
} as any);
}

Expand DownExpand Up@@ -223,7 +224,7 @@ describe('Supabase Integration', () => {
});
});

describe('sendDefaultPii', () => {
describe('operation data collection', () => {
let captureExceptionSpy: ReturnType<typeof vi.spyOn>;
let addBreadcrumbSpy: ReturnType<typeof vi.spyOn>;

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

it('omits db.query, db.body, and breadcrumb query/body when sendDefaultPii is false', async () => {
it('omits db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is false', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

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

it('includes db.query, db.body, and breadcrumb query/body when sendDefaultPii is true', async () => {
const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: true });
it('includes db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client);

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

it('omits supabase error context query/body when sendDefaultPii is false', async () => {
it('includes data when sendOperationData option is set, regardless of dataCollection.userInfo', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client, { sendOperationData: true });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('sendOperationData: false takes precedence over dataCollection.userInfo: true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client, { sendOperationData: false });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});
Comment thread
chargome marked this conversation as resolved.

it('includes data when legacy sendDefaultPii: true is bridged to dataCollection.userInfo', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: true });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('redacts data when legacy sendDefaultPii is not set (bridged defaults)', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: false });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.name).not.toContain('secret');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});

it('omits supabase error context query/body when data collection is off', async () => {
const client = createMockSupabaseClient(
{ status: 400, error: { message: 'Bad request', code: '400' } },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

Expand DownExpand Up@@ -328,7 +417,7 @@ describe('Supabase Integration', () => {
method: 'POST',
url: 'https://example.supabase.co/rest/v1/todos?columns=',
body: [{ title: 'Test Todo' }],
sendDefaultPii: true,
dataCollectionUserInfo: true,
},
);
instrumentSupabaseClient(client);
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
62 changes: 42 additions & 20 deletions packages/core/src/integrations/supabase.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,7 +151,7 @@ function isInstrumented<T>(fn: T): boolean | undefined {

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

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

instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder);
instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder, _options);

return rv;
},
Expand All@@ -344,7 +344,10 @@ function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void {
markAsInstrumented((SupabaseClient as SupabaseClientConstructor).prototype.from);
}

function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor']): void {
function instrumentPostgRESTFilterBuilder(
PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor'],
_options: { sendOperationData?: boolean },
): void {
Comment thread
chargome marked this conversation as resolved.
if (isInstrumented((PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then)) {
return;
}
Expand DownExpand Up@@ -381,7 +384,8 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}
}

const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii);
const client = getClient();
const shouldSendData = _options.sendOperationData ?? client?.getDataCollectionOptions().userInfo === true;
const bodyPayload = getMutationBodyPayloadForTelemetry(typedThis.body, body);

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

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
attributes['db.query'] = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
attributes['db.body'] = bodyPayload;
}

Expand DownExpand Up@@ -440,10 +444,10 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}

const supabaseContext: Record<string, any> = {};
if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
supabaseContext.query = queryItems;
}
if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
supabaseContext.body = bodyPayload;
}

Expand DownExpand Up@@ -471,11 +475,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
data.query = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
data.body = bodyPayload;
}

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

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

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

instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder);
instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder, _options);

return rv;
},
Expand All@@ -535,29 +542,44 @@ function instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder: new () => PostgR
}
}

export const instrumentSupabaseClient = (supabaseClient: unknown): void => {
export const instrumentSupabaseClient = (
supabaseClient: unknown,
options: { sendOperationData?: boolean } = {},
): void => {
if (!supabaseClient) {
DEBUG_BUILD && debug.warn('Supabase integration was not installed because no Supabase client was provided.');
return;
}
const SupabaseClientConstructor =
supabaseClient.constructor === Function ? supabaseClient : supabaseClient.constructor;

instrumentSupabaseClientConstructor(SupabaseClientConstructor);
instrumentSupabaseClientConstructor(SupabaseClientConstructor, options);
instrumentSupabaseAuthClient(supabaseClient as SupabaseClientInstance);
};

interface SupabaseIntegrationOptions {
supabaseClient: any;
/**
* Whether to attach PostgREST query filters and mutation body payloads
* to Sentry telemetry.
*
* Falls back to `dataCollection.userInfo` when not set.
* @default undefined
*/
sendOperationData?: boolean;
}

const INTEGRATION_NAME = 'Supabase';

const _supabaseIntegration = ((supabaseClient: unknown) => {
const _supabaseIntegration = ((supabaseClient: unknown, options: { sendOperationData?: boolean }) => {
return {
setupOnce() {
instrumentSupabaseClient(supabaseClient);
instrumentSupabaseClient(supabaseClient, options);
},
name: INTEGRATION_NAME,
};
}) satisfies IntegrationFn;

export const supabaseIntegration = defineIntegration((options: { supabaseClient: any }) => {
return _supabaseIntegration(options.supabaseClient);
export const supabaseIntegration = defineIntegration((options: SupabaseIntegrationOptions) => {
return _supabaseIntegration(options.supabaseClient, { sendOperationData: options.sendOperationData });
}) satisfies IntegrationFn;
115 changes: 102 additions & 13 deletions packages/core/test/lib/integrations/supabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import {
translateFiltersIntoMethods,
} from '../../../src/integrations/supabase';
import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase';
import { resolveDataCollectionOptions } from '../../../src/utils/data-collection/resolveDataCollectionOptions';

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

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

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

function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupabaseClientOptions): unknown {
if (options?.sendDefaultPii !== undefined) {
if (options?.dataCollectionUserInfo !== undefined) {
currentScopesMocks.getClient.mockReturnValue({
getOptions: () => ({ sendDefaultPii: options.sendDefaultPii }),
getDataCollectionOptions: () => ({ userInfo: options.dataCollectionUserInfo }),
} as any);
}

Expand DownExpand Up@@ -223,7 +224,7 @@ describe('Supabase Integration', () => {
});
});

describe('sendDefaultPii', () => {
describe('operation data collection', () => {
let captureExceptionSpy: ReturnType<typeof vi.spyOn>;
let addBreadcrumbSpy: ReturnType<typeof vi.spyOn>;

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

it('omits db.query, db.body, and breadcrumb query/body when sendDefaultPii is false', async () => {
it('omits db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is false', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

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

it('includes db.query, db.body, and breadcrumb query/body when sendDefaultPii is true', async () => {
const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: true });
it('includes db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client);

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

it('omits supabase error context query/body when sendDefaultPii is false', async () => {
it('includes data when sendOperationData option is set, regardless of dataCollection.userInfo', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client, { sendOperationData: true });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('sendOperationData: false takes precedence over dataCollection.userInfo: true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client, { sendOperationData: false });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});
Comment thread
chargome marked this conversation as resolved.

it('includes data when legacy sendDefaultPii: true is bridged to dataCollection.userInfo', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: true });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('redacts data when legacy sendDefaultPii is not set (bridged defaults)', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: false });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.name).not.toContain('secret');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});

it('omits supabase error context query/body when data collection is off', async () => {
const client = createMockSupabaseClient(
{ status: 400, error: { message: 'Bad request', code: '400' } },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

Expand DownExpand Up@@ -328,7 +417,7 @@ describe('Supabase Integration', () => {
method: 'POST',
url: 'https://example.supabase.co/rest/v1/todos?columns=',
body: [{ title: 'Test Todo' }],
sendDefaultPii: true,
dataCollectionUserInfo: true,
},
);
instrumentSupabaseClient(client);
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
62 changes: 42 additions & 20 deletions packages/core/src/integrations/supabase.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,7 +151,7 @@ function isInstrumented<T>(fn: T): boolean | undefined {

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

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

instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder);
instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder, _options);

return rv;
},
Expand All@@ -344,7 +344,10 @@ function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void {
markAsInstrumented((SupabaseClient as SupabaseClientConstructor).prototype.from);
}

function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor']): void {
function instrumentPostgRESTFilterBuilder(
PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor'],
_options: { sendOperationData?: boolean },
): void {
Comment thread
chargome marked this conversation as resolved.
if (isInstrumented((PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then)) {
return;
}
Expand DownExpand Up@@ -381,7 +384,8 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}
}

const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii);
const client = getClient();
const shouldSendData = _options.sendOperationData ?? client?.getDataCollectionOptions().userInfo === true;
const bodyPayload = getMutationBodyPayloadForTelemetry(typedThis.body, body);

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

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
attributes['db.query'] = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
attributes['db.body'] = bodyPayload;
}

Expand DownExpand Up@@ -440,10 +444,10 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}

const supabaseContext: Record<string, any> = {};
if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
supabaseContext.query = queryItems;
}
if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
supabaseContext.body = bodyPayload;
}

Expand DownExpand Up@@ -471,11 +475,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte

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

if (queryItems.length && sendDefaultPii) {
if (queryItems.length && shouldSendData) {
data.query = queryItems;
}

if (bodyPayload !== undefined && sendDefaultPii) {
if (bodyPayload !== undefined && shouldSendData) {
data.body = bodyPayload;
}

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

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

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

instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder);
instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder, _options);

return rv;
},
Expand All@@ -535,29 +542,44 @@ function instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder: new () => PostgR
}
}

export const instrumentSupabaseClient = (supabaseClient: unknown): void => {
export const instrumentSupabaseClient = (
supabaseClient: unknown,
options: { sendOperationData?: boolean } = {},
): void => {
if (!supabaseClient) {
DEBUG_BUILD && debug.warn('Supabase integration was not installed because no Supabase client was provided.');
return;
}
const SupabaseClientConstructor =
supabaseClient.constructor === Function ? supabaseClient : supabaseClient.constructor;

instrumentSupabaseClientConstructor(SupabaseClientConstructor);
instrumentSupabaseClientConstructor(SupabaseClientConstructor, options);
instrumentSupabaseAuthClient(supabaseClient as SupabaseClientInstance);
};

interface SupabaseIntegrationOptions {
supabaseClient: any;
/**
* Whether to attach PostgREST query filters and mutation body payloads
* to Sentry telemetry.
*
* Falls back to `dataCollection.userInfo` when not set.
* @default undefined
*/
sendOperationData?: boolean;
}

const INTEGRATION_NAME = 'Supabase';

const _supabaseIntegration = ((supabaseClient: unknown) => {
const _supabaseIntegration = ((supabaseClient: unknown, options: { sendOperationData?: boolean }) => {
return {
setupOnce() {
instrumentSupabaseClient(supabaseClient);
instrumentSupabaseClient(supabaseClient, options);
},
name: INTEGRATION_NAME,
};
}) satisfies IntegrationFn;

export const supabaseIntegration = defineIntegration((options: { supabaseClient: any }) => {
return _supabaseIntegration(options.supabaseClient);
export const supabaseIntegration = defineIntegration((options: SupabaseIntegrationOptions) => {
return _supabaseIntegration(options.supabaseClient, { sendOperationData: options.sendOperationData });
}) satisfies IntegrationFn;
115 changes: 102 additions & 13 deletions packages/core/test/lib/integrations/supabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import {
translateFiltersIntoMethods,
} from '../../../src/integrations/supabase';
import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase';
import { resolveDataCollectionOptions } from '../../../src/utils/data-collection/resolveDataCollectionOptions';

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

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

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

function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupabaseClientOptions): unknown {
if (options?.sendDefaultPii !== undefined) {
if (options?.dataCollectionUserInfo !== undefined) {
currentScopesMocks.getClient.mockReturnValue({
getOptions: () => ({ sendDefaultPii: options.sendDefaultPii }),
getDataCollectionOptions: () => ({ userInfo: options.dataCollectionUserInfo }),
} as any);
}

Expand DownExpand Up@@ -223,7 +224,7 @@ describe('Supabase Integration', () => {
});
});

describe('sendDefaultPii', () => {
describe('operation data collection', () => {
let captureExceptionSpy: ReturnType<typeof vi.spyOn>;
let addBreadcrumbSpy: ReturnType<typeof vi.spyOn>;

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

it('omits db.query, db.body, and breadcrumb query/body when sendDefaultPii is false', async () => {
it('omits db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is false', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

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

it('includes db.query, db.body, and breadcrumb query/body when sendDefaultPii is true', async () => {
const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: true });
it('includes db.query, db.body, and breadcrumb query/body when dataCollection.userInfo is true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client);

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

it('omits supabase error context query/body when sendDefaultPii is false', async () => {
it('includes data when sendOperationData option is set, regardless of dataCollection.userInfo', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client, { sendOperationData: true });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('sendOperationData: false takes precedence over dataCollection.userInfo: true', async () => {
const client = createMockSupabaseClient(
{ status: 200 },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: true },
);
instrumentSupabaseClient(client, { sendOperationData: false });

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});
Comment thread
chargome marked this conversation as resolved.

it('includes data when legacy sendDefaultPii: true is bridged to dataCollection.userInfo', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: true });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('eq(email, secret@example.com)');
expect(spanOptions.attributes['db.query']).toEqual(
expect.arrayContaining([expect.stringContaining('secret@example.com')]),
);
expect(spanOptions.attributes['db.body']).toEqual(
expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }),
);
});

it('redacts data when legacy sendDefaultPii is not set (bridged defaults)', async () => {
const resolved = resolveDataCollectionOptions({ sendDefaultPii: false });
currentScopesMocks.getClient.mockReturnValue({
getDataCollectionOptions: () => resolved,
} as any);

const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO });
instrumentSupabaseClient(client);

await (client as any).from('users').update({}).then();

const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as {
name: string;
attributes: Record<string, unknown>;
};
expect(spanOptions.name).toContain('[redacted]');
expect(spanOptions.name).not.toContain('secret');
expect(spanOptions.attributes['db.query']).toBeUndefined();
expect(spanOptions.attributes['db.body']).toBeUndefined();
});

it('omits supabase error context query/body when data collection is off', async () => {
const client = createMockSupabaseClient(
{ status: 400, error: { message: 'Bad request', code: '400' } },
{ ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false },
{ ...MOCK_SUPABASE_PII_SCENARIO, dataCollectionUserInfo: false },
);
instrumentSupabaseClient(client);

Expand DownExpand Up@@ -328,7 +417,7 @@ describe('Supabase Integration', () => {
method: 'POST',
url: 'https://example.supabase.co/rest/v1/todos?columns=',
body: [{ title: 'Test Todo' }],
sendDefaultPii: true,
dataCollectionUserInfo: true,
},
);
instrumentSupabaseClient(client);
Expand Down
Loading