1use async_trait::async_trait;
6use headless_lms_utils::services::suotar::{
7 SuotarCallAudit, SuotarCallFinished, SuotarCallStarted,
8};
9use utoipa::ToSchema;
10
11pub use headless_lms_utils::services::suotar::SuotarEndpoint;
14
15use crate::credit_registration_events::{scrub_suotar_body, scrub_text};
16use crate::prelude::*;
17
18pub const RETENTION_DAYS: i64 = 90;
20
21pub const FULL_BODY_ITEM_LIMIT: usize = 20;
23
24pub const SAMPLED_BODY_ITEM_COUNT: usize = 5;
26
27pub 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 pub error_message: Option<String>,
64 pub request_body_sample: Option<serde_json::Value>,
66 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#[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 pub error_message: Option<String>,
141 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
179pub 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
198pub 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
291pub 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#[derive(Debug, Clone, Default)]
319pub struct SuotarApiCallFilters {
320 pub endpoint: Option<SuotarEndpoint>,
321 pub succeeded: Option<bool>,
322 pub worker_name: Option<String>,
324 pub started_after: Option<DateTime<Utc>>,
325 pub started_before: Option<DateTime<Utc>>,
326 pub credit_registration_id: Option<Uuid>,
329}
330
331pub 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
352pub 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
407pub 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#[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
444pub 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#[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 pub consecutive_failures: i64,
515}
516
517pub 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#[derive(Debug, Clone, PartialEq)]
565pub struct SuotarFailureRun {
566 pub count: i64,
567 pub last_at: Option<DateTime<Utc>>,
568}
569
570pub 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
595pub 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
635pub 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}