Merge feature/us-805-metrics-api: Metrics API

This commit is contained in:
Brad Groux 2026-01-26 12:18:04 -06:00
commit 6282b760da
3 changed files with 406 additions and 0 deletions

View file

@ -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) => {

View file

@ -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;

View file

@ -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<TaskStatus, number>;
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<TaskMetrics> {
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<TaskStatus, number> = {
'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<RunMetrics> {
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<TokenMetrics> {
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<DurationMetrics> {
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;
}