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

@ -16,6 +16,7 @@
"express-rate-limit": "^8.2.1", "express-rate-limit": "^8.2.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"nats": "^2.28.2", "nats": "^2.28.2",
"prom-client": "^15.1.3",
"socket.io": "^4.8.1" "socket.io": "^4.8.1"
}, },
"devDependencies": { "devDependencies": {
@ -44,6 +45,15 @@
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz", "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz",
"integrity": "sha512-5gSP1liv10Gjp8cMEnFd6shzkL/D6W1uhXSFNCxDC+YI8+L8wkCYCbJ7n77Ezb4wE/xzMogecE+DtamEe9PZjg==" "integrity": "sha512-5gSP1liv10Gjp8cMEnFd6shzkL/D6W1uhXSFNCxDC+YI8+L8wkCYCbJ7n77Ezb4wE/xzMogecE+DtamEe9PZjg=="
}, },
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@socket.io/component-emitter": { "node_modules/@socket.io/component-emitter": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
@ -133,6 +143,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/bintrees": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz",
"integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==",
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@ -1191,6 +1207,19 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/prom-client": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz",
"integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.4.0",
"tdigest": "^0.1.1"
},
"engines": {
"node": "^16 || ^18 || >=20"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -1560,6 +1589,15 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/tdigest": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz",
"integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==",
"license": "MIT",
"dependencies": {
"bintrees": "1.0.2"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",

View file

@ -17,6 +17,7 @@
"express-rate-limit": "^8.2.1", "express-rate-limit": "^8.2.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"nats": "^2.28.2", "nats": "^2.28.2",
"prom-client": "^15.1.3",
"socket.io": "^4.8.1" "socket.io": "^4.8.1"
}, },
"devDependencies": { "devDependencies": {

View file

@ -26,6 +26,16 @@ import {
import { updateIssueEstimate } from './services/jiraService.js'; import { updateIssueEstimate } from './services/jiraService.js';
import { getSocketUser } from './middleware/auth.js'; import { getSocketUser } from './middleware/auth.js';
import { safeError } from './lib/errors.js'; import { safeError } from './lib/errors.js';
import {
register,
websocketConnections,
votesTotal,
sessionsSavedTotal,
httpRequestsTotal,
httpRequestDuration,
trackUniqueUser,
trackUniqueTenant
} from './lib/metrics.js';
dotenv.config(); dotenv.config();
@ -77,10 +87,28 @@ app.use(cookieParser());
app.use(express.json({ limit: '1mb' })); app.use(express.json({ limit: '1mb' }));
app.use('/api/', rateLimit({ windowMs: 60_000, max: 100, standardHeaders: true, legacyHeaders: false })); 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) => { app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' }); 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/poker', pokerRoutes);
app.use('/api/jira', jiraRoutes); app.use('/api/jira', jiraRoutes);
app.use('/api/poker/rooms', roomRoutes); app.use('/api/poker/rooms', roomRoutes);
@ -163,6 +191,10 @@ io.on('connection', (socket) => {
return; return;
} }
socket.user = user; socket.user = user;
websocketConnections.inc();
trackUniqueUser(user.jiraAccountId);
trackUniqueTenant(user.jiraCloudId);
socket.on('disconnect', () => { websocketConnections.dec(); });
const throttled = socketThrottle(socket); const throttled = socketThrottle(socket);
throttled('poker:join', async ({ sessionId }) => { 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.' }); socket.emit('poker:error', { error: 'Unable to submit vote for this session.' });
return; return;
} }
votesTotal.inc();
const reveal = await revealIfComplete(sessionId, socket.user.jiraCloudId); const reveal = await revealIfComplete(sessionId, socket.user.jiraCloudId);
await emitSessionState(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.' }); socket.emit('poker:error', { error: 'Unable to save estimate for this session.' });
return; return;
} }
sessionsSavedTotal.inc();
const issueRef = saved.session.issueId || saved.session.issueKey; const issueRef = saved.session.issueId || saved.session.issueKey;
try { try {

110
backend/src/lib/metrics.js Normal file
View file

@ -0,0 +1,110 @@
import client from 'prom-client';
export const register = client.register;
// Collect default Node.js metrics (CPU, memory, event loop)
client.collectDefaultMetrics();
// --- Gauges ---
export const websocketConnections = new client.Gauge({
name: 'pokerface_websocket_connections',
help: 'Current active Socket.IO connections'
});
export const roomsActive = new client.Gauge({
name: 'pokerface_rooms_active',
help: 'Current active rooms',
labelNames: ['tenant']
});
export const uniqueUsers = new client.Gauge({
name: 'pokerface_unique_users',
help: 'Unique jiraAccountIds seen (resets hourly)'
});
export const uniqueTenants = new client.Gauge({
name: 'pokerface_unique_tenants',
help: 'Unique jiraCloudIds seen (resets hourly)'
});
// --- Counters ---
export const oauthLoginsTotal = new client.Counter({
name: 'pokerface_oauth_logins_total',
help: 'OAuth login completions',
labelNames: ['status']
});
export const sessionsCreatedTotal = new client.Counter({
name: 'pokerface_sessions_created_total',
help: 'Poker sessions started'
});
export const sessionsSavedTotal = new client.Counter({
name: 'pokerface_sessions_saved_total',
help: 'Sessions completed with saved estimate'
});
export const votesTotal = new client.Counter({
name: 'pokerface_votes_total',
help: 'Votes cast'
});
export const httpRequestsTotal = new client.Counter({
name: 'pokerface_http_requests_total',
help: 'API HTTP requests',
labelNames: ['method', 'route', 'status']
});
export const jiraRequestsTotal = new client.Counter({
name: 'pokerface_jira_requests_total',
help: 'Jira API calls',
labelNames: ['endpoint']
});
export const jiraErrorsTotal = new client.Counter({
name: 'pokerface_jira_errors_total',
help: 'Failed Jira API calls'
});
// --- Histograms ---
export const httpRequestDuration = new client.Histogram({
name: 'pokerface_http_request_duration_seconds',
help: 'API request latency in seconds',
labelNames: ['method', 'route'],
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5]
});
export const jiraRequestDuration = new client.Histogram({
name: 'pokerface_jira_request_duration_seconds',
help: 'Jira API request latency in seconds',
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
});
// --- Unique tracking sets (reset hourly) ---
const uniqueUserIds = new Set();
const uniqueTenantIds = new Set();
export function trackUniqueUser(jiraAccountId) {
if (jiraAccountId) {
uniqueUserIds.add(jiraAccountId);
uniqueUsers.set(uniqueUserIds.size);
}
}
export function trackUniqueTenant(jiraCloudId) {
if (jiraCloudId) {
uniqueTenantIds.add(jiraCloudId);
uniqueTenants.set(uniqueTenantIds.size);
}
}
setInterval(() => {
uniqueUserIds.clear();
uniqueTenantIds.clear();
uniqueUsers.set(0);
uniqueTenants.set(0);
}, 3600_000);

View file

@ -18,6 +18,7 @@ import {
} from '../middleware/auth.js'; } from '../middleware/auth.js';
import { kvOAuthState } from '../lib/nats.js'; import { kvOAuthState } from '../lib/nats.js';
import { safeError } from '../lib/errors.js'; import { safeError } from '../lib/errors.js';
import { oauthLoginsTotal } from '../lib/metrics.js';
const router = express.Router(); const router = express.Router();
@ -63,9 +64,11 @@ router.get('/oauth/callback', async (req, res) => {
avatarUrl: profile.avatarUrl avatarUrl: profile.avatarUrl
}); });
setSessionCookie(res, jwt); setSessionCookie(res, jwt);
oauthLoginsTotal.inc({ status: 'success' });
res.redirect(`${frontendUrl}?auth=success`); res.redirect(`${frontendUrl}?auth=success`);
} catch (error) { } catch (error) {
oauthLoginsTotal.inc({ status: 'failure' });
console.error('[oauth] Callback failed:', error.message); console.error('[oauth] Callback failed:', error.message);
const safeMessage = process.env.NODE_ENV === 'production' ? 'Jira authentication failed.' : error.message; const safeMessage = process.env.NODE_ENV === 'production' ? 'Jira authentication failed.' : error.message;
res.redirect(`${frontendUrl}?auth=error&message=${encodeURIComponent(safeMessage)}`); res.redirect(`${frontendUrl}?auth=error&message=${encodeURIComponent(safeMessage)}`);

View file

@ -1,4 +1,5 @@
import { kvOAuth } from '../lib/nats.js'; import { kvOAuth } from '../lib/nats.js';
import { jiraRequestsTotal, jiraErrorsTotal, jiraRequestDuration } from '../lib/metrics.js';
const ATLASSIAN_AUTH_URL = 'https://auth.atlassian.com/authorize'; const ATLASSIAN_AUTH_URL = 'https://auth.atlassian.com/authorize';
const ATLASSIAN_TOKEN_URL = 'https://auth.atlassian.com/oauth/token'; const ATLASSIAN_TOKEN_URL = 'https://auth.atlassian.com/oauth/token';
@ -172,10 +173,16 @@ async function getValidConnection(jiraAccountId) {
} }
async function jiraFetch(jiraAccountId, path, options = {}) { async function jiraFetch(jiraAccountId, path, options = {}) {
const endpoint = path.split('?')[0];
jiraRequestsTotal.inc({ endpoint });
const end = jiraRequestDuration.startTimer();
const connection = await getValidConnection(jiraAccountId); const connection = await getValidConnection(jiraAccountId);
const url = `https://api.atlassian.com/ex/jira/${connection.cloudId}${path}`; const url = `https://api.atlassian.com/ex/jira/${connection.cloudId}${path}`;
const response = await fetch(url, { let response;
try {
response = await fetch(url, {
...options, ...options,
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -183,8 +190,16 @@ async function jiraFetch(jiraAccountId, path, options = {}) {
...(options.headers || {}) ...(options.headers || {})
} }
}); });
} catch (err) {
end();
jiraErrorsTotal.inc();
throw err;
}
end();
if (!response.ok) { if (!response.ok) {
jiraErrorsTotal.inc();
const errorText = await response.text(); const errorText = await response.text();
throw new Error(`Jira API failed: ${response.status} ${errorText}`); throw new Error(`Jira API failed: ${response.status} ${errorText}`);
} }

View file

@ -1,5 +1,6 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { kvSessions, kvSessionIndex, withCasRetry } from '../lib/nats.js'; import { kvSessions, kvSessionIndex, withCasRetry } from '../lib/nats.js';
import { sessionsCreatedTotal } from '../lib/metrics.js';
const FIBONACCI = [0, 0.5, 1, 2, 3, 5, 8, 13, 20, 40, 100]; const FIBONACCI = [0, 0.5, 1, 2, 3, 5, 8, 13, 20, 40, 100];
@ -134,6 +135,7 @@ export async function createScopedSession({ issueKey, issueId, issueTitle, roomI
}; };
await putSession(session); await putSession(session);
await kvSessionIndex.put(issueIndexKey(tenantCloudId, issueKey), id); await kvSessionIndex.put(issueIndexKey(tenantCloudId, issueKey), id);
sessionsCreatedTotal.inc();
return getSnapshot(session); return getSnapshot(session);
} }

View file

@ -1,5 +1,6 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { kvRooms, kvSprintIndex, kvActiveRooms, withCasRetry } from '../lib/nats.js'; import { kvRooms, kvSprintIndex, kvActiveRooms, withCasRetry } from '../lib/nats.js';
import { roomsActive } from '../lib/metrics.js';
function assertCloudId(cloudId) { function assertCloudId(cloudId) {
if (!cloudId) { if (!cloudId) {
@ -52,6 +53,7 @@ export async function createRoom(payload) {
await kvRooms.put(roomKey(cloudId, id), JSON.stringify(room)); await kvRooms.put(roomKey(cloudId, id), JSON.stringify(room));
await kvSprintIndex.put(sprintKey(cloudId, sprintId), id); await kvSprintIndex.put(sprintKey(cloudId, sprintId), id);
roomsActive.inc({ tenant: cloudId });
// Update active rooms set with CAS retry // Update active rooms set with CAS retry
await withCasRetry(kvActiveRooms, cloudId, (activeIds) => { await withCasRetry(kvActiveRooms, cloudId, (activeIds) => {
@ -113,6 +115,7 @@ export async function deleteRoom(roomId, cloudId) {
const room = entry.json(); const room = entry.json();
await kvRooms.delete(roomKey(cloudId, roomId)); await kvRooms.delete(roomKey(cloudId, roomId));
await kvSprintIndex.delete(sprintKey(cloudId, room.sprintId)); await kvSprintIndex.delete(sprintKey(cloudId, room.sprintId));
roomsActive.dec({ tenant: cloudId });
// Remove from active rooms set with CAS retry // Remove from active rooms set with CAS retry
await withCasRetry(kvActiveRooms, cloudId, (activeIds) => { await withCasRetry(kvActiveRooms, cloudId, (activeIds) => {

View file

@ -0,0 +1,223 @@
{
"annotations": { "list": [] },
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
"id": 100,
"title": "Overview",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"thresholds": { "steps": [{ "color": "green", "value": null }] },
"color": { "mode": "thresholds" }
}
},
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 },
"id": 1,
"options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } },
"title": "Unique Users",
"type": "stat",
"targets": [{ "expr": "pokerface_unique_users", "legendFormat": "", "refId": "A" }]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"thresholds": { "steps": [{ "color": "green", "value": null }] },
"color": { "mode": "thresholds" }
}
},
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 },
"id": 2,
"options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } },
"title": "Unique Tenants",
"type": "stat",
"targets": [{ "expr": "pokerface_unique_tenants", "legendFormat": "", "refId": "A" }]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"thresholds": { "steps": [{ "color": "blue", "value": null }] },
"color": { "mode": "thresholds" }
}
},
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 },
"id": 3,
"options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } },
"title": "Active Connections",
"type": "stat",
"targets": [{ "expr": "pokerface_websocket_connections", "legendFormat": "", "refId": "A" }]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"thresholds": { "steps": [{ "color": "purple", "value": null }] },
"color": { "mode": "thresholds" }
}
},
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 },
"id": 4,
"options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } },
"title": "Active Rooms",
"type": "stat",
"targets": [{ "expr": "sum(pokerface_rooms_active)", "legendFormat": "", "refId": "A" }]
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 },
"id": 101,
"title": "Activity",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": { "defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } } },
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 6 },
"id": 5,
"options": { "tooltip": { "mode": "single" } },
"title": "Sessions Created / hr",
"type": "timeseries",
"targets": [{ "expr": "rate(pokerface_sessions_created_total[1h]) * 3600", "legendFormat": "sessions/hr", "refId": "A" }]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": { "defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } } },
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 6 },
"id": 6,
"options": { "tooltip": { "mode": "single" } },
"title": "Votes / hr",
"type": "timeseries",
"targets": [{ "expr": "rate(pokerface_votes_total[1h]) * 3600", "legendFormat": "votes/hr", "refId": "A" }]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": { "defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } } },
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 6 },
"id": 7,
"options": { "tooltip": { "mode": "multi" } },
"title": "OAuth Logins / hr",
"type": "timeseries",
"targets": [
{ "expr": "rate(pokerface_oauth_logins_total{status=\"success\"}[1h]) * 3600", "legendFormat": "success", "refId": "A" },
{ "expr": "rate(pokerface_oauth_logins_total{status=\"failure\"}[1h]) * 3600", "legendFormat": "failure", "refId": "B" }
]
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 },
"id": 102,
"title": "Performance",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": { "defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } } },
"gridPos": { "h": 8, "w": 6, "x": 0, "y": 15 },
"id": 8,
"options": { "tooltip": { "mode": "multi" } },
"title": "HTTP Request Rate by Route",
"type": "timeseries",
"targets": [{ "expr": "sum by (route) (rate(pokerface_http_requests_total[5m]))", "legendFormat": "{{route}}", "refId": "A" }]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": { "defaults": { "unit": "s", "custom": { "drawStyle": "line", "fillOpacity": 10 } } },
"gridPos": { "h": 8, "w": 6, "x": 6, "y": 15 },
"id": 9,
"options": { "tooltip": { "mode": "multi" } },
"title": "HTTP Latency p95",
"type": "timeseries",
"targets": [{ "expr": "histogram_quantile(0.95, sum by (le, route) (rate(pokerface_http_request_duration_seconds_bucket[5m])))", "legendFormat": "{{route}}", "refId": "A" }]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": { "defaults": { "unit": "s", "custom": { "drawStyle": "line", "fillOpacity": 10 } } },
"gridPos": { "h": 8, "w": 6, "x": 12, "y": 15 },
"id": 10,
"options": { "tooltip": { "mode": "single" } },
"title": "Jira API Latency p95",
"type": "timeseries",
"targets": [{ "expr": "histogram_quantile(0.95, sum by (le) (rate(pokerface_jira_request_duration_seconds_bucket[5m])))", "legendFormat": "p95", "refId": "A" }]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": { "defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } } },
"gridPos": { "h": 8, "w": 6, "x": 18, "y": 15 },
"id": 11,
"options": { "tooltip": { "mode": "single" } },
"title": "Jira Error Rate",
"type": "timeseries",
"targets": [{ "expr": "rate(pokerface_jira_errors_total[5m])", "legendFormat": "errors/s", "refId": "A" }]
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 },
"id": 103,
"title": "System",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": { "defaults": { "unit": "bytes", "custom": { "drawStyle": "line", "fillOpacity": 10 } } },
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 24 },
"id": 12,
"options": { "tooltip": { "mode": "multi" } },
"title": "Node.js Memory Usage",
"type": "timeseries",
"targets": [
{ "expr": "process_resident_memory_bytes", "legendFormat": "RSS", "refId": "A" },
{ "expr": "nodejs_heap_size_used_bytes", "legendFormat": "Heap Used", "refId": "B" }
]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": { "defaults": { "unit": "s", "custom": { "drawStyle": "line", "fillOpacity": 10 } } },
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 24 },
"id": 13,
"options": { "tooltip": { "mode": "single" } },
"title": "Event Loop Lag",
"type": "timeseries",
"targets": [{ "expr": "nodejs_eventloop_lag_seconds", "legendFormat": "lag", "refId": "A" }]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": { "defaults": { "unit": "percentunit", "custom": { "drawStyle": "line", "fillOpacity": 10 } } },
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 24 },
"id": 14,
"options": { "tooltip": { "mode": "single" } },
"title": "CPU Usage",
"type": "timeseries",
"targets": [{ "expr": "rate(process_cpu_seconds_total[5m])", "legendFormat": "CPU", "refId": "A" }]
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": ["pokerface"],
"templating": {
"list": [
{
"current": {},
"hide": 0,
"includeAll": false,
"name": "datasource",
"options": [],
"query": "prometheus",
"refresh": 1,
"type": "datasource"
}
]
},
"time": { "from": "now-3h", "to": "now" },
"title": "Pokerface",
"uid": "pokerface-overview"
}