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, + } : {}), + }); +}