diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx
index 3dac8f441..b92d1d7e7 100644
--- a/packages/web/src/app/layout.tsx
+++ b/packages/web/src/app/layout.tsx
@@ -21,6 +21,7 @@ import { PlanProvider } from "@/features/entitlements/planProvider";
import { getEntitlements } from "@/lib/entitlements";
import { IdentityProvidersProvider } from "@/features/auth/identityProvidersProvider";
import { getIdentityProviderMetadata } from "@/lib/identityProviders";
+import { SentryUserProvider } from "./sentryUserProvider";
export const metadata: Metadata = {
metadataBase: env.AUTH_URL ? new URL(env.AUTH_URL) : undefined,
@@ -74,6 +75,9 @@ export default async function RootLayout({
+
);
-}
\ No newline at end of file
+}
diff --git a/packages/web/src/app/sentryUserProvider.test.tsx b/packages/web/src/app/sentryUserProvider.test.tsx
new file mode 100644
index 000000000..49dac0a79
--- /dev/null
+++ b/packages/web/src/app/sentryUserProvider.test.tsx
@@ -0,0 +1,52 @@
+import { cleanup, render } from '@testing-library/react';
+import { afterEach, describe, expect, test, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ setSentryUser: vi.fn(),
+ useSession: vi.fn(),
+}));
+
+vi.mock('@/lib/sentryUser', () => ({
+ setSentryUser: mocks.setSentryUser,
+}));
+
+vi.mock('next-auth/react', () => ({
+ useSession: mocks.useSession,
+}));
+
+const { SentryUserProvider } = await import('./sentryUserProvider');
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe('SentryUserProvider', () => {
+ test('waits for the session before changing the Sentry user', () => {
+ mocks.useSession.mockReturnValue({ data: undefined, status: 'loading' });
+
+ render();
+
+ expect(mocks.setSentryUser).not.toHaveBeenCalled();
+ });
+
+ test('sets and clears the Sentry user as authentication changes', () => {
+ const user = {
+ id: 'user-1',
+ email: 'user@example.com',
+ name: 'Example User',
+ };
+ mocks.useSession.mockReturnValue({
+ data: { user },
+ status: 'authenticated',
+ });
+
+ const { rerender } = render();
+ expect(mocks.setSentryUser).toHaveBeenLastCalledWith(user, true);
+
+ mocks.useSession.mockReturnValue({ data: null, status: 'unauthenticated' });
+ rerender();
+
+ expect(mocks.setSentryUser).toHaveBeenLastCalledWith(null, true);
+ });
+});
diff --git a/packages/web/src/app/sentryUserProvider.tsx b/packages/web/src/app/sentryUserProvider.tsx
new file mode 100644
index 000000000..008ed782a
--- /dev/null
+++ b/packages/web/src/app/sentryUserProvider.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import { setSentryUser } from '@/lib/sentryUser';
+import { useSession } from 'next-auth/react';
+import { useEffect } from 'react';
+
+interface SentryUserProviderProps {
+ isPiiEnabled: boolean;
+}
+
+export function SentryUserProvider({ isPiiEnabled }: SentryUserProviderProps) {
+ const { data: session, status } = useSession();
+
+ useEffect(() => {
+ if (status === 'loading') {
+ return;
+ }
+
+ setSentryUser(session?.user ?? null, isPiiEnabled);
+ }, [isPiiEnabled, session, status]);
+
+ return null;
+}
diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts
index f22114ce8..ac8bba89f 100644
--- a/packages/web/src/auth.ts
+++ b/packages/web/src/auth.ts
@@ -22,6 +22,7 @@ import { getAnonymousId } from '@/lib/anonymousId';
import { captureEvent } from '@/lib/posthog';
import { isEmailCodeLoginEnabled, isCredentialsLoginEnabled } from '@sourcebot/shared'
import { onCreateUser } from './features/membership/onCreateUser';
+import { setSentryUser } from './lib/sentryUser';
export const runtime = 'nodejs';
@@ -452,7 +453,12 @@ export const { handlers, signIn, signOut } = nextAuthResult;
* without re-running the upstream resolver.
*/
export const auth = cache(async (): Promise => {
- return nextAuthResult.auth();
+ const session = await nextAuthResult.auth();
+ setSentryUser(
+ session?.user ?? null,
+ env.SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED === 'true',
+ );
+ return session;
});
/**
diff --git a/packages/web/src/lib/sentryUser.test.ts b/packages/web/src/lib/sentryUser.test.ts
new file mode 100644
index 000000000..b2122bed5
--- /dev/null
+++ b/packages/web/src/lib/sentryUser.test.ts
@@ -0,0 +1,45 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+
+const setUser = vi.fn();
+
+vi.mock('@sentry/nextjs', () => ({
+ setUser,
+}));
+
+const { setSentryUser } = await import('./sentryUser');
+
+describe('setSentryUser', () => {
+ beforeEach(() => {
+ setUser.mockClear();
+ });
+
+ test('associates errors with the user id without PII by default', () => {
+ setSentryUser({
+ id: 'user-1',
+ email: 'user@example.com',
+ name: 'Example User',
+ }, false);
+
+ expect(setUser).toHaveBeenCalledWith({ id: 'user-1' });
+ });
+
+ test('includes user details when PII collection is enabled', () => {
+ setSentryUser({
+ id: 'user-1',
+ email: 'user@example.com',
+ name: 'Example User',
+ }, true);
+
+ expect(setUser).toHaveBeenCalledWith({
+ id: 'user-1',
+ email: 'user@example.com',
+ username: 'Example User',
+ });
+ });
+
+ test('clears the user for unauthenticated requests', () => {
+ setSentryUser(null, true);
+
+ expect(setUser).toHaveBeenCalledWith(null);
+ });
+});
diff --git a/packages/web/src/lib/sentryUser.ts b/packages/web/src/lib/sentryUser.ts
new file mode 100644
index 000000000..5f05673c6
--- /dev/null
+++ b/packages/web/src/lib/sentryUser.ts
@@ -0,0 +1,22 @@
+import * as Sentry from "@sentry/nextjs";
+
+type SentryUser = {
+ id: string;
+ email?: string | null;
+ name?: string | null;
+};
+
+export function setSentryUser(user: SentryUser | null, isPiiEnabled: boolean) {
+ if (!user) {
+ Sentry.setUser(null);
+ return;
+ }
+
+ Sentry.setUser({
+ id: user.id,
+ ...(isPiiEnabled ? {
+ email: user.email ?? undefined,
+ username: user.name ?? undefined,
+ } : {}),
+ });
+}