diff --git a/.env.example b/.env.example
index 61d2a0206..443d598e9 100644
--- a/.env.example
+++ b/.env.example
@@ -83,6 +83,9 @@ NEXT_PUBLIC_POLOTNO=""
# NOT_SECURED=false
API_LIMIT=30 # The limit of the public API hour limit
+# Sentry Error Tracking Settings (optional)
+# NEXT_PUBLIC_SENTRY_DSN=""
+
# Payment settings
FEE_AMOUNT=0.05
STRIPE_PUBLISHABLE_KEY=""
@@ -117,4 +120,4 @@ POSTIZ_OAUTH_CLIENT_SECRET=""
# LINK_DRIP_API_KEY="" # Your LinkDrip API key
# LINK_DRIP_API_ENDPOINT="https://api.linkdrip.com/v1/" # Your self-hosted LinkDrip API endpoint
-# LINK_DRIP_SHORT_LINK_DOMAIN="dripl.ink" # Your self-hosted LinkDrip domain
\ No newline at end of file
+# LINK_DRIP_SHORT_LINK_DOMAIN="dripl.ink" # Your self-hosted LinkDrip domain
diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts
index 3d9dd9687..19f341cf9 100644
--- a/apps/backend/src/api/routes/users.controller.ts
+++ b/apps/backend/src/api/routes/users.controller.ts
@@ -29,6 +29,7 @@ import { TrackEnum } from '@gitroom/nestjs-libraries/user/track.enum';
import { TrackService } from '@gitroom/nestjs-libraries/track/track.service';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { AuthorizationActions, Sections } from '@gitroom/backend/services/auth/permissions/permission.exception.class';
+import { clearSentryUserContext } from '@gitroom/nestjs-libraries/sentry/sentry.user.context';
@ApiTags('User')
@Controller('/user')
@@ -199,6 +200,9 @@ export class UsersController {
@Post('/logout')
logout(@Res({ passthrough: true }) response: Response) {
+ // Clear Sentry user context on logout
+ clearSentryUserContext();
+
response.header('logout', 'true');
response.cookie('auth', '', {
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
diff --git a/apps/backend/src/services/auth/auth.middleware.ts b/apps/backend/src/services/auth/auth.middleware.ts
index 3bb3fea58..2afd01663 100644
--- a/apps/backend/src/services/auth/auth.middleware.ts
+++ b/apps/backend/src/services/auth/auth.middleware.ts
@@ -6,6 +6,7 @@ import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/o
import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service';
import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter';
+import { setSentryUserContext } from '@gitroom/nestjs-libraries/sentry/sentry.user.context';
import { MastraService } from '@gitroom/nestjs-libraries/chat/mastra.service';
export const removeAuth = (res: Response) => {
@@ -33,6 +34,8 @@ export class AuthMiddleware implements NestMiddleware {
async use(req: Request, res: Response, next: NextFunction) {
const auth = req.headers.auth || req.cookies.auth;
if (!auth) {
+ // Clear Sentry user context when no auth token is present
+ setSentryUserContext(null);
throw new HttpForbiddenException();
}
try {
@@ -70,6 +73,10 @@ export class AuthMiddleware implements NestMiddleware {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
req.org = loadImpersonate.organization;
+
+ // Set Sentry user context for impersonated user
+ setSentryUserContext(user);
+
next();
return;
}
@@ -97,7 +104,12 @@ export class AuthMiddleware implements NestMiddleware {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
req.org = setOrg;
+
+ // Set Sentry user context for this request
+ setSentryUserContext(user);
} catch (err) {
+ // Clear Sentry user context on authentication failure
+ setSentryUserContext(null);
throw new HttpForbiddenException();
}
next();
diff --git a/apps/frontend/src/components/layout/logout.component.tsx b/apps/frontend/src/components/layout/logout.component.tsx
index 3128f522c..6debe0718 100644
--- a/apps/frontend/src/components/layout/logout.component.tsx
+++ b/apps/frontend/src/components/layout/logout.component.tsx
@@ -6,6 +6,7 @@ import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { setCookie } from '@gitroom/frontend/components/layout/layout.context';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
+import { clearSentryUserContext } from '@gitroom/react/sentry/sentry.user.context';
export const LogoutComponent = () => {
const fetch = useFetch();
const { isGeneral, isSecured } = useVariables();
@@ -21,6 +22,9 @@ export const LogoutComponent = () => {
t('yes_logout', 'Yes logout')
)
) {
+ // Clear Sentry user context on logout
+ clearSentryUserContext();
+
if (!isSecured) {
setCookie('auth', '', -10);
} else {
diff --git a/apps/frontend/src/components/layout/user.context.tsx b/apps/frontend/src/components/layout/user.context.tsx
index 9e5ec48d2..8ec59335e 100644
--- a/apps/frontend/src/components/layout/user.context.tsx
+++ b/apps/frontend/src/components/layout/user.context.tsx
@@ -1,11 +1,12 @@
'use client';
-import { createContext, FC, ReactNode, useContext } from 'react';
+import { createContext, FC, ReactNode, useContext, useEffect } from 'react';
import { User } from '@prisma/client';
import {
pricing,
PricingInnerInterface,
} from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
+import { setSentryUserContext } from '@gitroom/react/sentry/sentry.user.context';
export const UserContext = createContext<
| undefined
| (User & {
@@ -36,6 +37,22 @@ export const ContextWrapper: FC<{
tier: pricing[user.tier],
}
: ({} as any);
+
+ // Set Sentry user context whenever user changes
+ useEffect(() => {
+ if (user) {
+ setSentryUserContext({
+ id: user.id,
+ email: user.email,
+ orgId: user.orgId,
+ role: user.role,
+ tier: user.tier,
+ });
+ } else {
+ setSentryUserContext(null);
+ }
+ }, [user]);
+
return {children};
};
export const useUser = () => useContext(UserContext);
diff --git a/libraries/nestjs-libraries/src/sentry/sentry.user.context.ts b/libraries/nestjs-libraries/src/sentry/sentry.user.context.ts
new file mode 100644
index 000000000..df3146942
--- /dev/null
+++ b/libraries/nestjs-libraries/src/sentry/sentry.user.context.ts
@@ -0,0 +1,63 @@
+import * as Sentry from '@sentry/nestjs';
+import { User } from '@prisma/client';
+
+/**
+ * Sets user context for Sentry for the current request.
+ * This will include user information in all error reports and events.
+ * Only executes if Sentry DSN is configured.
+ *
+ * @param user - The user object from the database
+ */
+export const setSentryUserContext = (user: User | null) => {
+ // Only set context if Sentry is configured
+ if (!process.env.NEXT_PUBLIC_SENTRY_DSN) {
+ return;
+ }
+
+ try {
+ if (!user) {
+ // Clear user context when no user is present
+ Sentry.setUser(null);
+ return;
+ }
+
+ Sentry.setUser({
+ id: user.id,
+ email: user.email,
+ username: user.email, // Use email as username since that's the primary identifier
+ // Add additional useful context
+ ip_address: undefined, // Let Sentry auto-detect IP
+ });
+
+ // Also set additional tags for better filtering in Sentry
+ Sentry.setTag('user.activated', user.activated);
+ Sentry.setTag('user.provider', user.providerName || 'local');
+
+ if (user.isSuperAdmin) {
+ Sentry.setTag('user.super_admin', true);
+ }
+ } catch {
+ // Silently fail if Sentry throws an error - we don't want to break the app
+ }
+};
+
+/**
+ * Clears the Sentry user context.
+ * Useful when logging out or switching users.
+ * Only executes if Sentry DSN is configured.
+ */
+export const clearSentryUserContext = () => {
+ // Only clear context if Sentry is configured
+ if (!process.env.NEXT_PUBLIC_SENTRY_DSN) {
+ return;
+ }
+
+ try {
+ Sentry.setUser(null);
+ Sentry.setTag('user.activated', null);
+ Sentry.setTag('user.provider', null);
+ Sentry.setTag('user.super_admin', null);
+ } catch {
+ // Silently fail if Sentry throws an error - we don't want to break the app
+ }
+};
diff --git a/libraries/nestjs-libraries/src/sentry/sentry.user.interceptor.ts b/libraries/nestjs-libraries/src/sentry/sentry.user.interceptor.ts
new file mode 100644
index 000000000..1dae1c994
--- /dev/null
+++ b/libraries/nestjs-libraries/src/sentry/sentry.user.interceptor.ts
@@ -0,0 +1,54 @@
+import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
+import { Observable } from 'rxjs';
+import { Request } from 'express';
+import { User } from '@prisma/client';
+import { setSentryUserContext } from './sentry.user.context';
+
+/**
+ * Interceptor that automatically sets Sentry user context for all requests.
+ * This interceptor runs after authentication middleware has set req.user.
+ *
+ * Usage Options:
+ *
+ * 1. Global interceptor (recommended for APIs with consistent auth):
+ * In your app.module.ts:
+ * ```typescript
+ * import { APP_INTERCEPTOR } from '@nestjs/core';
+ * import { SentryUserInterceptor } from '@gitroom/nestjs-libraries/sentry/sentry.user.interceptor';
+ *
+ * @Module({
+ * providers: [
+ * { provide: APP_INTERCEPTOR, useClass: SentryUserInterceptor },
+ * ],
+ * })
+ * export class AppModule {}
+ * ```
+ *
+ * 2. Controller-level (for specific controllers):
+ * ```typescript
+ * @UseInterceptors(SentryUserInterceptor)
+ * @Controller('users')
+ * export class UsersController {}
+ * ```
+ *
+ * 3. Method-level (for specific routes):
+ * ```typescript
+ * @UseInterceptors(SentryUserInterceptor)
+ * @Get('profile')
+ * getProfile() {}
+ * ```
+ */
+@Injectable()
+export class SentryUserInterceptor implements NestInterceptor {
+ intercept(context: ExecutionContext, next: CallHandler): Observable {
+ const request = context.switchToHttp().getRequest();
+
+ // Get user from request (set by auth middleware)
+ const user = (request as any).user as User | undefined;
+
+ // Set Sentry user context for this request
+ setSentryUserContext(user || null);
+
+ return next.handle();
+ }
+}
diff --git a/libraries/react-shared-libraries/src/sentry/sentry.user.context.ts b/libraries/react-shared-libraries/src/sentry/sentry.user.context.ts
new file mode 100644
index 000000000..dd0206467
--- /dev/null
+++ b/libraries/react-shared-libraries/src/sentry/sentry.user.context.ts
@@ -0,0 +1,75 @@
+'use client';
+
+import * as Sentry from '@sentry/nextjs';
+
+interface UserInfo {
+ id: string;
+ email: string;
+ orgId?: string;
+ role?: string;
+ tier?: string;
+}
+
+/**
+ * Sets user context for Sentry in the frontend.
+ * This will include user information in all error reports and events.
+ * Only executes if Sentry DSN is configured.
+ *
+ * @param user - The user object from the API
+ */
+export const setSentryUserContext = (user: UserInfo | null) => {
+ // Only set context if Sentry is configured
+ if (!process.env.NEXT_PUBLIC_SENTRY_DSN) {
+ return;
+ }
+
+ try {
+ if (!user) {
+ // Clear user context when no user is present
+ Sentry.setUser(null);
+ return;
+ }
+
+ Sentry.setUser({
+ id: user.id,
+ email: user.email,
+ username: user.email, // Use email as username since that's the primary identifier
+ });
+
+ // Also set additional tags for better filtering in Sentry
+ if (user.orgId) {
+ Sentry.setTag('user.org_id', user.orgId);
+ }
+
+ if (user.role) {
+ Sentry.setTag('user.role', user.role);
+ }
+
+ if (user.tier) {
+ Sentry.setTag('user.tier', user.tier);
+ }
+ } catch {
+ // Silently fail if Sentry throws an error - we don't want to break the app
+ }
+};
+
+/**
+ * Clears the Sentry user context.
+ * Useful when logging out or switching users.
+ * Only executes if Sentry DSN is configured.
+ */
+export const clearSentryUserContext = () => {
+ // Only clear context if Sentry is configured
+ if (!process.env.NEXT_PUBLIC_SENTRY_DSN) {
+ return;
+ }
+
+ try {
+ Sentry.setUser(null);
+ Sentry.setTag('user.org_id', null);
+ Sentry.setTag('user.role', null);
+ Sentry.setTag('user.tier', null);
+ } catch {
+ // Silently fail if Sentry throws an error - we don't want to break the app
+ }
+};