-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add /metrics endpoint for observability #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<void> { | ||
| const config = await loadConfig(); | ||
|
|
@@ -28,6 +29,10 @@ async function main(): Promise<void> { | |
| res.json({ status: 'ok', version: '0.1.0' }); | ||
| }); | ||
|
|
||
| app.get('/metrics', (_req, res) => { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 SUGGESTION The /metrics endpoint doesn't set appropriate HTTP headers (e.g., Content-Type is not explicitly set, no Cache-Control headers). Add explicit response headers for clarity and to prevent caching of dynamic metrics data. |
||
| 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})`); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import { log } from './logger.js'; | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 SUGGESTION The MetricsCollector class is exported as a singleton instance but is never actually integrated with the rest of the application. The webhook handler, AI providers, and GitHub client don't call recordEvent(), recordError(), or recordAICall(). Add instrumentation throughout the codebase to make this metrics collection functional. |
||
|
|
||
| interface MetricEntry { | ||
| count: number; | ||
| lastOccurrence: string; | ||
| totalDurationMs: number; | ||
| } | ||
|
|
||
| interface MetricsSnapshot { | ||
| uptime: number; | ||
| startedAt: string; | ||
| events: Record<string, MetricEntry>; | ||
| errors: Record<string, number>; | ||
| aiCalls: { | ||
| total: number; | ||
| failures: number; | ||
| avgDurationMs: number; | ||
| }; | ||
| } | ||
|
|
||
| class MetricsCollector { | ||
| private startTime: number; | ||
| private startedAt: string; | ||
| private events: Map<string, MetricEntry> = new Map(); | ||
| private errors: Map<string, number> = new Map(); | ||
| private aiCallCount = 0; | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 SUGGESTION Memory leak potential: The events and errors Maps grow unbounded over the application's lifetime. For long-running applications, consider implementing a sliding window or TTL-based eviction strategy to prevent excessive memory consumption. |
||
| 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++; | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Calling |
||
| 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<string, MetricEntry> = {}; | ||
| for (const [key, value] of this.events) { | ||
| events[key] = { ...value }; | ||
| } | ||
|
|
||
| const errors: Record<string, number> = {}; | ||
| 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(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import { describe, it, expect, beforeEach } from 'vitest'; | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 SUGGESTION The test file only tests the MetricsCollector class in isolation. Add integration tests in src/index.ts to verify that the /metrics endpoint is properly secured, returns valid JSON, and integrates correctly with the Express app. |
||
| 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); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The /metrics endpoint exposes internal application metrics without any authentication or authorization checks. This could leak sensitive operational information to unauthorized users. Consider adding authentication middleware or restricting this endpoint to localhost only.