Skip to content

Commit 73d88d7

Browse files
update: add other fields to alert request, config and response
also exclude alert state from loading alerts
1 parent 621f7ef commit 73d88d7

File tree

5 files changed

+115
-3
lines changed

5 files changed

+115
-3
lines changed

src/alerts/alert_structs.rs

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
use std::{collections::HashMap, time::Duration};
2020

2121
use chrono::{DateTime, Utc};
22-
use serde::{Deserialize, Serialize};
22+
use serde::{Deserialize, Deserializer, Serialize};
23+
use serde_json::Value;
2324
use tokio::sync::{RwLock, mpsc};
2425
use ulid::Ulid;
2526

@@ -38,6 +39,52 @@ use crate::{
3839
storage::object_storage::{alert_json_path, alert_state_json_path},
3940
};
4041

42+
/// Custom deserializer for DateTime<Utc> that handles legacy empty strings
43+
///
44+
/// This is a compatibility layer for migrating old alerts that stored empty strings
45+
/// instead of valid timestamps. In production, this should log warnings to help
46+
/// identify data quality issues.
47+
///
48+
/// # Migration Path
49+
/// - Empty strings → Default to current time with a warning
50+
/// - Missing fields → Default to current time
51+
/// - Valid timestamps → Parse normally
52+
pub(crate) fn deserialize_datetime_with_empty_string_fallback<'de, D>(
53+
deserializer: D,
54+
) -> Result<DateTime<Utc>, D::Error>
55+
where
56+
D: Deserializer<'de>,
57+
{
58+
#[derive(Deserialize)]
59+
#[serde(untagged)]
60+
enum DateTimeOrString {
61+
DateTime(DateTime<Utc>),
62+
String(String),
63+
}
64+
65+
match DateTimeOrString::deserialize(deserializer)? {
66+
DateTimeOrString::DateTime(dt) => Ok(dt),
67+
DateTimeOrString::String(s) => {
68+
if s.is_empty() {
69+
// Log warning about data quality issue
70+
tracing::warn!(
71+
"Alert has empty 'created' field - this indicates a data quality issue. \
72+
Defaulting to current timestamp. Please investigate and fix the data source."
73+
);
74+
Ok(Utc::now())
75+
} else {
76+
s.parse::<DateTime<Utc>>().map_err(serde::de::Error::custom)
77+
}
78+
}
79+
}
80+
}
81+
82+
/// Default function for created timestamp - returns current time
83+
/// This handles the case where created field is missing in deserialization
84+
pub(crate) fn default_created_time() -> DateTime<Utc> {
85+
Utc::now()
86+
}
87+
4188
/// Helper struct for basic alert fields during migration
4289
pub struct BasicAlertFields {
4390
pub id: Ulid,
@@ -253,6 +300,8 @@ pub struct AlertRequest {
253300
pub eval_config: EvalConfig,
254301
pub targets: Vec<Ulid>,
255302
pub tags: Option<Vec<String>>,
303+
#[serde(flatten)]
304+
pub other_fields: Option<serde_json::Map<String, Value>>,
256305
}
257306

258307
impl AlertRequest {
@@ -309,6 +358,7 @@ impl AlertRequest {
309358
created: Utc::now(),
310359
tags: self.tags,
311360
last_triggered_at: None,
361+
other_fields: self.other_fields,
312362
};
313363
Ok(config)
314364
}
@@ -333,9 +383,15 @@ pub struct AlertConfig {
333383
pub state: AlertState,
334384
pub notification_state: NotificationState,
335385
pub notification_config: NotificationConfig,
386+
#[serde(
387+
default = "default_created_time",
388+
deserialize_with = "deserialize_datetime_with_empty_string_fallback"
389+
)]
336390
pub created: DateTime<Utc>,
337391
pub tags: Option<Vec<String>>,
338392
pub last_triggered_at: Option<DateTime<Utc>>,
393+
#[serde(flatten)]
394+
pub other_fields: Option<serde_json::Map<String, Value>>,
339395
}
340396

341397
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
@@ -359,9 +415,15 @@ pub struct AlertConfigResponse {
359415
pub state: AlertState,
360416
pub notification_state: NotificationState,
361417
pub notification_config: NotificationConfig,
418+
#[serde(
419+
default = "default_created_time",
420+
deserialize_with = "deserialize_datetime_with_empty_string_fallback"
421+
)]
362422
pub created: DateTime<Utc>,
363423
pub tags: Option<Vec<String>>,
364424
pub last_triggered_at: Option<DateTime<Utc>>,
425+
#[serde(flatten)]
426+
pub other_fields: Option<serde_json::Map<String, Value>>,
365427
}
366428

367429
impl AlertConfig {
@@ -401,6 +463,7 @@ impl AlertConfig {
401463
created: self.created,
402464
tags: self.tags,
403465
last_triggered_at: self.last_triggered_at,
466+
other_fields: self.other_fields,
404467
}
405468
}
406469
}

src/alerts/alert_types.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use std::{str::FromStr, time::Duration};
2020

2121
use chrono::{DateTime, Utc};
22+
use serde_json::Value;
2223
use tonic::async_trait;
2324
use tracing::{info, trace, warn};
2425
use ulid::Ulid;
@@ -28,7 +29,10 @@ use crate::{
2829
AlertConfig, AlertError, AlertState, AlertType, AlertVersion, EvalConfig, Severity,
2930
ThresholdConfig,
3031
alert_enums::NotificationState,
31-
alert_structs::{AlertStateEntry, GroupResult},
32+
alert_structs::{
33+
AlertStateEntry, GroupResult, default_created_time,
34+
deserialize_datetime_with_empty_string_fallback,
35+
},
3236
alert_traits::{AlertTrait, MessageCreation},
3337
alerts_utils::{evaluate_condition, execute_alert_query, extract_time_range},
3438
get_number_of_agg_exprs,
@@ -61,10 +65,16 @@ pub struct ThresholdAlert {
6165
pub state: AlertState,
6266
pub notification_state: NotificationState,
6367
pub notification_config: NotificationConfig,
68+
#[serde(
69+
default = "default_created_time",
70+
deserialize_with = "deserialize_datetime_with_empty_string_fallback"
71+
)]
6472
pub created: DateTime<Utc>,
6573
pub tags: Option<Vec<String>>,
6674
pub datasets: Vec<String>,
6775
pub last_triggered_at: Option<DateTime<Utc>>,
76+
#[serde(flatten)]
77+
pub other_fields: Option<serde_json::Map<String, Value>>,
6878
}
6979

7080
impl MetastoreObject for ThresholdAlert {
@@ -408,6 +418,7 @@ impl From<AlertConfig> for ThresholdAlert {
408418
tags: value.tags,
409419
datasets: value.datasets,
410420
last_triggered_at: value.last_triggered_at,
421+
other_fields: value.other_fields,
411422
}
412423
}
413424
}
@@ -431,6 +442,7 @@ impl From<ThresholdAlert> for AlertConfig {
431442
tags: val.tags,
432443
datasets: val.datasets,
433444
last_triggered_at: val.last_triggered_at,
445+
other_fields: val.other_fields,
434446
}
435447
}
436448
}

src/alerts/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ impl AlertConfig {
134134
created: Utc::now(),
135135
tags: None,
136136
last_triggered_at: None,
137+
other_fields: None,
137138
};
138139

139140
// Save the migrated alert back to storage
@@ -682,6 +683,12 @@ impl AlertConfig {
682683
);
683684
}
684685

686+
if let Some(other_fields) = &self.other_fields {
687+
for (key, value) in other_fields {
688+
map.insert(key.clone(), value.clone());
689+
}
690+
}
691+
685692
map
686693
}
687694
}

src/handlers/http/alerts.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ pub async fn list(req: HttpRequest) -> Result<impl Responder, AlertError> {
5151
let mut limit = 100usize; // Default limit
5252
const MAX_LIMIT: usize = 1000; // Maximum allowed limit
5353

54+
// Reserved query parameter names that are not treated as other_fields filters
55+
const RESERVED_PARAMS: [&str; 3] = ["tags", "offset", "limit"];
56+
let mut other_fields_filters: HashMap<String, String> = HashMap::new();
57+
5458
// Parse query parameters
5559
if !query_map.is_empty() {
5660
// Parse tags parameter
@@ -87,6 +91,13 @@ pub async fn list(req: HttpRequest) -> Result<impl Responder, AlertError> {
8791
));
8892
}
8993
}
94+
95+
// Collect all other query parameters as potential other_fields filters
96+
for (key, value) in query_map.iter() {
97+
if !RESERVED_PARAMS.contains(&key.as_str()) {
98+
other_fields_filters.insert(key.clone(), value.clone());
99+
}
100+
}
90101
}
91102
let guard = ALERTS.read().await;
92103
let alerts = if let Some(alerts) = guard.as_ref() {
@@ -100,6 +111,23 @@ pub async fn list(req: HttpRequest) -> Result<impl Responder, AlertError> {
100111
.iter()
101112
.map(|alert| alert.to_summary())
102113
.collect::<Vec<_>>();
114+
115+
// Filter by other_fields if any filters are specified
116+
if !other_fields_filters.is_empty() {
117+
alerts_summary.retain(|alert_summary| {
118+
// Check if all specified other_fields filters match
119+
other_fields_filters
120+
.iter()
121+
.all(|(filter_key, filter_value)| {
122+
alert_summary
123+
.get(filter_key)
124+
.and_then(|v| v.as_str())
125+
.map(|alert_value| alert_value == filter_value)
126+
.unwrap_or(false)
127+
})
128+
});
129+
}
130+
103131
// Sort by state priority (Triggered > NotTriggered) then by severity (Critical > High > Medium > Low)
104132
alerts_summary.sort_by(|a, b| {
105133
// Parse state and severity from JSON values back to enums

src/metastore/metastores/object_store_metastore.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,9 @@ impl Metastore for ObjectStoreMetastore {
180180
.storage
181181
.get_objects(
182182
Some(&alerts_path),
183-
Box::new(|file_name| file_name.ends_with(".json")),
183+
Box::new(|file_name| {
184+
!file_name.starts_with("alert_state_") && file_name.ends_with(".json")
185+
}),
184186
)
185187
.await?;
186188

0 commit comments

Comments
 (0)