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:
@@ -17,6 +17,14 @@ let studentLoginFailureTotal = 0;
|
|||||||
function recordStudentLoginSuccess() { studentLoginSuccessTotal++; }
|
function recordStudentLoginSuccess() { studentLoginSuccessTotal++; }
|
||||||
function recordStudentLoginFailure() { studentLoginFailureTotal++; }
|
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
|
// 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
|
// normal 15-30s Prometheus scrape interval, and always exactly correct (no separate value to
|
||||||
// keep in sync with reality).
|
// 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]));
|
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 {
|
return {
|
||||||
classroom_sessions_live: liveClassrooms.length,
|
classroom_sessions_live: liveClassrooms.length,
|
||||||
classroom_participants_online: onlineParticipants,
|
classroom_participants_online: onlineParticipants,
|
||||||
@@ -46,6 +65,9 @@ async function collectGauges(pool, provisioner) {
|
|||||||
labs_running: envCounts.running || 0,
|
labs_running: envCounts.running || 0,
|
||||||
labs_provisioning: (envCounts.requested || 0) + (envCounts.provisioning || 0) + (envCounts.resetting || 0),
|
labs_provisioning: (envCounts.requested || 0) + (envCounts.provisioning || 0) + (envCounts.resetting || 0),
|
||||||
labs_error: envCounts.error || 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`;
|
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) {
|
async function renderMetrics(pool, provisioner) {
|
||||||
const gauges = await collectGauges(pool, provisioner);
|
const gauges = await collectGauges(pool, provisioner);
|
||||||
return [
|
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_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_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),
|
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');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { recordStudentLoginSuccess, recordStudentLoginFailure, renderMetrics };
|
module.exports = { recordStudentLoginSuccess, recordStudentLoginFailure, recordEnrollmentEvent, renderMetrics };
|
||||||
|
|||||||
Reference in New Issue
Block a user