From c31161af1988dd84f418ed738730c9c01f467e34 Mon Sep 17 00:00:00 2001 From: Jan Willem Mannaerts Date: Sun, 1 Mar 2026 01:02:36 +0100 Subject: [PATCH] Add Prometheus metrics and Grafana dashboard 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 --- backend/package-lock.json | 38 +++++ backend/package.json | 1 + backend/src/index.js | 34 ++++ backend/src/lib/metrics.js | 110 +++++++++++++ backend/src/routes/jira.js | 3 + backend/src/services/jiraService.js | 31 +++- backend/src/services/pokerService.js | 2 + backend/src/services/roomService.js | 3 + grafana/pokerface-dashboard.json | 223 +++++++++++++++++++++++++++ 9 files changed, 437 insertions(+), 8 deletions(-) create mode 100644 backend/src/lib/metrics.js create mode 100644 grafana/pokerface-dashboard.json diff --git a/backend/package-lock.json b/backend/package-lock.json index 75ec30a..6cc1a80 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,6 +16,7 @@ "express-rate-limit": "^8.2.1", "jsonwebtoken": "^9.0.3", "nats": "^2.28.2", + "prom-client": "^15.1.3", "socket.io": "^4.8.1" }, "devDependencies": { @@ -44,6 +45,15 @@ "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz", "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": { "version": "3.1.2", "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" } }, + "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": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -1191,6 +1207,19 @@ "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": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1560,6 +1589,15 @@ "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": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index a808032..a0b3e23 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,6 +17,7 @@ "express-rate-limit": "^8.2.1", "jsonwebtoken": "^9.0.3", "nats": "^2.28.2", + "prom-client": "^15.1.3", "socket.io": "^4.8.1" }, "devDependencies": { diff --git a/backend/src/index.js b/backend/src/index.js index afa79dd..e92ee7c 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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 { diff --git a/backend/src/lib/metrics.js b/backend/src/lib/metrics.js new file mode 100644 index 0000000..db10f65 --- /dev/null +++ b/backend/src/lib/metrics.js @@ -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); diff --git a/backend/src/routes/jira.js b/backend/src/routes/jira.js index a6c591e..0ad377a 100644 --- a/backend/src/routes/jira.js +++ b/backend/src/routes/jira.js @@ -18,6 +18,7 @@ import { } from '../middleware/auth.js'; import { kvOAuthState } from '../lib/nats.js'; import { safeError } from '../lib/errors.js'; +import { oauthLoginsTotal } from '../lib/metrics.js'; const router = express.Router(); @@ -63,9 +64,11 @@ router.get('/oauth/callback', async (req, res) => { avatarUrl: profile.avatarUrl }); setSessionCookie(res, jwt); + oauthLoginsTotal.inc({ status: 'success' }); res.redirect(`${frontendUrl}?auth=success`); } catch (error) { + oauthLoginsTotal.inc({ status: 'failure' }); console.error('[oauth] Callback failed:', error.message); const safeMessage = process.env.NODE_ENV === 'production' ? 'Jira authentication failed.' : error.message; res.redirect(`${frontendUrl}?auth=error&message=${encodeURIComponent(safeMessage)}`); diff --git a/backend/src/services/jiraService.js b/backend/src/services/jiraService.js index 4fafeae..11d234e 100644 --- a/backend/src/services/jiraService.js +++ b/backend/src/services/jiraService.js @@ -1,4 +1,5 @@ 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_TOKEN_URL = 'https://auth.atlassian.com/oauth/token'; @@ -172,19 +173,33 @@ async function getValidConnection(jiraAccountId) { } async function jiraFetch(jiraAccountId, path, options = {}) { + const endpoint = path.split('?')[0]; + jiraRequestsTotal.inc({ endpoint }); + const end = jiraRequestDuration.startTimer(); + const connection = await getValidConnection(jiraAccountId); const url = `https://api.atlassian.com/ex/jira/${connection.cloudId}${path}`; - const response = await fetch(url, { - ...options, - headers: { - Accept: 'application/json', - Authorization: `Bearer ${connection.accessToken}`, - ...(options.headers || {}) - } - }); + let response; + try { + response = await fetch(url, { + ...options, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${connection.accessToken}`, + ...(options.headers || {}) + } + }); + } catch (err) { + end(); + jiraErrorsTotal.inc(); + throw err; + } + + end(); if (!response.ok) { + jiraErrorsTotal.inc(); const errorText = await response.text(); throw new Error(`Jira API failed: ${response.status} ${errorText}`); } diff --git a/backend/src/services/pokerService.js b/backend/src/services/pokerService.js index 4990820..4a9c4cf 100644 --- a/backend/src/services/pokerService.js +++ b/backend/src/services/pokerService.js @@ -1,5 +1,6 @@ import crypto from 'crypto'; 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]; @@ -134,6 +135,7 @@ export async function createScopedSession({ issueKey, issueId, issueTitle, roomI }; await putSession(session); await kvSessionIndex.put(issueIndexKey(tenantCloudId, issueKey), id); + sessionsCreatedTotal.inc(); return getSnapshot(session); } diff --git a/backend/src/services/roomService.js b/backend/src/services/roomService.js index b666cf9..1b9fa26 100644 --- a/backend/src/services/roomService.js +++ b/backend/src/services/roomService.js @@ -1,5 +1,6 @@ import crypto from 'crypto'; import { kvRooms, kvSprintIndex, kvActiveRooms, withCasRetry } from '../lib/nats.js'; +import { roomsActive } from '../lib/metrics.js'; function assertCloudId(cloudId) { if (!cloudId) { @@ -52,6 +53,7 @@ export async function createRoom(payload) { await kvRooms.put(roomKey(cloudId, id), JSON.stringify(room)); await kvSprintIndex.put(sprintKey(cloudId, sprintId), id); + roomsActive.inc({ tenant: cloudId }); // Update active rooms set with CAS retry await withCasRetry(kvActiveRooms, cloudId, (activeIds) => { @@ -113,6 +115,7 @@ export async function deleteRoom(roomId, cloudId) { const room = entry.json(); await kvRooms.delete(roomKey(cloudId, roomId)); await kvSprintIndex.delete(sprintKey(cloudId, room.sprintId)); + roomsActive.dec({ tenant: cloudId }); // Remove from active rooms set with CAS retry await withCasRetry(kvActiveRooms, cloudId, (activeIds) => { diff --git a/grafana/pokerface-dashboard.json b/grafana/pokerface-dashboard.json new file mode 100644 index 0000000..e52d751 --- /dev/null +++ b/grafana/pokerface-dashboard.json @@ -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" +}