Skip to main content

headless_lms_models/
suotar_api_calls.rs

1//! Per-request observability for calls to Suotar.
2//!
3//! Bodies must be scrubbed with [`crate::credit_registration_events::scrub_suotar_body`] before
4//! insert; `credit_registration_ids` replaces the removed identifiers for drill-down.
5use async_trait::async_trait;
6use headless_lms_utils::services::suotar::{
7    SuotarCallAudit, SuotarCallFinished, SuotarCallStarted,
8};
9use utoipa::ToSchema;
10
11/// Re-exported so row readers keep one import path; the enum lives with the client that calls the
12/// endpoints, which also stores it in the `suotar_endpoint` postgres enum.
13pub use headless_lms_utils::services::suotar::SuotarEndpoint;
14
15use crate::credit_registration_events::{scrub_suotar_body, scrub_text};
16use crate::prelude::*;
17
18/// How long call rows are kept.
19pub const RETENTION_DAYS: i64 = 90;
20
21/// Bodies are sampled in full up to this many items.
22pub const FULL_BODY_ITEM_LIMIT: usize = 20;
23
24/// Above [`FULL_BODY_ITEM_LIMIT`], only this many items are kept plus a count.
25pub const SAMPLED_BODY_ITEM_COUNT: usize = 5;
26
27/// Hard cap on a stored body, applied after sampling.
28pub const BODY_SAMPLE_MAX_BYTES: usize = 64 * 1024;
29
30#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
31pub struct SuotarApiCall {
32    pub id: Uuid,
33    pub created_at: DateTime<Utc>,
34    pub updated_at: DateTime<Utc>,
35    pub deleted_at: Option<DateTime<Utc>>,
36    pub endpoint: SuotarEndpoint,
37    pub request_item_count: i32,
38    pub http_status: Option<i32>,
39    pub duration_ms: Option<i32>,
40    pub succeeded: bool,
41    pub ok_item_count: i32,
42    pub error_item_count: i32,
43    pub request_level_error_code: Option<String>,
44    pub error_message: Option<String>,
45    pub request_body_sample: Option<serde_json::Value>,
46    pub response_body_sample: Option<serde_json::Value>,
47    pub credit_registration_ids: Vec<Uuid>,
48    pub worker_name: String,
49    pub started_at: DateTime<Utc>,
50}
51
52#[derive(Debug, Clone, PartialEq)]
53pub struct NewSuotarApiCall {
54    pub endpoint: SuotarEndpoint,
55    pub request_item_count: i32,
56    pub http_status: Option<i32>,
57    pub duration_ms: Option<i32>,
58    pub succeeded: bool,
59    pub ok_item_count: i32,
60    pub error_item_count: i32,
61    pub request_level_error_code: Option<String>,
62    /// Scrub before passing.
63    pub error_message: Option<String>,
64    /// Must already be scrubbed and sampled.
65    pub request_body_sample: Option<serde_json::Value>,
66    /// Must already be scrubbed and sampled.
67    pub response_body_sample: Option<serde_json::Value>,
68    pub credit_registration_ids: Vec<Uuid>,
69    pub worker_name: String,
70    pub started_at: DateTime<Utc>,
71}
72
73pub async fn insert(conn: &mut PgConnection, new: &NewSuotarApiCall) -> ModelResult<Uuid> {
74    let res = sqlx::query!(
75        r#"
76INSERT INTO suotar_api_calls (
77    endpoint,
78    request_item_count,
79    http_status,
80    duration_ms,
81    succeeded,
82    ok_item_count,
83    error_item_count,
84    request_level_error_code,
85    error_message,
86    request_body_sample,
87    response_body_sample,
88    credit_registration_ids,
89    worker_name,
90    started_at
91  )
92VALUES (
93    $1,
94    $2,
95    $3,
96    $4,
97    $5,
98    $6,
99    $7,
100    $8,
101    $9,
102    $10,
103    $11,
104    $12,
105    $13,
106    $14
107  )
108RETURNING id
109        "#,
110        new.endpoint as SuotarEndpoint,
111        new.request_item_count,
112        new.http_status,
113        new.duration_ms,
114        new.succeeded,
115        new.ok_item_count,
116        new.error_item_count,
117        new.request_level_error_code,
118        new.error_message,
119        new.request_body_sample,
120        new.response_body_sample,
121        &new.credit_registration_ids,
122        new.worker_name,
123        new.started_at,
124    )
125    .fetch_one(conn)
126    .await?;
127    Ok(res.id)
128}
129
130/// What only the response knows. The row itself is inserted before the request leaves.
131#[derive(Debug, Clone, PartialEq)]
132pub struct FinishedSuotarApiCall {
133    pub http_status: Option<i32>,
134    pub duration_ms: Option<i32>,
135    pub succeeded: bool,
136    pub ok_item_count: i32,
137    pub error_item_count: i32,
138    pub request_level_error_code: Option<String>,
139    /// Scrub before passing.
140    pub error_message: Option<String>,
141    /// Must already be scrubbed and sampled.
142    pub response_body_sample: Option<serde_json::Value>,
143}
144
145pub async fn finish(
146    conn: &mut PgConnection,
147    id: Uuid,
148    finished: &FinishedSuotarApiCall,
149) -> ModelResult<()> {
150    sqlx::query!(
151        r#"
152UPDATE suotar_api_calls
153SET http_status = $2,
154  duration_ms = $3,
155  succeeded = $4,
156  ok_item_count = $5,
157  error_item_count = $6,
158  request_level_error_code = $7,
159  error_message = $8,
160  response_body_sample = $9,
161  updated_at = now()
162WHERE id = $1
163        "#,
164        id,
165        finished.http_status,
166        finished.duration_ms,
167        finished.succeeded,
168        finished.ok_item_count,
169        finished.error_item_count,
170        finished.request_level_error_code,
171        finished.error_message,
172        finished.response_body_sample,
173    )
174    .execute(conn)
175    .await?;
176    Ok(())
177}
178
179/// Shortens an already-scrubbed body to what this table keeps: whole while the batch is small, then
180/// the leading items plus a count, then nothing but the measurements.
181pub fn sample_body(value: &serde_json::Value) -> serde_json::Value {
182    let sampled = match value.as_array() {
183        Some(items) if items.len() > FULL_BODY_ITEM_LIMIT => serde_json::json!({
184            "items": &items[..SAMPLED_BODY_ITEM_COUNT],
185            "totalItemCount": items.len(),
186        }),
187        _ => value.clone(),
188    };
189    let byte_count = serde_json::to_vec(&sampled)
190        .map(|bytes| bytes.len())
191        .unwrap_or(usize::MAX);
192    if byte_count <= BODY_SAMPLE_MAX_BYTES {
193        return sampled;
194    }
195    serde_json::json!({ "omitted": "over the sample size limit", "byteCount": byte_count })
196}
197
198/// Audits every [`headless_lms_utils::services::suotar::SuotarClient`] call.
199///
200/// Owns a pool rather than borrowing the caller's connection: the row is committed while the
201/// request is in flight and must survive whatever the caller's transaction does next.
202pub struct PgSuotarCallAudit {
203    pool: PgPool,
204}
205
206impl PgSuotarCallAudit {
207    pub fn new(pool: PgPool) -> Self {
208        Self { pool }
209    }
210}
211
212#[async_trait]
213impl SuotarCallAudit for PgSuotarCallAudit {
214    async fn started(&self, started: SuotarCallStarted) -> Option<Uuid> {
215        let new = NewSuotarApiCall {
216            endpoint: started.endpoint,
217            request_item_count: started.request_item_count.try_into().unwrap_or(i32::MAX),
218            http_status: None,
219            duration_ms: None,
220            succeeded: false,
221            ok_item_count: 0,
222            error_item_count: 0,
223            request_level_error_code: None,
224            error_message: None,
225            request_body_sample: Some(sample_body(&scrub_suotar_body(&started.request_body))),
226            response_body_sample: None,
227            credit_registration_ids: started.credit_registration_ids,
228            worker_name: started.worker_name,
229            started_at: started.started_at,
230        };
231        let mut conn = match self.pool.acquire().await {
232            Ok(conn) => conn,
233            Err(error) => {
234                error!("Could not open a connection for a suotar_api_calls row: {error}");
235                return None;
236            }
237        };
238        match insert(&mut conn, &new).await {
239            Ok(id) => Some(id),
240            Err(error) => {
241                error!("Could not insert a suotar_api_calls row: {error}");
242                None
243            }
244        }
245    }
246
247    async fn finished(&self, call_id: Uuid, finished: SuotarCallFinished) {
248        let finished = FinishedSuotarApiCall {
249            http_status: finished.http_status.map(i32::from),
250            duration_ms: Some(finished.duration.as_millis().try_into().unwrap_or(i32::MAX)),
251            succeeded: finished.succeeded,
252            ok_item_count: finished.ok_item_count.try_into().unwrap_or(i32::MAX),
253            error_item_count: finished.error_item_count.try_into().unwrap_or(i32::MAX),
254            request_level_error_code: finished.request_level_error_code,
255            error_message: finished.error_message.map(|message| scrub_text(&message)),
256            response_body_sample: finished
257                .response_body
258                .map(|body| sample_body(&scrub_suotar_body(&body))),
259        };
260        let mut conn = match self.pool.acquire().await {
261            Ok(conn) => conn,
262            Err(error) => {
263                error!(
264                    "Could not open a connection to complete suotar_api_calls {call_id}: {error}"
265                );
266                return;
267            }
268        };
269        if let Err(error) = finish(&mut conn, call_id, &finished).await {
270            error!("Could not complete suotar_api_calls {call_id}: {error}");
271        }
272    }
273}
274
275pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<SuotarApiCall> {
276    let res = sqlx::query_as!(
277        SuotarApiCall,
278        r#"
279SELECT *
280FROM suotar_api_calls
281WHERE id = $1
282  AND deleted_at IS NULL
283        "#,
284        id
285    )
286    .fetch_one(conn)
287    .await?;
288    Ok(res)
289}
290
291/// Calls that mention a ledger row, for the per-item drill-down.
292///
293/// Containment, not `= ANY`: only `@>` can use the GIN index on `credit_registration_ids`.
294pub async fn get_by_credit_registration_id(
295    conn: &mut PgConnection,
296    credit_registration_id: Uuid,
297    limit: i64,
298) -> ModelResult<Vec<SuotarApiCall>> {
299    let res = sqlx::query_as!(
300        SuotarApiCall,
301        r#"
302SELECT *
303FROM suotar_api_calls
304WHERE credit_registration_ids @> ARRAY [$1::uuid]
305  AND deleted_at IS NULL
306ORDER BY started_at DESC
307LIMIT $2
308        "#,
309        credit_registration_id,
310        limit,
311    )
312    .fetch_all(conn)
313    .await?;
314    Ok(res)
315}
316
317/// The narrowings the API log applies, all of them in SQL.
318#[derive(Debug, Clone, Default)]
319pub struct SuotarApiCallFilters {
320    pub endpoint: Option<SuotarEndpoint>,
321    pub succeeded: Option<bool>,
322    /// The phase or manual action that made the call, matched exactly.
323    pub worker_name: Option<String>,
324    pub started_after: Option<DateTime<Utc>>,
325    pub started_before: Option<DateTime<Utc>>,
326    /// Searches the ledger rows a call covered, never the bodies: the bodies are scrubbed, so a
327    /// student number is not in them to find.
328    pub credit_registration_id: Option<Uuid>,
329}
330
331/// A call with the page's total attached, so a page and its count cannot come from two queries.
332///
333/// No body columns: the list this feeds never renders a body, only the detail view
334/// ([`get_by_id`]) does, so the listing query does not drag up to 64KB per row across the wire for
335/// nothing.
336pub struct SuotarApiCallPageRow {
337    pub id: Uuid,
338    pub endpoint: SuotarEndpoint,
339    pub request_item_count: i32,
340    pub http_status: Option<i32>,
341    pub duration_ms: Option<i32>,
342    pub succeeded: bool,
343    pub ok_item_count: i32,
344    pub error_item_count: i32,
345    pub request_level_error_code: Option<String>,
346    pub credit_registration_ids: Vec<Uuid>,
347    pub worker_name: String,
348    pub started_at: DateTime<Utc>,
349    pub total_count: i64,
350}
351
352/// A page of the call log, newest first, without bodies. See [`get_by_id`] for a call's bodies.
353pub async fn get_page(
354    conn: &mut PgConnection,
355    filters: &SuotarApiCallFilters,
356    limit: i64,
357    offset: i64,
358) -> ModelResult<Vec<SuotarApiCallPageRow>> {
359    let res = sqlx::query_as!(
360        SuotarApiCallPageRow,
361        r#"
362SELECT id,
363  endpoint AS "endpoint!: SuotarEndpoint",
364  request_item_count,
365  http_status,
366  duration_ms,
367  succeeded,
368  ok_item_count,
369  error_item_count,
370  request_level_error_code,
371  credit_registration_ids,
372  worker_name,
373  started_at,
374  COUNT(*) OVER () AS "total_count!"
375FROM suotar_api_calls
376WHERE deleted_at IS NULL
377  AND (
378    $1::suotar_endpoint IS NULL
379    OR endpoint = $1
380  )
381  AND ($2::bool IS NULL OR succeeded = $2)
382  AND ($3::text IS NULL OR worker_name = $3)
383  AND ($4::timestamptz IS NULL OR started_at >= $4)
384  AND ($5::timestamptz IS NULL OR started_at <= $5)
385  AND (
386    $6::uuid IS NULL
387    OR credit_registration_ids @> ARRAY [$6::uuid]
388  )
389ORDER BY started_at DESC,
390  id
391LIMIT $7 OFFSET $8
392        "#,
393        filters.endpoint as Option<SuotarEndpoint>,
394        filters.succeeded,
395        filters.worker_name.as_deref(),
396        filters.started_after,
397        filters.started_before,
398        filters.credit_registration_id,
399        limit,
400        offset,
401    )
402    .fetch_all(conn)
403    .await?;
404    Ok(res)
405}
406
407/// The distinct `worker_name` values in the log, so the filter offers what exists rather than a
408/// hardcoded list of phase names.
409pub async fn get_worker_names(conn: &mut PgConnection) -> ModelResult<Vec<String>> {
410    let res = sqlx::query_scalar!(
411        r#"
412SELECT DISTINCT worker_name
413FROM suotar_api_calls
414WHERE deleted_at IS NULL
415ORDER BY worker_name
416        "#,
417    )
418    .fetch_all(conn)
419    .await?;
420    Ok(res)
421}
422
423/// One endpoint's traffic over a window, one of possibly several computed together by
424/// [`get_endpoint_stats_for_windows`].
425///
426/// `duration_ms IS NULL` means still in flight, since the row is inserted before the request
427/// leaves; the counts below take finished calls only, or one in progress would read as a failure.
428#[derive(Debug, Clone, PartialEq)]
429pub struct SuotarEndpointStatsForWindow {
430    pub window_secs: i64,
431    pub endpoint: SuotarEndpoint,
432    pub call_count: i64,
433    pub failed_call_count: i64,
434    pub in_flight_count: i64,
435    pub ok_item_count: i64,
436    pub error_item_count: i64,
437    pub p50_duration_ms: Option<i32>,
438    pub p95_duration_ms: Option<i32>,
439    pub last_success_at: Option<DateTime<Utc>>,
440    pub last_failure_at: Option<DateTime<Utc>>,
441    pub last_request_level_error_code: Option<String>,
442}
443
444/// One scan of the table joined against the window list, so several windows cost one pass rather
445/// than one full pass each. A single window is just a one-element `window_secs`.
446pub async fn get_endpoint_stats_for_windows(
447    conn: &mut PgConnection,
448    window_secs: &[i64],
449) -> ModelResult<Vec<SuotarEndpointStatsForWindow>> {
450    let now = Utc::now();
451    let window_secs = window_secs.to_vec();
452    let since: Vec<DateTime<Utc>> = window_secs
453        .iter()
454        .map(|secs| now - chrono::Duration::seconds(*secs))
455        .collect();
456    let rows = sqlx::query_as!(
457        SuotarEndpointStatsForWindow,
458        r#"
459WITH windows AS (
460  SELECT * FROM UNNEST($1::bigint [], $2::timestamptz []) AS w(window_secs, since)
461)
462SELECT w.window_secs AS "window_secs!",
463  c.endpoint,
464  COUNT(*) FILTER (WHERE c.duration_ms IS NOT NULL) AS "call_count!",
465  COUNT(*) FILTER (
466    WHERE c.duration_ms IS NOT NULL
467      AND NOT c.succeeded
468  ) AS "failed_call_count!",
469  COUNT(*) FILTER (WHERE c.duration_ms IS NULL) AS "in_flight_count!",
470  COALESCE(SUM(c.ok_item_count), 0) AS "ok_item_count!",
471  COALESCE(SUM(c.error_item_count), 0) AS "error_item_count!",
472  PERCENTILE_DISC(0.5) WITHIN GROUP (
473    ORDER BY c.duration_ms
474  ) AS "p50_duration_ms",
475  PERCENTILE_DISC(0.95) WITHIN GROUP (
476    ORDER BY c.duration_ms
477  ) AS "p95_duration_ms",
478  MAX(c.started_at) FILTER (WHERE c.succeeded) AS "last_success_at",
479  MAX(c.started_at) FILTER (
480    WHERE c.duration_ms IS NOT NULL
481      AND NOT c.succeeded
482  ) AS "last_failure_at",
483  (
484    ARRAY_AGG(
485      c.request_level_error_code
486      ORDER BY c.started_at DESC
487    ) FILTER (
488      WHERE c.duration_ms IS NOT NULL
489        AND NOT c.succeeded
490        AND c.request_level_error_code IS NOT NULL
491    )
492  ) [1] AS "last_request_level_error_code"
493FROM windows w
494  JOIN suotar_api_calls c ON c.started_at >= w.since
495  AND c.deleted_at IS NULL
496GROUP BY w.window_secs,
497  c.endpoint
498        "#,
499        &window_secs,
500        &since,
501    )
502    .fetch_all(conn)
503    .await?;
504    Ok(rows)
505}
506
507/// Where one endpoint stands right now.
508#[derive(Debug, Clone, PartialEq)]
509pub struct SuotarEndpointStanding {
510    pub endpoint: SuotarEndpoint,
511    pub last_success_at: Option<DateTime<Utc>>,
512    pub last_failure_at: Option<DateTime<Utc>>,
513    /// Finished failures since the last success, within the retention window.
514    pub consecutive_failures: i64,
515}
516
517/// Bounded to the last [`RETENTION_DAYS`]: this table grows one row per Suotar batch call, and the
518/// Overview and the health tab each poll this every 30 seconds.
519pub async fn get_endpoint_standings(
520    conn: &mut PgConnection,
521) -> ModelResult<Vec<SuotarEndpointStanding>> {
522    let since = Utc::now() - chrono::Duration::days(RETENTION_DAYS);
523    let rows = sqlx::query_as!(
524        SuotarEndpointStanding,
525        r#"
526WITH last_success AS (
527  SELECT endpoint,
528    MAX(started_at) AS at
529  FROM suotar_api_calls
530  WHERE succeeded
531    AND started_at >= $1
532    AND deleted_at IS NULL
533  GROUP BY endpoint
534)
535SELECT c.endpoint AS "endpoint!: SuotarEndpoint",
536  ls.at AS "last_success_at",
537  MAX(c.started_at) FILTER (
538    WHERE c.duration_ms IS NOT NULL
539      AND NOT c.succeeded
540  ) AS "last_failure_at",
541  COUNT(*) FILTER (
542    WHERE c.duration_ms IS NOT NULL
543      AND NOT c.succeeded
544      AND (
545        ls.at IS NULL
546        OR c.started_at > ls.at
547      )
548  ) AS "consecutive_failures!"
549FROM suotar_api_calls c
550  LEFT JOIN last_success ls ON ls.endpoint = c.endpoint
551WHERE c.started_at >= $1
552  AND c.deleted_at IS NULL
553GROUP BY c.endpoint,
554  ls.at
555        "#,
556        since,
557    )
558    .fetch_all(conn)
559    .await?;
560    Ok(rows)
561}
562
563/// A run of failures the health rules key on.
564#[derive(Debug, Clone, PartialEq)]
565pub struct SuotarFailureRun {
566    pub count: i64,
567    pub last_at: Option<DateTime<Utc>>,
568}
569
570/// Calls Suotar refused our credentials on. One is enough to stop everything registering.
571pub async fn count_credential_rejections_since(
572    conn: &mut PgConnection,
573    since: DateTime<Utc>,
574) -> ModelResult<SuotarFailureRun> {
575    let row = sqlx::query_as!(
576        SuotarFailureRun,
577        r#"
578SELECT COUNT(*) AS "count!",
579  MAX(started_at) AS "last_at"
580FROM suotar_api_calls
581WHERE started_at >= $1
582  AND deleted_at IS NULL
583  AND (
584    http_status IN (401, 403)
585    OR request_level_error_code = 'unauthorized'
586  )
587        "#,
588        since,
589    )
590    .fetch_one(conn)
591    .await?;
592    Ok(row)
593}
594
595/// The unbroken run of "Suotar did not answer usefully" at the end of the window. A transport
596/// failure carries no HTTP status, which is how it is told from a refusal Suotar composed itself.
597pub async fn count_unreachable_run_since(
598    conn: &mut PgConnection,
599    since: DateTime<Utc>,
600) -> ModelResult<SuotarFailureRun> {
601    let row = sqlx::query_as!(
602        SuotarFailureRun,
603        r#"
604WITH last_success AS (
605  SELECT MAX(started_at) AS at
606  FROM suotar_api_calls
607  WHERE succeeded
608    AND started_at >= $1
609    AND deleted_at IS NULL
610)
611SELECT COUNT(*) AS "count!",
612  MAX(c.started_at) AS "last_at"
613FROM suotar_api_calls c
614  CROSS JOIN last_success ls
615WHERE c.started_at >= $1
616  AND c.deleted_at IS NULL
617  AND c.duration_ms IS NOT NULL
618  AND NOT c.succeeded
619  AND (
620    c.http_status IS NULL
621    OR c.http_status >= 500
622  )
623  AND (
624    ls.at IS NULL
625    OR c.started_at > ls.at
626  )
627        "#,
628        since,
629    )
630    .fetch_one(conn)
631    .await?;
632    Ok(row)
633}
634
635/// Hard-deletes rows past the retention window: the stored bodies must stop existing.
636///
637/// Bounded, because the first sweep after the window opens has ninety days of traffic to clear and
638/// one unbounded statement would hold every `credit_registration_events` row referencing them
639/// locked while `ON DELETE SET NULL` fires. Returns how many were deleted, so the caller can tell a
640/// finished sweep from one that hit the bound.
641pub async fn delete_older_than(
642    conn: &mut PgConnection,
643    cutoff: DateTime<Utc>,
644    limit: i64,
645) -> ModelResult<u64> {
646    let res = sqlx::query!(
647        r#"
648DELETE FROM suotar_api_calls
649WHERE id IN (
650    SELECT id
651    FROM suotar_api_calls
652    WHERE started_at < $1
653    ORDER BY started_at
654    LIMIT $2
655  )
656        "#,
657        cutoff,
658        limit,
659    )
660    .execute(conn)
661    .await?;
662    Ok(res.rows_affected())
663}