From 49d04297c6aa082806940bd28c712d4e9d827395 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 14 Jul 2026 15:15:32 -0700 Subject: [PATCH 1/4] feat(web): associate Sentry errors with users --- packages/web/src/app/layout.tsx | 6 ++- .../web/src/app/sentryUserProvider.test.tsx | 52 +++++++++++++++++++ packages/web/src/app/sentryUserProvider.tsx | 23 ++++++++ packages/web/src/auth.ts | 8 ++- packages/web/src/lib/sentryUser.test.ts | 45 ++++++++++++++++ packages/web/src/lib/sentryUser.ts | 22 ++++++++ 6 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/app/sentryUserProvider.test.tsx create mode 100644 packages/web/src/app/sentryUserProvider.tsx create mode 100644 packages/web/src/lib/sentryUser.test.ts create mode 100644 packages/web/src/lib/sentryUser.ts 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, + } : {}), + }); +} From 87756c8294e9a1f3aa4a15c59711357155ed515e Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:29:01 +0000 Subject: [PATCH 2/4] refactor(web): set Sentry user in withAuth path instead of auth() Move setSentryUser out of the memoized auth() resolver and into getAuthContext so it captures the resolved principal for all auth sources (session, OAuth Bearer, API key) rather than clearing the identity on API-key / Bearer requests where auth() returns null. Co-authored-by: Brendan Kellam <10233483+brendan-kellam@users.noreply.github.com> --- packages/web/src/auth.ts | 8 +------- packages/web/src/middleware/withAuth.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index ac8bba89f..f22114ce8 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -22,7 +22,6 @@ 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'; @@ -453,12 +452,7 @@ export const { handlers, signIn, signOut } = nextAuthResult; * without re-running the upstream resolver. */ export const auth = cache(async (): Promise => { - const session = await nextAuthResult.auth(); - setSentryUser( - session?.user ?? null, - env.SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED === 'true', - ); - return session; + return nextAuthResult.auth(); }); /** diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts index ba2d5876e..306c69655 100644 --- a/packages/web/src/middleware/withAuth.ts +++ b/packages/web/src/middleware/withAuth.ts @@ -13,6 +13,7 @@ import { activatePendingMembership } from "@/features/membership/membership.serv import { hasRequiredOAuthScopes, parseOAuthScopeString } from "@/ee/features/oauth/utils"; import { DPOP_AUTH_SCHEME, DPOP_PROOF_HEADER, verifyDpopProof } from "@/ee/features/oauth/dpop"; import { getCurrentRequest } from "@/lib/requestContext"; +import { setSentryUser } from "@/lib/sentryUser"; const LAST_ACTIVE_AT_THRESHOLD_MS = 5 * 60 * 1000; @@ -83,6 +84,14 @@ export const getAuthContext = async (options: AuthOptions = {}): Promise Date: Fri, 17 Jul 2026 18:32:06 -0400 Subject: [PATCH 3/4] nit --- packages/web/src/middleware/withAuth.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts index 306c69655..00f7276de 100644 --- a/packages/web/src/middleware/withAuth.ts +++ b/packages/web/src/middleware/withAuth.ts @@ -44,6 +44,11 @@ export const withAuth = async (fn: (params: RequiredAuthContext) => Promise(fn: (params: OptionalAuthContext) => P return authContext; } + setSentryUser( + authContext.user ?? null, + env.SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED === 'true', + ); + if ( (!authContext.user || !authContext.role) && !(await isAnonymousAccessEnabled()) @@ -84,14 +94,6 @@ export const getAuthContext = async (options: AuthOptions = {}): Promise Date: Fri, 17 Jul 2026 18:55:59 -0400 Subject: [PATCH 4/4] fix(web): associate Sentry errors with resolved users --- packages/web/src/auth.ts | 8 +++- packages/web/src/lib/sentryUser.ts | 5 +++ packages/web/src/middleware/withAuth.test.ts | 42 ++++++++++++++++++++ packages/web/src/middleware/withAuth.ts | 18 +++------ 4 files changed, 60 insertions(+), 13 deletions(-) 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.ts b/packages/web/src/lib/sentryUser.ts index 5f05673c6..fd719d44d 100644 --- a/packages/web/src/lib/sentryUser.ts +++ b/packages/web/src/lib/sentryUser.ts @@ -6,6 +6,11 @@ type SentryUser = { name?: string | null; }; +/** + * Associates subsequent Sentry events with the current user, including their + * email and name only when PII collection is enabled. If Sentry is not + * configured, this is effectively a no-op and does not send any data. + */ export function setSentryUser(user: SentryUser | null, isPiiEnabled: boolean) { if (!user) { Sentry.setUser(null); diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index 74af00b0e..5437b7541 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => { isAnonymousAccessAvailable: vi.fn(() => false), syncWithLighthouse: vi.fn(async (_orgId: number) => undefined), getSeatCap: vi.fn(() => undefined as number | undefined), + setSentryUser: vi.fn(), env: {} as Record, } }); @@ -29,6 +30,10 @@ vi.mock('../auth', () => ({ auth: mocks.auth, })); +vi.mock('@/lib/sentryUser', () => ({ + setSentryUser: mocks.setSentryUser, +})); + vi.mock('next/headers', () => ({ headers: mocks.headers, })); @@ -390,6 +395,39 @@ describe('getAuthenticatedUser', () => { }); describe('getAuthContext', () => { + test('sets the Sentry user for direct callers', async () => { + const userId = 'test-user-id'; + const user = { + ...MOCK_USER_WITH_ACCOUNTS, + id: userId, + }; + prisma.user.findUnique.mockResolvedValue(user); + prisma.org.findUnique.mockResolvedValue(MOCK_ORG); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId, + orgId: MOCK_ORG.id, + suspendedAt: null, + scimExternalId: null, + lastActiveAt: new Date(), + role: OrgRole.MEMBER, + }); + mocks.env.SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED = 'true'; + setMockSession(createMockSession({ user: { id: userId } })); + + await getAuthContext(); + + expect(mocks.setSentryUser).toHaveBeenCalledWith(user, true); + }); + + test('clears the Sentry user for unauthenticated direct callers', async () => { + prisma.org.findUnique.mockResolvedValue(MOCK_ORG); + + await getAuthContext(); + + expect(mocks.setSentryUser).toHaveBeenCalledWith(null, false); + }); + test('should return a auth context object if a valid session is present and the user is a member of the organization', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ @@ -734,6 +772,10 @@ describe('getAuthContext', () => { org: MOCK_ORG, prisma: undefined, }); + expect(mocks.setSentryUser).toHaveBeenCalledWith( + expect.objectContaining({ id: userId }), + false, + ); }); describe('DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS', () => { diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts index 00f7276de..3e68e8bf4 100644 --- a/packages/web/src/middleware/withAuth.ts +++ b/packages/web/src/middleware/withAuth.ts @@ -44,11 +44,6 @@ export const withAuth = async (fn: (params: RequiredAuthContext) => Promise(fn: (params: OptionalAuthContext) => P return authContext; } - setSentryUser( - authContext.user ?? null, - env.SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED === 'true', - ); - if ( (!authContext.user || !authContext.role) && !(await isAnonymousAccessEnabled()) @@ -81,6 +71,12 @@ export const withOptionalAuth = async (fn: (params: OptionalAuthContext) => P export const getAuthContext = async (options: AuthOptions = {}): Promise => { const authResult = await getAuthenticatedUser(); + const user = authResult?.user; + + setSentryUser( + user ?? null, + env.SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED === 'true', + ); const org = await __unsafePrisma.org.findUnique({ where: { @@ -92,8 +88,6 @@ export const getAuthContext = async (options: AuthOptions = {}): Promise