1use headless_lms_models::credit_registrations::{
8 CreditRegistrationState, StuckRegistrationCount, StuckThresholds,
9};
10use headless_lms_models::library::credit_registration::materialize::get_unmaterialised_eligible_completions;
11use headless_lms_models::{ModelResult, prelude::*};
12use headless_lms_models::{
13 course_module_suotar_configurations, credit_registration_account_linking_emails,
14 credit_registration_events, credit_registration_phase_state,
15 credit_registration_roster_schedules, credit_registrations,
16 study_registry_student_number_conflicts, suotar_api_calls,
17};
18use utoipa::ToSchema;
19
20use crate::domain::system_health::HealthStatus;
21use chrono::TimeDelta;
22use headless_lms_credit_registration::CreditRegistrationPhase;
23pub(crate) use headless_lms_credit_registration::attention::{
24 PHASE_HEARTBEAT_INTERVAL_MULTIPLIER, is_heartbeat_late,
25};
26use headless_lms_credit_registration::registry_health::max_study_registry_wait;
27use headless_lms_models::credit_registration_phase_state::CreditRegistrationPhaseState;
28
29const CREDENTIAL_REJECTION_WINDOW: TimeDelta = TimeDelta::hours(1);
31const UNREACHABLE_WINDOW: TimeDelta = TimeDelta::hours(4);
33const UNREACHABLE_CONSECUTIVE_FAILURES: i64 = 3;
35const SERVICE_OUTAGE_WINDOW: TimeDelta = TimeDelta::hours(1);
36const SERVICE_OUTAGE_MIN_ITEMS: i64 = 10;
38const SERVICE_OUTAGE_FAILURE_SHARE_PERCENT: i64 = 30;
39const STUCK_CRITICAL_COUNT: i64 = 50;
41const LINKING_MAIL_WINDOW: TimeDelta = TimeDelta::days(7);
42pub(crate) const PHASE_CONSECUTIVE_FAILURE_LIMIT: i32 = 5;
44const PHASE_SUCCESS_INTERVAL_MULTIPLIER: i32 = 10;
49const PHASE_SUCCESS_CALL_MARGIN: TimeDelta = TimeDelta::minutes(10);
50const TERMINAL_WINDOW: TimeDelta = TimeDelta::days(1);
52const PERMANENT_FAILURE_COUNT: i64 = 20;
53const PERMANENT_FAILURE_RATE_PERCENT: i64 = 10;
54const MISREGISTRATION_CRITICAL_COUNT: i64 = 5;
56const LINKING_MAIL_HOURLY_CAP: i64 = 500;
58const LINKING_MAIL_RATE_WINDOW: TimeDelta = TimeDelta::hours(1);
59const IDLE_QUEUE_DEPTH: i64 = 20;
61const NEVER_ENTERED_MIN_AGE: TimeDelta = TimeDelta::hours(6);
64const NEVER_ENTERED_SAMPLE_LIMIT: i64 = 100;
66const LATENCY_WINDOW: TimeDelta = TimeDelta::days(7);
67const LATENCY_REGRESSION_FLOOR: TimeDelta = TimeDelta::hours(6);
69const LATENCY_REGRESSION_FACTOR: i64 = 2;
70
71#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
72#[serde(rename_all = "snake_case")]
73pub enum CreditRegistrationAlertId {
74 CredentialsRejected,
75 StudyRegistryUnreachable,
76 ServiceUnavailable,
77 StuckRegistrations,
78 LinkingMailSendFailed,
79 LinkingMailRateCapExceeded,
80 PhaseHeartbeatStale,
81 PhaseFailing,
82 PermanentFailuresAccumulating,
83 MisregistrationsDetected,
84 CourseConfigurationBroken,
85 PipelineIdle,
86 CompletionsNeverEntered,
87 ConfirmationLatencyRegressed,
88 PipelinePausedGlobally,
89 StudyRegistryStudentNumberConflicts,
90 RosterCourseCodeFailing,
91}
92
93#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, ToSchema)]
94#[serde(rename_all = "snake_case")]
95pub enum CreditRegistrationAlertSeverity {
96 Info,
98 Warning,
99 Critical,
100}
101
102#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
103pub struct CreditRegistrationAlert {
104 pub id: CreditRegistrationAlertId,
105 pub severity: CreditRegistrationAlertSeverity,
106 pub count: i64,
108 pub total: Option<i64>,
111 pub window_secs: Option<i64>,
115 pub at: Option<DateTime<Utc>>,
117 pub subject: Option<String>,
120}
121
122#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
123pub struct CreditRegistrationHealth {
124 pub status: HealthStatus,
125 pub alerts: Vec<CreditRegistrationAlert>,
127 pub thresholds: StuckThresholds,
130}
131
132pub fn stuck_thresholds() -> StuckThresholds {
133 StuckThresholds::CURRENT
134}
135
136pub(crate) fn is_phase_failing(
143 phase: &CreditRegistrationPhaseState,
144 now: DateTime<Utc>,
145 depth_of: impl Fn(CreditRegistrationState) -> i64,
146 due_enrolment_checks: i64,
147) -> bool {
148 if phase.paused_at.is_some() {
149 return false;
150 }
151 let known_phase = CreditRegistrationPhase::from_phase_name(&phase.phase);
152 let owns_work =
153 known_phase.is_some_and(|known| known.queue_depth(&depth_of, due_enrolment_checks) > 0);
154 let slowest_iteration_secs = known_phase.map_or(0, |known| {
155 i64::try_from(max_study_registry_wait(known).as_secs()).unwrap_or(i64::MAX)
156 + PHASE_SUCCESS_CALL_MARGIN.num_seconds()
157 });
158 let unproductive_after_secs = (i64::from(phase.expected_interval_secs)
159 * i64::from(PHASE_SUCCESS_INTERVAL_MULTIPLIER))
160 .max(slowest_iteration_secs);
161 let unproductive = owns_work
162 && phase.last_success_at.is_some_and(|last_success_at| {
163 (now - last_success_at).num_seconds() > unproductive_after_secs
164 });
165 let hung = phase.last_run_started_at.is_some_and(|started_at| {
168 phase
169 .last_run_finished_at
170 .is_none_or(|finished_at| finished_at < started_at)
171 && (now - started_at).num_seconds() > unproductive_after_secs
172 });
173 phase.consecutive_failures >= PHASE_CONSECUTIVE_FAILURE_LIMIT || unproductive || hung
174}
175
176pub async fn evaluate(
182 conn: &mut PgConnection,
183 stuck: &[StuckRegistrationCount],
184 depths: &[(CreditRegistrationState, i64)],
185) -> ModelResult<CreditRegistrationHealth> {
186 let now = Utc::now();
187 let mut alerts = Vec::new();
188
189 let credentials = suotar_api_calls::count_credential_rejections_since(
190 conn,
191 now - CREDENTIAL_REJECTION_WINDOW,
192 )
193 .await?;
194 if credentials.count > 0 {
195 alerts.push(CreditRegistrationAlert {
196 id: CreditRegistrationAlertId::CredentialsRejected,
197 window_secs: Some(CREDENTIAL_REJECTION_WINDOW.num_seconds()),
198 severity: CreditRegistrationAlertSeverity::Critical,
199 count: credentials.count,
200 total: None,
201 at: credentials.last_at,
202 subject: None,
203 });
204 }
205
206 let unreachable =
207 suotar_api_calls::count_unreachable_run_since(conn, now - UNREACHABLE_WINDOW).await?;
208 if unreachable.count >= UNREACHABLE_CONSECUTIVE_FAILURES {
209 alerts.push(CreditRegistrationAlert {
210 id: CreditRegistrationAlertId::StudyRegistryUnreachable,
211 window_secs: Some(UNREACHABLE_WINDOW.num_seconds()),
212 severity: CreditRegistrationAlertSeverity::Critical,
213 count: unreachable.count,
214 total: None,
215 at: unreachable.last_at,
216 subject: None,
217 });
218 }
219
220 if let Some(alert) = service_outage_alert(conn, now).await? {
221 alerts.push(alert);
222 }
223 if let Some(alert) = stuck_alert(stuck) {
224 alerts.push(alert);
225 }
226 if let Some(alert) = linking_mail_alert(conn, now).await? {
227 alerts.push(alert);
228 }
229 if let Some(alert) = linking_mail_rate_alert(conn, now).await? {
230 alerts.push(alert);
231 }
232 alerts.extend(phase_alerts(conn, now, depths).await?);
233 alerts.extend(terminal_outcome_alerts(conn, now, depths).await?);
234 if let Some(alert) = course_configuration_alert(conn).await? {
235 alerts.push(alert);
236 }
237 if let Some(alert) = never_entered_alert(conn).await? {
238 alerts.push(alert);
239 }
240 if let Some(alert) = latency_regression_alert(conn, now).await? {
241 alerts.push(alert);
242 }
243 if let Some(alert) = study_registry_conflict_alert(conn).await? {
244 alerts.push(alert);
245 }
246 if let Some(alert) = roster_course_code_alert(conn).await? {
247 alerts.push(alert);
248 }
249
250 alerts.sort_by_key(|alert| {
251 (
252 std::cmp::Reverse(alert.severity),
253 alert.id != CreditRegistrationAlertId::CredentialsRejected,
254 )
255 });
256 let status = match alerts.iter().map(|alert| alert.severity).max() {
257 Some(CreditRegistrationAlertSeverity::Critical) => HealthStatus::Error,
258 Some(CreditRegistrationAlertSeverity::Warning) => HealthStatus::Warning,
259 Some(CreditRegistrationAlertSeverity::Info) | None => HealthStatus::Healthy,
260 };
261
262 Ok(CreditRegistrationHealth {
263 status,
264 alerts,
265 thresholds: stuck_thresholds(),
266 })
267}
268
269async fn service_outage_alert(
273 conn: &mut PgConnection,
274 now: DateTime<Utc>,
275) -> ModelResult<Option<CreditRegistrationAlert>> {
276 let totals =
277 credit_registration_events::count_item_outcomes_since(conn, now - SERVICE_OUTAGE_WINDOW)
278 .await?;
279 if totals.item_count < SERVICE_OUTAGE_MIN_ITEMS
280 || totals.service_unavailable_count * 100
281 < totals.item_count * SERVICE_OUTAGE_FAILURE_SHARE_PERCENT
282 {
283 return Ok(None);
284 }
285 Ok(Some(CreditRegistrationAlert {
286 id: CreditRegistrationAlertId::ServiceUnavailable,
287 window_secs: Some(SERVICE_OUTAGE_WINDOW.num_seconds()),
288 severity: CreditRegistrationAlertSeverity::Critical,
289 count: totals.service_unavailable_count,
290 total: Some(totals.item_count),
291 at: totals.last_service_unavailable_at,
292 subject: None,
293 }))
294}
295
296fn stuck_alert(stuck: &[StuckRegistrationCount]) -> Option<CreditRegistrationAlert> {
299 let total: i64 = stuck.iter().map(|row| row.count).sum();
300 if total == 0 {
301 return None;
302 }
303 let severe: i64 = stuck.iter().map(|row| row.severely_stuck_count).sum();
304 let worst = stuck.iter().max_by_key(|row| row.count);
305 let severity = if total > STUCK_CRITICAL_COUNT || severe > 0 {
306 CreditRegistrationAlertSeverity::Critical
307 } else {
308 CreditRegistrationAlertSeverity::Warning
309 };
310 Some(CreditRegistrationAlert {
311 id: CreditRegistrationAlertId::StuckRegistrations,
312 window_secs: None,
313 severity,
314 count: total,
315 total: None,
316 at: worst.and_then(|row| row.oldest_state_changed_at),
317 subject: worst.map(|row| state_name(row.state)),
318 })
319}
320
321async fn linking_mail_alert(
324 conn: &mut PgConnection,
325 now: DateTime<Utc>,
326) -> ModelResult<Option<CreditRegistrationAlert>> {
327 let since = now - LINKING_MAIL_WINDOW;
328 let totals =
329 credit_registration_account_linking_emails::get_send_status_totals_since(conn, since, now)
330 .await?;
331 if totals.send_failed == 0 {
332 return Ok(None);
333 }
334 let top_domain = credit_registration_account_linking_emails::get_send_failure_domains_since(
335 conn, since, now,
336 )
337 .await?
338 .into_iter()
339 .next()
340 .map(|row| row.domain);
341 Ok(Some(CreditRegistrationAlert {
342 id: CreditRegistrationAlertId::LinkingMailSendFailed,
343 window_secs: Some(LINKING_MAIL_WINDOW.num_seconds()),
344 severity: CreditRegistrationAlertSeverity::Warning,
345 count: totals.send_failed,
346 total: Some(totals.mails_in_window),
347 at: totals.last_send_failed_at,
348 subject: top_domain,
349 }))
350}
351
352async fn linking_mail_rate_alert(
355 conn: &mut PgConnection,
356 now: DateTime<Utc>,
357) -> ModelResult<Option<CreditRegistrationAlert>> {
358 let sent = credit_registration_account_linking_emails::count_sent_since(
359 conn,
360 now - LINKING_MAIL_RATE_WINDOW,
361 )
362 .await?;
363 if sent <= LINKING_MAIL_HOURLY_CAP {
364 return Ok(None);
365 }
366 let severity = if sent > LINKING_MAIL_HOURLY_CAP * 2 {
367 CreditRegistrationAlertSeverity::Critical
368 } else {
369 CreditRegistrationAlertSeverity::Warning
370 };
371 Ok(Some(CreditRegistrationAlert {
372 id: CreditRegistrationAlertId::LinkingMailRateCapExceeded,
373 window_secs: Some(LINKING_MAIL_RATE_WINDOW.num_seconds()),
374 severity,
375 count: sent,
376 total: Some(LINKING_MAIL_HOURLY_CAP),
377 at: Some(now),
378 subject: None,
379 }))
380}
381
382async fn phase_alerts(
388 conn: &mut PgConnection,
389 now: DateTime<Utc>,
390 depths: &[(CreditRegistrationState, i64)],
391) -> ModelResult<Vec<CreditRegistrationAlert>> {
392 let phases = credit_registration_phase_state::get_all(conn).await?;
393 let due_enrolment_checks = credit_registrations::count_due_enrolment_checks(conn).await?;
394 let mut stale: Vec<&str> = Vec::new();
395 let mut failing: Vec<&str> = Vec::new();
396 let mut paused = 0;
397 let mut last_paused_at = None;
398 for phase in &phases {
399 if let Some(paused_at) = phase.paused_at {
400 paused += 1;
401 last_paused_at = last_paused_at.max(Some(paused_at));
402 continue;
403 }
404 if is_heartbeat_late(
405 phase.last_heartbeat_at,
406 phase.expected_interval_secs,
407 None,
408 now,
409 ) {
410 stale.push(&phase.phase);
411 }
412 if is_phase_failing(
413 phase,
414 now,
415 |state| depth_of(depths, state),
416 due_enrolment_checks,
417 ) {
418 failing.push(&phase.phase);
419 }
420 }
421
422 let mut alerts = Vec::new();
423 if !stale.is_empty() {
424 alerts.push(CreditRegistrationAlert {
425 id: CreditRegistrationAlertId::PhaseHeartbeatStale,
426 window_secs: None,
427 severity: CreditRegistrationAlertSeverity::Critical,
428 count: stale.len() as i64,
429 total: Some(phases.len() as i64),
430 at: None,
431 subject: stale.first().map(|phase| (*phase).to_string()),
432 });
433 }
434 if !failing.is_empty() {
435 alerts.push(CreditRegistrationAlert {
436 id: CreditRegistrationAlertId::PhaseFailing,
437 window_secs: None,
438 severity: CreditRegistrationAlertSeverity::Critical,
439 count: failing.len() as i64,
440 total: Some(phases.len() as i64),
441 at: None,
442 subject: failing.first().map(|phase| (*phase).to_string()),
443 });
444 }
445 if paused > 0 && paused == phases.len() {
446 alerts.push(CreditRegistrationAlert {
447 id: CreditRegistrationAlertId::PipelinePausedGlobally,
448 window_secs: None,
449 severity: CreditRegistrationAlertSeverity::Info,
450 count: paused as i64,
451 total: Some(phases.len() as i64),
452 at: last_paused_at,
453 subject: None,
454 });
455 }
456 Ok(alerts)
457}
458
459async fn terminal_outcome_alerts(
462 conn: &mut PgConnection,
463 now: DateTime<Utc>,
464 depths: &[(CreditRegistrationState, i64)],
465) -> ModelResult<Vec<CreditRegistrationAlert>> {
466 let since = now - TERMINAL_WINDOW;
467 let totals = credit_registrations::count_terminal_outcomes_since(conn, since).await?;
468 let mut alerts = Vec::new();
469
470 let rate_broken = totals.total_count >= PERMANENT_FAILURE_COUNT
471 && totals.failed_permanent_count * 100
472 > totals.total_count * PERMANENT_FAILURE_RATE_PERCENT;
473 if totals.failed_permanent_count >= PERMANENT_FAILURE_COUNT || rate_broken {
474 alerts.push(CreditRegistrationAlert {
475 id: CreditRegistrationAlertId::PermanentFailuresAccumulating,
476 window_secs: Some(TERMINAL_WINDOW.num_seconds()),
477 severity: CreditRegistrationAlertSeverity::Warning,
478 count: totals.failed_permanent_count,
479 total: Some(totals.total_count),
480 at: Some(now),
481 subject: None,
482 });
483 }
484
485 let misregistered = credit_registrations::count_entered_state_since(
486 conn,
487 CreditRegistrationState::Misregistered,
488 since,
489 )
490 .await?;
491 if misregistered > 0 {
492 alerts.push(CreditRegistrationAlert {
493 id: CreditRegistrationAlertId::MisregistrationsDetected,
494 window_secs: Some(TERMINAL_WINDOW.num_seconds()),
495 severity: if misregistered >= MISREGISTRATION_CRITICAL_COUNT {
496 CreditRegistrationAlertSeverity::Critical
497 } else {
498 CreditRegistrationAlertSeverity::Warning
499 },
500 count: misregistered,
501 total: None,
502 at: Some(now),
503 subject: None,
504 });
505 }
506
507 let queued = depth_of(depths, CreditRegistrationState::ReadyToSubmit)
508 + depth_of(depths, CreditRegistrationState::AwaitingVerification)
509 + depth_of(depths, CreditRegistrationState::PartiallyRegistered);
510 if totals.total_count == 0 && queued > IDLE_QUEUE_DEPTH {
511 alerts.push(CreditRegistrationAlert {
512 id: CreditRegistrationAlertId::PipelineIdle,
513 window_secs: Some(TERMINAL_WINDOW.num_seconds()),
514 severity: CreditRegistrationAlertSeverity::Warning,
515 count: queued,
516 total: None,
517 at: Some(now),
518 subject: None,
519 });
520 }
521 Ok(alerts)
522}
523
524async fn course_configuration_alert(
527 conn: &mut PgConnection,
528) -> ModelResult<Option<CreditRegistrationAlert>> {
529 let count =
530 course_module_suotar_configurations::count_modules_failing_config_check(conn).await?;
531 Ok((count > 0).then_some(CreditRegistrationAlert {
532 id: CreditRegistrationAlertId::CourseConfigurationBroken,
533 window_secs: None,
534 severity: CreditRegistrationAlertSeverity::Warning,
535 count,
536 total: None,
537 at: None,
538 subject: None,
539 }))
540}
541
542async fn never_entered_alert(
545 conn: &mut PgConnection,
546) -> ModelResult<Option<CreditRegistrationAlert>> {
547 let found = get_unmaterialised_eligible_completions(
548 conn,
549 NEVER_ENTERED_MIN_AGE.num_seconds(),
550 NEVER_ENTERED_SAMPLE_LIMIT,
551 )
552 .await?;
553 if found.is_empty() {
554 return Ok(None);
555 }
556 Ok(Some(CreditRegistrationAlert {
557 id: CreditRegistrationAlertId::CompletionsNeverEntered,
558 window_secs: None,
559 severity: CreditRegistrationAlertSeverity::Warning,
560 count: found.len() as i64,
561 total: Some(NEVER_ENTERED_SAMPLE_LIMIT),
562 at: found.first().map(|row| row.created_at),
563 subject: None,
564 }))
565}
566
567async fn latency_regression_alert(
570 conn: &mut PgConnection,
571 now: DateTime<Utc>,
572) -> ModelResult<Option<CreditRegistrationAlert>> {
573 let window = LATENCY_WINDOW;
574 let current =
575 credit_registrations::get_registration_latency_between(conn, now - window, now).await?;
576 let (Some(current_p95), true) = (current.p95_confirmation_secs, current.registered_count > 0)
577 else {
578 return Ok(None);
579 };
580 if current_p95 < LATENCY_REGRESSION_FLOOR.num_seconds() {
581 return Ok(None);
582 }
583 let previous = credit_registrations::get_registration_latency_between(
584 conn,
585 now - window * 2,
586 now - window,
587 )
588 .await?;
589 let Some(previous_p95) = previous
590 .p95_confirmation_secs
591 .filter(|_| previous.registered_count > 0)
592 else {
593 return Ok(None);
594 };
595 if current_p95 <= previous_p95 * LATENCY_REGRESSION_FACTOR {
596 return Ok(None);
597 }
598 Ok(Some(CreditRegistrationAlert {
599 id: CreditRegistrationAlertId::ConfirmationLatencyRegressed,
600 window_secs: Some(LATENCY_WINDOW.num_seconds()),
601 severity: CreditRegistrationAlertSeverity::Info,
602 count: current_p95,
603 total: Some(previous_p95),
604 at: Some(now),
605 subject: None,
606 }))
607}
608
609async fn study_registry_conflict_alert(
611 conn: &mut PgConnection,
612) -> ModelResult<Option<CreditRegistrationAlert>> {
613 let count = study_registry_student_number_conflicts::count_unresolved(conn).await?;
614 Ok((count > 0).then_some(CreditRegistrationAlert {
615 id: CreditRegistrationAlertId::StudyRegistryStudentNumberConflicts,
616 window_secs: None,
617 severity: CreditRegistrationAlertSeverity::Warning,
618 count,
619 total: None,
620 at: None,
621 subject: None,
622 }))
623}
624
625async fn roster_course_code_alert(
628 conn: &mut PgConnection,
629) -> ModelResult<Option<CreditRegistrationAlert>> {
630 let failing = credit_registration_roster_schedules::get_failing_codes(conn).await?;
631 let Some(worst) = failing.first() else {
632 return Ok(None);
633 };
634 Ok(Some(CreditRegistrationAlert {
635 id: CreditRegistrationAlertId::RosterCourseCodeFailing,
636 window_secs: None,
637 severity: CreditRegistrationAlertSeverity::Warning,
638 count: i64::try_from(failing.len()).unwrap_or(i64::MAX),
639 total: None,
640 at: worst.last_attempted_at,
641 subject: Some(worst.course_code.clone()),
642 }))
643}
644
645fn depth_of(depths: &[(CreditRegistrationState, i64)], state: CreditRegistrationState) -> i64 {
646 depths
647 .iter()
648 .find(|(row_state, _)| *row_state == state)
649 .map_or(0, |(_, count)| *count)
650}
651
652fn state_name(state: CreditRegistrationState) -> String {
654 serde_json::to_value(state)
655 .ok()
656 .and_then(|value| value.as_str().map(str::to_string))
657 .unwrap_or_default()
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 #[test]
666 fn a_state_names_itself_the_way_the_wire_does() {
667 assert_eq!(
668 state_name(CreditRegistrationState::AwaitingVerification),
669 "awaiting_verification"
670 );
671 for state in CreditRegistrationState::ALL {
672 assert!(!state_name(state).is_empty());
673 }
674 }
675
676 #[test]
678 fn severity_ranks_the_way_the_banner_reads_it() {
679 assert!(
680 CreditRegistrationAlertSeverity::Critical > CreditRegistrationAlertSeverity::Warning
681 );
682 assert!(CreditRegistrationAlertSeverity::Warning > CreditRegistrationAlertSeverity::Info);
683 }
684}