diff --git a/.changeset/calm-lamps-doubt.md b/.changeset/calm-lamps-doubt.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/calm-lamps-doubt.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/public/posthog-init.js b/server/public/posthog-init.js index 1a788da4ab..c99a6bde86 100644 --- a/server/public/posthog-init.js +++ b/server/public/posthog-init.js @@ -64,25 +64,25 @@ capture_exceptions: true, }); - // Capture unhandled errors - window.onerror = function(message, source, lineno, colno, error) { + // Capture unhandled errors (using addEventListener to not overwrite existing handlers) + window.addEventListener('error', function(event) { if (window.posthog && window.posthog.captureException) { - posthog.captureException(error || new Error(message), { - source: source, - lineno: lineno, - colno: colno, + posthog.captureException(event.error || new Error(event.message), { + source: event.filename, + lineno: event.lineno, + colno: event.colno, }); } - }; + }); // Capture unhandled promise rejections - window.onunhandledrejection = function(event) { + window.addEventListener('unhandledrejection', function(event) { if (window.posthog && window.posthog.captureException) { posthog.captureException(event.reason, { type: 'unhandledrejection', }); } - }; + }); // Identify user if logged in const user = config.user; diff --git a/server/src/http.ts b/server/src/http.ts index be4de88906..2c942d8dd6 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -7122,27 +7122,18 @@ Disallow: /api/admin/ } }); - // Global error handler - captures unhandled errors to PostHog + // Global error handler - logger.error() automatically captures to PostHog via error hook this.app.use((err: Error, req: express.Request, res: express.Response, _next: express.NextFunction) => { - // Capture to PostHog if configured - import('./utils/posthog.js').then(({ captureException }) => { - const userId = req.user?.id || 'anonymous'; - captureException(err, userId, { - path: req.path, - method: req.method, - query: req.query, - userAgent: req.get('user-agent'), - }); - }).catch(() => { - // PostHog capture failed silently - }); - logger.error({ err, path: req.path, method: req.method }, 'Unhandled error'); res.status(500).json({ error: 'Internal server error' }); }); } async start(port: number = 3000): Promise { + // Initialize PostHog error tracking (captures all logger.error() calls) + const { initPostHogErrorTracking } = await import('./utils/posthog.js'); + initPostHogErrorTracking(); + // Initialize database const { initializeDatabase } = await import("./db/client.js"); const { runMigrations } = await import("./db/migrate.js"); diff --git a/server/src/logger.ts b/server/src/logger.ts index 193ddba13b..8a21790314 100644 --- a/server/src/logger.ts +++ b/server/src/logger.ts @@ -3,15 +3,87 @@ * * Provides a centralized logger instance with appropriate configuration * for development and production environments. + * + * Supports an error hook for sending errors to external services (e.g., PostHog). */ import pino from 'pino'; const isDevelopment = process.env.NODE_ENV !== 'production'; +/** + * Error hook type - called when logger.error() or logger.fatal() is invoked. + * Set via setErrorHook() to avoid circular dependencies with PostHog. + */ +type ErrorHook = ( + message: string, + error?: Error, + context?: Record +) => void; + +let errorHook: ErrorHook | null = null; + +/** + * Set the error hook for external error reporting. + * Call this after PostHog is initialized to send errors there. + */ +export function setErrorHook(hook: ErrorHook): void { + errorHook = hook; +} + +// Pino hooks to capture error/fatal logs for external reporting +const hooks: pino.LoggerOptions['hooks'] = { + logMethod(inputArgs, method, level) { + // Only process error (50) and fatal (60) levels + if (level >= 50 && errorHook) { + const args = inputArgs as unknown[]; + let message = ''; + let error: Error | undefined; + let context: Record = {}; + + // Parse Pino's flexible argument format + for (const arg of args) { + if (arg instanceof Error) { + error = arg; + } else if (typeof arg === 'string') { + message = arg; + } else if (typeof arg === 'object' && arg !== null) { + // Context object - extract error if present + const obj = arg as Record; + if (obj.err instanceof Error) { + error = obj.err; + } else if (obj.error instanceof Error) { + error = obj.error; + } + // Copy other context (excluding the error we already extracted) + const { err, error: _error, ...rest } = obj; + context = { ...context, ...rest }; + } + } + + // If no Error object but we have a message, create one for stack trace + if (!error && message) { + error = new Error(message); + } + + // Call the hook asynchronously to not block logging + try { + errorHook(message || error?.message || 'Unknown error', error, context); + } catch { + // Silently ignore hook errors to prevent logging loops + } + } + + // Always call the original method + return method.apply(this, inputArgs); + }, +}; + export const logger = pino({ level: process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'info'), + hooks, + // Use pino-pretty in development for human-readable logs transport: isDevelopment ? { diff --git a/server/src/utils/posthog.ts b/server/src/utils/posthog.ts index 8b990adbb0..5336d705fb 100644 --- a/server/src/utils/posthog.ts +++ b/server/src/utils/posthog.ts @@ -3,10 +3,12 @@ * * Provides server-side event tracking and error capture. * Only initializes if POSTHOG_API_KEY is set. + * + * Integrates with the logger to automatically capture all error/fatal logs. */ import { PostHog } from 'posthog-node'; -import { createLogger } from '../logger.js'; +import { createLogger, setErrorHook } from '../logger.js'; const logger = createLogger('posthog'); @@ -25,13 +27,18 @@ export function getPostHog(): PostHog | null { } if (!posthogClient) { - posthogClient = new PostHog(POSTHOG_API_KEY, { - host: POSTHOG_HOST, - // Flush events in batches - flushAt: 20, - flushInterval: 10000, // 10 seconds - }); - logger.info('PostHog client initialized'); + try { + posthogClient = new PostHog(POSTHOG_API_KEY, { + host: POSTHOG_HOST, + // Flush events in batches + flushAt: 20, + flushInterval: 10000, // 10 seconds + }); + logger.info('PostHog client initialized'); + } catch (err) { + logger.warn({ err }, 'Failed to initialize PostHog client'); + return null; + } } return posthogClient; @@ -115,3 +122,78 @@ export async function shutdownPostHog(): Promise { logger.info('PostHog client shut down'); } } + +// Rate limiting for error capture to prevent flooding PostHog +const ERROR_RATE_LIMIT_MS = 100; // Min 100ms between errors +const ERROR_DEDUP_WINDOW_MS = 5000; // Dedupe same error within 5 seconds +let lastErrorTime = 0; +const recentErrors = new Map(); // message -> timestamp + +/** + * Initialize PostHog error tracking integration with the logger. + * Call this once at server startup to capture all logger.error() and logger.fatal() calls. + * + * Includes rate limiting to prevent flooding PostHog during error storms. + * + * @returns true if PostHog error tracking was enabled, false if POSTHOG_API_KEY is not set + */ +export function initPostHogErrorTracking(): boolean { + if (!POSTHOG_API_KEY) { + return false; + } + + // Ensure client is initialized + getPostHog(); + + // Set up the error hook in the logger + setErrorHook((message, error, context) => { + const client = getPostHog(); + if (!client) return; + + const now = Date.now(); + + // Rate limit: skip if too soon after last error + if (now - lastErrorTime < ERROR_RATE_LIMIT_MS) { + return; + } + + // Dedupe: skip if same error message within window + const errorKey = `${message}:${error?.name || 'Error'}`; + const lastSeen = recentErrors.get(errorKey); + if (lastSeen && now - lastSeen < ERROR_DEDUP_WINDOW_MS) { + return; + } + + lastErrorTime = now; + recentErrors.set(errorKey, now); + + // Clean up old entries periodically + if (recentErrors.size > 100) { + const cutoff = now - ERROR_DEDUP_WINDOW_MS; + for (const [key, time] of recentErrors) { + if (time < cutoff) recentErrors.delete(key); + } + } + + // Extract module from context if available (set by createLogger) + const module = (context?.module as string) || 'unknown'; + + client.capture({ + distinctId: 'server-logs', + event: '$exception', + properties: { + $exception_message: message, + $exception_type: error?.name || 'Error', + $exception_stack_trace_raw: error?.stack, + // Include context for debugging + module, + ...context, + $lib: 'posthog-node', + source: 'server-logger', + }, + }); + }); + + logger.info('PostHog error tracking initialized - all logger.error() calls will be captured'); + return true; +}