Skip to content

Commit 4c3b85d

Browse files
feat: save alert state history (#1442)
state history for an alert is saved at path .alerts/alert_state_{alertid}.json details to save - state, last_updated_at use to show full track of a lifecycle of an alert not triggered -> triggered -> disabled (if disabled by user) -> not triggered (when issue is resolved)
1 parent f1ef68b commit 4c3b85d

File tree

8 files changed

+255
-18
lines changed

8 files changed

+255
-18
lines changed

src/alerts/alert_structs.rs

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ use crate::{
3535
},
3636
metastore::metastore_traits::MetastoreObject,
3737
query::resolve_stream_names,
38-
storage::object_storage::alert_json_path,
38+
storage::object_storage::{alert_json_path, alert_state_json_path},
3939
};
4040

4141
/// Helper struct for basic alert fields during migration
@@ -520,6 +520,79 @@ pub struct NotificationStateRequest {
520520
pub state: String,
521521
}
522522

523+
/// Represents a single state transition
524+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
525+
pub struct StateTransition {
526+
/// The alert state
527+
pub state: AlertState,
528+
/// Timestamp when this state was set/updated
529+
pub last_updated_at: DateTime<Utc>,
530+
}
531+
532+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
533+
pub struct AlertStateEntry {
534+
/// The unique identifier for the alert
535+
pub alert_id: Ulid,
536+
pub states: Vec<StateTransition>,
537+
}
538+
539+
impl StateTransition {
540+
/// Creates a new state transition with the current timestamp
541+
pub fn new(state: AlertState) -> Self {
542+
Self {
543+
state,
544+
last_updated_at: Utc::now(),
545+
}
546+
}
547+
}
548+
549+
impl AlertStateEntry {
550+
/// Creates a new alert state entry with an initial state
551+
pub fn new(alert_id: Ulid, initial_state: AlertState) -> Self {
552+
Self {
553+
alert_id,
554+
states: vec![StateTransition::new(initial_state)],
555+
}
556+
}
557+
558+
/// Updates the state (only adds new entry if state has changed)
559+
/// Returns true if the state was changed, false if it remained the same
560+
pub fn update_state(&mut self, new_state: AlertState) -> bool {
561+
match self.states.last() {
562+
Some(last_transition) => {
563+
if last_transition.state != new_state {
564+
// State changed - add new transition
565+
self.states.push(StateTransition::new(new_state));
566+
true
567+
} else {
568+
// If state hasn't changed, do nothing - preserve the original timestamp
569+
false
570+
}
571+
}
572+
None => {
573+
// No previous states - add the first one
574+
self.states.push(StateTransition::new(new_state));
575+
true
576+
}
577+
}
578+
}
579+
580+
/// Gets the current (latest) state
581+
pub fn current_state(&self) -> Option<&StateTransition> {
582+
self.states.last()
583+
}
584+
}
585+
586+
impl MetastoreObject for AlertStateEntry {
587+
fn get_object_id(&self) -> String {
588+
self.alert_id.to_string()
589+
}
590+
591+
fn get_object_path(&self) -> String {
592+
alert_state_json_path(self.alert_id).to_string()
593+
}
594+
}
595+
523596
impl MetastoreObject for AlertConfig {
524597
fn get_object_id(&self) -> String {
525598
self.id.to_string()

src/alerts/alert_types.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use crate::{
2828
AlertConfig, AlertError, AlertState, AlertType, AlertVersion, EvalConfig, Severity,
2929
ThresholdConfig,
3030
alert_enums::NotificationState,
31-
alert_structs::GroupResult,
31+
alert_structs::{AlertStateEntry, GroupResult},
3232
alert_traits::{AlertTrait, MessageCreation},
3333
alerts_utils::{evaluate_condition, execute_alert_query, extract_time_range},
3434
get_number_of_agg_exprs,
@@ -216,7 +216,11 @@ impl AlertTrait for ThresholdAlert {
216216
.metastore
217217
.put_alert(&self.to_alert_config())
218218
.await?;
219-
// The task should have already been removed from the list of running tasks
219+
let state_entry = AlertStateEntry::new(self.id, self.state);
220+
PARSEABLE
221+
.metastore
222+
.put_alert_state(&state_entry as &dyn MetastoreObject)
223+
.await?;
220224
return Ok(());
221225
}
222226

@@ -252,6 +256,12 @@ impl AlertTrait for ThresholdAlert {
252256
.metastore
253257
.put_alert(&self.to_alert_config())
254258
.await?;
259+
let state_entry = AlertStateEntry::new(self.id, self.state);
260+
261+
PARSEABLE
262+
.metastore
263+
.put_alert_state(&state_entry as &dyn MetastoreObject)
264+
.await?;
255265

256266
if trigger_notif.is_some() && self.notification_state.eq(&NotificationState::Notify) {
257267
trace!("trigger notif on-\n{}", self.state);

src/alerts/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ pub use crate::alerts::alert_enums::{
4949
LogicalOperator, NotificationState, Severity, WhereConfigOperator,
5050
};
5151
pub use crate::alerts::alert_structs::{
52-
AlertConfig, AlertInfo, AlertRequest, Alerts, AlertsInfo, AlertsInfoByState, AlertsSummary,
53-
BasicAlertFields, Context, DeploymentInfo, RollingWindow, ThresholdConfig,
52+
AlertConfig, AlertInfo, AlertRequest, AlertStateEntry, Alerts, AlertsInfo, AlertsInfoByState,
53+
AlertsSummary, BasicAlertFields, Context, DeploymentInfo, RollingWindow, StateTransition,
54+
ThresholdConfig,
5455
};
5556
use crate::alerts::alert_traits::{AlertManagerTrait, AlertTrait};
5657
use crate::alerts::alert_types::ThresholdAlert;

src/handlers/http/alerts.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@ use crate::{
2222
alerts::{
2323
ALERTS, AlertError, AlertState, Severity,
2424
alert_enums::{AlertType, NotificationState},
25-
alert_structs::{AlertConfig, AlertRequest, NotificationStateRequest},
25+
alert_structs::{AlertConfig, AlertRequest, AlertStateEntry, NotificationStateRequest},
2626
alert_traits::AlertTrait,
2727
alert_types::ThresholdAlert,
2828
target::Retry,
2929
},
30+
metastore::metastore_traits::MetastoreObject,
3031
parseable::PARSEABLE,
3132
utils::{actix::extract_session_key_from_req, user_auth_for_query},
3233
};
@@ -214,6 +215,13 @@ pub async fn post(
214215
.put_alert(&alert.to_alert_config())
215216
.await?;
216217

218+
// create initial alert state entry (default to NotTriggered)
219+
let state_entry = AlertStateEntry::new(*alert.get_id(), AlertState::NotTriggered);
220+
PARSEABLE
221+
.metastore
222+
.put_alert_state(&state_entry as &dyn MetastoreObject)
223+
.await?;
224+
217225
// update in memory
218226
alerts.update(alert).await;
219227

@@ -262,6 +270,13 @@ pub async fn delete(req: HttpRequest, alert_id: Path<Ulid>) -> Result<impl Respo
262270

263271
PARSEABLE.metastore.delete_alert(&*alert).await?;
264272

273+
// delete the associated alert state
274+
let state_to_delete = AlertStateEntry::new(alert_id, AlertState::NotTriggered); // state doesn't matter for deletion
275+
PARSEABLE
276+
.metastore
277+
.delete_alert_state(&state_to_delete as &dyn MetastoreObject)
278+
.await?;
279+
265280
// delete from memory
266281
alerts.delete(alert_id).await?;
267282

src/metastore/metastore_traits.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,15 @@ use chrono::{DateTime, Utc};
2424
use dashmap::DashMap;
2525
use erased_serde::Serialize as ErasedSerialize;
2626
use tonic::async_trait;
27+
use ulid::Ulid;
2728

2829
use crate::{
29-
alerts::target::Target, catalog::manifest::Manifest, handlers::http::modal::NodeType,
30-
metastore::MetastoreError, option::Mode, users::filters::Filter,
30+
alerts::{alert_structs::AlertStateEntry, target::Target},
31+
catalog::manifest::Manifest,
32+
handlers::http::modal::NodeType,
33+
metastore::MetastoreError,
34+
option::Mode,
35+
users::filters::Filter,
3136
};
3237

3338
/// A metastore is a logically separated compartment to store metadata for Parseable.
@@ -44,6 +49,15 @@ pub trait Metastore: std::fmt::Debug + Send + Sync {
4449
async fn put_alert(&self, obj: &dyn MetastoreObject) -> Result<(), MetastoreError>;
4550
async fn delete_alert(&self, obj: &dyn MetastoreObject) -> Result<(), MetastoreError>;
4651

52+
/// alerts state
53+
async fn get_alert_states(&self) -> Result<Vec<AlertStateEntry>, MetastoreError>;
54+
async fn get_alert_state_entry(
55+
&self,
56+
alert_id: &Ulid,
57+
) -> Result<Option<AlertStateEntry>, MetastoreError>;
58+
async fn put_alert_state(&self, obj: &dyn MetastoreObject) -> Result<(), MetastoreError>;
59+
async fn delete_alert_state(&self, obj: &dyn MetastoreObject) -> Result<(), MetastoreError>;
60+
4761
/// llmconfig
4862
async fn get_llmconfigs(&self) -> Result<Vec<Bytes>, MetastoreError>;
4963
async fn put_llmconfig(&self, obj: &dyn MetastoreObject) -> Result<(), MetastoreError>;

src/metastore/metastores/object_store_metastore.rs

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ use tracing::warn;
3232
use ulid::Ulid;
3333

3434
use crate::{
35-
alerts::target::Target,
35+
alerts::{alert_structs::AlertStateEntry, target::Target},
3636
catalog::{manifest::Manifest, partition_path},
3737
handlers::http::{
3838
modal::{Metadata, NodeMetadata, NodeType},
@@ -49,8 +49,8 @@ use crate::{
4949
SETTINGS_ROOT_DIRECTORY, STREAM_METADATA_FILE_NAME, STREAM_ROOT_DIRECTORY,
5050
TARGETS_ROOT_DIRECTORY,
5151
object_storage::{
52-
alert_json_path, filter_path, manifest_path, parseable_json_path, schema_path,
53-
stream_json_path, to_bytes,
52+
alert_json_path, alert_state_json_path, filter_path, manifest_path,
53+
parseable_json_path, schema_path, stream_json_path, to_bytes,
5454
},
5555
},
5656
users::filters::{Filter, migrate_v1_v2},
@@ -115,6 +115,102 @@ impl Metastore for ObjectStoreMetastore {
115115
.await?)
116116
}
117117

118+
/// alerts state
119+
async fn get_alert_states(&self) -> Result<Vec<AlertStateEntry>, MetastoreError> {
120+
let base_path = RelativePathBuf::from_iter([ALERTS_ROOT_DIRECTORY]);
121+
let alert_state_bytes = self
122+
.storage
123+
.get_objects(
124+
Some(&base_path),
125+
Box::new(|file_name| {
126+
file_name.starts_with("alert_state_") && file_name.ends_with(".json")
127+
}),
128+
)
129+
.await?;
130+
131+
let mut alert_states = Vec::new();
132+
for bytes in alert_state_bytes {
133+
if let Ok(entry) = serde_json::from_slice::<AlertStateEntry>(&bytes) {
134+
alert_states.push(entry);
135+
}
136+
}
137+
Ok(alert_states)
138+
}
139+
140+
async fn get_alert_state_entry(
141+
&self,
142+
alert_id: &Ulid,
143+
) -> Result<Option<AlertStateEntry>, MetastoreError> {
144+
let path = alert_state_json_path(*alert_id);
145+
match self.storage.get_object(&path).await {
146+
Ok(bytes) => {
147+
if let Ok(entry) = serde_json::from_slice::<AlertStateEntry>(&bytes) {
148+
Ok(Some(entry))
149+
} else {
150+
Ok(None)
151+
}
152+
}
153+
Err(ObjectStorageError::NoSuchKey(_)) => Ok(None),
154+
Err(e) => Err(MetastoreError::ObjectStorageError(e)),
155+
}
156+
}
157+
158+
async fn put_alert_state(&self, obj: &dyn MetastoreObject) -> Result<(), MetastoreError> {
159+
let id = Ulid::from_string(&obj.get_object_id()).map_err(|e| MetastoreError::Error {
160+
status_code: StatusCode::BAD_REQUEST,
161+
message: e.to_string(),
162+
flow: "put_alert_state".into(),
163+
})?;
164+
let path = alert_state_json_path(id);
165+
166+
// Parse the new state entry from the MetastoreObject
167+
let new_state_entry: AlertStateEntry = serde_json::from_slice(&to_bytes(obj))?;
168+
let new_state = new_state_entry
169+
.current_state()
170+
.ok_or_else(|| MetastoreError::InvalidJsonStructure {
171+
expected: "AlertStateEntry with at least one state".to_string(),
172+
found: "AlertStateEntry with empty states".to_string(),
173+
})?
174+
.state;
175+
176+
// Try to read existing file
177+
let mut alert_entry = match self.storage.get_object(&path).await {
178+
Ok(existing_bytes) => {
179+
if let Ok(entry) = serde_json::from_slice::<AlertStateEntry>(&existing_bytes) {
180+
entry
181+
} else {
182+
// Create new entry if parsing fails or file doesn't exist
183+
AlertStateEntry::new(id, new_state)
184+
}
185+
}
186+
Err(_) => {
187+
// File doesn't exist, create new entry
188+
AlertStateEntry::new(id, new_state)
189+
}
190+
};
191+
192+
// Update the state and only save if it actually changed
193+
let state_changed = alert_entry.update_state(new_state);
194+
195+
if state_changed {
196+
let updated_bytes =
197+
serde_json::to_vec(&alert_entry).map_err(MetastoreError::JsonParseError)?;
198+
199+
self.storage.put_object(&path, updated_bytes.into()).await?;
200+
}
201+
202+
Ok(())
203+
}
204+
205+
/// Delete an alert state file
206+
async fn delete_alert_state(&self, obj: &dyn MetastoreObject) -> Result<(), MetastoreError> {
207+
let path = obj.get_object_path();
208+
Ok(self
209+
.storage
210+
.delete_object(&RelativePathBuf::from(path))
211+
.await?)
212+
}
213+
118214
/// This function fetches all the llmconfigs from the underlying object store
119215
async fn get_llmconfigs(&self) -> Result<Vec<Bytes>, MetastoreError> {
120216
let base_path = RelativePathBuf::from_iter([SETTINGS_ROOT_DIRECTORY, "llmconfigs"]);

src/storage/localfs.rs

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,31 @@ impl ObjectStorage for LocalFS {
127127
),
128128
)))
129129
}
130-
async fn head(&self, _path: &RelativePath) -> Result<ObjectMeta, ObjectStorageError> {
131-
Err(ObjectStorageError::UnhandledError(Box::new(
132-
std::io::Error::new(
133-
std::io::ErrorKind::Unsupported,
134-
"Head operation not implemented for LocalFS yet",
135-
),
136-
)))
130+
async fn head(&self, path: &RelativePath) -> Result<ObjectMeta, ObjectStorageError> {
131+
let file_path = self.path_in_root(path);
132+
133+
// Check if file exists and get metadata
134+
match fs::metadata(&file_path).await {
135+
Ok(metadata) => {
136+
// Convert the relative path to object store path format
137+
let location = object_store::path::Path::from(path.as_str());
138+
139+
// Create ObjectMeta with file information
140+
let object_meta = ObjectMeta {
141+
location,
142+
last_modified: metadata
143+
.modified()
144+
.map_err(ObjectStorageError::IoError)?
145+
.into(),
146+
size: metadata.len() as usize,
147+
e_tag: None,
148+
version: None,
149+
};
150+
151+
Ok(object_meta)
152+
}
153+
Err(e) => Err(ObjectStorageError::IoError(e)),
154+
}
137155
}
138156
async fn get_object(&self, path: &RelativePath) -> Result<Bytes, ObjectStorageError> {
139157
let file_path;

src/storage/object_storage.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1142,6 +1142,16 @@ pub fn target_json_path(target_id: &Ulid) -> RelativePathBuf {
11421142
])
11431143
}
11441144

1145+
/// Constructs the path for storing alert state JSON files
1146+
/// Format: ".parseable/alerts/alert_state_{alert_id}.json"
1147+
#[inline(always)]
1148+
pub fn alert_state_json_path(alert_id: Ulid) -> RelativePathBuf {
1149+
RelativePathBuf::from_iter([
1150+
ALERTS_ROOT_DIRECTORY,
1151+
&format!("alert_state_{alert_id}.json"),
1152+
])
1153+
}
1154+
11451155
#[inline(always)]
11461156
pub fn manifest_path(prefix: &str) -> RelativePathBuf {
11471157
let hostname = hostname::get()

0 commit comments

Comments
 (0)