import type { ErrorReportingLevel, ErrorReportingService, ErrorReportingUser, } from './error-reporting-service'; import type { NotificationService } from '../notifications/notification-service'; import type { LoggerService } from '../logging/logger-service'; export type GlitchtipErrorReportingServiceOptions = { enabled: boolean; dsn?: string; tracesSampleRate?: number; }; // GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN. // Sentry is dynamically imported to avoid a ~100KB main-thread execution penalty on initial load. export class GlitchtipErrorReportingService implements ErrorReportingService { private logger: LoggerService; private sentryPromise: Promise | null = null; constructor( private readonly options: GlitchtipErrorReportingServiceOptions, logger: LoggerService, private readonly notifications?: NotificationService, ) { this.logger = logger.child({ component: 'error-reporting-glitchtip' }); if (this.options.enabled) { if (typeof window !== 'undefined') { // On client-side, wait until idle before fetching Sentry if ('requestIdleCallback' in window) { window.requestIdleCallback(() => { this.getSentry(); }); } else { setTimeout(() => { this.getSentry(); }, 3000); } } else { // Pre-fetch on server-side this.getSentry(); } } } private getSentry(): Promise { if (!this.sentryPromise) { this.sentryPromise = import('@sentry/nextjs').then((Sentry) => { // Client-side initialization must happen here since sentry.client.config.ts is empty if (typeof window !== 'undefined' && this.options.enabled) { Sentry.init({ dsn: this.options.dsn || 'https://public@errors.infra.mintel.me/1', tunnel: '/errors/api/relay', enabled: true, tracesSampleRate: this.options.tracesSampleRate ?? 0.1, replaysOnErrorSampleRate: 1.0, replaysSessionSampleRate: 0.1, }); } return Sentry; }); } return this.sentryPromise; } async captureException(error: unknown, context?: Record) { if (!this.options.enabled) return undefined; // Send to Gotify if it's considered critical or if we just want all exceptions there if (this.notifications) { const errorMessage = error instanceof Error ? error.message : String(error); const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : ''; await this.notifications.notify({ title: '🔥 Critical Error Captured', message: `Error: ${errorMessage}${contextStr}`, priority: 7, }); } const Sentry = await this.getSentry(); return Sentry.captureException(error, context as any) as any; } async captureMessage(message: string, level: ErrorReportingLevel = 'error') { if (!this.options.enabled) return undefined; // Send 404s and critical messages straight to Gotify if ( this.notifications && (level === 'error' || level === 'fatal' || message.includes('Route Not Found')) ) { await this.notifications.notify({ title: level === 'warning' ? '⚠️ Warning Captured' : '🔥 Critical Message Captured', message: message, priority: level === 'warning' ? 5 : 7, }); } const Sentry = await this.getSentry(); return Sentry.captureMessage(message, level as any) as any; } setUser(user: ErrorReportingUser | null) { if (!this.options.enabled) return; this.getSentry().then((Sentry) => Sentry.setUser(user as any)); } setTag(key: string, value: string) { if (!this.options.enabled) return; this.getSentry().then((Sentry) => Sentry.setTag(key, value)); } withScope(fn: () => T, _context?: Record): T { if (!this.options.enabled) return fn(); // Since withScope mandates executing fn() synchronously to return T, // and Sentry load is async, if context mapping is absolutely required // for this feature we would need an async API. // For now we degrade gracefully by just executing the function. return fn(); } }