mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 16:10:43 +00:00
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
3012 lines
123 KiB
Rust
3012 lines
123 KiB
Rust
// Copyright 2026 OpenObserve Inc.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
use alert::to_float;
|
|
use arrow_schema::{DataType, Schema};
|
|
use async_trait::async_trait;
|
|
use chrono::{Duration, Utc};
|
|
use config::{
|
|
TIMESTAMP_COL_NAME, get_config, ider,
|
|
meta::{
|
|
alerts::{
|
|
AggFunction, AlertConditionParams, Condition, ConditionList, Operator, QueryCondition,
|
|
QueryType, TriggerCondition, TriggerEvalResults, grouping::GroupObservation,
|
|
},
|
|
cluster::RoleGroup,
|
|
search::{SearchEventContext, SearchEventType, SqlQuery},
|
|
sql::resolve_stream_names,
|
|
stream::StreamType,
|
|
},
|
|
utils::{
|
|
base64,
|
|
json::{Map, Value},
|
|
},
|
|
};
|
|
use search::utils::is_permissable_function_error;
|
|
use tracing::Instrument;
|
|
use usage_reporting::http_report_metrics;
|
|
|
|
use crate::{search as SearchService, service::setup_tracing_with_trace_id};
|
|
|
|
pub mod alert;
|
|
pub mod backfill;
|
|
pub mod composite;
|
|
pub mod composite_graph_lock;
|
|
#[cfg(feature = "enterprise")]
|
|
pub mod deduplication;
|
|
pub mod derived_streams;
|
|
pub mod destinations;
|
|
pub mod external_alerts;
|
|
#[cfg(feature = "enterprise")]
|
|
pub mod grouping;
|
|
#[cfg(feature = "enterprise")]
|
|
pub mod incidents;
|
|
pub mod notifications;
|
|
#[cfg(feature = "enterprise")]
|
|
pub mod org_config;
|
|
pub mod scheduler;
|
|
pub mod templates;
|
|
|
|
#[async_trait]
|
|
pub trait QueryConditionExt: Sync + Send + 'static {
|
|
async fn evaluate_realtime(
|
|
&self,
|
|
row: Option<&Map<String, Value>>,
|
|
) -> Result<TriggerEvalResults, anyhow::Error>;
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn evaluate_scheduled(
|
|
&self,
|
|
org_id: &str,
|
|
stream_name: Option<&str>,
|
|
stream_type: StreamType,
|
|
trigger_condition: &TriggerCondition,
|
|
(start_time, end_time): (Option<i64>, i64),
|
|
search_type: Option<SearchEventType>,
|
|
search_event_context: Option<SearchEventContext>,
|
|
trace_id: Option<String>,
|
|
) -> Result<TriggerEvalResults, anyhow::Error>;
|
|
}
|
|
|
|
#[async_trait]
|
|
impl QueryConditionExt for QueryCondition {
|
|
async fn evaluate_realtime(
|
|
&self,
|
|
row: Option<&Map<String, Value>>,
|
|
) -> Result<TriggerEvalResults, anyhow::Error> {
|
|
let now = Utc::now().timestamp_micros();
|
|
let mut eval_results = TriggerEvalResults {
|
|
end_time: now,
|
|
..Default::default()
|
|
};
|
|
let row = match row {
|
|
Some(row) => row,
|
|
None => {
|
|
return Ok(eval_results);
|
|
}
|
|
};
|
|
if self.conditions.is_none() {
|
|
return Ok(eval_results);
|
|
}
|
|
let conditions = self.conditions.as_ref().unwrap();
|
|
if !conditions.evaluate(row).await {
|
|
return Ok(eval_results);
|
|
}
|
|
eval_results.data = Some(vec![row.to_owned()]);
|
|
return Ok(eval_results);
|
|
}
|
|
|
|
async fn evaluate_scheduled(
|
|
&self,
|
|
org_id: &str,
|
|
stream_name: Option<&str>,
|
|
stream_type: StreamType,
|
|
trigger_condition: &TriggerCondition,
|
|
(start_time, end_time): (Option<i64>, i64),
|
|
search_type: Option<SearchEventType>,
|
|
search_event_context: Option<SearchEventContext>,
|
|
trace_id: Option<String>,
|
|
) -> Result<TriggerEvalResults, anyhow::Error> {
|
|
let trace_id = trace_id.unwrap_or_else(ider::generate_trace_id);
|
|
// create context with trace_id
|
|
let eval_span = setup_tracing_with_trace_id(
|
|
&trace_id,
|
|
tracing::info_span!("service:alerts:evaluate_scheduled"),
|
|
)
|
|
.await;
|
|
|
|
let mut eval_results = TriggerEvalResults {
|
|
end_time,
|
|
..Default::default()
|
|
};
|
|
let sql = match self.query_type {
|
|
QueryType::Custom => {
|
|
let (Some(stream_name), Some(v)) = (stream_name, self.conditions.as_ref()) else {
|
|
// CustomQuery type needs to provide source StreamName.
|
|
// CustomQuery is only used by Alerts' triggers.
|
|
return Ok(eval_results);
|
|
};
|
|
|
|
build_sql(org_id, stream_name, stream_type, self, v).await?
|
|
}
|
|
QueryType::SQL => {
|
|
let Some(v) = self.sql.as_ref() else {
|
|
return Ok(eval_results);
|
|
};
|
|
if v.is_empty() {
|
|
return Ok(eval_results);
|
|
} else {
|
|
v.to_string()
|
|
}
|
|
}
|
|
QueryType::Slo => {
|
|
// An SLO alert runs no query — it reads the running aggregate
|
|
// the ingest pass already computed, so several alerts on one
|
|
// SLO cost only cheap status reads and no extra raw-data
|
|
// scans. `alert.rs` branches before reaching here; this arm
|
|
// keeps the dispatch exhaustive so a mis-routed SLO alert
|
|
// degrades to "nothing matched" rather than running an empty
|
|
// SQL string.
|
|
return Ok(eval_results);
|
|
}
|
|
QueryType::PromQL => {
|
|
let Some(v) = self.promql.as_ref() else {
|
|
return Ok(eval_results);
|
|
};
|
|
if v.is_empty() {
|
|
return Ok(eval_results);
|
|
}
|
|
let start = if let Some(start_time) = start_time {
|
|
start_time
|
|
} else {
|
|
end_time
|
|
- Duration::try_minutes(trigger_condition.period)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap()
|
|
};
|
|
let end = end_time;
|
|
let condition = self.promql_condition.as_ref().unwrap();
|
|
// Same strategy as the SQL HAVING clause: query at the LESS
|
|
// severe threshold so the warning band comes back too, then
|
|
// classify each series below. With no warning threshold this
|
|
// widens to critical, leaving the expression unchanged.
|
|
let promql_critical = to_float(&condition.value);
|
|
let promql_filter = config::meta::alerts::aggregation_level::widened_threshold(
|
|
condition.operator,
|
|
promql_critical,
|
|
self.promql_warning_value,
|
|
);
|
|
// Per-series alerts run the raw expression and classify in
|
|
// Rust, mirroring the SQL multi path dropping its HAVING: with
|
|
// the threshold baked into the expression, a recovered series
|
|
// simply stops being returned and could only resolve later via
|
|
// the reaper timeout, with a NULL value. Single alerts keep the
|
|
// filtered query.
|
|
let query = if self.promql_multi_alert {
|
|
format!("({v})")
|
|
} else {
|
|
format!(
|
|
"({}) {} {}",
|
|
v,
|
|
match &condition.operator {
|
|
&Operator::EqualTo => "==".to_string(),
|
|
_ => condition.operator.to_string(),
|
|
},
|
|
promql_filter
|
|
)
|
|
};
|
|
let req = promql_service::MetricsQueryRequest {
|
|
query,
|
|
start,
|
|
end,
|
|
step: std::cmp::max(
|
|
::promql::micros(::promql::MINIMAL_INTERVAL),
|
|
(end - start) / ::promql::MAX_DATA_POINTS,
|
|
),
|
|
query_exemplars: false,
|
|
use_cache: None,
|
|
search_type: Some(SearchEventType::Alerts),
|
|
regions: vec![],
|
|
clusters: vec![],
|
|
};
|
|
// check super cluster
|
|
#[cfg(not(feature = "enterprise"))]
|
|
let is_super_cluster = false;
|
|
#[cfg(feature = "enterprise")]
|
|
let is_super_cluster = o2_enterprise::enterprise::common::config::get_config()
|
|
.super_cluster
|
|
.enabled;
|
|
let resp = match promql_service::search::search(
|
|
&trace_id,
|
|
org_id,
|
|
&req,
|
|
"",
|
|
0,
|
|
is_super_cluster,
|
|
)
|
|
.await
|
|
{
|
|
Ok(v) => v,
|
|
// A failed search is an ERROR, not an empty result. Returning
|
|
// Ok here would record outcome=Normal/level=Ok and refresh
|
|
// `level_at`, silently clearing a prior Critical — an error
|
|
// must leave the level untouched.
|
|
Err(e) => {
|
|
return Err(anyhow::anyhow!("PromQL search error for alert query: {e}"));
|
|
}
|
|
};
|
|
let config::meta::promql::value::Value::Matrix(value) = resp else {
|
|
return Err(anyhow::anyhow!(
|
|
"PromQL query returned unexpected (non-matrix) response: {resp:?}"
|
|
));
|
|
};
|
|
let values: Vec<_> =
|
|
value
|
|
.iter()
|
|
.map(|v| {
|
|
let mut val = Map::with_capacity(v.labels.len() + 2);
|
|
val.extend(v.labels.iter().map(|label| {
|
|
(label.name.to_string(), label.value.to_string().into())
|
|
}));
|
|
|
|
let last_sample = v.samples.last().unwrap();
|
|
val.insert("_timestamp".to_string(), last_sample.timestamp.into());
|
|
val.insert("value".to_string(), last_sample.value.into());
|
|
val
|
|
})
|
|
.collect();
|
|
|
|
// Two axes, exactly like aggregation: each SERIES is classified
|
|
// against the promql condition value, then the SERIES COUNT is
|
|
// gated by `trigger_condition`. Counting series alone would
|
|
// ignore severity and fire spuriously on the widened set.
|
|
let series_values: Vec<f64> = values
|
|
.iter()
|
|
.filter_map(|v| v.get("value").and_then(|x| x.as_f64()))
|
|
.collect();
|
|
let level = config::meta::alerts::aggregation_level::evaluate_level_over_items(
|
|
&series_values,
|
|
condition.operator,
|
|
promql_critical,
|
|
self.promql_warning_value,
|
|
trigger_condition,
|
|
);
|
|
// Worst series' value, so history reports one coherent
|
|
// observation rather than a bare series count. Direction is
|
|
// operator-aware: for `<`/`<=` the worst offender is the MIN.
|
|
//
|
|
// KNOWN LIMITATION, single alerts only: their filter is widened
|
|
// just to the warning level, so a healthy run returns no series
|
|
// and records actual_value=None — history shows "— → Ok".
|
|
// Per-series alerts run unfiltered (above) and record the real
|
|
// healthy reading.
|
|
eval_results.actual_value = config::meta::alerts::level::worst_observed_value(
|
|
&series_values,
|
|
condition.operator,
|
|
);
|
|
// Label the worst series by its PromQL labels, so history shows
|
|
// which series the value came from.
|
|
eval_results.group_label = eval_results.actual_value.and_then(|w| {
|
|
values
|
|
.iter()
|
|
.find(|v| v.get("value").and_then(|x| x.as_f64()) == Some(w))
|
|
.map(|v| {
|
|
v.iter()
|
|
.filter(|(k, _)| k != &"_timestamp" && k != &"value")
|
|
.map(|(k, val)| match val.as_str() {
|
|
Some(s) => format!("{k}={s}"),
|
|
None => format!("{k}={val}"),
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
})
|
|
.filter(|label| !label.is_empty())
|
|
});
|
|
// ── Per-series fan-out ──────────────────────────────────────
|
|
// Purely additive, like the aggregation path below: the
|
|
// worst-series collapse computed above is what the single
|
|
// per-evaluation trigger record needs; this only ADDS the
|
|
// per-series view.
|
|
if self.promql_multi_alert {
|
|
eval_results.group_classification =
|
|
Some(config::meta::alerts::grouping::classify_promql_series(
|
|
&values,
|
|
condition.operator,
|
|
promql_critical,
|
|
self.promql_warning_value,
|
|
get_config().limit.alert_max_groups,
|
|
));
|
|
}
|
|
|
|
eval_results.level = level;
|
|
eval_results.data = level.map(|_| values);
|
|
log::info!(
|
|
"Alert evaluate: trace_id: {trace_id}, PromQL query {v} returned response after filtering: {eval_results:?}"
|
|
);
|
|
return Ok(eval_results);
|
|
}
|
|
};
|
|
|
|
let stream_names = resolve_stream_names(&sql)
|
|
.map_err(|e| anyhow::anyhow!("Error resolving stream names in SQL query: {e}"))?;
|
|
|
|
// SQL may contain multiple stream names, check for each stream
|
|
// if the query period is greater than the max query range
|
|
for stream in stream_names.iter() {
|
|
if infra::schema::get_stream_schema_from_cache(org_id, stream, stream_type)
|
|
.await
|
|
.is_none()
|
|
{
|
|
return Err(anyhow::anyhow!(
|
|
"Stream \"{stream}\" not found in schema, skipping alert evaluation"
|
|
));
|
|
};
|
|
|
|
if let Some(settings) = infra::schema::get_settings(org_id, stream, stream_type).await {
|
|
let max_query_range = settings.max_query_range;
|
|
if max_query_range > 0 && trigger_condition.period > max_query_range * 60 {
|
|
return Err(anyhow::anyhow!(
|
|
"Query period is greater than max query range of {max_query_range} hours for stream \"{stream}\""
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut time_diff = Duration::try_minutes(trigger_condition.period)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap();
|
|
let start_time = if let Some(start_time) = start_time {
|
|
time_diff = end_time - start_time;
|
|
Some(start_time)
|
|
} else {
|
|
Some(end_time - time_diff)
|
|
};
|
|
// Hybrid count evaluation — decide from a COUNT(*) pre-query and fetch
|
|
// rows only as a notification sample. Guards, in order:
|
|
// - threshold bypass (search_event_type) — no threshold, no hybrid;
|
|
// - aggregation — already exact, needs per-group rows;
|
|
// - VRL — transforms rows post-query, a SQL count could disagree;
|
|
// - multi-window — separate SQL list, out of scope v1.
|
|
let hybrid = self.search_event_type.is_none()
|
|
&& self.aggregation.is_none()
|
|
&& self.vrl_function.is_none()
|
|
&& self
|
|
.multi_time_range
|
|
.as_ref()
|
|
.is_none_or(|mtr| mtr.is_empty())
|
|
&& matches!(
|
|
config::meta::alerts::level::evaluation_strategy(
|
|
trigger_condition,
|
|
get_config().limit.alert_hybrid_count_threshold,
|
|
),
|
|
config::meta::alerts::level::EvaluationStrategy::CountPlusSample { .. }
|
|
);
|
|
|
|
// Exact count from the COUNT(*) pre-query; also carries the decision.
|
|
let mut hybrid_exact_count: Option<f64> = None;
|
|
if hybrid {
|
|
let count_resp = run_alert_count_query(
|
|
&trace_id,
|
|
org_id,
|
|
stream_type,
|
|
&sql,
|
|
(start_time.unwrap_or(end_time - time_diff), end_time),
|
|
search_type,
|
|
search_event_context.clone(),
|
|
)
|
|
.await?;
|
|
let exact = count_resp.0;
|
|
eval_results.query_took = Some(count_resp.1);
|
|
eval_results.actual_value = Some(exact);
|
|
let level = config::meta::alerts::level::evaluate_level(exact, trigger_condition);
|
|
eval_results.level = level;
|
|
if level.is_none() {
|
|
// Not firing: the count alone decides. No payload query at all
|
|
// — a healthy hybrid evaluation is CHEAPER than the old
|
|
// 100-row floor fetch.
|
|
return Ok(eval_results);
|
|
}
|
|
hybrid_exact_count = Some(exact);
|
|
}
|
|
|
|
// Per-group evaluation reads a page sized to the max-groups cap, not to
|
|
// the threshold: a multi-alert's count gate is always "any group", so
|
|
// `required_search_size` would ask for a handful of rows and the
|
|
// fan-out would see a fraction of the groups.
|
|
let multi_group_cap = self
|
|
.aggregation
|
|
.as_ref()
|
|
.filter(|a| a.multi_alert && a.group_by.as_ref().is_some_and(|g| !g.is_empty()))
|
|
.map(|_| get_config().limit.alert_max_groups);
|
|
|
|
let size = if self.search_event_type.is_some() {
|
|
-1
|
|
} else if let Some(cap) = multi_group_cap {
|
|
// ONE row past the cap, so a full page is itself the overflow
|
|
// signal and the persisted counts can honestly be marked lower
|
|
// bounds. `cap == 0` means unlimited.
|
|
if cap == 0 { -1 } else { cap as i64 + 1 }
|
|
} else if hybrid {
|
|
// Decision already made from the exact count; this fetch is only
|
|
// the notification payload sample.
|
|
config::meta::alerts::level::PAYLOAD_SAMPLE_ROWS
|
|
} else {
|
|
config::meta::alerts::level::required_search_size(trigger_condition)
|
|
};
|
|
|
|
let req_start = std::time::Instant::now();
|
|
let resp = if self
|
|
.multi_time_range
|
|
.as_ref()
|
|
.is_some_and(|mtr| !mtr.is_empty())
|
|
{
|
|
let req = config::meta::search::MultiStreamRequest {
|
|
sql: {
|
|
let mut sqls =
|
|
Vec::with_capacity(self.multi_time_range.as_ref().unwrap().len() + 1);
|
|
sqls.push(SqlQuery {
|
|
sql: sql.clone(),
|
|
start_time,
|
|
end_time: Some(end_time),
|
|
query_fn: None,
|
|
is_old_format: false,
|
|
});
|
|
for timerange in self.multi_time_range.as_ref().unwrap() {
|
|
let (offset, unit) = timerange.offset.split_at(timerange.offset.len() - 1);
|
|
// Default is 1 if parsing fails
|
|
let offset = offset.parse::<i64>().unwrap_or(1);
|
|
let end_time = match unit {
|
|
"h" => {
|
|
end_time
|
|
- Duration::try_hours(offset)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap()
|
|
}
|
|
"d" => {
|
|
end_time
|
|
- Duration::try_days(offset)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap()
|
|
}
|
|
"w" => {
|
|
end_time
|
|
- Duration::try_weeks(offset)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap()
|
|
}
|
|
"M" => {
|
|
end_time
|
|
- Duration::try_days(offset * 30)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap()
|
|
}
|
|
// Default to minutes
|
|
_ => {
|
|
end_time
|
|
- Duration::try_minutes(offset)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap()
|
|
}
|
|
};
|
|
sqls.push(SqlQuery {
|
|
sql: sql.clone(),
|
|
start_time: Some(end_time - time_diff),
|
|
end_time: Some(end_time),
|
|
query_fn: None,
|
|
is_old_format: false,
|
|
});
|
|
}
|
|
sqls
|
|
},
|
|
encoding: config::meta::search::RequestEncoding::Empty,
|
|
regions: vec![],
|
|
clusters: vec![],
|
|
timeout: 0,
|
|
search_type,
|
|
search_event_context,
|
|
from: 0,
|
|
size,
|
|
start_time: 0, // ignored
|
|
end_time: 0, // ignored
|
|
sort_by: None,
|
|
quick_mode: false,
|
|
track_total_hits: false,
|
|
query_type: "".to_string(),
|
|
uses_zo_fn: false,
|
|
query_fn: self.vrl_function.clone(),
|
|
skip_wal: false,
|
|
index_type: "".to_string(),
|
|
per_query_response: false, // Will return results in single array
|
|
};
|
|
log::debug!(
|
|
"evaluate_scheduled trace_id: {trace_id}, begin to call SearchService::search_multi, {req:?}"
|
|
);
|
|
SearchService::grpc_search::grpc_search_multi(
|
|
&trace_id,
|
|
org_id,
|
|
stream_type,
|
|
None,
|
|
&req,
|
|
Some(RoleGroup::Background),
|
|
)
|
|
.instrument(eval_span)
|
|
.await
|
|
// SearchService::search_multi(&trace_id, org_id, stream_type, None, &req).await
|
|
} else {
|
|
let encode_query_fn = if let Some(v) = &self.vrl_function {
|
|
match base64::decode_url(v) {
|
|
Ok(v) => Some(v),
|
|
Err(e) => {
|
|
return Err(anyhow::anyhow!(
|
|
"Error decoding alert vrl query function: {e}"
|
|
));
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
// fire the query
|
|
let req = config::meta::search::Request {
|
|
query: config::meta::search::Query {
|
|
sql: sql.clone(),
|
|
from: 0,
|
|
size,
|
|
start_time: start_time.unwrap(),
|
|
end_time,
|
|
quick_mode: false,
|
|
query_type: "".to_string(),
|
|
track_total_hits: false,
|
|
action_id: None,
|
|
uses_zo_fn: false,
|
|
query_fn: encode_query_fn,
|
|
skip_wal: false,
|
|
sampling_config: None,
|
|
sampling_ratio: None,
|
|
streaming_output: false,
|
|
streaming_id: None,
|
|
histogram_interval: 0,
|
|
timezone: None,
|
|
},
|
|
encoding: config::meta::search::RequestEncoding::Empty,
|
|
regions: vec![],
|
|
clusters: vec![],
|
|
timeout: 0,
|
|
search_type,
|
|
search_event_context,
|
|
use_cache: false,
|
|
clear_cache: false,
|
|
local_mode: None,
|
|
agent_options: None,
|
|
};
|
|
log::debug!(
|
|
"evaluate_scheduled trace_id: {trace_id}, begin to call SearchService::search, {req:?}"
|
|
);
|
|
// SearchService::search(&trace_id, org_id, stream_type, None, &req).await
|
|
SearchService::grpc_search::grpc_search(
|
|
&trace_id,
|
|
org_id,
|
|
stream_type,
|
|
None,
|
|
&req,
|
|
Some(RoleGroup::Background),
|
|
)
|
|
.instrument(eval_span)
|
|
.await
|
|
};
|
|
|
|
// Resp hits can be of two types -
|
|
// 1. Vec<Map<String, Value>> - for normal alert
|
|
// 2. Vec<Vec<Map<String, Value>>> - for multi_time_range alert
|
|
let resp = match resp {
|
|
Ok(mut v) => {
|
|
// Check if function error is only query limit default error
|
|
if is_permissable_function_error(&v.function_error) {
|
|
v.function_error.clear();
|
|
v.is_partial = false;
|
|
}
|
|
|
|
// the search request doesn't via cache layer, so need report usage separately
|
|
http_report_metrics(
|
|
req_start,
|
|
org_id,
|
|
stream_type,
|
|
"200",
|
|
"_search",
|
|
&SearchEventType::Alerts.to_string(),
|
|
"",
|
|
);
|
|
if v.is_partial {
|
|
return Err(anyhow::anyhow!(
|
|
"Partial response: {}",
|
|
v.function_error.join(", ")
|
|
));
|
|
} else {
|
|
v
|
|
}
|
|
}
|
|
Err(e) => {
|
|
if let infra::errors::Error::ErrorCode(e) = e {
|
|
return Err(anyhow::anyhow!(
|
|
"{} {}",
|
|
e.get_message(),
|
|
e.get_inner_message()
|
|
));
|
|
} else {
|
|
return Err(anyhow::anyhow!("{}", e));
|
|
}
|
|
}
|
|
};
|
|
let mut records = vec![];
|
|
resp.hits.iter().for_each(|hit| {
|
|
match hit {
|
|
Value::Object(hit) => records.push(hit.clone()),
|
|
// For multi timerange alerts, the hits can be an array of hits
|
|
Value::Array(hits) => hits.iter().for_each(|hit| {
|
|
if let Value::Object(hit) = hit {
|
|
records.push(hit.clone());
|
|
}
|
|
}),
|
|
_ => {}
|
|
}
|
|
});
|
|
log::debug!(
|
|
"alert trace_id: {trace_id}, resp hits len:{:#?}",
|
|
records.len()
|
|
);
|
|
eval_results.query_took = Some(resp.took as i64);
|
|
// Apply the threshold when the search event type is unset or explicitly
|
|
// set to `Alerts`. Any other search event type bypasses the threshold.
|
|
let apply_threshold = match self.search_event_type {
|
|
None => true,
|
|
Some(search_event_type) => search_event_type == SearchEventType::Alerts,
|
|
};
|
|
eval_results.data = if apply_threshold {
|
|
match self.aggregation.as_ref() {
|
|
// ── Aggregation alerts ──────────────────────────────────────
|
|
// The threshold is `having.value` / `warning_value` applied to
|
|
// each row's aggregate, NOT a row count. The SQL HAVING was
|
|
// widened to the less severe threshold, so the returned set
|
|
// deliberately includes the warning band and MUST be
|
|
// re-classified here — counting rows would both ignore severity
|
|
// and fire spuriously on the widened set.
|
|
Some(agg) => {
|
|
// Classify each group's aggregate, then re-apply the
|
|
// GROUP-COUNT threshold. Both axes must hold — dropping the
|
|
// count silently rewrites "for at least 3 groups" as "for
|
|
// any group".
|
|
let classified: Vec<_> = records
|
|
.iter()
|
|
.filter_map(|r| r.get("alert_agg_value").and_then(|v| v.as_f64()))
|
|
.collect();
|
|
|
|
let level =
|
|
config::meta::alerts::aggregation_level::evaluate_aggregation_alert(
|
|
&classified,
|
|
agg,
|
|
trigger_condition,
|
|
)
|
|
.unwrap_or(None);
|
|
|
|
// Report the worst group's value, so history's
|
|
// "fired at X against Y" is one coherent observation.
|
|
// Direction is operator-aware: for `<`/`<=` the worst
|
|
// offender is the MIN, not the max.
|
|
//
|
|
// KNOWN LIMITATION: the HAVING filter is widened only to
|
|
// the warning level, so a healthy run returns no rows and
|
|
// records actual_value=None — history shows "— → Ok".
|
|
// Dropping the filter would cost a full per-group fetch on
|
|
// every healthy evaluation; deliberately deferred to the
|
|
// SLO work.
|
|
let offenders: Vec<f64> = classified
|
|
.iter()
|
|
.filter(|v| {
|
|
config::meta::alerts::aggregation_level::evaluate_aggregation_level(
|
|
**v, agg,
|
|
)
|
|
.ok()
|
|
.flatten()
|
|
.is_some()
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
let worst = config::meta::alerts::level::worst_observed_value(
|
|
&offenders,
|
|
agg.having.operator,
|
|
);
|
|
|
|
// Identify WHICH group produced the worst value, so history
|
|
// reads "avg(cpu)=97.2 for host=b" and not just a number.
|
|
// Label = the group_by columns of that row.
|
|
let group_label = worst.and_then(|w| {
|
|
let group_by = agg.group_by.as_deref().unwrap_or(&[]);
|
|
if group_by.is_empty() {
|
|
return None;
|
|
}
|
|
records
|
|
.iter()
|
|
.find(|r| r.get("alert_agg_value").and_then(|v| v.as_f64()) == Some(w))
|
|
.map(|r| {
|
|
group_by
|
|
.iter()
|
|
.filter_map(|col| {
|
|
r.get(col).map(|v| match v.as_str() {
|
|
Some(s) => format!("{col}={s}"),
|
|
None => format!("{col}={v}"),
|
|
})
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
})
|
|
.filter(|label| !label.is_empty())
|
|
});
|
|
|
|
eval_results.level = level;
|
|
eval_results.actual_value = worst;
|
|
eval_results.group_label = group_label;
|
|
|
|
// ── Per-group fan-out ───────────────────────────────────
|
|
// Purely additive: everything above still runs, because the
|
|
// worst-group collapse is what the single per-evaluation
|
|
// trigger record needs and what every non-multi alert is
|
|
// evaluated by. This only *adds* the per-group view.
|
|
if let Some(cap) = multi_group_cap
|
|
&& let Some(group_by) = agg.group_by.as_ref()
|
|
{
|
|
// Labels come from the SHARED extractor, not a local
|
|
// copy: dispatch keys each group's notification
|
|
// payload by `group_key(row_group_labels(row))`, so if
|
|
// the two renderings ever diverged, every dispatch
|
|
// item would fail to find its row and the feature
|
|
// would break silently.
|
|
let observations: Vec<GroupObservation> = records
|
|
.iter()
|
|
.filter_map(|r| {
|
|
let value = r.get("alert_agg_value")?.as_f64()?;
|
|
let labels =
|
|
config::meta::alerts::dispatch::row_group_labels(r, group_by);
|
|
Some(GroupObservation::new(labels, value))
|
|
})
|
|
.collect();
|
|
|
|
let classification = config::meta::alerts::grouping::classify_groups_by(
|
|
observations,
|
|
|v| {
|
|
config::meta::alerts::aggregation_level::evaluate_aggregation_level(
|
|
v, agg,
|
|
)
|
|
.ok()
|
|
.flatten()
|
|
},
|
|
cap,
|
|
);
|
|
|
|
// Both facts come free from the classification: the
|
|
// page filled if we got everything we asked for, and it
|
|
// reached healthy groups if not every observed group
|
|
// was firing. Together they decide whether the counts
|
|
// are exact and whether absence proves disappearance.
|
|
let observed = classification.groups.len() + classification.dropped.len();
|
|
let page = config::meta::alerts::grouping::FetchPage {
|
|
filled: size > 0 && records.len() as i64 >= size,
|
|
reached_healthy: classification.firing_observed < observed,
|
|
};
|
|
|
|
eval_results.group_classification = Some(classification.with_page(page));
|
|
}
|
|
|
|
level.map(|_| records)
|
|
}
|
|
// ── Count-based alerts ──────────────────────────────────────
|
|
None => {
|
|
// Hybrid mode already decided from COUNT(*) — and it only
|
|
// reaches here when firing, so `data` is Some
|
|
// unconditionally. Re-deriving from the 100-row payload
|
|
// sample would silently overwrite the exact count with a
|
|
// clamped one.
|
|
if let Some(exact) = hybrid_exact_count {
|
|
eval_results.actual_value = Some(exact);
|
|
// level already set from the exact count
|
|
Some(records)
|
|
} else {
|
|
let actual = records.len() as f64;
|
|
let level =
|
|
config::meta::alerts::level::evaluate_level(actual, trigger_condition);
|
|
eval_results.actual_value = Some(actual);
|
|
// The fetch was capped at `size`; a full page means the
|
|
// true count may be higher — record it as a lower bound
|
|
// so history can render "≥ N".
|
|
eval_results.value_is_lower_bound =
|
|
size > 0 && records.len() as i64 >= size;
|
|
eval_results.level = level;
|
|
level.map(|_| records)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Threshold bypassed (non-alert search event types) — no level.
|
|
Some(records)
|
|
};
|
|
|
|
Ok(eval_results)
|
|
}
|
|
}
|
|
|
|
/// Run the COUNT(*) decision query for a hybrid count-based alert.
|
|
///
|
|
/// Returns `(exact_count, query_took_ms)`. The user's SQL runs verbatim inside
|
|
/// the wrapper, over the same time window the payload query would use, so the
|
|
/// two cannot disagree about which rows exist.
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn run_alert_count_query(
|
|
trace_id: &str,
|
|
org_id: &str,
|
|
stream_type: StreamType,
|
|
sql: &str,
|
|
(start_time, end_time): (i64, i64),
|
|
search_type: Option<SearchEventType>,
|
|
search_event_context: Option<SearchEventContext>,
|
|
) -> Result<(f64, i64), anyhow::Error> {
|
|
let req = config::meta::search::Request {
|
|
query: config::meta::search::Query {
|
|
sql: config::meta::alerts::level::count_query_sql(sql),
|
|
from: 0,
|
|
// COUNT(*) over a subquery yields exactly one row.
|
|
size: 1,
|
|
start_time,
|
|
end_time,
|
|
quick_mode: false,
|
|
query_type: "".to_string(),
|
|
track_total_hits: false,
|
|
action_id: None,
|
|
uses_zo_fn: false,
|
|
query_fn: None, // guard upstream: hybrid excludes VRL alerts
|
|
skip_wal: false,
|
|
sampling_config: None,
|
|
sampling_ratio: None,
|
|
streaming_output: false,
|
|
streaming_id: None,
|
|
histogram_interval: 0,
|
|
timezone: None,
|
|
},
|
|
encoding: config::meta::search::RequestEncoding::Empty,
|
|
regions: vec![],
|
|
clusters: vec![],
|
|
timeout: 0,
|
|
search_type,
|
|
search_event_context,
|
|
use_cache: false,
|
|
clear_cache: false,
|
|
local_mode: None,
|
|
agent_options: None,
|
|
};
|
|
let resp = SearchService::grpc_search::grpc_search(
|
|
trace_id,
|
|
org_id,
|
|
stream_type,
|
|
None,
|
|
&req,
|
|
Some(RoleGroup::Background),
|
|
)
|
|
.await?;
|
|
let count = resp
|
|
.hits
|
|
.first()
|
|
.and_then(|h| h.get("zo_alert_count"))
|
|
.and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|i| i as f64)))
|
|
.ok_or_else(|| anyhow::anyhow!("alert count query returned no zo_alert_count column"))?;
|
|
Ok((count, resp.took as i64))
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait ConditionListExt: Sync + Send + 'static {
|
|
async fn len(&self) -> u32;
|
|
async fn to_sql(&self, schema: &Schema) -> Result<String, anyhow::Error>;
|
|
async fn is_empty(&self) -> bool;
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ConditionListExt for ConditionList {
|
|
/// Returns end node count of a Condition list
|
|
async fn len(&self) -> u32 {
|
|
match self {
|
|
ConditionList::OrNode { or: conditions }
|
|
| ConditionList::AndNode { and: conditions } => {
|
|
let mut count = 0;
|
|
for condition in conditions.iter() {
|
|
count += condition.len().await
|
|
}
|
|
count
|
|
}
|
|
ConditionList::NotNode { not: inner } => inner.len().await,
|
|
ConditionList::EndCondition(_) => 1,
|
|
ConditionList::LegacyConditions(conditions) => conditions.len() as u32,
|
|
}
|
|
}
|
|
|
|
/// Converts Condition list to SQL query as per schema
|
|
async fn to_sql(&self, schema: &Schema) -> Result<String, anyhow::Error> {
|
|
match self {
|
|
ConditionList::OrNode { or: conditions } => {
|
|
let mut cond_sql_list = Vec::new();
|
|
for condition in conditions.iter() {
|
|
cond_sql_list.push(condition.to_sql(schema).await?);
|
|
}
|
|
Ok(format!("({})", cond_sql_list.join(" OR ")))
|
|
}
|
|
ConditionList::LegacyConditions(conditions) => {
|
|
let mut cond_sql_list = Vec::new();
|
|
for cond in conditions {
|
|
let data_type = match schema.field_with_name(&cond.column) {
|
|
Ok(field) => field.data_type(),
|
|
Err(_) => {
|
|
return Err(anyhow::anyhow!("Column {} not found", cond.column));
|
|
}
|
|
};
|
|
cond_sql_list.push(build_expr(cond, "", data_type)?);
|
|
}
|
|
Ok(format!("({})", cond_sql_list.join(" AND ")))
|
|
}
|
|
ConditionList::AndNode { and: conditions } => {
|
|
let mut cond_sql_list = Vec::new();
|
|
for condition in conditions.iter() {
|
|
cond_sql_list.push(condition.to_sql(schema).await?);
|
|
}
|
|
Ok(format!("({})", cond_sql_list.join(" AND ")))
|
|
}
|
|
ConditionList::NotNode { not: inner } => {
|
|
Ok(format!("NOT ({})", inner.to_sql(schema).await?))
|
|
}
|
|
ConditionList::EndCondition(node) => {
|
|
let data_type = match schema.field_with_name(&node.column) {
|
|
Ok(field) => field.data_type(),
|
|
Err(_) => {
|
|
return Err(anyhow::anyhow!("Column {} not found", node.column));
|
|
}
|
|
};
|
|
build_expr(node, "", data_type)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn is_empty(&self) -> bool {
|
|
match self {
|
|
ConditionList::OrNode { or: conditions } => {
|
|
for condition in conditions.iter() {
|
|
if condition.is_empty().await {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
ConditionList::AndNode { and: conditions } => {
|
|
for condition in conditions.iter() {
|
|
if !condition.is_empty().await {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
ConditionList::NotNode { not: inner } => inner.is_empty().await,
|
|
ConditionList::LegacyConditions(conditions) => conditions.is_empty(),
|
|
ConditionList::EndCondition(_) => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait ConditionExt: Sync + Send + 'static {
|
|
async fn evaluate(&self, row: &Map<String, Value>) -> bool;
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ConditionExt for ConditionList {
|
|
async fn evaluate(&self, row: &Map<String, Value>) -> bool {
|
|
match self {
|
|
ConditionList::OrNode { or: conditions } => {
|
|
let mut eval = false;
|
|
for condition in conditions {
|
|
eval = eval || condition.evaluate(row).await
|
|
}
|
|
eval
|
|
}
|
|
ConditionList::LegacyConditions(conditions) => {
|
|
let mut eval = true;
|
|
for condition in conditions {
|
|
eval = eval && condition.evaluate(row).await
|
|
}
|
|
eval
|
|
}
|
|
ConditionList::AndNode { and: conditions } => {
|
|
let mut eval = true;
|
|
for condition in conditions {
|
|
eval = eval && condition.evaluate(row).await
|
|
}
|
|
eval
|
|
}
|
|
ConditionList::NotNode { not: conditions } => !conditions.evaluate(row).await,
|
|
ConditionList::EndCondition(condition) => condition.evaluate(row).await,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ConditionExt for Condition {
|
|
async fn evaluate(&self, row: &Map<String, Value>) -> bool {
|
|
// this is a special case introduced for draft workflows
|
|
// ideal in other places FE does not allow empty cols,
|
|
// but on BE we do not have specific restrictions, so when col is empty
|
|
// it is UB. Thus we use that for draft workflows and cond always evaluates to true
|
|
if self.column.is_empty() {
|
|
return true;
|
|
}
|
|
match self.operator {
|
|
Operator::IsNull => return row.get(&self.column).is_none_or(Value::is_null),
|
|
Operator::IsNotNull => return row.get(&self.column).is_some_and(|v| !v.is_null()),
|
|
Operator::IsEmpty => return row.get(&self.column).is_none_or(is_empty_value),
|
|
Operator::IsNotEmpty => {
|
|
return row.get(&self.column).is_some_and(|v| !is_empty_value(v));
|
|
}
|
|
_ => {}
|
|
}
|
|
let val = match row.get(&self.column) {
|
|
Some(val) => val,
|
|
None => {
|
|
return false;
|
|
}
|
|
};
|
|
match val {
|
|
Value::String(v) => {
|
|
let val = v.as_str();
|
|
let con_val = self.value.as_str().unwrap_or_default().trim_matches('"');
|
|
match self.operator {
|
|
Operator::EqualTo => val == con_val,
|
|
Operator::NotEqualTo => val != con_val,
|
|
Operator::GreaterThan => val > con_val,
|
|
Operator::GreaterThanEquals => val >= con_val,
|
|
Operator::LessThan => val < con_val,
|
|
Operator::LessThanEquals => val <= con_val,
|
|
Operator::Contains => val.contains(con_val),
|
|
Operator::NotContains => !val.contains(con_val),
|
|
_ => false,
|
|
}
|
|
}
|
|
Value::Number(_) => {
|
|
let val = val.as_f64().unwrap_or_default();
|
|
let con_val = if self.value.is_number() {
|
|
self.value.as_f64().unwrap_or_default()
|
|
} else {
|
|
self.value
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.parse()
|
|
.unwrap_or_default()
|
|
};
|
|
match self.operator {
|
|
Operator::EqualTo => val == con_val,
|
|
Operator::NotEqualTo => val != con_val,
|
|
Operator::GreaterThan => val > con_val,
|
|
Operator::GreaterThanEquals => val >= con_val,
|
|
Operator::LessThan => val < con_val,
|
|
Operator::LessThanEquals => val <= con_val,
|
|
_ => false,
|
|
}
|
|
}
|
|
Value::Bool(v) => {
|
|
let val = v.to_owned();
|
|
let con_val = if self.value.is_boolean() {
|
|
self.value.as_bool().unwrap_or_default()
|
|
} else {
|
|
self.value
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.parse()
|
|
.unwrap_or_default()
|
|
};
|
|
match self.operator {
|
|
Operator::EqualTo => val == con_val,
|
|
Operator::NotEqualTo => val != con_val,
|
|
_ => false,
|
|
}
|
|
}
|
|
Value::Null => {
|
|
matches!(self.operator, Operator::EqualTo)
|
|
&& matches!(&self.value, Value::String(v) if v == "null")
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Trait implementations for AlertConditionParams to support both v1 and v2
|
|
#[async_trait]
|
|
impl ConditionExt for AlertConditionParams {
|
|
async fn evaluate(&self, row: &Map<String, Value>) -> bool {
|
|
match self {
|
|
AlertConditionParams::V1(conditions) => conditions.evaluate(row).await,
|
|
AlertConditionParams::V2(conditions) => conditions.evaluate(row).await,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ConditionListExt for AlertConditionParams {
|
|
async fn len(&self) -> u32 {
|
|
match self {
|
|
AlertConditionParams::V1(conditions) => conditions.len().await,
|
|
AlertConditionParams::V2(conditions) => conditions.conditions.len() as u32,
|
|
}
|
|
}
|
|
|
|
async fn to_sql(&self, schema: &Schema) -> Result<String, anyhow::Error> {
|
|
match self {
|
|
AlertConditionParams::V1(conditions) => conditions.to_sql(schema).await,
|
|
AlertConditionParams::V2(conditions) => conditions.to_sql(schema).await,
|
|
}
|
|
}
|
|
|
|
async fn is_empty(&self) -> bool {
|
|
match self {
|
|
AlertConditionParams::V1(conditions) => conditions.is_empty().await,
|
|
AlertConditionParams::V2(conditions) => conditions.conditions.is_empty(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Trait and implementation for ConditionGroup (V2 format)
|
|
#[async_trait]
|
|
pub trait ConditionGroupExt: Sync + Send + 'static {
|
|
async fn evaluate(&self, row: &Map<String, Value>) -> bool;
|
|
async fn to_sql(&self, schema: &Schema) -> Result<String, anyhow::Error>;
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ConditionGroupExt for config::meta::alerts::ConditionGroup {
|
|
async fn evaluate(&self, row: &Map<String, Value>) -> bool {
|
|
evaluate_condition_items(&self.conditions, row).await
|
|
}
|
|
|
|
async fn to_sql(&self, schema: &Schema) -> Result<String, anyhow::Error> {
|
|
if self.conditions.is_empty() {
|
|
return Ok("".to_string());
|
|
}
|
|
|
|
// Convert items to SQL left-to-right with operators
|
|
let mut sql_parts = Vec::new();
|
|
for item in &self.conditions {
|
|
sql_parts.push(condition_item_to_sql(item, schema).await?);
|
|
}
|
|
|
|
// Apply logical operators left-to-right
|
|
// The logicalOperator on an item indicates the operator that comes BEFORE that item
|
|
if sql_parts.len() == 1 {
|
|
return Ok(format!("({})", sql_parts[0]));
|
|
}
|
|
|
|
let mut result = sql_parts[0].clone();
|
|
for (item, item_sql) in self.conditions.iter().skip(1).zip(sql_parts.iter().skip(1)) {
|
|
// Use the current item's logical operator (it indicates the operator before this item)
|
|
// Concatenate with the operator, relying on SQL operator precedence
|
|
match item.logical_operator() {
|
|
config::meta::alerts::LogicalOperator::And => {
|
|
result = format!("{} AND {}", result, item_sql);
|
|
}
|
|
config::meta::alerts::LogicalOperator::Or => {
|
|
result = format!("{} OR {}", result, item_sql);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wrap the entire result in parentheses at the end
|
|
Ok(format!("({})", result))
|
|
}
|
|
}
|
|
|
|
// Trait implementation for ConditionItem
|
|
#[async_trait]
|
|
impl ConditionGroupExt for config::meta::alerts::ConditionItem {
|
|
async fn evaluate(&self, row: &Map<String, Value>) -> bool {
|
|
match self {
|
|
config::meta::alerts::ConditionItem::Condition(v) => {
|
|
evaluate_condition(
|
|
row,
|
|
&v.column,
|
|
&v.operator,
|
|
&v.value,
|
|
v.ignore_case.unwrap_or(false),
|
|
)
|
|
.await
|
|
}
|
|
config::meta::alerts::ConditionItem::Group { conditions, .. } => {
|
|
evaluate_condition_items(conditions, row).await
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn to_sql(&self, schema: &Schema) -> Result<String, anyhow::Error> {
|
|
condition_item_to_sql(self, schema).await
|
|
}
|
|
}
|
|
|
|
/// Evaluates a list of condition items with left-to-right logical operator application
|
|
///
|
|
/// Algorithm:
|
|
/// 1. Start with the first item's evaluation result
|
|
/// 2. For each subsequent item:
|
|
/// - Evaluate the item
|
|
/// - Apply the previous item's logical_operator to combine results
|
|
/// 3. Continue left-to-right until all items processed
|
|
///
|
|
/// Example: [A AND, B OR, C AND]
|
|
/// - result = eval(A)
|
|
/// - result = result AND eval(B) // Apply A's operator
|
|
/// - result = result OR eval(C) // Apply B's operator
|
|
async fn evaluate_condition_items(
|
|
items: &[config::meta::alerts::ConditionItem],
|
|
row: &Map<String, Value>,
|
|
) -> bool {
|
|
if items.is_empty() {
|
|
return true;
|
|
}
|
|
|
|
// Evaluate with operator precedence: AND before OR (matching SQL semantics)
|
|
// The logicalOperator on an item indicates the operator that comes BEFORE that item
|
|
|
|
// First, evaluate all items
|
|
let mut results = Vec::new();
|
|
let mut operators = Vec::new();
|
|
|
|
for (i, item) in items.iter().enumerate() {
|
|
results.push(item.evaluate(row).await);
|
|
if i > 0 {
|
|
operators.push(item.logical_operator());
|
|
}
|
|
}
|
|
|
|
// Phase 1: Process all AND operations first (higher precedence)
|
|
let mut i = 0;
|
|
while i < operators.len() {
|
|
if matches!(operators[i], config::meta::alerts::LogicalOperator::And) {
|
|
// Combine results[i] AND results[i+1]
|
|
results[i] = results[i] && results[i + 1];
|
|
results.remove(i + 1);
|
|
operators.remove(i);
|
|
// Don't increment i, check same position again
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
|
|
// Phase 2: Process all OR operations (lower precedence)
|
|
// After phase 1, only OR operators should remain
|
|
let mut result = results[0];
|
|
for res in results.iter().skip(1) {
|
|
result = result || *res;
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
/// Null, or an empty string; a missing column is handled by the callers.
|
|
fn is_empty_value(v: &Value) -> bool {
|
|
v.is_null() || matches!(v, Value::String(s) if s.is_empty())
|
|
}
|
|
|
|
/// Evaluates a single condition against a record
|
|
async fn evaluate_condition(
|
|
row: &Map<String, Value>,
|
|
column: &str,
|
|
operator: &Operator,
|
|
condition_value: &Value,
|
|
ignore_case: bool,
|
|
) -> bool {
|
|
// this is a special case introduced for draft workflows
|
|
// ideal in other places FE does not allow empty cols,
|
|
// but on BE we do not have specific restrictions, so when col is empty
|
|
// it is UB. Thus we use that for draft workflows and cond always evaluates to true
|
|
if column.is_empty() {
|
|
return true;
|
|
}
|
|
match operator {
|
|
Operator::IsNull => return row.get(column).is_none_or(Value::is_null),
|
|
Operator::IsNotNull => return row.get(column).is_some_and(|v| !v.is_null()),
|
|
Operator::IsEmpty => return row.get(column).is_none_or(is_empty_value),
|
|
Operator::IsNotEmpty => return row.get(column).is_some_and(|v| !is_empty_value(v)),
|
|
_ => {}
|
|
}
|
|
let val: &Value = match row.get(column) {
|
|
Some(val) => val,
|
|
None => {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
match val {
|
|
Value::String(v) => {
|
|
let val = v.as_str();
|
|
let con_val = condition_value.as_str().unwrap_or_default();
|
|
|
|
// Handle case-insensitive comparison
|
|
if ignore_case {
|
|
let val_lower = val.to_lowercase();
|
|
let con_val_lower = con_val.to_lowercase();
|
|
match operator {
|
|
Operator::EqualTo => val_lower == con_val_lower,
|
|
Operator::NotEqualTo => val_lower != con_val_lower,
|
|
Operator::GreaterThan => val_lower > con_val_lower,
|
|
Operator::GreaterThanEquals => val_lower >= con_val_lower,
|
|
Operator::LessThan => val_lower < con_val_lower,
|
|
Operator::LessThanEquals => val_lower <= con_val_lower,
|
|
Operator::Contains => val_lower.contains(&con_val_lower),
|
|
Operator::NotContains => !val_lower.contains(&con_val_lower),
|
|
_ => false,
|
|
}
|
|
} else {
|
|
match operator {
|
|
Operator::EqualTo => val == con_val,
|
|
Operator::NotEqualTo => val != con_val,
|
|
Operator::GreaterThan => val > con_val,
|
|
Operator::GreaterThanEquals => val >= con_val,
|
|
Operator::LessThan => val < con_val,
|
|
Operator::LessThanEquals => val <= con_val,
|
|
Operator::Contains => val.contains(con_val),
|
|
Operator::NotContains => !val.contains(con_val),
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
Value::Number(_) => {
|
|
let val = val.as_f64().unwrap_or_default();
|
|
let con_val = if condition_value.is_number() {
|
|
condition_value.as_f64().unwrap_or_default()
|
|
} else {
|
|
condition_value
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.parse()
|
|
.unwrap_or_default()
|
|
};
|
|
match operator {
|
|
Operator::EqualTo => val == con_val,
|
|
Operator::NotEqualTo => val != con_val,
|
|
Operator::GreaterThan => val > con_val,
|
|
Operator::GreaterThanEquals => val >= con_val,
|
|
Operator::LessThan => val < con_val,
|
|
Operator::LessThanEquals => val <= con_val,
|
|
_ => false,
|
|
}
|
|
}
|
|
Value::Bool(v) => {
|
|
let val = *v;
|
|
let con_val = if condition_value.is_boolean() {
|
|
condition_value.as_bool().unwrap_or_default()
|
|
} else {
|
|
condition_value
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.parse()
|
|
.unwrap_or_default()
|
|
};
|
|
match operator {
|
|
Operator::EqualTo => val == con_val,
|
|
Operator::NotEqualTo => val != con_val,
|
|
_ => false,
|
|
}
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
// Helper function to convert a ConditionItem to SQL
|
|
async fn condition_item_to_sql(
|
|
item: &config::meta::alerts::ConditionItem,
|
|
schema: &Schema,
|
|
) -> Result<String, anyhow::Error> {
|
|
match item {
|
|
config::meta::alerts::ConditionItem::Condition(v) => {
|
|
// Create a Condition struct to use with build_expr
|
|
let condition = config::meta::alerts::Condition {
|
|
column: v.column.clone(),
|
|
operator: v.operator,
|
|
value: v.value.clone(),
|
|
ignore_case: v.ignore_case.unwrap_or(false),
|
|
};
|
|
|
|
let data_type = match schema.field_with_name(&condition.column) {
|
|
Ok(field) => field.data_type(),
|
|
Err(_) => {
|
|
return Err(anyhow::anyhow!("Column {} not found", condition.column));
|
|
}
|
|
};
|
|
|
|
build_expr(&condition, "", data_type)
|
|
}
|
|
config::meta::alerts::ConditionItem::Group {
|
|
conditions,
|
|
logical_operator,
|
|
} => {
|
|
// Recursively handle nested group
|
|
let nested_group = config::meta::alerts::ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: *logical_operator,
|
|
conditions: conditions.clone(),
|
|
};
|
|
nested_group.to_sql(schema).await
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn build_sql(
|
|
org_id: &str,
|
|
stream_name: &str,
|
|
stream_type: StreamType,
|
|
query_condition: &QueryCondition,
|
|
conditions: &AlertConditionParams,
|
|
) -> Result<String, anyhow::Error> {
|
|
let schema = infra::schema::get(org_id, stream_name, stream_type).await?;
|
|
let where_sql = if conditions.len().await == 0 {
|
|
"".to_string()
|
|
} else {
|
|
format!(
|
|
" WHERE {}",
|
|
conditions
|
|
.to_sql(&schema)
|
|
.await
|
|
.map_err(|err| anyhow::anyhow!(
|
|
"Error building SQL on stream {stream_name}: {err}"
|
|
))?
|
|
)
|
|
};
|
|
if query_condition.aggregation.is_none() {
|
|
return Ok(format!("SELECT * FROM \"{stream_name}\" {where_sql}"));
|
|
};
|
|
|
|
// handle aggregation
|
|
let mut sql = String::new();
|
|
let agg = query_condition.aggregation.as_ref().unwrap();
|
|
let having_expr = {
|
|
let data_type = match schema.field_with_name(&agg.having.column) {
|
|
Ok(field) => field.data_type(),
|
|
Err(_) => {
|
|
return Err(anyhow::anyhow!(
|
|
"Aggregation column {} not found on stream {stream_name}",
|
|
agg.having.column,
|
|
));
|
|
}
|
|
};
|
|
build_having_expr(agg, data_type)?
|
|
};
|
|
|
|
let func_expr = match agg.function {
|
|
AggFunction::Avg => format!("AVG(\"{}\")", agg.having.column),
|
|
AggFunction::Max => format!("MAX(\"{}\")", agg.having.column),
|
|
AggFunction::Min => format!("MIN(\"{}\")", agg.having.column),
|
|
AggFunction::Sum => format!("SUM(\"{}\")", agg.having.column),
|
|
AggFunction::Count => format!("COUNT(\"{}\")", agg.having.column),
|
|
AggFunction::Median => format!("MEDIAN(\"{}\")", agg.having.column),
|
|
AggFunction::P50 => format!(
|
|
"approx_percentile_cont(0.5) WITHIN GROUP (ORDER BY \"{}\")",
|
|
agg.having.column
|
|
),
|
|
AggFunction::P75 => format!(
|
|
"approx_percentile_cont(0.75) WITHIN GROUP (ORDER BY \"{}\")",
|
|
agg.having.column
|
|
),
|
|
AggFunction::P90 => format!(
|
|
"approx_percentile_cont(0.9) WITHIN GROUP (ORDER BY \"{}\")",
|
|
agg.having.column
|
|
),
|
|
AggFunction::P95 => format!(
|
|
"approx_percentile_cont(0.95) WITHIN GROUP (ORDER BY \"{}\")",
|
|
agg.having.column
|
|
),
|
|
AggFunction::P99 => format!(
|
|
"approx_percentile_cont(0.99) WITHIN GROUP (ORDER BY \"{}\")",
|
|
agg.having.column
|
|
),
|
|
};
|
|
|
|
if let Some(group) = agg.group_by.as_ref()
|
|
&& !group.is_empty()
|
|
{
|
|
let cols = group.join(", ");
|
|
if agg.multi_alert {
|
|
// Multi-alerts drop the HAVING filter. A group that falls back
|
|
// under the threshold must still be RETURNED: otherwise its
|
|
// recovery is indistinguishable from it vanishing, and it would
|
|
// only resolve later via the reaper timeout, with a NULL value
|
|
// where the real reading should be.
|
|
//
|
|
// The page stays bounded, so it is ordered worst-first. That
|
|
// ordering keeps the rollup level exact and lets the cap admit the
|
|
// true top of the distribution rather than an arbitrary slice. The
|
|
// group columns are the deterministic tiebreak within a severity
|
|
// band.
|
|
let severity_order =
|
|
config::meta::alerts::aggregation_level::severity_order_sql(agg, "alert_agg_value")
|
|
.map_err(|e| anyhow::anyhow!("Invalid aggregation threshold: {e}"))?;
|
|
sql = format!(
|
|
"SELECT {cols}, {func_expr} AS alert_agg_value, MIN({TIMESTAMP_COL_NAME}) as zo_sql_min_time, MAX({TIMESTAMP_COL_NAME}) AS zo_sql_max_time FROM \"{stream_name}\"{where_sql} GROUP BY {cols} ORDER BY {severity_order}, {cols}"
|
|
);
|
|
} else {
|
|
sql = format!(
|
|
"SELECT {cols}, {func_expr} AS alert_agg_value, MIN({TIMESTAMP_COL_NAME}) as zo_sql_min_time, MAX({TIMESTAMP_COL_NAME}) AS zo_sql_max_time FROM \"{stream_name}\"{where_sql} GROUP BY {cols} HAVING {having_expr}"
|
|
);
|
|
}
|
|
}
|
|
if sql.is_empty() {
|
|
sql = format!(
|
|
"SELECT {func_expr} AS alert_agg_value, MIN({TIMESTAMP_COL_NAME}) as zo_sql_min_time, MAX({TIMESTAMP_COL_NAME}) AS zo_sql_max_time FROM \"{stream_name}\"{where_sql} HAVING {having_expr}"
|
|
);
|
|
}
|
|
Ok(sql)
|
|
}
|
|
|
|
/// `HAVING` clause filtering the `alert_agg_value` aggregate.
|
|
///
|
|
/// `column_type` is the type of the source column as stored in the schema; the
|
|
/// comparison itself runs against [`having_data_type`].
|
|
fn build_having_expr(
|
|
agg: &config::meta::alerts::Aggregation,
|
|
column_type: &DataType,
|
|
) -> Result<String, anyhow::Error> {
|
|
// Widen the clause to the LESS severe of the critical and warning
|
|
// thresholds, so every group that could be warning-or-worse comes back and
|
|
// Rust classifies each one. Filtering on the critical threshold would drop
|
|
// the entire warning band inside the database, where nothing downstream
|
|
// could recover it. With no warning threshold this widens to critical,
|
|
// leaving the clause unchanged.
|
|
let filter_value = config::meta::alerts::aggregation_level::having_filter_value(agg)
|
|
.map_err(|e| anyhow::anyhow!("Invalid aggregation threshold: {e}"))?;
|
|
let widened = Condition {
|
|
// The widened threshold is an f64, but `json!(1.0_f64)` serializes as
|
|
// `1.0` and never `1`. An integral threshold has to stay an integer:
|
|
// a string comparison would otherwise quote it as '1.0' rather than
|
|
// the '1' the alert was saved with (#13786).
|
|
value: if filter_value.fract() == 0.0 && filter_value.abs() < i64::MAX as f64 {
|
|
serde_json::json!(filter_value as i64)
|
|
} else {
|
|
serde_json::json!(filter_value)
|
|
},
|
|
..agg.having.clone()
|
|
};
|
|
build_expr(
|
|
&widened,
|
|
"alert_agg_value",
|
|
&having_data_type(&agg.function, column_type),
|
|
)
|
|
}
|
|
|
|
/// Type the `HAVING` comparison is built for: the aggregate's OUTPUT type, not
|
|
/// the source column's.
|
|
///
|
|
/// `COUNT("host")` is Int64 even when `host` is Utf8, so typing the comparison
|
|
/// from the column emits a quoted `'1'` literal that DataFusion then has to cast
|
|
/// back to Int64 — and fails on anything non-integral (#13786). `MIN`/`MAX`/
|
|
/// `SUM` do return their input type, so those keep the column's.
|
|
fn having_data_type(func: &AggFunction, column_type: &DataType) -> DataType {
|
|
match func {
|
|
AggFunction::Count => DataType::Int64,
|
|
AggFunction::Avg
|
|
| AggFunction::Median
|
|
| AggFunction::P50
|
|
| AggFunction::P75
|
|
| AggFunction::P90
|
|
| AggFunction::P95
|
|
| AggFunction::P99 => DataType::Float64,
|
|
AggFunction::Min | AggFunction::Max | AggFunction::Sum => column_type.clone(),
|
|
}
|
|
}
|
|
|
|
fn build_expr(
|
|
cond: &Condition,
|
|
field_alias: &str,
|
|
field_type: &DataType,
|
|
) -> Result<String, anyhow::Error> {
|
|
let field_alias = if !field_alias.is_empty() {
|
|
field_alias
|
|
} else {
|
|
cond.column.as_str()
|
|
};
|
|
// Null/empty checks take no value. On non-string columns "empty" can only
|
|
// mean null, so the empty checks degrade to the null checks there.
|
|
let is_string_type = matches!(
|
|
field_type,
|
|
DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View
|
|
);
|
|
match cond.operator {
|
|
Operator::IsNull => return Ok(format!("\"{field_alias}\" IS NULL")),
|
|
Operator::IsNotNull => return Ok(format!("\"{field_alias}\" IS NOT NULL")),
|
|
Operator::IsEmpty => {
|
|
return Ok(if is_string_type {
|
|
format!("(\"{field_alias}\" IS NULL OR \"{field_alias}\" = '')")
|
|
} else {
|
|
format!("\"{field_alias}\" IS NULL")
|
|
});
|
|
}
|
|
Operator::IsNotEmpty => {
|
|
return Ok(if is_string_type {
|
|
format!("(\"{field_alias}\" IS NOT NULL AND \"{field_alias}\" != '')")
|
|
} else {
|
|
format!("\"{field_alias}\" IS NOT NULL")
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
let expr = match field_type {
|
|
DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => {
|
|
let val = if cond.value.is_string() {
|
|
cond.value.as_str().unwrap_or_default().to_string()
|
|
} else {
|
|
cond.value.to_string()
|
|
};
|
|
match cond.operator {
|
|
Operator::EqualTo => format!("\"{field_alias}\" = '{val}'"),
|
|
Operator::NotEqualTo => format!("\"{field_alias}\" != '{val}'"),
|
|
Operator::GreaterThan => format!("\"{field_alias}\" > '{val}'"),
|
|
Operator::GreaterThanEquals => {
|
|
format!("\"{field_alias}\" >= '{val}'")
|
|
}
|
|
Operator::LessThan => format!("\"{field_alias}\" < '{val}'"),
|
|
Operator::LessThanEquals => format!("\"{field_alias}\" <= '{val}'"),
|
|
Operator::Contains => format!("str_match(\"{field_alias}\", '{val}')"),
|
|
Operator::NotContains => {
|
|
format!("\"{field_alias}\" NOT LIKE '%{val}%'")
|
|
}
|
|
// Null checks returned above; keep the dead arm panic-free.
|
|
_ => {
|
|
return Err(anyhow::anyhow!(
|
|
"Column {} has data_type [{field_type}] and it does not supported operator [{:?}]",
|
|
cond.column,
|
|
cond.operator
|
|
));
|
|
}
|
|
}
|
|
}
|
|
DataType::Int16 | DataType::Int32 | DataType::Int64 => {
|
|
let val = if cond.value.is_number() {
|
|
// `as_i64` returns None for a fractional number AND for an
|
|
// integral one that was serialized as a float (`1.0`). Falling
|
|
// back to the f64 keeps the comparison the user asked for
|
|
// instead of silently defaulting the threshold to 0; DataFusion
|
|
// coerces Int64 against a float literal. Display of an integral
|
|
// f64 drops the `.0`, so `1.0` still renders as `1`.
|
|
match cond.value.as_i64() {
|
|
Some(v) => v.to_string(),
|
|
None => cond
|
|
.value
|
|
.as_f64()
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"Column [{}] dataType is [{field_type}] but value is [{}]",
|
|
cond.column,
|
|
cond.value,
|
|
)
|
|
})?
|
|
.to_string(),
|
|
}
|
|
} else {
|
|
cond.value
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.parse::<i64>()
|
|
.map_err(|e| {
|
|
anyhow::anyhow!(
|
|
"Column [{}] dataType is [{field_type}] but value is [{}], err: {e}",
|
|
cond.column,
|
|
cond.value,
|
|
)
|
|
})?
|
|
.to_string()
|
|
};
|
|
match cond.operator {
|
|
Operator::EqualTo => format!("\"{field_alias}\" = {val}"),
|
|
Operator::NotEqualTo => format!("\"{field_alias}\" != {val}"),
|
|
Operator::GreaterThan => format!("\"{field_alias}\" > {val}"),
|
|
Operator::GreaterThanEquals => {
|
|
format!("\"{field_alias}\" >= {val}")
|
|
}
|
|
Operator::LessThan => format!("\"{field_alias}\" < {val}"),
|
|
Operator::LessThanEquals => {
|
|
format!("\"{field_alias}\" <= {val}")
|
|
}
|
|
_ => {
|
|
return Err(anyhow::anyhow!(
|
|
"Column {} has data_type [{field_type}] and it does not supported operator [{:?}]",
|
|
cond.column,
|
|
cond.operator
|
|
));
|
|
}
|
|
}
|
|
}
|
|
DataType::Float32 | DataType::Float64 => {
|
|
let val = if cond.value.is_number() {
|
|
cond.value.as_f64().unwrap_or_default()
|
|
} else {
|
|
cond.value
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.parse()
|
|
.map_err(|e| {
|
|
anyhow::anyhow!(
|
|
"Column [{}] dataType is [{field_type}] but value is [{}], err: {e}",
|
|
cond.column,
|
|
cond.value,
|
|
)
|
|
})?
|
|
};
|
|
match cond.operator {
|
|
Operator::EqualTo => format!("\"{field_alias}\" = {val}"),
|
|
Operator::NotEqualTo => format!("\"{field_alias}\" != {val}"),
|
|
Operator::GreaterThan => format!("\"{field_alias}\" > {val}"),
|
|
Operator::GreaterThanEquals => {
|
|
format!("\"{field_alias}\" >= {val}")
|
|
}
|
|
Operator::LessThan => format!("\"{field_alias}\" < {val}"),
|
|
Operator::LessThanEquals => {
|
|
format!("\"{field_alias}\" <= {val}")
|
|
}
|
|
_ => {
|
|
return Err(anyhow::anyhow!(
|
|
"Column {} has data_type [{field_type}] and it does not supported operator [{:?}]",
|
|
cond.column,
|
|
cond.operator
|
|
));
|
|
}
|
|
}
|
|
}
|
|
DataType::Boolean => {
|
|
let val = if cond.value.is_boolean() {
|
|
cond.value.as_bool().unwrap_or_default()
|
|
} else {
|
|
cond.value
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.parse()
|
|
.map_err(|e| {
|
|
anyhow::anyhow!(
|
|
"Column [{}] dataType is [{field_type}] but value is [{}], err: {e}",
|
|
cond.column,
|
|
cond.value,
|
|
)
|
|
})?
|
|
};
|
|
match cond.operator {
|
|
Operator::EqualTo => format!("\"{field_alias}\" = {val}"),
|
|
Operator::NotEqualTo => format!("\"{field_alias}\" != {val}"),
|
|
_ => {
|
|
return Err(anyhow::anyhow!(
|
|
"Column {} has data_type [{field_type}] and it does not supported operator [{:?}]",
|
|
cond.column,
|
|
cond.operator
|
|
));
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
return Err(anyhow::anyhow!(
|
|
"Column {} has data_type [{field_type}] and it does not supported by alert, if you think this is a bug please report it to us",
|
|
cond.column,
|
|
));
|
|
}
|
|
};
|
|
Ok(expr)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use arrow_schema::{DataType, Field, Schema};
|
|
use config::{
|
|
meta::alerts::{
|
|
Aggregation, ConditionGroup, ConditionItem, ConditionItemCondition, LogicalOperator,
|
|
Operator,
|
|
},
|
|
utils::json::Value,
|
|
};
|
|
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_group_to_sql_simple() {
|
|
// Create a simple schema
|
|
let schema = Schema::new(vec![
|
|
Field::new("level", DataType::Utf8, false),
|
|
Field::new("service", DataType::Utf8, false),
|
|
]);
|
|
|
|
// Create a simple condition group: level = 'error' AND service = 'api'
|
|
// Remember: the logicalOperator on an item comes BEFORE that item
|
|
let condition_group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "level".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("error".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, // Not used (first item)
|
|
}),
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "service".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("api".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, // AND before this item
|
|
}),
|
|
],
|
|
};
|
|
|
|
let sql = condition_group.to_sql(&schema).await.unwrap();
|
|
|
|
// Should produce: ("level" = 'error' AND "service" = 'api')
|
|
assert_eq!(sql, "(\"level\" = 'error' AND \"service\" = 'api')");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_group_to_sql_with_or() {
|
|
let schema = Schema::new(vec![
|
|
Field::new("level", DataType::Utf8, false),
|
|
Field::new("status", DataType::Utf8, false),
|
|
]);
|
|
|
|
// Create condition group: level = 'error' OR status = 'critical'
|
|
let condition_group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "level".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("error".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, // Not used (first item)
|
|
}),
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "status".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("critical".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::Or, // OR before this item
|
|
}),
|
|
],
|
|
};
|
|
|
|
let sql = condition_group.to_sql(&schema).await.unwrap();
|
|
|
|
// Should produce: ("level" = 'error' OR "status" = 'critical')
|
|
assert_eq!(sql, "(\"level\" = 'error' OR \"status\" = 'critical')");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_group_to_sql_nested_groups() {
|
|
let schema = Schema::new(vec![
|
|
Field::new("level", DataType::Utf8, false),
|
|
Field::new("service", DataType::Utf8, false),
|
|
Field::new("status", DataType::Utf8, false),
|
|
]);
|
|
|
|
// Create nested condition group:
|
|
// level = 'error' AND (service = 'api' OR service = 'web')
|
|
let condition_group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "level".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("error".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, // Not used (first item)
|
|
}),
|
|
ConditionItem::Group {
|
|
logical_operator: LogicalOperator::And, // AND before this group
|
|
conditions: vec![
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "service".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("api".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, /* Not used (first item in
|
|
* group) */
|
|
}),
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "service".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("web".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::Or, // OR before this item
|
|
}),
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
let sql = condition_group.to_sql(&schema).await.unwrap();
|
|
|
|
// Exact SQL match for nested group:
|
|
// level = 'error' AND (service = 'api' OR service = 'web')
|
|
assert_eq!(
|
|
sql,
|
|
"(\"level\" = 'error' AND (\"service\" = 'api' OR \"service\" = 'web'))"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_group_to_sql_numeric_conditions() {
|
|
let schema = Schema::new(vec![
|
|
Field::new("count", DataType::Int64, false),
|
|
Field::new("temperature", DataType::Float64, false),
|
|
]);
|
|
|
|
// Create condition group: count > 100 AND temperature >= 50.5
|
|
let condition_group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "count".to_string(),
|
|
operator: Operator::GreaterThan,
|
|
value: Value::Number(serde_json::Number::from(100)),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, // Not used (first item)
|
|
}),
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "temperature".to_string(),
|
|
operator: Operator::GreaterThanEquals,
|
|
value: Value::Number(serde_json::Number::from_f64(50.5).unwrap()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, // AND before this item
|
|
}),
|
|
],
|
|
};
|
|
|
|
let sql = condition_group.to_sql(&schema).await.unwrap();
|
|
|
|
// Should produce: ("count" > 100 AND "temperature" >= 50.5)
|
|
assert_eq!(sql, "(\"count\" > 100 AND \"temperature\" >= 50.5)");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_group_to_sql_contains_operator() {
|
|
let schema = Schema::new(vec![Field::new("message", DataType::Utf8, false)]);
|
|
|
|
// Create condition group with Contains operator
|
|
let condition_group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![ConditionItem::Condition(ConditionItemCondition {
|
|
column: "message".to_string(),
|
|
operator: Operator::Contains,
|
|
value: Value::String("error".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And,
|
|
})],
|
|
};
|
|
|
|
let sql = condition_group.to_sql(&schema).await.unwrap();
|
|
|
|
// Contains should generate str_match function
|
|
assert_eq!(sql, "(str_match(\"message\", 'error'))");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_group_to_sql_empty_conditions() {
|
|
let schema = Schema::new(Vec::<Field>::new());
|
|
|
|
// Empty condition group should return empty string
|
|
let condition_group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![],
|
|
};
|
|
|
|
let sql = condition_group.to_sql(&schema).await.unwrap();
|
|
assert_eq!(sql, "");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_group_to_sql_single_condition() {
|
|
let schema = Schema::new(vec![Field::new("level", DataType::Utf8, false)]);
|
|
|
|
// Single condition should be wrapped in parentheses
|
|
let condition_group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![ConditionItem::Condition(ConditionItemCondition {
|
|
column: "level".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("error".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And,
|
|
})],
|
|
};
|
|
|
|
let sql = condition_group.to_sql(&schema).await.unwrap();
|
|
|
|
assert_eq!(sql, "(\"level\" = 'error')");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_group_to_sql_mixed_and_or_same_level() {
|
|
let schema = Schema::new(vec![
|
|
Field::new("level", DataType::Utf8, false),
|
|
Field::new("status", DataType::Utf8, false),
|
|
Field::new("service", DataType::Utf8, false),
|
|
]);
|
|
|
|
// Create condition group with mixed operators at same level:
|
|
// level = 'error' AND status = 'active' OR service = 'api'
|
|
// This tests left-to-right evaluation: (level = 'error' AND status = 'active') OR service =
|
|
// 'api'
|
|
let condition_group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "level".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("error".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, // Not used (first item)
|
|
}),
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "status".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("active".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, // AND before this item
|
|
}),
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "service".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("api".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::Or, // OR before this item
|
|
}),
|
|
],
|
|
};
|
|
|
|
let sql = condition_group.to_sql(&schema).await.unwrap();
|
|
|
|
// Verify evaluation with operator precedence (AND before OR)
|
|
// level = 'error' AND status = 'active' OR service = 'api'
|
|
// SQL operator precedence will parse this as: (level = 'error' AND status = 'active') OR
|
|
// service = 'api'
|
|
assert_eq!(
|
|
sql,
|
|
"(\"level\" = 'error' AND \"status\" = 'active' OR \"service\" = 'api')"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_group_to_sql_missing_column() {
|
|
let schema = Schema::new(vec![Field::new("level", DataType::Utf8, false)]);
|
|
|
|
// Reference non-existent column
|
|
let condition_group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![ConditionItem::Condition(ConditionItemCondition {
|
|
column: "nonexistent".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("error".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And,
|
|
})],
|
|
};
|
|
|
|
let result = condition_group.to_sql(&schema).await;
|
|
|
|
assert!(result.is_err());
|
|
assert!(
|
|
result
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("Column nonexistent not found")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_group_evaluate_complex() {
|
|
use config::utils::json::json;
|
|
|
|
use crate::alerts::ConditionGroupExt;
|
|
|
|
// Test the condition: kubernetes_docker_id = 'test' OR (kubernetes_container_image = 'test'
|
|
// AND kubernetes_host = 'test2') With proper Group logic structure
|
|
let condition_group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "kubernetes_docker_id".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("test".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, // Ignored because next is Group
|
|
}),
|
|
ConditionItem::Group {
|
|
logical_operator: LogicalOperator::Or, // OR before this group
|
|
conditions: vec![
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "kubernetes_container_image".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("test".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And,
|
|
}),
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "kubernetes_host".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("test2".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And,
|
|
}),
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
// Test case 1: Data that should NOT pass (none of the conditions match)
|
|
let test_data_fail = json!({
|
|
"kubernetes_docker_id": "tes123t",
|
|
"kubernetes_container_image": "test1234",
|
|
"kubernetes_host": "test2",
|
|
"log": "request id : camelcase"
|
|
});
|
|
let result = condition_group
|
|
.evaluate(test_data_fail.as_object().unwrap())
|
|
.await;
|
|
assert!(
|
|
!result,
|
|
"Should NOT pass: kubernetes_docker_id doesn't match, and kubernetes_container_image doesn't match"
|
|
);
|
|
|
|
// Test case 2: Data that should pass (first condition matches)
|
|
let test_data_pass1 = json!({
|
|
"kubernetes_docker_id": "test",
|
|
"kubernetes_container_image": "anything",
|
|
"kubernetes_host": "anything"
|
|
});
|
|
let result = condition_group
|
|
.evaluate(test_data_pass1.as_object().unwrap())
|
|
.await;
|
|
assert!(
|
|
result,
|
|
"Should pass: first condition matches (kubernetes_docker_id = 'test')"
|
|
);
|
|
|
|
// Test case 3: Data that should pass (nested group matches)
|
|
let test_data_pass2 = json!({
|
|
"kubernetes_docker_id": "something_else",
|
|
"kubernetes_container_image": "test",
|
|
"kubernetes_host": "test2"
|
|
});
|
|
let result = condition_group
|
|
.evaluate(test_data_pass2.as_object().unwrap())
|
|
.await;
|
|
assert!(
|
|
result,
|
|
"Should pass: nested group matches (kubernetes_container_image = 'test' AND kubernetes_host = 'test2')"
|
|
);
|
|
|
|
// Test case 4: Data that should NOT pass (only one condition in nested group matches)
|
|
let test_data_fail2 = json!({
|
|
"kubernetes_docker_id": "something_else",
|
|
"kubernetes_container_image": "test",
|
|
"kubernetes_host": "wrong_host"
|
|
});
|
|
let result = condition_group
|
|
.evaluate(test_data_fail2.as_object().unwrap())
|
|
.await;
|
|
assert!(
|
|
!result,
|
|
"Should NOT pass: only kubernetes_container_image matches, but kubernetes_host doesn't"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_group_evaluate_with_nested_group() {
|
|
use config::utils::json::json;
|
|
|
|
use crate::alerts::ConditionGroupExt;
|
|
|
|
// Test evaluation with nested group: kubernetes_docker_id = 'test' OR
|
|
// (kubernetes_container_image = 'test' AND kubernetes_host = 'test2') Structure:
|
|
// - kubernetes_docker_id = 'test' [OR with next]
|
|
// - A group containing: (kubernetes_container_image = 'test' AND kubernetes_host = 'test2')
|
|
|
|
let condition_group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "kubernetes_docker_id".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("test".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::Or, // OR with next item
|
|
}),
|
|
ConditionItem::Group {
|
|
logical_operator: LogicalOperator::And, // AND inside the group
|
|
conditions: vec![
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "kubernetes_container_image".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("test".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, // AND with next in group
|
|
}),
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "kubernetes_host".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("test2".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And,
|
|
}),
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
// Test with the data from the conversation
|
|
let test_data = json!({
|
|
"kubernetes_docker_id": "tes123t",
|
|
"kubernetes_container_image": "test1234",
|
|
"kubernetes_host": "test2"
|
|
});
|
|
|
|
let result = condition_group
|
|
.evaluate(test_data.as_object().unwrap())
|
|
.await;
|
|
|
|
// Evaluation: kubernetes_docker_id = 'test' OR (kubernetes_container_image = 'test' AND
|
|
// kubernetes_host = 'test2') = FALSE OR (FALSE AND TRUE)
|
|
// = FALSE OR FALSE
|
|
// = FALSE
|
|
assert!(
|
|
!result,
|
|
"Should NOT PASS: kubernetes_docker_id doesn't match, and in the group only kubernetes_host matches (need both)"
|
|
);
|
|
|
|
println!("Test data: {:?}", test_data);
|
|
println!("Evaluation result: {} (should be false)", result);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_group_to_sql_complex_with_nested_group() {
|
|
use arrow_schema::{DataType, Field, Schema};
|
|
|
|
use crate::alerts::ConditionGroupExt;
|
|
|
|
let schema = Schema::new(vec![
|
|
Field::new("kubernetes_docker_id", DataType::Utf8, false),
|
|
Field::new("kubernetes_container_image", DataType::Utf8, false),
|
|
Field::new("kubernetes_host", DataType::Utf8, false),
|
|
]);
|
|
|
|
// Test SQL generation with nested group and mixed operators
|
|
let condition_group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "kubernetes_docker_id".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("test".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And,
|
|
}),
|
|
ConditionItem::Group {
|
|
logical_operator: LogicalOperator::Or,
|
|
conditions: vec![ConditionItem::Condition(ConditionItemCondition {
|
|
column: "kubernetes_container_image".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("test".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And,
|
|
})],
|
|
},
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "kubernetes_host".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("test2".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And,
|
|
}),
|
|
],
|
|
};
|
|
|
|
let sql = condition_group.to_sql(&schema).await.unwrap();
|
|
println!("Generated SQL: {}", sql);
|
|
|
|
// Verify the generated SQL has correct operator placement
|
|
assert!(sql.contains("OR") && sql.contains("AND"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_group_evaluate_operator_precedence() {
|
|
use config::utils::json::json;
|
|
|
|
use crate::alerts::ConditionGroupExt;
|
|
|
|
// Test operator precedence: A OR (B) AND C should evaluate as A OR ((B) AND C)
|
|
// Structure: kubernetes_docker_id = 'test' OR (kubernetes_container_image = 'test') AND
|
|
// kubernetes_host = 'test2'
|
|
let condition_group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "kubernetes_docker_id".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("test".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And,
|
|
}),
|
|
ConditionItem::Group {
|
|
logical_operator: LogicalOperator::Or,
|
|
conditions: vec![ConditionItem::Condition(ConditionItemCondition {
|
|
column: "kubernetes_container_image".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("test".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And,
|
|
})],
|
|
},
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "kubernetes_host".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("test2".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And,
|
|
}),
|
|
],
|
|
};
|
|
|
|
// Test with the data from the conversation - should NOT pass
|
|
let test_data_fail = json!({
|
|
"kubernetes_docker_id": "tes123t",
|
|
"kubernetes_container_image": "test1234",
|
|
"kubernetes_host": "test2"
|
|
});
|
|
|
|
let result = condition_group
|
|
.evaluate(test_data_fail.as_object().unwrap())
|
|
.await;
|
|
|
|
// With the fix:
|
|
// - Between Item0 and Group: use Group's operator = OR
|
|
// - kubernetes_docker_id = 'test' -> false
|
|
// - Group (kubernetes_container_image = 'test') -> false
|
|
// - result = false OR false = false
|
|
//
|
|
// Wait, we still need to process Item2 (kubernetes_host)...
|
|
// Let me trace through:
|
|
// - i=0: result = kubernetes_docker_id = 'test' = false next = Group =
|
|
// kubernetes_container_image = 'test' = false operator = Group's OR result = false OR
|
|
// false = false
|
|
// - i=1: result = false (from above) next = kubernetes_host = 'test2' = true operator =
|
|
// Group.conditions[0].logicalOperator = AND result = false AND true = false
|
|
//
|
|
// Hmm, this gives false, but we need to think about what the Group's internal condition's
|
|
// operator means...
|
|
|
|
println!("Test data: {:?}", test_data_fail);
|
|
println!("Evaluation result: {} (should be false)", result);
|
|
|
|
assert!(!result, "Should NOT PASS: neither condition matches");
|
|
|
|
// Test case where kubernetes_docker_id matches (should PASS with OR precedence)
|
|
let test_data_docker_id_matches = json!({
|
|
"kubernetes_docker_id": "test",
|
|
"kubernetes_container_image": "wrong",
|
|
"kubernetes_host": "wrong"
|
|
});
|
|
|
|
let result2 = condition_group
|
|
.evaluate(test_data_docker_id_matches.as_object().unwrap())
|
|
.await;
|
|
|
|
println!("\nTest with docker_id matching:");
|
|
println!("Test data: {:?}", test_data_docker_id_matches);
|
|
println!("Evaluation result: {} (should be true)", result2);
|
|
|
|
// SQL: (kubernetes_docker_id = 'test' OR (kubernetes_container_image = 'test' AND
|
|
// kubernetes_host = 'test2')) = (TRUE OR (FALSE AND FALSE)) = TRUE
|
|
assert!(result2, "Should PASS when kubernetes_docker_id matches");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_deeply_nested_groups_with_precedence() {
|
|
use config::utils::json::json;
|
|
|
|
use crate::alerts::ConditionGroupExt;
|
|
|
|
// Complex nested structure: A OR (B AND C OR (D AND E)) AND F
|
|
// This tests: nested groups + operator precedence at multiple levels
|
|
let condition_group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "A".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("match".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, // Not used (first)
|
|
}),
|
|
ConditionItem::Group {
|
|
logical_operator: LogicalOperator::Or, // OR before this group
|
|
conditions: vec![
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "B".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("match".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, // Not used (first in group)
|
|
}),
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "C".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("match".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, // AND before this
|
|
}),
|
|
ConditionItem::Group {
|
|
logical_operator: LogicalOperator::Or, // OR before this nested group
|
|
conditions: vec![
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "D".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("match".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And,
|
|
}),
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "E".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("match".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, // AND before this
|
|
}),
|
|
],
|
|
},
|
|
],
|
|
},
|
|
ConditionItem::Condition(ConditionItemCondition {
|
|
column: "F".to_string(),
|
|
operator: Operator::EqualTo,
|
|
value: Value::String("match".to_string()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And, // AND before this
|
|
}),
|
|
],
|
|
};
|
|
|
|
// Test case 1: Only A matches
|
|
// A OR (B AND C OR (D AND E)) AND F
|
|
// = TRUE OR (FALSE AND FALSE OR (FALSE AND FALSE)) AND FALSE
|
|
// = TRUE OR (FALSE OR FALSE) AND FALSE
|
|
// = TRUE OR FALSE AND FALSE
|
|
// With precedence: TRUE OR (FALSE AND FALSE)
|
|
// = TRUE OR FALSE = TRUE ✓
|
|
let test1 = json!({"A": "match", "B": "no", "C": "no", "D": "no", "E": "no", "F": "no"});
|
|
assert!(
|
|
condition_group.evaluate(test1.as_object().unwrap()).await,
|
|
"Should PASS: A matches, and there's OR before the group"
|
|
);
|
|
|
|
// Test case 2: D and E match (inner nested group), and F matches
|
|
// A OR (B AND C OR (D AND E)) AND F
|
|
// = FALSE OR (FALSE AND FALSE OR (TRUE AND TRUE)) AND TRUE
|
|
// = FALSE OR (FALSE OR TRUE) AND TRUE
|
|
// = FALSE OR TRUE AND TRUE
|
|
// With precedence: FALSE OR (TRUE AND TRUE)
|
|
// = FALSE OR TRUE = TRUE ✓
|
|
let test2 =
|
|
json!({"A": "no", "B": "no", "C": "no", "D": "match", "E": "match", "F": "match"});
|
|
assert!(
|
|
condition_group.evaluate(test2.as_object().unwrap()).await,
|
|
"Should PASS: Inner nested group (D AND E) matches, plus F matches"
|
|
);
|
|
|
|
// Test case 3: B and C match, and F matches
|
|
// A OR (B AND C OR (D AND E)) AND F
|
|
// = FALSE OR (TRUE AND TRUE OR (FALSE AND FALSE)) AND TRUE
|
|
// = FALSE OR (TRUE OR FALSE) AND TRUE
|
|
// = FALSE OR TRUE AND TRUE
|
|
// With precedence: FALSE OR (TRUE AND TRUE)
|
|
// = FALSE OR TRUE = TRUE ✓
|
|
let test3 =
|
|
json!({"A": "no", "B": "match", "C": "match", "D": "no", "E": "no", "F": "match"});
|
|
assert!(
|
|
condition_group.evaluate(test3.as_object().unwrap()).await,
|
|
"Should PASS: B AND C match, plus F matches"
|
|
);
|
|
|
|
// Test case 4: Only F matches (should fail)
|
|
// A OR (B AND C OR (D AND E)) AND F
|
|
// = FALSE OR (FALSE AND FALSE OR (FALSE AND FALSE)) AND TRUE
|
|
// = FALSE OR (FALSE OR FALSE) AND TRUE
|
|
// = FALSE OR FALSE AND TRUE
|
|
// With precedence: FALSE OR (FALSE AND TRUE)
|
|
// = FALSE OR FALSE = FALSE ✓
|
|
let test4 = json!({"A": "no", "B": "no", "C": "no", "D": "no", "E": "no", "F": "match"});
|
|
assert!(
|
|
!condition_group.evaluate(test4.as_object().unwrap()).await,
|
|
"Should FAIL: Only F matches, but the OR part fails"
|
|
);
|
|
|
|
println!("✓ All deeply nested group tests with operator precedence passed!");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_evaluate_null_operators() {
|
|
use config::utils::json::json;
|
|
|
|
let is_null = make_cond("err", Operator::IsNull, Value::String(String::new()));
|
|
let is_not_null = make_cond("err", Operator::IsNotNull, Value::String(String::new()));
|
|
|
|
let missing = json!({"other": 1});
|
|
let null_val = json!({"err": null});
|
|
let present = json!({"err": "boom"});
|
|
|
|
assert!(is_null.evaluate(missing.as_object().unwrap()).await);
|
|
assert!(is_null.evaluate(null_val.as_object().unwrap()).await);
|
|
assert!(!is_null.evaluate(present.as_object().unwrap()).await);
|
|
|
|
assert!(!is_not_null.evaluate(missing.as_object().unwrap()).await);
|
|
assert!(!is_not_null.evaluate(null_val.as_object().unwrap()).await);
|
|
assert!(is_not_null.evaluate(present.as_object().unwrap()).await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_evaluate_empty_operators() {
|
|
use config::utils::json::json;
|
|
|
|
let is_empty = make_cond("input", Operator::IsEmpty, Value::String(String::new()));
|
|
let is_not_empty = make_cond("input", Operator::IsNotEmpty, Value::String(String::new()));
|
|
|
|
let missing = json!({"other": 1});
|
|
let null_val = json!({"input": null});
|
|
let empty_str = json!({"input": ""});
|
|
let present = json!({"input": "hello"});
|
|
let number = json!({"input": 0});
|
|
|
|
assert!(is_empty.evaluate(missing.as_object().unwrap()).await);
|
|
assert!(is_empty.evaluate(null_val.as_object().unwrap()).await);
|
|
assert!(is_empty.evaluate(empty_str.as_object().unwrap()).await);
|
|
assert!(!is_empty.evaluate(present.as_object().unwrap()).await);
|
|
assert!(!is_empty.evaluate(number.as_object().unwrap()).await);
|
|
|
|
assert!(!is_not_empty.evaluate(missing.as_object().unwrap()).await);
|
|
assert!(!is_not_empty.evaluate(null_val.as_object().unwrap()).await);
|
|
assert!(!is_not_empty.evaluate(empty_str.as_object().unwrap()).await);
|
|
assert!(is_not_empty.evaluate(present.as_object().unwrap()).await);
|
|
assert!(is_not_empty.evaluate(number.as_object().unwrap()).await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_group_empty_operators() {
|
|
use config::utils::json::json;
|
|
|
|
let schema = Schema::new(vec![Field::new("input", DataType::Utf8, true)]);
|
|
let group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![ConditionItem::Condition(ConditionItemCondition {
|
|
column: "input".to_string(),
|
|
operator: Operator::IsEmpty,
|
|
value: Value::String(String::new()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And,
|
|
})],
|
|
};
|
|
|
|
assert_eq!(
|
|
group.to_sql(&schema).await.unwrap(),
|
|
"((\"input\" IS NULL OR \"input\" = ''))"
|
|
);
|
|
let empty_str = json!({"input": ""});
|
|
assert!(group.evaluate(empty_str.as_object().unwrap()).await);
|
|
let present = json!({"input": "x"});
|
|
assert!(!group.evaluate(present.as_object().unwrap()).await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_condition_group_null_operators() {
|
|
use config::utils::json::json;
|
|
|
|
let schema = Schema::new(vec![Field::new("err", DataType::Utf8, true)]);
|
|
let group = ConditionGroup {
|
|
filter_type: "group".to_string(),
|
|
logical_operator: LogicalOperator::And,
|
|
conditions: vec![ConditionItem::Condition(ConditionItemCondition {
|
|
column: "err".to_string(),
|
|
operator: Operator::IsNull,
|
|
value: Value::String(String::new()),
|
|
ignore_case: None,
|
|
logical_operator: LogicalOperator::And,
|
|
})],
|
|
};
|
|
|
|
assert_eq!(group.to_sql(&schema).await.unwrap(), "(\"err\" IS NULL)");
|
|
let empty = json!({});
|
|
assert!(group.evaluate(empty.as_object().unwrap()).await);
|
|
let present = json!({"err": "x"});
|
|
assert!(!group.evaluate(present.as_object().unwrap()).await);
|
|
}
|
|
|
|
// ── build_expr sync unit tests ───────────────────────────────────────────
|
|
|
|
fn make_cond(column: &str, operator: Operator, value: Value) -> Condition {
|
|
Condition {
|
|
column: column.to_string(),
|
|
operator,
|
|
value,
|
|
ignore_case: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_bool_equal_to() {
|
|
let cond = make_cond("active", Operator::EqualTo, Value::Bool(true));
|
|
let expr = build_expr(&cond, "", &DataType::Boolean).unwrap();
|
|
assert_eq!(expr, "\"active\" = true");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_bool_not_equal_to() {
|
|
let cond = make_cond("active", Operator::NotEqualTo, Value::Bool(false));
|
|
let expr = build_expr(&cond, "", &DataType::Boolean).unwrap();
|
|
assert_eq!(expr, "\"active\" != false");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_bool_bool_as_string_value() {
|
|
let cond = make_cond(
|
|
"active",
|
|
Operator::EqualTo,
|
|
Value::String("true".to_string()),
|
|
);
|
|
let expr = build_expr(&cond, "", &DataType::Boolean).unwrap();
|
|
assert_eq!(expr, "\"active\" = true");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_bool_unsupported_operator_returns_error() {
|
|
let cond = make_cond("active", Operator::Contains, Value::Bool(true));
|
|
let result = build_expr(&cond, "", &DataType::Boolean);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_int_contains_returns_error() {
|
|
let cond = make_cond("count", Operator::Contains, Value::Number(1.into()));
|
|
let result = build_expr(&cond, "", &DataType::Int64);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_float_contains_returns_error() {
|
|
let cond = make_cond("score", Operator::Contains, Value::Number(1.into()));
|
|
let result = build_expr(&cond, "", &DataType::Float64);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_unsupported_datatype_returns_error() {
|
|
let cond = make_cond("ts", Operator::EqualTo, Value::String("x".to_string()));
|
|
let result = build_expr(&cond, "", &DataType::Date32);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_field_alias_override() {
|
|
let cond = make_cond(
|
|
"level",
|
|
Operator::EqualTo,
|
|
Value::String("error".to_string()),
|
|
);
|
|
let expr = build_expr(&cond, "log_level", &DataType::Utf8).unwrap();
|
|
assert_eq!(expr, "\"log_level\" = 'error'");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_string_not_contains() {
|
|
let cond = make_cond(
|
|
"msg",
|
|
Operator::NotContains,
|
|
Value::String("spam".to_string()),
|
|
);
|
|
let expr = build_expr(&cond, "", &DataType::Utf8).unwrap();
|
|
assert_eq!(expr, "\"msg\" NOT LIKE '%spam%'");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_null_operators_any_type() {
|
|
let cond = make_cond("msg", Operator::IsNull, Value::String(String::new()));
|
|
let expr = build_expr(&cond, "", &DataType::Utf8).unwrap();
|
|
assert_eq!(expr, "\"msg\" IS NULL");
|
|
|
|
let cond = make_cond("code", Operator::IsNull, Value::String(String::new()));
|
|
let expr = build_expr(&cond, "", &DataType::Int64).unwrap();
|
|
assert_eq!(expr, "\"code\" IS NULL");
|
|
|
|
let cond = make_cond("msg", Operator::IsNotNull, Value::String(String::new()));
|
|
let expr = build_expr(&cond, "", &DataType::Utf8).unwrap();
|
|
assert_eq!(expr, "\"msg\" IS NOT NULL");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_utf8view_value_comparisons() {
|
|
let cond = make_cond(
|
|
"level",
|
|
Operator::EqualTo,
|
|
Value::String("error".to_string()),
|
|
);
|
|
let expr = build_expr(&cond, "", &DataType::Utf8View).unwrap();
|
|
assert_eq!(expr, "\"level\" = 'error'");
|
|
|
|
let cond = make_cond("msg", Operator::Contains, Value::String("oom".to_string()));
|
|
let expr = build_expr(&cond, "", &DataType::Utf8View).unwrap();
|
|
assert_eq!(expr, "str_match(\"msg\", 'oom')");
|
|
|
|
let cond = make_cond(
|
|
"msg",
|
|
Operator::NotContains,
|
|
Value::String("spam".to_string()),
|
|
);
|
|
let expr = build_expr(&cond, "", &DataType::Utf8View).unwrap();
|
|
assert_eq!(expr, "\"msg\" NOT LIKE '%spam%'");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_empty_operators() {
|
|
let cond = make_cond("msg", Operator::IsEmpty, Value::String(String::new()));
|
|
let expr = build_expr(&cond, "", &DataType::Utf8).unwrap();
|
|
assert_eq!(expr, "(\"msg\" IS NULL OR \"msg\" = '')");
|
|
|
|
let cond = make_cond("msg", Operator::IsNotEmpty, Value::String(String::new()));
|
|
let expr = build_expr(&cond, "", &DataType::Utf8).unwrap();
|
|
assert_eq!(expr, "(\"msg\" IS NOT NULL AND \"msg\" != '')");
|
|
|
|
// Utf8View is a string type too — it must get the empty-string check.
|
|
let cond = make_cond("msg", Operator::IsEmpty, Value::String(String::new()));
|
|
let expr = build_expr(&cond, "", &DataType::Utf8View).unwrap();
|
|
assert_eq!(expr, "(\"msg\" IS NULL OR \"msg\" = '')");
|
|
|
|
let cond = make_cond("msg", Operator::IsNotEmpty, Value::String(String::new()));
|
|
let expr = build_expr(&cond, "", &DataType::Utf8View).unwrap();
|
|
assert_eq!(expr, "(\"msg\" IS NOT NULL AND \"msg\" != '')");
|
|
|
|
// Non-string columns can only be "empty" by being null.
|
|
let cond = make_cond("code", Operator::IsEmpty, Value::String(String::new()));
|
|
let expr = build_expr(&cond, "", &DataType::Int64).unwrap();
|
|
assert_eq!(expr, "\"code\" IS NULL");
|
|
|
|
let cond = make_cond("code", Operator::IsNotEmpty, Value::String(String::new()));
|
|
let expr = build_expr(&cond, "", &DataType::Int64).unwrap();
|
|
assert_eq!(expr, "\"code\" IS NOT NULL");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_int_greater_than() {
|
|
let cond = make_cond(
|
|
"code",
|
|
Operator::GreaterThan,
|
|
Value::Number(serde_json::Number::from(400)),
|
|
);
|
|
let expr = build_expr(&cond, "", &DataType::Int32).unwrap();
|
|
assert_eq!(expr, "\"code\" > 400");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_float_less_than_equal() {
|
|
let cond = make_cond(
|
|
"rate",
|
|
Operator::LessThanEquals,
|
|
Value::Number(serde_json::Number::from_f64(0.5).unwrap()),
|
|
);
|
|
let expr = build_expr(&cond, "", &DataType::Float32).unwrap();
|
|
assert_eq!(expr, "\"rate\" <= 0.5");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_int_invalid_string_returns_error() {
|
|
let cond = make_cond(
|
|
"n",
|
|
Operator::EqualTo,
|
|
Value::String("not_a_number".to_string()),
|
|
);
|
|
let result = build_expr(&cond, "", &DataType::Int64);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_float_invalid_string_returns_error() {
|
|
let cond = make_cond("f", Operator::EqualTo, Value::String("abc".to_string()));
|
|
let result = build_expr(&cond, "", &DataType::Float64);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_bool_invalid_string_returns_error() {
|
|
let cond = make_cond("b", Operator::EqualTo, Value::String("maybe".to_string()));
|
|
let result = build_expr(&cond, "", &DataType::Boolean);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
/// An integral threshold serialized as a float (`json!(1.0_f64)`, which the
|
|
/// widened HAVING value always is) must not fall through `as_i64() == None`
|
|
/// to a silent 0 — that replaces the user's threshold with "anything above
|
|
/// zero" and the alert fires on every evaluation.
|
|
#[test]
|
|
fn test_build_expr_int_float_valued_number_is_not_zeroed() {
|
|
let cond = make_cond(
|
|
"code",
|
|
Operator::GreaterThan,
|
|
Value::Number(serde_json::Number::from_f64(400.0).unwrap()),
|
|
);
|
|
let expr = build_expr(&cond, "", &DataType::Int64).unwrap();
|
|
assert_eq!(expr, "\"code\" > 400");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_expr_int_fractional_number_kept() {
|
|
let cond = make_cond(
|
|
"code",
|
|
Operator::LessThan,
|
|
Value::Number(serde_json::Number::from_f64(1.5).unwrap()),
|
|
);
|
|
let expr = build_expr(&cond, "", &DataType::Int64).unwrap();
|
|
assert_eq!(expr, "\"code\" < 1.5");
|
|
}
|
|
|
|
// ── HAVING clause type resolution ───────────────────────────────────────
|
|
|
|
fn make_agg(function: AggFunction, operator: Operator, value: Value) -> Aggregation {
|
|
Aggregation {
|
|
group_by: None,
|
|
function,
|
|
having: make_cond("host", operator, value),
|
|
warning_value: None,
|
|
multi_alert: false,
|
|
}
|
|
}
|
|
|
|
/// #13786: `COUNT` over a string column is Int64, so the threshold must be
|
|
/// an unquoted integer. Typing the comparison from the column emitted
|
|
/// `< '1.0'`, which DataFusion cannot cast to Int64 — the query failed with
|
|
/// "Cast error: Cannot cast string '1.0' to value of Int64 type".
|
|
#[test]
|
|
fn test_build_having_expr_count_over_string_column() {
|
|
let agg = make_agg(
|
|
AggFunction::Count,
|
|
Operator::LessThan,
|
|
Value::Number(serde_json::Number::from(1)),
|
|
);
|
|
let expr = build_having_expr(&agg, &DataType::Utf8).unwrap();
|
|
assert_eq!(expr, "\"alert_agg_value\" < 1");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_having_expr_count_over_int_column() {
|
|
let agg = make_agg(
|
|
AggFunction::Count,
|
|
Operator::GreaterThanEquals,
|
|
Value::Number(serde_json::Number::from(10)),
|
|
);
|
|
let expr = build_having_expr(&agg, &DataType::Int64).unwrap();
|
|
assert_eq!(expr, "\"alert_agg_value\" >= 10");
|
|
}
|
|
|
|
/// The threshold reaches the clause as an f64 no matter how it was stored,
|
|
/// so an integer column must still get its exact threshold and not 0.
|
|
#[test]
|
|
fn test_build_having_expr_avg_over_int_column_keeps_threshold() {
|
|
let agg = make_agg(
|
|
AggFunction::Avg,
|
|
Operator::GreaterThan,
|
|
Value::Number(serde_json::Number::from(100)),
|
|
);
|
|
let expr = build_having_expr(&agg, &DataType::Int64).unwrap();
|
|
assert_eq!(expr, "\"alert_agg_value\" > 100");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_having_expr_fractional_threshold() {
|
|
let agg = make_agg(
|
|
AggFunction::Avg,
|
|
Operator::GreaterThan,
|
|
Value::Number(serde_json::Number::from_f64(99.5).unwrap()),
|
|
);
|
|
let expr = build_having_expr(&agg, &DataType::Float64).unwrap();
|
|
assert_eq!(expr, "\"alert_agg_value\" > 99.5");
|
|
}
|
|
|
|
/// A numeric string threshold is a documented shape of `having.value`.
|
|
#[test]
|
|
fn test_build_having_expr_string_threshold() {
|
|
let agg = make_agg(
|
|
AggFunction::Count,
|
|
Operator::GreaterThan,
|
|
Value::String("5".to_string()),
|
|
);
|
|
let expr = build_having_expr(&agg, &DataType::Utf8).unwrap();
|
|
assert_eq!(expr, "\"alert_agg_value\" > 5");
|
|
}
|
|
|
|
/// With a warning band the clause widens to the LESS severe threshold, and
|
|
/// that widened value goes through the same typing.
|
|
#[test]
|
|
fn test_build_having_expr_widens_to_warning_threshold() {
|
|
let mut agg = make_agg(
|
|
AggFunction::Count,
|
|
Operator::GreaterThan,
|
|
Value::Number(serde_json::Number::from(100)),
|
|
);
|
|
agg.warning_value = Some(50.0);
|
|
let expr = build_having_expr(&agg, &DataType::Utf8).unwrap();
|
|
assert_eq!(expr, "\"alert_agg_value\" > 50");
|
|
}
|
|
|
|
#[test]
|
|
fn test_having_data_type_by_function() {
|
|
// COUNT is Int64 regardless of the column it counts.
|
|
assert_eq!(
|
|
having_data_type(&AggFunction::Count, &DataType::Utf8),
|
|
DataType::Int64
|
|
);
|
|
// Averages and percentiles are Float64.
|
|
for func in [
|
|
AggFunction::Avg,
|
|
AggFunction::Median,
|
|
AggFunction::P50,
|
|
AggFunction::P75,
|
|
AggFunction::P90,
|
|
AggFunction::P95,
|
|
AggFunction::P99,
|
|
] {
|
|
assert_eq!(
|
|
having_data_type(&func, &DataType::Int64),
|
|
DataType::Float64,
|
|
"{func} should compare as Float64"
|
|
);
|
|
}
|
|
// MIN/MAX/SUM return their input type.
|
|
for func in [AggFunction::Min, AggFunction::Max, AggFunction::Sum] {
|
|
assert_eq!(
|
|
having_data_type(&func, &DataType::Int64),
|
|
DataType::Int64,
|
|
"{func} should keep the column type"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
having_data_type(&AggFunction::Max, &DataType::Utf8),
|
|
DataType::Utf8
|
|
);
|
|
}
|
|
}
|