Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/calm-lamps-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
18 changes: 9 additions & 9 deletions server/public/posthog-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 5 additions & 14 deletions server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
// 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");
Expand Down
72 changes: 72 additions & 0 deletions server/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
) => 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<string, unknown> = {};

// 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<string, unknown>;
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
? {
Expand Down
98 changes: 90 additions & 8 deletions server/src/utils/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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;
Expand Down Expand Up @@ -115,3 +122,78 @@ export async function shutdownPostHog(): Promise<void> {
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<string, number>(); // 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;
}