Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -28,6 +29,10 @@ async function main(): Promise<void> {
res.json({ status: 'ok', version: '0.1.0' });
});

app.get('/metrics', (_req, res) => {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ WARNING

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.

Copy link
Owner Author

Choose a reason for hiding this comment

The 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})`);
Expand Down
102 changes: 102 additions & 0 deletions src/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { log } from './logger.js';
Copy link
Owner Author

Choose a reason for hiding this comment

The 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;
Copy link
Owner Author

Choose a reason for hiding this comment

The 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++;
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ WARNING

Calling new Date().toISOString() on every event recording (lines 38, 44) creates unnecessary object allocations. Consider using a single timestamp per batch or caching the current time to improve performance in high-throughput scenarios.

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();
60 changes: 60 additions & 0 deletions tests/metrics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach } from 'vitest';
Copy link
Owner Author

Choose a reason for hiding this comment

The 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);
});
});