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 utoipa::ToSchema;
6
7use crate::prelude::*;
8
9/// How long call rows are kept.
10pub const RETENTION_DAYS: i64 = 90;
11
12/// Bodies are sampled in full up to this many items.
13pub const FULL_BODY_ITEM_LIMIT: usize = 20;
14
15/// Above [`FULL_BODY_ITEM_LIMIT`], only this many items are kept plus a count.
16pub const SAMPLED_BODY_ITEM_COUNT: usize = 5;
17
18/// Hard cap on a stored body, applied after sampling.
19pub const BODY_SAMPLE_MAX_BYTES: usize = 64 * 1024;
20
21#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
22#[sqlx(type_name = "suotar_endpoint", rename_all = "snake_case")]
23#[serde(rename_all = "snake_case")]
24pub enum SuotarEndpoint {
25    ResolvePersons,
26    ResolveEnrolments,
27    ImportAttainments,
28    VerifyAttainments,
29    ProductAccessTokens,
30    ListByCourse,
31}
32
33#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
34pub struct SuotarApiCall {
35    pub id: Uuid,
36    pub created_at: DateTime<Utc>,
37    pub updated_at: DateTime<Utc>,
38    pub deleted_at: Option<DateTime<Utc>>,
39    pub endpoint: SuotarEndpoint,
40    pub request_item_count: i32,
41    pub http_status: Option<i32>,
42    pub duration_ms: Option<i32>,
43    pub succeeded: bool,
44    pub ok_item_count: i32,
45    pub error_item_count: i32,
46    pub request_level_error_code: Option<String>,
47    pub error_message: Option<String>,
48    pub request_body_sample: Option<serde_json::Value>,
49    pub response_body_sample: Option<serde_json::Value>,
50    pub credit_registration_ids: Vec<Uuid>,
51    pub worker_name: String,
52    pub started_at: DateTime<Utc>,
53}
54
55#[derive(Debug, Clone, PartialEq)]
56pub struct NewSuotarApiCall {
57    pub endpoint: SuotarEndpoint,
58    pub request_item_count: i32,
59    pub http_status: Option<i32>,
60    pub duration_ms: Option<i32>,
61    pub succeeded: bool,
62    pub ok_item_count: i32,
63    pub error_item_count: i32,
64    pub request_level_error_code: Option<String>,
65    /// Scrub before passing.
66    pub error_message: Option<String>,
67    /// Must already be scrubbed and sampled.
68    pub request_body_sample: Option<serde_json::Value>,
69    /// Must already be scrubbed and sampled.
70    pub response_body_sample: Option<serde_json::Value>,
71    pub credit_registration_ids: Vec<Uuid>,
72    pub worker_name: String,
73    pub started_at: DateTime<Utc>,
74}
75
76pub async fn insert(conn: &mut PgConnection, new: &NewSuotarApiCall) -> ModelResult<Uuid> {
77    let res = sqlx::query!(
78        r#"
79INSERT INTO suotar_api_calls (
80    endpoint,
81    request_item_count,
82    http_status,
83    duration_ms,
84    succeeded,
85    ok_item_count,
86    error_item_count,
87    request_level_error_code,
88    error_message,
89    request_body_sample,
90    response_body_sample,
91    credit_registration_ids,
92    worker_name,
93    started_at
94  )
95VALUES (
96    $1,
97    $2,
98    $3,
99    $4,
100    $5,
101    $6,
102    $7,
103    $8,
104    $9,
105    $10,
106    $11,
107    $12,
108    $13,
109    $14
110  )
111RETURNING id
112        "#,
113        new.endpoint as SuotarEndpoint,
114        new.request_item_count,
115        new.http_status,
116        new.duration_ms,
117        new.succeeded,
118        new.ok_item_count,
119        new.error_item_count,
120        new.request_level_error_code,
121        new.error_message,
122        new.request_body_sample,
123        new.response_body_sample,
124        &new.credit_registration_ids,
125        new.worker_name,
126        new.started_at,
127    )
128    .fetch_one(conn)
129    .await?;
130    Ok(res.id)
131}
132
133pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<SuotarApiCall> {
134    let res = sqlx::query_as!(
135        SuotarApiCall,
136        r#"
137SELECT *
138FROM suotar_api_calls
139WHERE id = $1
140  AND deleted_at IS NULL
141        "#,
142        id
143    )
144    .fetch_one(conn)
145    .await?;
146    Ok(res)
147}
148
149pub async fn get_recent(conn: &mut PgConnection, limit: i64) -> ModelResult<Vec<SuotarApiCall>> {
150    let res = sqlx::query_as!(
151        SuotarApiCall,
152        r#"
153SELECT *
154FROM suotar_api_calls
155WHERE deleted_at IS NULL
156ORDER BY started_at DESC
157LIMIT $1
158        "#,
159        limit
160    )
161    .fetch_all(conn)
162    .await?;
163    Ok(res)
164}
165
166pub async fn get_recent_by_endpoint(
167    conn: &mut PgConnection,
168    endpoint: SuotarEndpoint,
169    limit: i64,
170) -> ModelResult<Vec<SuotarApiCall>> {
171    let res = sqlx::query_as!(
172        SuotarApiCall,
173        r#"
174SELECT *
175FROM suotar_api_calls
176WHERE endpoint = $1
177  AND deleted_at IS NULL
178ORDER BY started_at DESC
179LIMIT $2
180        "#,
181        endpoint as SuotarEndpoint,
182        limit,
183    )
184    .fetch_all(conn)
185    .await?;
186    Ok(res)
187}
188
189pub async fn get_recent_failures(
190    conn: &mut PgConnection,
191    limit: i64,
192) -> ModelResult<Vec<SuotarApiCall>> {
193    let res = sqlx::query_as!(
194        SuotarApiCall,
195        r#"
196SELECT *
197FROM suotar_api_calls
198WHERE NOT succeeded
199  AND deleted_at IS NULL
200ORDER BY started_at DESC
201LIMIT $1
202        "#,
203        limit
204    )
205    .fetch_all(conn)
206    .await?;
207    Ok(res)
208}
209
210/// Calls that mention a ledger row, for the per-item drill-down.
211///
212/// Containment, not `= ANY`: only `@>` can use the GIN index on `credit_registration_ids`.
213pub async fn get_by_credit_registration_id(
214    conn: &mut PgConnection,
215    credit_registration_id: Uuid,
216    limit: i64,
217) -> ModelResult<Vec<SuotarApiCall>> {
218    let res = sqlx::query_as!(
219        SuotarApiCall,
220        r#"
221SELECT *
222FROM suotar_api_calls
223WHERE credit_registration_ids @> ARRAY [$1::uuid]
224  AND deleted_at IS NULL
225ORDER BY started_at DESC
226LIMIT $2
227        "#,
228        credit_registration_id,
229        limit,
230    )
231    .fetch_all(conn)
232    .await?;
233    Ok(res)
234}
235
236/// Hard-deletes rows past the retention window: the stored bodies must stop existing.
237pub async fn delete_older_than(conn: &mut PgConnection, cutoff: DateTime<Utc>) -> ModelResult<u64> {
238    let res = sqlx::query!(
239        r#"
240DELETE FROM suotar_api_calls
241WHERE started_at < $1
242        "#,
243        cutoff
244    )
245    .execute(conn)
246    .await?;
247    Ok(res.rows_affected())
248}