Skip to content

Commit 0591325

Browse files
authored
Dynamic message in alert message (#348)
This PR adds a way to send log content (for a specific event) from a specific column to the alert target. Users can specify the column name they want to send as a part of alert message in the alert config. Like `message: "Alert triggered for status: {status_message}"` Fixes #331
1 parent 497677e commit 0591325

File tree

5 files changed

+77
-9
lines changed

5 files changed

+77
-9
lines changed

Cargo.lock

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ once_cell = "1.17.1"
7676
pyroscope = { version = "0.5.3", optional = true }
7777
pyroscope_pprofrs = { version = "0.2", optional = true }
7878
uptime_lib = "0.2.2"
79+
regex = "1.7.3"
7980

8081
[build-dependencies]
8182
static-files = "0.2"

server/src/alerts/mod.rs

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
*/
1818

1919
use async_trait::async_trait;
20+
use datafusion::arrow::datatypes::Schema;
21+
use regex::Regex;
2022
use serde::{Deserialize, Serialize};
2123
use std::fmt;
2224

@@ -51,7 +53,8 @@ pub struct Alert {
5153
#[serde(default = "crate::utils::uid::gen")]
5254
pub id: uid::Uid,
5355
pub name: String,
54-
pub message: String,
56+
#[serde(flatten)]
57+
pub message: Message,
5558
pub rule: Rule,
5659
pub targets: Vec<Target>,
5760
}
@@ -63,7 +66,7 @@ impl Alert {
6366
match resolves {
6467
AlertState::Listening | AlertState::Firing => (),
6568
alert_state @ (AlertState::SetToFiring | AlertState::Resolved) => {
66-
let context = self.get_context(stream_name, alert_state, &self.rule);
69+
let context = self.get_context(stream_name, alert_state, &self.rule, event_json);
6770
ALERTS_STATES
6871
.with_label_values(&[
6972
context.stream.as_str(),
@@ -78,7 +81,13 @@ impl Alert {
7881
}
7982
}
8083

81-
fn get_context(&self, stream_name: String, alert_state: AlertState, rule: &Rule) -> Context {
84+
fn get_context(
85+
&self,
86+
stream_name: String,
87+
alert_state: AlertState,
88+
rule: &Rule,
89+
event_json: &serde_json::Value,
90+
) -> Context {
8291
let deployment_instance = format!(
8392
"{}://{}",
8493
CONFIG.parseable.get_scheme(),
@@ -102,7 +111,7 @@ impl Alert {
102111
stream_name,
103112
AlertInfo::new(
104113
self.name.clone(),
105-
self.message.clone(),
114+
self.message.get(event_json),
106115
rule.trigger_reason(),
107116
alert_state,
108117
),
@@ -111,6 +120,49 @@ impl Alert {
111120
)
112121
}
113122
}
123+
124+
#[derive(Debug, Serialize, Deserialize, Clone)]
125+
#[serde(rename_all = "camelCase")]
126+
pub struct Message {
127+
pub message: String,
128+
}
129+
130+
impl Message {
131+
// checks if message (with a column name) is valid (i.e. the column name is present in the schema)
132+
pub fn valid(&self, schema: &Schema, column: Option<&str>) -> bool {
133+
if let Some(col) = column {
134+
return schema.field_with_name(col).is_ok();
135+
}
136+
true
137+
}
138+
139+
pub fn extract_column_name(&self) -> Option<&str> {
140+
let re = Regex::new(r"\{(.*?)\}").unwrap();
141+
let tokens: Vec<&str> = re
142+
.captures_iter(self.message.as_str())
143+
.map(|cap| cap.get(1).unwrap().as_str())
144+
.collect();
145+
// the message can have either no column name ({column_name} not present) or one column name
146+
// return Some only if there is exactly one column name present
147+
if tokens.len() == 1 {
148+
return Some(tokens[0]);
149+
}
150+
None
151+
}
152+
153+
// returns the message with the column name replaced with the value of the column
154+
fn get(&self, event_json: &serde_json::Value) -> String {
155+
if let Some(column) = self.extract_column_name() {
156+
if let Some(value) = event_json.get(column) {
157+
return self
158+
.message
159+
.replace(&format!("{{{column}}}"), value.to_string().as_str());
160+
}
161+
}
162+
self.message.clone()
163+
}
164+
}
165+
114166
#[async_trait]
115167
pub trait CallableTarget {
116168
async fn call(&self, payload: &Context);

server/src/handlers/http/logstream.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,15 @@ pub async fn put_alert(
153153

154154
let schema = STREAM_INFO.merged_schema(&stream_name)?;
155155
for alert in &alerts.alerts {
156+
let column = alert.message.extract_column_name();
157+
let is_valid = alert.message.valid(&schema, column);
158+
if !is_valid {
159+
let col = column.unwrap_or("");
160+
return Err(StreamError::InvalidAlertMessage(
161+
alert.name.to_owned(),
162+
col.to_string(),
163+
));
164+
}
156165
if !alert.rule.valid_for_schema(&schema) {
157166
return Err(StreamError::InvalidAlert(alert.name.to_owned()));
158167
}
@@ -301,6 +310,10 @@ pub mod error {
301310
AlertValidation(#[from] AlertValidationError),
302311
#[error("alert - \"{0}\" is invalid, please check if alert is valid according to this stream's schema and try again")]
303312
InvalidAlert(String),
313+
#[error(
314+
"alert - \"{0}\" is invalid, column \"{1}\" does not exist in this stream's schema"
315+
)]
316+
InvalidAlertMessage(String, String),
304317
#[error("failed to set retention configuration due to err: {0}")]
305318
InvalidRetentionConfig(serde_json::Error),
306319
#[error("{msg}")]
@@ -319,6 +332,7 @@ pub mod error {
319332
StreamError::BadAlertJson { .. } => StatusCode::BAD_REQUEST,
320333
StreamError::AlertValidation(_) => StatusCode::BAD_REQUEST,
321334
StreamError::InvalidAlert(_) => StatusCode::BAD_REQUEST,
335+
StreamError::InvalidAlertMessage(_, _) => StatusCode::BAD_REQUEST,
322336
StreamError::InvalidRetentionConfig(_) => StatusCode::BAD_REQUEST,
323337
}
324338
}

server/src/validator.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ pub fn alert(alerts: &Alerts) -> Result<(), AlertValidationError> {
3737
if alert.name.is_empty() {
3838
return Err(AlertValidationError::EmptyName);
3939
}
40-
if alert.message.is_empty() {
40+
if alert.message.message.is_empty() {
4141
return Err(AlertValidationError::EmptyMessage);
4242
}
4343
if alert.targets.is_empty() {

0 commit comments

Comments
 (0)