From ac8ffea1e53b1fa3440828646264a346689e0b11 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Mon, 26 Jan 2026 12:18:00 -0600 Subject: [PATCH] feat(metrics): add metrics API for dashboard (US-805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New endpoints: - GET /api/metrics/tasks — Task counts by status, total, completed, archived - GET /api/metrics/runs?period=24h|7d — Error rate, success rate, run counts - GET /api/metrics/tokens?period=24h|7d — Token totals, per-run stats (avg, p50, p95) - GET /api/metrics/duration?period=24h|7d — Duration stats (avg, p50, p95) - GET /api/metrics/all?period=24h|7d — All metrics in one call All endpoints support optional project filter. --- server/src/index.ts | 2 + server/src/routes/metrics.ts | 124 +++++++++++ server/src/services/metrics-service.ts | 280 +++++++++++++++++++++++++ 3 files changed, 406 insertions(+) create mode 100644 server/src/routes/metrics.ts create mode 100644 server/src/services/metrics-service.ts diff --git a/server/src/index.ts b/server/src/index.ts index 2c909051..89da9c60 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -15,6 +15,7 @@ import githubRoutes from './routes/github.js'; import previewRoutes from './routes/preview.js'; import conflictRoutes from './routes/conflicts.js'; import telemetryRoutes from './routes/telemetry.js'; +import metricsRoutes from './routes/metrics.js'; import { getTelemetryService } from './services/telemetry-service.js'; import type { AgentOutput } from './services/agent-service.js'; @@ -44,6 +45,7 @@ app.use('/api/github', githubRoutes); app.use('/api/preview', previewRoutes); app.use('/api/conflicts', conflictRoutes); app.use('/api/telemetry', telemetryRoutes); +app.use('/api/metrics', metricsRoutes); // Initialize telemetry service (runs retention cleanup) getTelemetryService().init().catch((err) => { diff --git a/server/src/routes/metrics.ts b/server/src/routes/metrics.ts new file mode 100644 index 00000000..415d6866 --- /dev/null +++ b/server/src/routes/metrics.ts @@ -0,0 +1,124 @@ +import { Router, type Router as RouterType } from 'express'; +import { getMetricsService, type MetricsPeriod } from '../services/metrics-service.js'; + +const router: RouterType = Router(); + +/** + * GET /api/metrics/tasks + * Get task counts by status + * + * Query params: + * - project: Filter by project + */ +router.get('/tasks', async (req, res, next) => { + try { + const metrics = getMetricsService(); + const project = req.query.project as string | undefined; + const result = await metrics.getTaskMetrics(project); + res.json(result); + } catch (error) { + next(error); + } +}); + +/** + * GET /api/metrics/runs + * Get run metrics (error rate, success rate) + * + * Query params: + * - period: '24h' | '7d' (default: '24h') + * - project: Filter by project + */ +router.get('/runs', async (req, res, next) => { + try { + const metrics = getMetricsService(); + const period = (req.query.period as MetricsPeriod) || '24h'; + const project = req.query.project as string | undefined; + + if (period !== '24h' && period !== '7d') { + return res.status(400).json({ error: 'period must be "24h" or "7d"' }); + } + + const result = await metrics.getRunMetrics(period, project); + res.json(result); + } catch (error) { + next(error); + } +}); + +/** + * GET /api/metrics/tokens + * Get token usage metrics + * + * Query params: + * - period: '24h' | '7d' (default: '24h') + * - project: Filter by project + */ +router.get('/tokens', async (req, res, next) => { + try { + const metrics = getMetricsService(); + const period = (req.query.period as MetricsPeriod) || '24h'; + const project = req.query.project as string | undefined; + + if (period !== '24h' && period !== '7d') { + return res.status(400).json({ error: 'period must be "24h" or "7d"' }); + } + + const result = await metrics.getTokenMetrics(period, project); + res.json(result); + } catch (error) { + next(error); + } +}); + +/** + * GET /api/metrics/duration + * Get run duration metrics + * + * Query params: + * - period: '24h' | '7d' (default: '24h') + * - project: Filter by project + */ +router.get('/duration', async (req, res, next) => { + try { + const metrics = getMetricsService(); + const period = (req.query.period as MetricsPeriod) || '24h'; + const project = req.query.project as string | undefined; + + if (period !== '24h' && period !== '7d') { + return res.status(400).json({ error: 'period must be "24h" or "7d"' }); + } + + const result = await metrics.getDurationMetrics(period, project); + res.json(result); + } catch (error) { + next(error); + } +}); + +/** + * GET /api/metrics/all + * Get all metrics in one call (for dashboard) + * + * Query params: + * - period: '24h' | '7d' (default: '24h') + * - project: Filter by project + */ +router.get('/all', async (req, res, next) => { + try { + const metrics = getMetricsService(); + const period = (req.query.period as MetricsPeriod) || '24h'; + const project = req.query.project as string | undefined; + + if (period !== '24h' && period !== '7d') { + return res.status(400).json({ error: 'period must be "24h" or "7d"' }); + } + + const result = await metrics.getAllMetrics(period, project); + res.json(result); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/server/src/services/metrics-service.ts b/server/src/services/metrics-service.ts new file mode 100644 index 00000000..a0766acb --- /dev/null +++ b/server/src/services/metrics-service.ts @@ -0,0 +1,280 @@ +import { getTelemetryService, type TelemetryService } from './telemetry-service.js'; +import { TaskService } from './task-service.js'; +import type { + TaskStatus, + RunTelemetryEvent, + TokenTelemetryEvent, + AnyTelemetryEvent +} from '@veritas-kanban/shared'; + +export type MetricsPeriod = '24h' | '7d'; + +export interface TaskMetrics { + byStatus: Record; + total: number; + completed: number; // done + archived + archived: number; +} + +export interface RunMetrics { + period: MetricsPeriod; + runs: number; + successes: number; + failures: number; + errors: number; + errorRate: number; // (failures + errors) / runs + successRate: number; // successes / runs +} + +export interface TokenMetrics { + period: MetricsPeriod; + totalTokens: number; + inputTokens: number; + outputTokens: number; + runs: number; + perSuccessfulRun: { + avg: number; + p50: number; + p95: number; + }; +} + +export interface DurationMetrics { + period: MetricsPeriod; + runs: number; + avgMs: number; + p50Ms: number; + p95Ms: number; +} + +export class MetricsService { + private telemetry: TelemetryService; + private taskService: TaskService; + + constructor() { + this.telemetry = getTelemetryService(); + this.taskService = new TaskService(); + } + + /** + * Get timestamp for start of period + */ + private getPeriodStart(period: MetricsPeriod): string { + const now = new Date(); + if (period === '24h') { + now.setHours(now.getHours() - 24); + } else { + now.setDate(now.getDate() - 7); + } + return now.toISOString(); + } + + /** + * Calculate percentile from sorted array + */ + private percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const index = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, index)]; + } + + /** + * Get task counts by status + */ + async getTaskMetrics(project?: string): Promise { + const [activeTasks, archivedTasks] = await Promise.all([ + this.taskService.listTasks(), + this.taskService.listArchivedTasks(), + ]); + + // Filter by project if specified + const filteredActive = project + ? activeTasks.filter(t => t.project === project) + : activeTasks; + const filteredArchived = project + ? archivedTasks.filter(t => t.project === project) + : archivedTasks; + + // Count by status + const byStatus: Record = { + 'todo': 0, + 'in-progress': 0, + 'review': 0, + 'done': 0, + }; + + for (const task of filteredActive) { + byStatus[task.status]++; + } + + const archived = filteredArchived.length; + const total = filteredActive.length + archived; + const completed = byStatus['done'] + archived; + + return { + byStatus, + total, + completed, + archived, + }; + } + + /** + * Get run metrics (error rate, success rate) + */ + async getRunMetrics(period: MetricsPeriod, project?: string): Promise { + const since = this.getPeriodStart(period); + + const events = await this.telemetry.getEvents({ + type: ['run.completed', 'run.error'], + since, + ...(project && { project }), + }); + + let successes = 0; + let failures = 0; + let errors = 0; + + for (const event of events) { + if (event.type === 'run.error') { + errors++; + } else if (event.type === 'run.completed') { + const runEvent = event as RunTelemetryEvent; + if (runEvent.success) { + successes++; + } else { + failures++; + } + } + } + + const runs = successes + failures + errors; + const errorRate = runs > 0 ? (failures + errors) / runs : 0; + const successRate = runs > 0 ? successes / runs : 0; + + return { + period, + runs, + successes, + failures, + errors, + errorRate, + successRate, + }; + } + + /** + * Get token metrics + */ + async getTokenMetrics(period: MetricsPeriod, project?: string): Promise { + const since = this.getPeriodStart(period); + + const events = await this.telemetry.getEvents({ + type: 'run.tokens', + since, + ...(project && { project }), + }); + + let totalTokens = 0; + let inputTokens = 0; + let outputTokens = 0; + const tokensPerRun: number[] = []; + + for (const event of events) { + const tokenEvent = event as TokenTelemetryEvent; + totalTokens += tokenEvent.totalTokens; + inputTokens += tokenEvent.inputTokens; + outputTokens += tokenEvent.outputTokens; + tokensPerRun.push(tokenEvent.totalTokens); + } + + // Sort for percentile calculations + tokensPerRun.sort((a, b) => a - b); + + const runs = events.length; + const avg = runs > 0 ? totalTokens / runs : 0; + const p50 = this.percentile(tokensPerRun, 50); + const p95 = this.percentile(tokensPerRun, 95); + + return { + period, + totalTokens, + inputTokens, + outputTokens, + runs, + perSuccessfulRun: { + avg: Math.round(avg), + p50, + p95, + }, + }; + } + + /** + * Get duration metrics + */ + async getDurationMetrics(period: MetricsPeriod, project?: string): Promise { + const since = this.getPeriodStart(period); + + const events = await this.telemetry.getEvents({ + type: 'run.completed', + since, + ...(project && { project }), + }); + + const durations: number[] = []; + + for (const event of events) { + const runEvent = event as RunTelemetryEvent; + if (runEvent.durationMs !== undefined && runEvent.durationMs > 0) { + durations.push(runEvent.durationMs); + } + } + + // Sort for percentile calculations + durations.sort((a, b) => a - b); + + const runs = durations.length; + const sum = durations.reduce((a, b) => a + b, 0); + const avgMs = runs > 0 ? Math.round(sum / runs) : 0; + const p50Ms = this.percentile(durations, 50); + const p95Ms = this.percentile(durations, 95); + + return { + period, + runs, + avgMs, + p50Ms, + p95Ms, + }; + } + + /** + * Get all metrics in one call (for dashboard) + */ + async getAllMetrics(period: MetricsPeriod = '24h', project?: string): Promise<{ + tasks: TaskMetrics; + runs: RunMetrics; + tokens: TokenMetrics; + duration: DurationMetrics; + }> { + const [tasks, runs, tokens, duration] = await Promise.all([ + this.getTaskMetrics(project), + this.getRunMetrics(period, project), + this.getTokenMetrics(period, project), + this.getDurationMetrics(period, project), + ]); + + return { tasks, runs, tokens, duration }; + } +} + +// Singleton instance +let instance: MetricsService | null = null; + +export function getMetricsService(): MetricsService { + if (!instance) { + instance = new MetricsService(); + } + return instance; +}