import { Router } from 'express'; import type { Request, Response } from 'express'; import { getDb } from '../db/index.js'; export const analyticsRouter = Router(); // Map range to a JS-computed ISO timestamp passed as a bind parameter, // so the SQL string never includes user-controlled fragments. function getSinceTimestamp(range: string): string { const now = Date.now(); switch (range) { case '24h': return new Date(now - 24 * 60 * 60 * 1000).toISOString(); case '30d': return new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString(); case '7d': default: return new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(); } } // Summary stats analyticsRouter.get('/summary', (req: Request, res: Response) => { const range = (req.query.range as string) ?? '7d'; const since = getSinceTimestamp(range); const db = getDb(); const stats = db.prepare(` SELECT COUNT(*) as total_requests, SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count, SUM(input_tokens) as total_input_tokens, SUM(output_tokens) as total_output_tokens, AVG(latency_ms) as avg_latency_ms FROM requests WHERE created_at >= ? `).get(since) as any; const totalRequests = stats.total_requests ?? 0; const successRate = totalRequests > 0 ? (stats.success_count / totalRequests) * 100 : 0; const totalTokens = (stats.total_input_tokens ?? 0) + (stats.total_output_tokens ?? 0); // Estimate cost savings: average ~$3/M input + $15/M output tokens (GPT-4o pricing) const inputCost = ((stats.total_input_tokens ?? 0) / 1_000_000) * 3; const outputCost = ((stats.total_output_tokens ?? 0) / 1_000_000) * 15; res.json({ totalRequests, successRate: Math.round(successRate * 10) / 10, totalInputTokens: stats.total_input_tokens ?? 0, totalOutputTokens: stats.total_output_tokens ?? 0, avgLatencyMs: Math.round(stats.avg_latency_ms ?? 0), estimatedCostSavings: Math.round((inputCost + outputCost) * 100) / 100, }); }); // Stats grouped by model analyticsRouter.get('/by-model', (req: Request, res: Response) => { const range = (req.query.range as string) ?? '7d'; const since = getSinceTimestamp(range); const db = getDb(); const rows = db.prepare(` SELECT r.platform, r.model_id, m.display_name, COUNT(*) as requests, SUM(CASE WHEN r.status = 'success' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as success_rate, AVG(r.latency_ms) as avg_latency_ms, SUM(r.input_tokens) as total_input_tokens, SUM(r.output_tokens) as total_output_tokens FROM requests r LEFT JOIN models m ON m.platform = r.platform AND m.model_id = r.model_id WHERE r.created_at >= ? GROUP BY r.platform, r.model_id ORDER BY requests DESC `).all(since) as any[]; res.json(rows.map(r => ({ platform: r.platform, modelId: r.model_id, displayName: r.display_name ?? r.model_id, requests: r.requests, successRate: Math.round(r.success_rate * 10) / 10, avgLatencyMs: Math.round(r.avg_latency_ms), totalInputTokens: r.total_input_tokens ?? 0, totalOutputTokens: r.total_output_tokens ?? 0, }))); }); // Stats grouped by platform analyticsRouter.get('/by-platform', (req: Request, res: Response) => { const range = (req.query.range as string) ?? '7d'; const since = getSinceTimestamp(range); const db = getDb(); const rows = db.prepare(` SELECT platform, COUNT(*) as requests, SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as success_rate, AVG(latency_ms) as avg_latency_ms, SUM(input_tokens) as total_input_tokens, SUM(output_tokens) as total_output_tokens FROM requests WHERE created_at >= ? GROUP BY platform ORDER BY requests DESC `).all(since) as any[]; res.json(rows.map(r => ({ platform: r.platform, requests: r.requests, successRate: Math.round(r.success_rate * 10) / 10, avgLatencyMs: Math.round(r.avg_latency_ms), totalInputTokens: r.total_input_tokens ?? 0, totalOutputTokens: r.total_output_tokens ?? 0, }))); }); // Timeline data analyticsRouter.get('/timeline', (req: Request, res: Response) => { const range = (req.query.range as string) ?? '7d'; const interval = (req.query.interval as string) ?? (range === '24h' ? 'hour' : 'day'); const since = getSinceTimestamp(range); const db = getDb(); // dateFormat is a hardcoded whitelist — never user-controlled. const dateFormat = interval === 'hour' ? '%Y-%m-%dT%H:00:00' : '%Y-%m-%d'; const rows = db.prepare(` SELECT strftime('${dateFormat}', created_at) as timestamp, COUNT(*) as requests, SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count, SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as failure_count FROM requests WHERE created_at >= ? GROUP BY strftime('${dateFormat}', created_at) ORDER BY timestamp ASC `).all(since) as any[]; res.json(rows.map(r => ({ timestamp: r.timestamp, requests: r.requests, successCount: r.success_count, failureCount: r.failure_count, }))); }); // Error distribution (grouped by error type and platform) analyticsRouter.get('/error-distribution', (req: Request, res: Response) => { const range = (req.query.range as string) ?? '7d'; const since = getSinceTimestamp(range); const db = getDb(); // Group errors by category (extract the key part of the error message) const rows = db.prepare(` SELECT platform, model_id, CASE WHEN error LIKE '%429%' OR error LIKE '%rate limit%' OR error LIKE '%too many%' OR error LIKE '%quota%' THEN 'Rate Limited (429)' WHEN error LIKE '%401%' OR error LIKE '%unauthorized%' OR error LIKE '%invalid.*key%' THEN 'Auth Error (401)' WHEN error LIKE '%403%' OR error LIKE '%forbidden%' THEN 'Forbidden (403)' WHEN error LIKE '%404%' OR error LIKE '%not found%' THEN 'Not Found (404)' WHEN error LIKE '%timeout%' OR error LIKE '%ETIMEDOUT%' OR error LIKE '%ECONNREFUSED%' THEN 'Timeout/Connection' WHEN error LIKE '%500%' OR error LIKE '%internal server%' THEN 'Server Error (500)' WHEN error LIKE '%503%' OR error LIKE '%unavailable%' THEN 'Unavailable (503)' ELSE 'Other' END as error_category, COUNT(*) as count FROM requests WHERE status = 'error' AND created_at >= ? GROUP BY platform, error_category ORDER BY count DESC `).all(since) as any[]; // Also get totals by category const byCategory = db.prepare(` SELECT CASE WHEN error LIKE '%429%' OR error LIKE '%rate limit%' OR error LIKE '%too many%' OR error LIKE '%quota%' THEN 'Rate Limited (429)' WHEN error LIKE '%401%' OR error LIKE '%unauthorized%' OR error LIKE '%invalid.*key%' THEN 'Auth Error (401)' WHEN error LIKE '%403%' OR error LIKE '%forbidden%' THEN 'Forbidden (403)' WHEN error LIKE '%404%' OR error LIKE '%not found%' THEN 'Not Found (404)' WHEN error LIKE '%timeout%' OR error LIKE '%ETIMEDOUT%' OR error LIKE '%ECONNREFUSED%' THEN 'Timeout/Connection' WHEN error LIKE '%500%' OR error LIKE '%internal server%' THEN 'Server Error (500)' WHEN error LIKE '%503%' OR error LIKE '%unavailable%' THEN 'Unavailable (503)' ELSE 'Other' END as category, COUNT(*) as count FROM requests WHERE status = 'error' AND created_at >= ? GROUP BY category ORDER BY count DESC `).all(since) as any[]; // Errors by platform const byPlatform = db.prepare(` SELECT platform, COUNT(*) as count FROM requests WHERE status = 'error' AND created_at >= ? GROUP BY platform ORDER BY count DESC `).all(since) as any[]; res.json({ byCategory, byPlatform, detailed: rows, }); }); // Recent errors analyticsRouter.get('/errors', (req: Request, res: Response) => { const range = (req.query.range as string) ?? '7d'; const since = getSinceTimestamp(range); const db = getDb(); const rows = db.prepare(` SELECT id, platform, model_id, error, latency_ms, created_at FROM requests WHERE status = 'error' AND created_at >= ? ORDER BY created_at DESC LIMIT 50 `).all(since) as any[]; res.json(rows.map(r => ({ id: r.id, platform: r.platform, modelId: r.model_id, error: r.error, latencyMs: r.latency_ms, createdAt: r.created_at, }))); });