1use crate::prelude::*;
2use headless_lms_utils::error_identifier::{
3 calculate_error_grouping_identifier, calculate_exact_error_identifier, normalize_message,
4 normalize_stack_trace,
5};
6use rand::RngExt;
7use utoipa::ToSchema;
8
9#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, Type, ToSchema)]
10#[serde(rename_all = "snake_case")]
11#[sqlx(type_name = "error_source", rename_all = "snake_case")]
12pub enum ErrorSource {
13 Backend,
14 Frontend,
15}
16
17#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
18pub struct ErrorVariant {
19 pub id: Uuid,
20 pub service: String,
21 pub exact_error_identifier: String,
22 pub error_grouping_identifier: String,
23 pub error_source: ErrorSource,
24 pub example_message: String,
25 pub example_stack_trace: Option<String>,
26 pub normalized_message: String,
27 pub normalized_stack_trace: Option<String>,
28 pub occurrence_count: i32,
29 pub last_seen_at: DateTime<Utc>,
30 pub resolved_at: Option<DateTime<Utc>>,
31 pub created_at: DateTime<Utc>,
32 pub updated_at: DateTime<Utc>,
33 pub deleted_at: Option<DateTime<Utc>>,
34}
35
36#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
37pub struct NewErrorReport {
38 pub service: String,
39 pub error_source: Option<ErrorSource>,
40 pub message: String,
41 pub stack_trace: Option<String>,
42 pub path: Option<String>,
43 pub app_version: Option<String>,
44 pub details: Option<serde_json::Value>,
45}
46
47pub async fn insert(
52 conn: &mut PgConnection,
53 user_id: Option<Uuid>,
54 report: &NewErrorReport,
55) -> ModelResult<Uuid> {
56 let service = report.service.trim();
57 if service.is_empty() {
58 return Err(model_err!(
59 InvalidRequest,
60 "service must not be empty".to_string()
61 ));
62 }
63
64 let error_source = report.error_source.unwrap_or(ErrorSource::Frontend);
65 let error_source_str = match error_source {
66 ErrorSource::Frontend => "frontend",
67 ErrorSource::Backend => "backend",
68 };
69
70 let normalized_message = normalize_message(&report.message);
71 let normalized_stack_trace = report.stack_trace.as_deref().map(normalize_stack_trace);
72
73 let exact_error_identifier = calculate_exact_error_identifier(
74 service,
75 error_source_str,
76 &normalized_message,
77 normalized_stack_trace.as_deref(),
78 );
79 let error_grouping_identifier =
80 calculate_error_grouping_identifier(service, error_source_str, &normalized_message);
81
82 let mut tx = conn.begin().await?;
83 let variant_id = sqlx::query!(
84 r#"
85INSERT INTO error_variants (
86 id,
87 service,
88 exact_error_identifier,
89 error_grouping_identifier,
90 error_source,
91 example_message,
92 example_stack_trace,
93 normalized_message,
94 normalized_stack_trace
95)
96VALUES (uuid_generate_v4(), $1, $2, $3, $4, $5, $6, $7, $8)
97ON CONFLICT (service, exact_error_identifier, deleted_at) DO UPDATE SET
98 deleted_at = NULL,
99 resolved_at = NULL,
100 occurrence_count = error_variants.occurrence_count + 1,
101 last_seen_at = now(),
102 updated_at = now()
103RETURNING id
104 "#,
105 service,
106 exact_error_identifier,
107 error_grouping_identifier,
108 error_source as ErrorSource,
109 report.message,
110 report.stack_trace,
111 normalized_message,
112 normalized_stack_trace
113 )
114 .fetch_one(&mut *tx)
115 .await?
116 .id;
117
118 let occurrence_id = Uuid::new_v4();
119 sqlx::query!(
120 "
121INSERT INTO error_occurrences (id, error_variant_id, service, user_id, path, app_version, details)
122VALUES ($1, $2, $3, $4, $5, $6, $7)
123 ",
124 occurrence_id,
125 variant_id,
126 service,
127 user_id,
128 report.path,
129 report.app_version,
130 report.details
131 )
132 .execute(&mut *tx)
133 .await?;
134
135 let service_consistent = sqlx::query_scalar!(
136 r#"
137SELECT EXISTS(
138 SELECT 1
139 FROM error_occurrences o
140 JOIN error_variants v ON v.id = o.error_variant_id
141 WHERE o.id = $1
142 AND o.service = v.service
143) AS "exists!"
144 "#,
145 occurrence_id
146 )
147 .fetch_one(&mut *tx)
148 .await?;
149 if !service_consistent {
150 return Err(model_err!(
151 Generic,
152 "error occurrence service must match variant service".to_string()
153 ));
154 }
155
156 tx.commit().await?;
157
158 Ok(variant_id)
159}
160
161pub async fn get_all_variants(
162 conn: &mut PgConnection,
163 pagination: Pagination,
164) -> ModelResult<Vec<ErrorVariant>> {
165 let res = sqlx::query!(
166 r#"
167SELECT
168 id,
169 service,
170 exact_error_identifier,
171 error_grouping_identifier,
172 error_source AS "error_source: ErrorSource",
173 example_message,
174 example_stack_trace,
175 normalized_message,
176 normalized_stack_trace,
177 occurrence_count,
178 last_seen_at,
179 resolved_at,
180 created_at,
181 updated_at,
182 deleted_at
183FROM error_variants
184WHERE deleted_at IS NULL
185ORDER BY last_seen_at DESC
186LIMIT $1 OFFSET $2
187 "#,
188 pagination.limit(),
189 pagination.offset()
190 )
191 .map(|r| ErrorVariant {
192 id: r.id,
193 service: r.service,
194 exact_error_identifier: r.exact_error_identifier,
195 error_grouping_identifier: r.error_grouping_identifier,
196 error_source: r.error_source,
197 example_message: r.example_message,
198 example_stack_trace: r.example_stack_trace,
199 normalized_message: r.normalized_message,
200 normalized_stack_trace: r.normalized_stack_trace,
201 occurrence_count: r.occurrence_count,
202 last_seen_at: r.last_seen_at,
203 resolved_at: r.resolved_at,
204 created_at: r.created_at,
205 updated_at: r.updated_at,
206 deleted_at: r.deleted_at,
207 })
208 .fetch_all(conn)
209 .await?;
210 Ok(res)
211}
212
213pub async fn delete_expired(conn: &mut PgConnection) -> ModelResult<()> {
214 use std::collections::HashSet;
215
216 let mut tx = conn.begin().await?;
217 let deleted_variant_ids = sqlx::query!(
218 r#"
219UPDATE error_occurrences
220SET deleted_at = now()
221WHERE created_at < now() - interval '2 months'
222 AND deleted_at IS NULL
223RETURNING error_variant_id
224 "#
225 )
226 .fetch_all(&mut *tx)
227 .await?
228 .into_iter()
229 .map(|r| r.error_variant_id)
230 .collect::<HashSet<_>>()
231 .into_iter()
232 .collect::<Vec<_>>();
233
234 sqlx::query!(
236 r#"
237UPDATE error_variants v
238SET
239 occurrence_count = (
240 SELECT COUNT(*)::int
241 FROM error_occurrences
242 WHERE error_variant_id = v.id
243 AND deleted_at IS NULL
244 ),
245 last_seen_at = COALESCE(
246 (
247 SELECT MAX(created_at)
248 FROM error_occurrences
249 WHERE error_variant_id = v.id
250 AND deleted_at IS NULL
251 ),
252 v.created_at
253 ),
254 updated_at = now()
255WHERE v.id = ANY($1)
256 "#,
257 &deleted_variant_ids
258 )
259 .execute(&mut *tx)
260 .await?;
261
262 tx.commit().await?;
263 Ok(())
264}
265
266pub async fn maybe_delete_expired(conn: &mut PgConnection) -> ModelResult<()> {
267 if rand::rng().random_range(1..=1000) == 1 {
268 info!("Cleaning up expired errors");
269 delete_expired(conn).await?;
270 }
271 Ok(())
272}