Add Prometheus metrics and Grafana dashboard
All checks were successful
Build & Push Container Image / build (push) Successful in 9s

Instrument backend with prom-client: HTTP request count/latency,
WebSocket connections, Jira API health, session/vote/room counters,
and unique user/tenant tracking. Expose unauthenticated /metrics
endpoint. Include pre-built Grafana dashboard JSON.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jan Willem Mannaerts 2026-03-01 01:02:36 +01:00
parent 99cdd5b102
commit c31161af19
9 changed files with 437 additions and 8 deletions

View file

@ -26,6 +26,16 @@ import {
import { updateIssueEstimate } from './services/jiraService.js';
import { getSocketUser } from './middleware/auth.js';
import { safeError } from './lib/errors.js';
import {
register,
websocketConnections,
votesTotal,
sessionsSavedTotal,
httpRequestsTotal,
httpRequestDuration,
trackUniqueUser,
trackUniqueTenant
} from './lib/metrics.js';
dotenv.config();
@ -77,10 +87,28 @@ app.use(cookieParser());
app.use(express.json({ limit: '1mb' }));
app.use('/api/', rateLimit({ windowMs: 60_000, max: 100, standardHeaders: true, legacyHeaders: false }));
// Prometheus metrics endpoint (no auth, before rate limiter)
app.get('/metrics', async (_req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });
});
// HTTP metrics middleware
app.use((req, res, next) => {
if (req.path === '/metrics' || req.path === '/api/health') return next();
const end = httpRequestDuration.startTimer();
res.on('finish', () => {
const route = req.route?.path || req.path;
httpRequestsTotal.inc({ method: req.method, route, status: res.statusCode });
end({ method: req.method, route });
});
next();
});
app.use('/api/poker', pokerRoutes);
app.use('/api/jira', jiraRoutes);
app.use('/api/poker/rooms', roomRoutes);
@ -163,6 +191,10 @@ io.on('connection', (socket) => {
return;
}
socket.user = user;
websocketConnections.inc();
trackUniqueUser(user.jiraAccountId);
trackUniqueTenant(user.jiraCloudId);
socket.on('disconnect', () => { websocketConnections.dec(); });
const throttled = socketThrottle(socket);
throttled('poker:join', async ({ sessionId }) => {
@ -222,6 +254,7 @@ io.on('connection', (socket) => {
socket.emit('poker:error', { error: 'Unable to submit vote for this session.' });
return;
}
votesTotal.inc();
const reveal = await revealIfComplete(sessionId, socket.user.jiraCloudId);
await emitSessionState(sessionId, socket.user.jiraCloudId);
@ -272,6 +305,7 @@ io.on('connection', (socket) => {
socket.emit('poker:error', { error: 'Unable to save estimate for this session.' });
return;
}
sessionsSavedTotal.inc();
const issueRef = saved.session.issueId || saved.session.issueKey;
try {