Skip to content

feat: Add Supabase Integration #15719

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 56 commits into from
Apr 17, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
18cb6b8
feat(core): Add Supabase Integration
onurtemizkan Mar 18, 2025
a6c0d4d
Add missing package exports
onurtemizkan Mar 18, 2025
ab36c16
Remove debug logging
onurtemizkan Mar 18, 2025
9f58b89
Bump next 14 version
onurtemizkan Mar 18, 2025
f37fa5a
Hard-code default development variables
onurtemizkan Mar 18, 2025
68e7774
Add playwright to dev-dependencies.
onurtemizkan Mar 18, 2025
d384cc3
Move `supabase` into its own internal package.
onurtemizkan Mar 18, 2025
4973f04
Add new package reference to Remix integration tests
onurtemizkan Mar 18, 2025
b5ff6db
Update deps
onurtemizkan Mar 19, 2025
38962d1
Separate anon and service clients.
onurtemizkan Mar 19, 2025
dedf54e
Make `supabase` a public package
onurtemizkan Mar 19, 2025
01bf0d6
Add `supabase` to verdaccio config
onurtemizkan Mar 19, 2025
79dd5d7
Remove unused resolutions and dependencies
onurtemizkan Mar 20, 2025
5f00c3c
Move `supabaseIntegration` to `@sentry/core`
onurtemizkan Mar 24, 2025
390744c
Update import paths
onurtemizkan Mar 25, 2025
5b249fb
Fix tests
onurtemizkan Mar 25, 2025
832c4ef
Fix tests
onurtemizkan Mar 25, 2025
fa8199e
Fix formatting
onurtemizkan Mar 25, 2025
5123016
Dedupe dependencies.
onurtemizkan Mar 25, 2025
0e335bd
Skip tests on non-tracing bundles
onurtemizkan Mar 26, 2025
eeaa8a2
Remove test-debug mode
onurtemizkan Mar 26, 2025
adc5e10
Try reducing bundle size
onurtemizkan Mar 26, 2025
cad1352
Remove `supabaseIntegration` from non-Tracing bundles
onurtemizkan Mar 26, 2025
f0d66ce
Bring filter-mappings back.
onurtemizkan Mar 26, 2025
5cb5f58
Export supabase from all tracing bundles
onurtemizkan Mar 26, 2025
16e4a9d
Add vendor license
onurtemizkan Mar 26, 2025
5f67b4a
Clean up
onurtemizkan Mar 27, 2025
318f0a5
Add `auth` support
onurtemizkan Mar 27, 2025
1acde43
Add `auth` error capturing
onurtemizkan Mar 27, 2025
e01ce65
Remove `signOut` from `admin` operations
onurtemizkan Mar 27, 2025
7535386
Clean up
onurtemizkan Mar 31, 2025
0532f8a
Address review comments
onurtemizkan Apr 3, 2025
0ea2cdb
Update packages/core/src/integrations/supabase.ts
onurtemizkan Apr 3, 2025
af31dcf
Remove README.md
onurtemizkan Apr 3, 2025
ace4036
Mark SupabaseConstructor objects as instrumented and check for rewrap…
onurtemizkan Apr 10, 2025
116fca5
Update packages/core/src/integrations/supabase.ts
onurtemizkan Apr 10, 2025
68b04a4
Expose `instrumentSupabase`
onurtemizkan Apr 10, 2025
affaf36
Lint
onurtemizkan Apr 10, 2025
ba7c4e3
Add `db.system` attribute
onurtemizkan Apr 11, 2025
2d8ca55
Update test `dsn`s and tunnel
onurtemizkan Apr 11, 2025
c6ef16f
Update auth `op`s
onurtemizkan Apr 11, 2025
d61aefe
Dedupe deps
onurtemizkan Apr 11, 2025
0c3ff1d
Fix empty arguments on `auth`
onurtemizkan Apr 11, 2025
556703c
Mark and check `auth` as instrumented
onurtemizkan Apr 14, 2025
5776eb7
Rename to `instrumentSupabaseClient` with separate options
onurtemizkan Apr 16, 2025
f081a5d
Remove unnecessary option
Apr 16, 2025
1e87c93
Merge branch 'develop' into onur/supabase-integration
Apr 16, 2025
975d061
tests
Apr 16, 2025
9c2aa4d
woops
Apr 16, 2025
2c638eb
Merge branch 'develop' into onur/supabase-integration
Apr 16, 2025
83902e7
Fix test usage
onurtemizkan Apr 16, 2025
3f5e172
Merge branch 'develop' into onur/supabase-integration
Apr 17, 2025
82043f0
Don't export from browser bundles yet
Apr 17, 2025
d4e3d09
Make types compatible with TS 3.x
onurtemizkan Apr 17, 2025
0ae2ed4
Skip browser bundles on supabase integration tests
onurtemizkan Apr 17, 2025
2f8b5e6
Skip bundle tests per file
onurtemizkan Apr 17, 2025
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
Prev Previous commit
Next Next commit
Add auth support
  • Loading branch information
onurtemizkan committed Apr 14, 2025
commit 318f0a56f92884c0e69f365eefe78c5ce8d019e0
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as Sentry from '@sentry/browser';

import { createClient } from '@supabase/supabase-js';
window.Sentry = Sentry;

const supabase = createClient(
'https://test.supabase.co',
'test-key'
);

Sentry.init({
dsn: 'https://[email protected]/1337',
integrations: [
Sentry.browserTracingIntegration(),
Sentry.supabaseIntegration(supabase)
],
tracesSampleRate: 1.0,
});

// Simulate authentication operations
async function performAuthenticationOperations() {
try {
await supabase.auth.signInWithPassword({
email: '[email protected]',
password: 'test-password',
});

await supabase.auth.signOut();
} catch (error) {
Sentry.captureException(error);
}
}

performAuthenticationOperations();
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import type { Event } from '@sentry/core';

import { sentryTest } from '../../../../utils/fixtures';
import {
getFirstSentryEnvelopeRequest,
getMultipleSentryEnvelopeRequests,
shouldSkipTracingTest,
} from '../../../../utils/helpers';

async function mockSupabaseAuthRoutes(page: Page) {
await page.route('**/auth/v1/token?grant_type=password**', route => {
return route.fulfill({
status: 200,
body: JSON.stringify({
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
token_type: 'bearer',
expires_in: 3600,
}),
headers: {
'Content-Type': 'application/json',
},
});
});

await page.route('**/auth/v1/logout**', route => {
return route.fulfill({
status: 200,
body: JSON.stringify({
message: 'Logged out',
}),
headers: {
'Content-Type': 'application/json',
},
});
});
}

sentryTest('should capture Supabase authentication spans', async ({ getLocalTestUrl, page }) => {
if (shouldSkipTracingTest()) {
return;
}

await mockSupabaseAuthRoutes(page);

const url = await getLocalTestUrl({ testDir: __dirname });

const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
const supabaseSpans = eventData.spans?.filter(({ op }) => op?.startsWith('db.supabase.auth'));

expect(supabaseSpans).toHaveLength(2);
expect(supabaseSpans![0]).toMatchObject({
description: 'signInWithPassword',
parent_span_id: eventData.contexts?.trace?.span_id,
span_id: expect.any(String),
start_timestamp: expect.any(Number),
timestamp: expect.any(Number),
trace_id: eventData.contexts?.trace?.trace_id,
status: 'ok',
data: expect.objectContaining({
'sentry.op': 'db.supabase.auth.signInWithPassword',
'sentry.origin': 'auto.db.supabase',
}),
});

expect(supabaseSpans![1]).toMatchObject({
description: 'signOut',
parent_span_id: eventData.contexts?.trace?.span_id,
span_id: expect.any(String),
start_timestamp: expect.any(Number),
timestamp: expect.any(Number),
trace_id: eventData.contexts?.trace?.trace_id,
status: 'ok',
data: expect.objectContaining({
'sentry.op': 'db.supabase.auth.signOut',
'sentry.origin': 'auto.db.supabase',
}),
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Page} from '@playwright/test';
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import type { Event } from '@sentry/core';

Expand Down
152 changes: 98 additions & 54 deletions packages/core/src/integrations/supabase.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,56 @@
// Ported from Kamil Ogórek's work on:
// Based on Kamil Ogórek's work on:
// https://github.com/supabase-community/sentry-integration-js

// MIT License

// Copyright (c) 2024 Supabase

// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:

// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.

// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

/* eslint-disable max-lines */
import { logger, isPlainObject } from '../utils-hoist';

import type { IntegrationFn } from '../types-hoist';
import { setHttpStatus, startInactiveSpan } from '../tracing';
import { setHttpStatus, startInactiveSpan, startSpan } from '../tracing';
import { addBreadcrumb } from '../breadcrumbs';
import { defineIntegration } from '../integration';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes';
import { captureException } from '../exports';
import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '../tracing';

export interface SupabaseClient {
export interface SupabaseClientConstructor {
prototype: {
from: (table: string) => PostgrestQueryBuilder;
};
}

const AUTH_OPERATIONS_TO_INSTRUMENT = [
'reauthenticate',
'signInAnonymously',
'signInWithOAuth',
'signInWithIdToken',
'signInWithOtp',
'signInWithPassword',
'signInWithSSO',
'signOut',
'signUp',
'verifyOtp',
];

const AUTH_ADMIN_OPERATIONS_TO_INSTRUMENT = [
'createUser',
'deleteUser',
'listUsers',
'getUserById',
'updateUserById',
'inviteUserByEmail',
'signOut',
];

type AuthOperationFn = (...args: unknown[]) => Promise<unknown>;
type AuthOperationName = (typeof AUTH_OPERATIONS_TO_INSTRUMENT)[number];
type AuthAdminOperationName = (typeof AUTH_ADMIN_OPERATIONS_TO_INSTRUMENT)[number];

export interface SupabaseClientInstance {
auth: {
admin: Record<AuthAdminOperationName, AuthOperationFn>;
} & Record<AuthOperationName, AuthOperationFn>;
}

export interface PostgrestQueryBuilder {
select: (...args: unknown[]) => PostgrestFilterBuilder;
insert: (...args: unknown[]) => PostgrestFilterBuilder;
Expand Down Expand Up @@ -181,17 +193,65 @@ export function translateFiltersIntoMethods(key: string, query: string): string
return `${method}(${key}, ${value.join('.')})`;
}

function instrumentSupabaseClient(SupabaseClient: unknown): void {
if (instrumented.has(SupabaseClient)) {
function instrumentAuthOperation(operation: AuthOperationFn, isAdmin = false): AuthOperationFn {
return new Proxy(operation, {
apply(target, thisArg, argumentsList) {
startSpan(
{
name: operation.name,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: `db.supabase.auth.${isAdmin ? 'admin.' : ''}${operation.name}`,
},
},
span => {
return Reflect.apply(target, thisArg, argumentsList).then((res: unknown) => {
debugger;
if (res && typeof res === 'object' && 'error' in res && res.error) {
span.setStatus({ code: SPAN_STATUS_ERROR });
} else {
span.setStatus({ code: SPAN_STATUS_OK });
}

span.end();
debugger;
return res;
});
},
);
},
});
}

function instrumentSupabaseAuthClient(supabaseClientInstance: SupabaseClientInstance): void {
const auth = supabaseClientInstance.auth;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to check for instrumented.has() here as well?

if (!auth) {
return;
}

instrumented.set(SupabaseClient, {
from: (SupabaseClient as unknown as SupabaseClient).prototype.from,
AUTH_OPERATIONS_TO_INSTRUMENT.forEach((operation: AuthOperationName) => {
const authOperation = auth[operation];
if (typeof authOperation === 'function') {
auth[operation] = instrumentAuthOperation(authOperation);
}
});

(SupabaseClient as unknown as SupabaseClient).prototype.from = new Proxy(
(SupabaseClient as unknown as SupabaseClient).prototype.from,
AUTH_ADMIN_OPERATIONS_TO_INSTRUMENT.forEach((operation: AuthAdminOperationName) => {
const authAdminOperation = auth.admin[operation];
if (typeof authAdminOperation === 'function') {
auth.admin[operation] = instrumentAuthOperation(authAdminOperation);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this need the isAdmin as a second parameter if it is an admin operation? 🤔

Maybe also add a test for the admin behavior.

}
});
}

function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void {
if (instrumented.has(SupabaseClient)) {
return;
}

(SupabaseClient as unknown as SupabaseClientConstructor).prototype.from = new Proxy(
(SupabaseClient as unknown as SupabaseClientConstructor).prototype.from,
{
apply(target, thisArg, argumentsList) {
const rv = Reflect.apply(target, thisArg, argumentsList);
Expand Down Expand Up @@ -398,31 +458,15 @@ function instrumentPostgrestQueryBuilder(PostgrestQueryBuilder: new () => Postgr
}
}

export const patchCreateClient = (moduleExports: { createClient?: (...args: unknown[]) => unknown }): void => {
const originalCreateClient = moduleExports.createClient;
if (!originalCreateClient) {
return;
}

moduleExports.createClient = function wrappedCreateClient(...args: any[]) {
const client = originalCreateClient.apply(this, args);

instrumentSupabaseClient(client);

return client;
};
};

const instrumentSupabase = (supabaseClient: unknown): void => {
if (!supabaseClient) {
throw new Error('SupabaseClient class constructor is required');
const instrumentSupabase = (supabaseClientInstance: unknown): void => {
if (!supabaseClientInstance) {
throw new Error('Supabase client instance is not defined.');
}
const SupabaseClientConstructor =
supabaseClientInstance.constructor === Function ? supabaseClientInstance : supabaseClientInstance.constructor;

// We want to allow passing either `SupabaseClient` constructor
// or an instance returned from `createClient()`.
const SupabaseClient = supabaseClient.constructor === Function ? supabaseClient : supabaseClient.constructor;

instrumentSupabaseClient(SupabaseClient);
instrumentSupabaseClientConstructor(SupabaseClientConstructor);
instrumentSupabaseAuthClient(supabaseClientInstance as SupabaseClientInstance);
};

const INTEGRATION_NAME = 'Supabase';
Expand Down