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..fd719d44d
--- /dev/null
+++ b/packages/web/src/lib/sentryUser.ts
@@ -0,0 +1,27 @@
+import * as Sentry from "@sentry/nextjs";
+
+type SentryUser = {
+ id: string;
+ email?: string | null;
+ 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);
+ return;
+ }
+
+ Sentry.setUser({
+ id: user.id,
+ ...(isPiiEnabled ? {
+ email: user.email ?? undefined,
+ username: user.name ?? undefined,
+ } : {}),
+ });
+}
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 ba2d5876e..3e68e8bf4 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;
@@ -70,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: {
@@ -81,8 +88,6 @@ export const getAuthContext = async (options: AuthOptions = {}): Promise