diff --git a/src/index.ts b/src/index.ts index cac7e7a..110ca90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { createAIProvider } from './ai/index.js'; import { GitHubClient } from './github/client.js'; import { createWebhookHandler } from './webhook/handler.js'; import { log } from './logger.js'; +import { metrics } from './metrics.js'; async function main(): Promise { const config = await loadConfig(); @@ -28,6 +29,10 @@ async function main(): Promise { res.json({ status: 'ok', version: '0.1.0' }); }); + app.get('/metrics', (_req, res) => { + res.json(metrics.getSnapshot()); + }); + app.listen(config.port, () => { log('info', `RepoKeeper listening on port ${config.port}`); log('info', `AI provider: ${config.ai.provider} (${config.ai.model})`); diff --git a/src/metrics.ts b/src/metrics.ts new file mode 100644 index 0000000..ab61448 --- /dev/null +++ b/src/metrics.ts @@ -0,0 +1,102 @@ +import { log } from './logger.js'; + +interface MetricEntry { + count: number; + lastOccurrence: string; + totalDurationMs: number; +} + +interface MetricsSnapshot { + uptime: number; + startedAt: string; + events: Record; + errors: Record; + aiCalls: { + total: number; + failures: number; + avgDurationMs: number; + }; +} + +class MetricsCollector { + private startTime: number; + private startedAt: string; + private events: Map = new Map(); + private errors: Map = new Map(); + private aiCallCount = 0; + private aiFailureCount = 0; + private aiTotalDurationMs = 0; + + constructor() { + this.startTime = Date.now(); + this.startedAt = new Date().toISOString(); + } + + recordEvent(eventType: string, durationMs: number): void { + const existing = this.events.get(eventType); + if (existing) { + existing.count++; + existing.lastOccurrence = new Date().toISOString(); + existing.totalDurationMs += durationMs; + } else { + this.events.set(eventType, { + count: 1, + lastOccurrence: new Date().toISOString(), + totalDurationMs: durationMs, + }); + } + } + + recordError(errorType: string): void { + const count = this.errors.get(errorType) ?? 0; + this.errors.set(errorType, count + 1); + } + + recordAICall(durationMs: number, failed: boolean): void { + this.aiCallCount++; + this.aiTotalDurationMs += durationMs; + if (failed) { + this.aiFailureCount++; + } + } + + getSnapshot(): MetricsSnapshot { + const events: Record = {}; + for (const [key, value] of this.events) { + events[key] = { ...value }; + } + + const errors: Record = {}; + for (const [key, value] of this.errors) { + errors[key] = value; + } + + return { + uptime: Math.floor((Date.now() - this.startTime) / 1000), + startedAt: this.startedAt, + events, + errors, + aiCalls: { + total: this.aiCallCount, + failures: this.aiFailureCount, + avgDurationMs: this.aiCallCount > 0 + ? Math.round(this.aiTotalDurationMs / this.aiCallCount) + : 0, + }, + }; + } + + reset(): void { + this.events.clear(); + this.errors.clear(); + this.aiCallCount = 0; + this.aiFailureCount = 0; + this.aiTotalDurationMs = 0; + this.startTime = Date.now(); + this.startedAt = new Date().toISOString(); + log('info', 'Metrics reset'); + } +} + +// Singleton instance — imported by index.ts for the /metrics endpoint +export const metrics = new MetricsCollector(); diff --git a/tests/metrics.test.ts b/tests/metrics.test.ts new file mode 100644 index 0000000..808a71f --- /dev/null +++ b/tests/metrics.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { metrics } from '../src/metrics.js'; + +describe('MetricsCollector', () => { + beforeEach(() => { + metrics.reset(); + }); + + it('starts with zero counts', () => { + const snap = metrics.getSnapshot(); + expect(snap.aiCalls.total).toBe(0); + expect(snap.aiCalls.failures).toBe(0); + expect(Object.keys(snap.events)).toHaveLength(0); + expect(Object.keys(snap.errors)).toHaveLength(0); + }); + + it('records events and increments count', () => { + metrics.recordEvent('issues.opened', 150); + metrics.recordEvent('issues.opened', 200); + const snap = metrics.getSnapshot(); + expect(snap.events['issues.opened'].count).toBe(2); + expect(snap.events['issues.opened'].totalDurationMs).toBe(350); + }); + + it('records errors by type', () => { + metrics.recordError('webhook_validation'); + metrics.recordError('webhook_validation'); + metrics.recordError('ai_timeout'); + const snap = metrics.getSnapshot(); + expect(snap.errors['webhook_validation']).toBe(2); + expect(snap.errors['ai_timeout']).toBe(1); + }); + + it('records AI call metrics', () => { + metrics.recordAICall(100, false); + metrics.recordAICall(300, false); + metrics.recordAICall(50, true); + const snap = metrics.getSnapshot(); + expect(snap.aiCalls.total).toBe(3); + expect(snap.aiCalls.failures).toBe(1); + expect(snap.aiCalls.avgDurationMs).toBe(150); + }); + + it('tracks uptime', () => { + const snap = metrics.getSnapshot(); + expect(snap.uptime).toBeGreaterThanOrEqual(0); + expect(snap.startedAt).toBeTruthy(); + }); + + it('resets all counters', () => { + metrics.recordEvent('test', 100); + metrics.recordError('test'); + metrics.recordAICall(50, false); + metrics.reset(); + const snap = metrics.getSnapshot(); + expect(snap.aiCalls.total).toBe(0); + expect(Object.keys(snap.events)).toHaveLength(0); + expect(Object.keys(snap.errors)).toHaveLength(0); + }); +});