Skip to content

Commit cbb8942

Browse files
update: add other fields to alert request, config and response (#1459)
1 parent a91a3cc commit cbb8942

File tree

5 files changed

+281
-60
lines changed

5 files changed

+281
-60
lines changed

src/alerts/alert_structs.rs

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,32 @@
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

27+
const RESERVED_FIELDS: &[&str] = &[
28+
"id",
29+
"version",
30+
"severity",
31+
"title",
32+
"query",
33+
"datasets",
34+
"alertType",
35+
"anomalyConfig",
36+
"forecastConfig",
37+
"thresholdConfig",
38+
"notificationConfig",
39+
"evalConfig",
40+
"targets",
41+
"tags",
42+
"state",
43+
"notificationState",
44+
"created",
45+
"lastTriggeredAt",
46+
];
47+
2648
use crate::{
2749
alerts::{
2850
AlertError, CURRENT_ALERTS_VERSION,
@@ -38,6 +60,52 @@ use crate::{
3860
storage::object_storage::{alert_json_path, alert_state_json_path},
3961
};
4062

63+
/// Custom deserializer for DateTime<Utc> that handles legacy empty strings
64+
///
65+
/// This is a compatibility layer for migrating old alerts that stored empty strings
66+
/// instead of valid timestamps. In production, this should log warnings to help
67+
/// identify data quality issues.
68+
///
69+
/// # Migration Path
70+
/// - Empty strings → Default to current time with a warning
71+
/// - Missing fields → Default to current time
72+
/// - Valid timestamps → Parse normally
73+
pub fn deserialize_datetime_with_empty_string_fallback<'de, D>(
74+
deserializer: D,
75+
) -> Result<DateTime<Utc>, D::Error>
76+
where
77+
D: Deserializer<'de>,
78+
{
79+
#[derive(Deserialize)]
80+
#[serde(untagged)]
81+
enum DateTimeOrString {
82+
DateTime(DateTime<Utc>),
83+
String(String),
84+
}
85+
86+
match DateTimeOrString::deserialize(deserializer)? {
87+
DateTimeOrString::DateTime(dt) => Ok(dt),
88+
DateTimeOrString::String(s) => {
89+
if s.is_empty() {
90+
// Log warning about data quality issue
91+
tracing::warn!(
92+
"Alert has empty 'created' field - this indicates a data quality issue. \
93+
Defaulting to current timestamp. Please investigate and fix the data source."
94+
);
95+
Ok(Utc::now())
96+
} else {
97+
s.parse::<DateTime<Utc>>().map_err(serde::de::Error::custom)
98+
}
99+
}
100+
}
101+
}
102+
103+
/// Default function for created timestamp - returns current time
104+
/// This handles the case where created field is missing in deserialization
105+
pub fn default_created_time() -> DateTime<Utc> {
106+
Utc::now()
107+
}
108+
41109
/// Helper struct for basic alert fields during migration
42110
pub struct BasicAlertFields {
43111
pub id: Ulid,
@@ -253,10 +321,32 @@ pub struct AlertRequest {
253321
pub eval_config: EvalConfig,
254322
pub targets: Vec<Ulid>,
255323
pub tags: Option<Vec<String>>,
324+
#[serde(flatten)]
325+
pub other_fields: Option<serde_json::Map<String, Value>>,
256326
}
257327

258328
impl AlertRequest {
259329
pub async fn into(self) -> Result<AlertConfig, AlertError> {
330+
// Validate that other_fields doesn't contain reserved field names
331+
if let Some(ref other_fields) = self.other_fields {
332+
// Limit other_fields to maximum 10 fields
333+
if other_fields.len() > 10 {
334+
return Err(AlertError::ValidationFailure(format!(
335+
"other_fields can contain at most 10 fields, found {}",
336+
other_fields.len()
337+
)));
338+
}
339+
340+
for key in other_fields.keys() {
341+
if RESERVED_FIELDS.contains(&key.as_str()) {
342+
return Err(AlertError::ValidationFailure(format!(
343+
"Field '{}' cannot be in other_fields as it's a reserved field name",
344+
key
345+
)));
346+
}
347+
}
348+
}
349+
260350
// Validate that all target IDs exist
261351
for id in &self.targets {
262352
TARGETS.get_target_by_id(id).await?;
@@ -309,6 +399,7 @@ impl AlertRequest {
309399
created: Utc::now(),
310400
tags: self.tags,
311401
last_triggered_at: None,
402+
other_fields: self.other_fields,
312403
};
313404
Ok(config)
314405
}
@@ -333,9 +424,15 @@ pub struct AlertConfig {
333424
pub state: AlertState,
334425
pub notification_state: NotificationState,
335426
pub notification_config: NotificationConfig,
427+
#[serde(
428+
default = "default_created_time",
429+
deserialize_with = "deserialize_datetime_with_empty_string_fallback"
430+
)]
336431
pub created: DateTime<Utc>,
337432
pub tags: Option<Vec<String>>,
338433
pub last_triggered_at: Option<DateTime<Utc>>,
434+
#[serde(flatten)]
435+
pub other_fields: Option<serde_json::Map<String, Value>>,
339436
}
340437

341438
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
@@ -359,9 +456,15 @@ pub struct AlertConfigResponse {
359456
pub state: AlertState,
360457
pub notification_state: NotificationState,
361458
pub notification_config: NotificationConfig,
459+
#[serde(
460+
default = "default_created_time",
461+
deserialize_with = "deserialize_datetime_with_empty_string_fallback"
462+
)]
362463
pub created: DateTime<Utc>,
363464
pub tags: Option<Vec<String>>,
364465
pub last_triggered_at: Option<DateTime<Utc>>,
466+
#[serde(flatten)]
467+
pub other_fields: Option<serde_json::Map<String, Value>>,
365468
}
366469

367470
impl AlertConfig {
@@ -401,6 +504,7 @@ impl AlertConfig {
401504
created: self.created,
402505
tags: self.tags,
403506
last_triggered_at: self.last_triggered_at,
507+
other_fields: self.other_fields,
404508
}
405509
}
406510
}

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: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ pub use crate::alerts::alert_enums::{
5151
pub use crate::alerts::alert_structs::{
5252
AlertConfig, AlertInfo, AlertRequest, AlertStateEntry, Alerts, AlertsInfo, AlertsInfoByState,
5353
AlertsSummary, BasicAlertFields, Context, DeploymentInfo, RollingWindow, StateTransition,
54-
ThresholdConfig,
54+
ThresholdConfig, default_created_time, deserialize_datetime_with_empty_string_fallback,
5555
};
5656
use crate::alerts::alert_traits::{AlertManagerTrait, AlertTrait};
5757
use crate::alerts::alert_types::ThresholdAlert;
@@ -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
}

0 commit comments

Comments
 (0)