1919use std:: { collections:: HashMap , time:: Duration } ;
2020
2121use chrono:: { DateTime , Utc } ;
22- use serde:: { Deserialize , Serialize } ;
22+ use serde:: { Deserialize , Deserializer , Serialize } ;
23+ use serde_json:: Value ;
2324use tokio:: sync:: { RwLock , mpsc} ;
2425use 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+
2648use 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
42110pub 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
258328impl 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
367470impl 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}
0 commit comments