From f89a769540b8c3a125712d5fcc5f784f739b91c2 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 25 Aug 2026 12:09:17 -0500 Subject: [PATCH] feat(observability): enrollment/completion metrics, no PII/high-cardinality labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - metrics.js: rootnode_enrollments_total{status,delivery_mode}, rootnode_selfpaced_active_ enrollments, rootnode_program_completions_total{program_id}, rootnode_enrollment_events_ {created,duplicate,rejected}_total. Every label is a bounded enum or an admin-curated slug — never student_id/email/enrollment_id/external_reference. --- platform/api/src/metrics.js | 40 ++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/platform/api/src/metrics.js b/platform/api/src/metrics.js index 8cc05ce..54fc255 100644 --- a/platform/api/src/metrics.js +++ b/platform/api/src/metrics.js @@ -17,6 +17,14 @@ let studentLoginFailureTotal = 0; function recordStudentLoginSuccess() { studentLoginSuccessTotal++; } function recordStudentLoginFailure() { studentLoginFailureTotal++; } +// Phase 5B §16 — the integration endpoint's result counter. `result` is a fixed 3-value enum +// ('created' | 'duplicate' | 'rejected'), never anything from the request body, so it can never +// become high-cardinality no matter what a caller sends. +const enrollmentEventTotals = { created: 0, duplicate: 0, rejected: 0 }; +function recordEnrollmentEvent(result) { + if (result in enrollmentEventTotals) enrollmentEventTotals[result]++; +} + // Gauges are computed fresh at scrape time from Postgres/the Provisioner — cheap enough at a // normal 15-30s Prometheus scrape interval, and always exactly correct (no separate value to // keep in sync with reality). @@ -39,6 +47,17 @@ async function collectGauges(pool, provisioner) { } const envCounts = Object.fromEntries(envRes.rows.map((r) => [r.status, r.n])); + + // Phase 5B — enrollments by status/delivery_mode (both fixed enums, safe labels) and + // completions by program (program_id is an admin-curated slug, small and bounded by design — + // the same trust level assessment_definition_id already has, unlike student_id/email/ + // external_reference, which must NEVER appear as a label — see the file header). + const [{ rows: enrollmentBreakdown }, { rows: selfPacedActive }, { rows: completionsByProgram }] = await Promise.all([ + pool.query(`SELECT status, delivery_mode, count(*)::int AS n FROM enrollments GROUP BY status, delivery_mode`), + pool.query(`SELECT count(*)::int AS n FROM enrollments WHERE status = 'active' AND delivery_mode = 'self_paced'`), + pool.query(`SELECT program_id, count(*)::int AS n FROM enrollments WHERE status = 'completed' GROUP BY program_id`), + ]); + return { classroom_sessions_live: liveClassrooms.length, classroom_participants_online: onlineParticipants, @@ -46,6 +65,9 @@ async function collectGauges(pool, provisioner) { labs_running: envCounts.running || 0, labs_provisioning: (envCounts.requested || 0) + (envCounts.provisioning || 0) + (envCounts.resetting || 0), labs_error: envCounts.error || 0, + enrollment_breakdown: enrollmentBreakdown, + selfpaced_active_enrollments: selfPacedActive[0].n, + completions_by_program: completionsByProgram, }; } @@ -53,6 +75,16 @@ function line(name, help, type, value) { return `# HELP ${name} ${help}\n# TYPE ${name} ${type}\n${name} ${value}\n`; } +function labeledGauge(name, help, rows, labelCols, valueCol) { + const header = `# HELP ${name} ${help}\n# TYPE ${name} gauge\n`; + if (!rows.length) return header; + const body = rows.map((r) => { + const labels = labelCols.map((c) => `${c}="${String(r[c]).replace(/"/g, '\\"')}"`).join(','); + return `${name}{${labels}} ${r[valueCol]}`; + }).join('\n'); + return header + body + '\n'; +} + async function renderMetrics(pool, provisioner) { const gauges = await collectGauges(pool, provisioner); return [ @@ -64,7 +96,13 @@ async function renderMetrics(pool, provisioner) { line('rootnode_labs_error', 'Number of student Lab environments in an error state.', 'gauge', gauges.labs_error), line('rootnode_student_login_success_total', 'Total successful student logins since process start.', 'counter', studentLoginSuccessTotal), line('rootnode_student_login_failure_total', 'Total failed student login attempts since process start.', 'counter', studentLoginFailureTotal), + labeledGauge('rootnode_enrollments_total', 'Number of enrollments by lifecycle status and delivery mode.', gauges.enrollment_breakdown, ['status', 'delivery_mode'], 'n'), + line('rootnode_selfpaced_active_enrollments', 'Number of currently active self-paced enrollments.', 'gauge', gauges.selfpaced_active_enrollments), + labeledGauge('rootnode_program_completions_total', 'Number of completed enrollments by program.', gauges.completions_by_program, ['program_id'], 'n'), + line('rootnode_enrollment_events_created_total', 'Integration enrollment events that created a new enrollment.', 'counter', enrollmentEventTotals.created), + line('rootnode_enrollment_events_duplicate_total', 'Integration enrollment events that were idempotent replays of an already-processed event.', 'counter', enrollmentEventTotals.duplicate), + line('rootnode_enrollment_events_rejected_total', 'Integration enrollment events rejected (bad auth, unknown program, invalid payload).', 'counter', enrollmentEventTotals.rejected), ].join('\n'); } -module.exports = { recordStudentLoginSuccess, recordStudentLoginFailure, renderMetrics }; +module.exports = { recordStudentLoginSuccess, recordStudentLoginFailure, recordEnrollmentEvent, renderMetrics };