diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/login/LogoutPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/login/LogoutPage.tsx index 247d0698f..4e3e50ae4 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/login/LogoutPage.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/login/LogoutPage.tsx @@ -10,6 +10,7 @@ import { CardHeader, CardTitle, } from '@/components/ui/card' +import { resetIdentity } from '@/lib/analytics/identify' import { signOut } from '@/lib/auth/auth-client' import { providersStorage } from '@/lib/llm-providers/storage' import { scheduledJobStorage } from '@/lib/schedules/scheduleStorage' @@ -26,6 +27,7 @@ export const LogoutPage: FC = () => { queryClient.clear() await localforage.clear() + resetIdentity() await signOut() navigate('/home', { replace: true }) } diff --git a/packages/browseros-agent/apps/agent/lib/analytics/identify.ts b/packages/browseros-agent/apps/agent/lib/analytics/identify.ts new file mode 100644 index 000000000..9d5225b03 --- /dev/null +++ b/packages/browseros-agent/apps/agent/lib/analytics/identify.ts @@ -0,0 +1,23 @@ +import { sentry } from '../sentry/sentry' +import { posthog } from './posthog' + +/** + * Identify the current user across all analytics and error tracking services. + * Call this when the user logs in or when a stored session is restored. + */ +export function identify(user: { id: string; email?: string; name?: string }) { + sentry.setUser({ id: user.id, email: user.email }) + posthog.identify(user.id, { + email: user.email, + name: user.name, + }) +} + +/** + * Clear user identity across all services. + * Call this when the user logs out. + */ +export function resetIdentity() { + sentry.setUser(null) + posthog.reset() +} diff --git a/packages/browseros-agent/apps/agent/lib/auth/AuthProvider.tsx b/packages/browseros-agent/apps/agent/lib/auth/AuthProvider.tsx index 86928f3fb..f0aeb2019 100644 --- a/packages/browseros-agent/apps/agent/lib/auth/AuthProvider.tsx +++ b/packages/browseros-agent/apps/agent/lib/auth/AuthProvider.tsx @@ -1,5 +1,6 @@ import type { FC, PropsWithChildren } from 'react' import { useEffect } from 'react' +import { identify, resetIdentity } from '@/lib/analytics/identify' import { useSession } from './auth-client' import { useSessionInfo } from './sessionStorage' @@ -14,6 +15,16 @@ export const AuthProvider: FC = ({ children }) => { session: data?.session, user: data?.user, }) + + if (data?.user?.id) { + identify({ + id: data.user.id, + email: data.user.email, + name: data.user.name || undefined, + }) + } else { + resetIdentity() + } } }, [data, isPending]) diff --git a/packages/browseros-agent/apps/agent/lib/sentry/sanitize.ts b/packages/browseros-agent/apps/agent/lib/sentry/sanitize.ts new file mode 100644 index 000000000..463ac3c4c --- /dev/null +++ b/packages/browseros-agent/apps/agent/lib/sentry/sanitize.ts @@ -0,0 +1,77 @@ +/** + * Sanitize Sentry event data by redacting values at keys that match known + * sensitive patterns. Used in `beforeSend` to prevent credentials from + * leaking into error reports. + */ + +const REDACTED = '[REDACTED]' + +const SENSITIVE_KEY_PATTERNS = [ + 'apikey', + 'api_key', + 'accesskeyid', + 'secretaccesskey', + 'sessiontoken', + 'authorization', + 'token', + 'password', + 'secret', + 'credential', +] + +function isSensitiveKey(key: string): boolean { + const lower = key.toLowerCase() + return SENSITIVE_KEY_PATTERNS.some((p) => lower.includes(p)) +} + +function sanitize(obj: T): T { + if (obj === null || obj === undefined) return obj + if ( + typeof obj === 'string' || + typeof obj === 'number' || + typeof obj === 'boolean' + ) { + return obj + } + if (Array.isArray(obj)) { + return obj.map(sanitize) as T + } + if (typeof obj === 'object') { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + result[key] = isSensitiveKey(key) ? REDACTED : sanitize(value) + } + return result as T + } + return obj +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Sentry event type varies by SDK +export function sanitizeEvent(event: E): E { + const e = event as Record + + if (Array.isArray(e.breadcrumbs)) { + e.breadcrumbs = e.breadcrumbs.map((b: Record) => ({ + ...b, + data: b.data ? sanitize(b.data) : b.data, + })) + } + + if (e.contexts) { + e.contexts = sanitize(e.contexts) + } + + if (e.extra) { + e.extra = sanitize(e.extra) + } + + for (const value of e.exception?.values ?? []) { + for (const frame of value.stacktrace?.frames ?? []) { + if (frame.vars) { + frame.vars = sanitize(frame.vars) + } + } + } + + return event +} diff --git a/packages/browseros-agent/apps/agent/lib/sentry/sentry.ts b/packages/browseros-agent/apps/agent/lib/sentry/sentry.ts index 576a8c1d7..853b932af 100644 --- a/packages/browseros-agent/apps/agent/lib/sentry/sentry.ts +++ b/packages/browseros-agent/apps/agent/lib/sentry/sentry.ts @@ -1,6 +1,7 @@ import * as Sentry from '@sentry/react' import { getBrowserOSAdapter } from '../browseros/adapter' import { env } from '../env' +import { sanitizeEvent } from './sanitize' /** Errors that are expected during normal operation and should not be reported */ const SUPPRESSED_ERRORS = ['The browser is shutting down', 'No current window'] @@ -35,7 +36,8 @@ if (env.VITE_PUBLIC_SENTRY_DSN) { ...event.tags, extensionPage: getExtensionPage(), } - return event + + return sanitizeEvent(event) }, integrations: [ diff --git a/packages/browseros-agent/apps/server/src/api/routes/chat.ts b/packages/browseros-agent/apps/server/src/api/routes/chat.ts index 33961279c..4e3a9e8d1 100644 --- a/packages/browseros-agent/apps/server/src/api/routes/chat.ts +++ b/packages/browseros-agent/apps/server/src/api/routes/chat.ts @@ -51,7 +51,15 @@ export function createChatRoutes(deps: ChatRouteDeps) { Sentry.setContext('request', { provider: request.provider, model: request.model, - baseUrl: request.baseUrl, + baseUrl: request.baseUrl + ? (() => { + try { + return new URL(request.baseUrl).origin + } catch { + return undefined + } + })() + : undefined, }) metrics.log('chat.request', { diff --git a/packages/browseros-agent/apps/server/src/lib/sentry.ts b/packages/browseros-agent/apps/server/src/lib/sentry.ts index bff7ae5c4..963fa169c 100644 --- a/packages/browseros-agent/apps/server/src/lib/sentry.ts +++ b/packages/browseros-agent/apps/server/src/lib/sentry.ts @@ -2,6 +2,8 @@ * @license * Copyright 2025 BrowserOS */ + +import { sanitizeEvent } from '@browseros/shared/sentry/sanitize' import * as Sentry from '@sentry/bun' import { INLINED_ENV } from '../env' @@ -17,6 +19,10 @@ Sentry.init({ sendDefaultPii: true, environment: SENTRY_ENVIRONMENT, release: VERSION, + + beforeSend(event) { + return sanitizeEvent(event) + }, }) export { Sentry } diff --git a/packages/browseros-agent/apps/server/src/main.ts b/packages/browseros-agent/apps/server/src/main.ts index 220c8b14d..c675df8bb 100644 --- a/packages/browseros-agent/apps/server/src/main.ts +++ b/packages/browseros-agent/apps/server/src/main.ts @@ -198,6 +198,7 @@ export class Application { logger.debug('Sentry disabled: missing SENTRY_DSN') } + Sentry.setUser({ id: browserosId }) Sentry.setContext('browseros', { client_id: this.config.instanceClientId, install_id: this.config.instanceInstallId, diff --git a/packages/browseros-agent/packages/shared/package.json b/packages/browseros-agent/packages/shared/package.json index f963edb20..226552014 100644 --- a/packages/browseros-agent/packages/shared/package.json +++ b/packages/browseros-agent/packages/shared/package.json @@ -52,6 +52,10 @@ "./schemas/browser-context": { "types": "./src/schemas/browser-context.ts", "default": "./src/schemas/browser-context.ts" + }, + "./sentry/sanitize": { + "types": "./src/sentry/sanitize.ts", + "default": "./src/sentry/sanitize.ts" } } } diff --git a/packages/browseros-agent/packages/shared/src/sentry/sanitize.ts b/packages/browseros-agent/packages/shared/src/sentry/sanitize.ts new file mode 100644 index 000000000..906f7d8a5 --- /dev/null +++ b/packages/browseros-agent/packages/shared/src/sentry/sanitize.ts @@ -0,0 +1,87 @@ +/** + * Sanitize Sentry event data by redacting values at keys that match known + * sensitive patterns. Used in `beforeSend` hooks on both agent and server. + */ + +const REDACTED = '[REDACTED]' + +const SENSITIVE_KEY_PATTERNS = [ + 'apikey', + 'api_key', + 'accesskeyid', + 'secretaccesskey', + 'sessiontoken', + 'authorization', + 'token', + 'password', + 'secret', + 'credential', +] + +function isSensitiveKey(key: string): boolean { + const lower = key.toLowerCase() + return SENSITIVE_KEY_PATTERNS.some((p) => lower.includes(p)) +} + +/** + * Recursively walk an object and replace values at sensitive keys with + * `[REDACTED]`. Returns a new object — the input is never mutated. + */ +export function sanitize(obj: T): T { + if (obj === null || obj === undefined) return obj + if ( + typeof obj === 'string' || + typeof obj === 'number' || + typeof obj === 'boolean' + ) { + return obj + } + if (Array.isArray(obj)) { + return obj.map(sanitize) as T + } + if (typeof obj === 'object') { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + result[key] = isSensitiveKey(key) ? REDACTED : sanitize(value) + } + return result as T + } + return obj +} + +/** + * Sanitize a Sentry event in-place before it is sent. Scrubs: + * - Breadcrumb data (fetch URLs, console output, etc.) + * - Context objects (request context, custom contexts) + * - Extra data + * - Local variables captured in stack frames + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Sentry event type varies by SDK +export function sanitizeEvent(event: E): E { + const e = event as Record + + if (Array.isArray(e.breadcrumbs)) { + e.breadcrumbs = e.breadcrumbs.map((b: Record) => ({ + ...b, + data: b.data ? sanitize(b.data) : b.data, + })) + } + + if (e.contexts) { + e.contexts = sanitize(e.contexts) + } + + if (e.extra) { + e.extra = sanitize(e.extra) + } + + for (const value of e.exception?.values ?? []) { + for (const frame of value.stacktrace?.frames ?? []) { + if (frame.vars) { + frame.vars = sanitize(frame.vars) + } + } + } + + return event +}