feat(observability): enrollment/completion metrics, no PII/high-cardinality labels

- 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.
This commit is contained in:
2026-08-25 12:09:17 -05:00
parent 4bc202e5f3
commit f89a769540
+39 -1
View File
@@ -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 };