1use std::fmt::Write as _;
8
9use serde::{Deserialize, Serialize};
10
11use crate::ast::{
12 ArithOp, ChangeKind, CheckQuery, CmpOp, CompositeExpr, Condition, Filter, FuncKind, FuncParam,
13 MetricExpr, MetricQuery, MonitorQuery, ParamValue, SearchQuery, SearchSource, Series, SloQuery,
14 TimeAgg,
15};
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct QuerySummary {
24 pub headline: String,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub metric: Option<String>,
29 pub scope: Vec<KeyVal>,
31 pub group_by: Vec<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub window: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub evaluation: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub condition: Option<String>,
42 pub raw: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct KeyVal {
50 pub key: String,
52 pub value: String,
54 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
61 pub negated: bool,
62}
63
64#[must_use]
76pub fn explain(query: &MonitorQuery) -> String {
77 match query {
78 MonitorQuery::Metric(m) => explain_metric(m),
79 MonitorQuery::Search(s) => explain_search(s),
80 MonitorQuery::ServiceCheck(c) => explain_check(c),
81 MonitorQuery::Slo(s) => explain_slo(s),
82 MonitorQuery::Composite(c) => {
83 format!(
84 "Alerts when this boolean condition over other monitors holds: {}.",
85 render_composite(c)
86 )
87 }
88 MonitorQuery::Unparsed { reason, .. } => {
89 format!("Raw Datadog query (could not be parsed: {reason}).")
90 }
91 }
92}
93
94#[must_use]
99pub fn summarize(query: &MonitorQuery, raw: &str) -> QuerySummary {
100 let mut summary = QuerySummary {
101 headline: String::new(),
102 metric: None,
103 scope: Vec::new(),
104 group_by: Vec::new(),
105 window: None,
106 evaluation: None,
107 condition: None,
108 raw: raw.to_string(),
109 };
110 match query {
111 MonitorQuery::Metric(m) => summarize_metric(m, &mut summary),
112 MonitorQuery::Search(s) => summarize_search(s, &mut summary),
113 MonitorQuery::ServiceCheck(c) => {
114 summary.headline = format!("Service check `{}`", c.check);
115 summary.group_by = c.by.clone();
116 summary.condition = Some(format!("last {} runs by status", c.last));
117 }
118 MonitorQuery::Slo(s) => {
119 summary.headline = format!("SLO error budget `{}`", s.id);
120 summary.window = Some(s.over.clone());
121 summary.condition = Some(render_condition(&s.condition));
122 }
123 MonitorQuery::Composite(c) => {
124 summary.headline = "Composite monitor".to_string();
125 summary.condition = Some(render_composite(c));
126 }
127 MonitorQuery::Unparsed { .. } => {
128 summary.headline = "Raw Datadog query".to_string();
129 }
130 }
131 summary
132}
133
134fn explain_metric(m: &MetricQuery) -> String {
137 let mut out = String::new();
138 let _ = write!(
139 out,
140 "Alert if the {} over the {} of {} is {}.",
141 time_agg_word(m.time_aggregation),
142 m.window.display.trim_start_matches("last "),
143 describe_expr(&m.expr),
144 render_condition(&m.condition),
145 );
146 out
147}
148
149fn time_agg_word(agg: TimeAgg) -> &'static str {
150 match agg {
151 TimeAgg::Avg => "average",
152 TimeAgg::Sum => "sum",
153 TimeAgg::Min => "minimum",
154 TimeAgg::Max => "maximum",
155 TimeAgg::Count => "count",
156 }
157}
158
159fn summarize_metric(m: &MetricQuery, summary: &mut QuerySummary) {
160 if let Some(series) = first_series(&m.expr) {
161 summary.metric = Some(series.metric.clone());
162 summary.scope = series.filter.iter().filter_map(filter_to_keyval).collect();
163 summary.group_by = series.group_by.clone();
164 }
165 summary.window = Some(m.window.display.clone());
166 summary.evaluation = evaluation_phrase(&m.expr);
167 summary.condition = Some(render_condition(&m.condition));
168 summary.headline = metric_headline(&m.expr, summary.metric.as_deref());
169}
170
171fn metric_headline(expr: &MetricExpr, metric: Option<&str>) -> String {
172 let subject = metric.unwrap_or("metric");
173 match top_function(expr) {
174 Some(FuncKind::Anomalies) => format!("Anomaly detection on {subject}"),
175 Some(FuncKind::Outliers) => format!("Outlier detection on {subject}"),
176 Some(FuncKind::Forecast) => format!("Forecast alert on {subject}"),
177 None => match expr {
178 MetricExpr::Change { kind, .. } => format!("{} on {subject}", change_word(*kind, true)),
179 _ => format!("Threshold alert on {subject}"),
180 },
181 }
182}
183
184fn describe_expr(expr: &MetricExpr) -> String {
186 match expr {
187 MetricExpr::Series(s) => describe_series(s),
188 MetricExpr::Function { name, arg, params } => {
189 let inner = describe_expr(arg);
190 format!("{} ({inner})", function_phrase(*name, params))
191 }
192 MetricExpr::Transform { name, arg, .. } => {
193 format!("{} of {}", humanize_transform(name), describe_expr(arg))
194 }
195 MetricExpr::Arith { op, lhs, rhs } => {
196 format!(
197 "({} {} {})",
198 describe_expr(lhs),
199 arith_word(*op),
200 describe_expr(rhs)
201 )
202 }
203 MetricExpr::Change {
204 kind, shift, arg, ..
205 } => format!(
206 "{} over {} of {}",
207 change_word(*kind, false),
208 shift.display,
209 describe_expr(arg)
210 ),
211 MetricExpr::Scalar(s) => s.to_string(),
212 }
213}
214
215fn describe_series(s: &Series) -> String {
216 let mut text = format!("{} of `{}`", s.space_aggregation, s.metric);
217 let scope: Vec<String> = s
218 .filter
219 .iter()
220 .filter_map(filter_to_keyval)
221 .map(|kv| {
222 if kv.negated {
223 format!("not {}:{}", kv.key, kv.value)
224 } else {
225 format!("{}:{}", kv.key, kv.value)
226 }
227 })
228 .collect();
229 if !scope.is_empty() {
230 let _ = write!(text, " ({})", scope.join(", "));
231 }
232 if !s.group_by.is_empty() {
233 let _ = write!(text, " by {}", s.group_by.join(", "));
234 }
235 text
236}
237
238fn function_phrase(name: FuncKind, params: &[FuncParam]) -> String {
239 match name {
240 FuncKind::Anomalies => {
241 let algo = positional_str(params, 0).unwrap_or("basic");
242 let bound = param_count(params, 1);
243 let direction = keyword_str(params, "direction").unwrap_or("both");
244 let seasonality = keyword_str(params, "seasonality");
245 let mut phrase = format!("anomaly detection (algorithm {algo}");
246 if let Some(n) = bound {
247 let _ = write!(phrase, ", ~{}σ", trim_num(n));
248 }
249 let _ = write!(phrase, ", direction {direction}");
250 if let Some(season) = seasonality {
251 let _ = write!(phrase, ", {season} seasonality");
252 }
253 phrase.push(')');
254 phrase
255 }
256 FuncKind::Outliers => {
257 let algo = positional_str(params, 0).unwrap_or("DBSCAN");
258 let tolerance = param_count(params, 1);
259 let mut phrase = format!("outlier detection (algorithm {algo}");
260 if let Some(n) = tolerance {
261 let _ = write!(phrase, ", tolerance {}", trim_num(n));
262 }
263 phrase.push(')');
264 phrase
265 }
266 FuncKind::Forecast => {
267 let algo = positional_str(params, 0).unwrap_or("linear");
268 format!("forecast (algorithm {algo})")
269 }
270 }
271}
272
273fn evaluation_phrase(expr: &MetricExpr) -> Option<String> {
274 match expr {
275 MetricExpr::Function { name, params, .. } => Some(function_phrase(*name, params)),
276 MetricExpr::Change { kind, shift, .. } => Some(format!(
277 "{} over {}",
278 change_word(*kind, false),
279 shift.display
280 )),
281 MetricExpr::Arith { lhs, rhs, .. } => {
282 evaluation_phrase(lhs).or_else(|| evaluation_phrase(rhs))
283 }
284 MetricExpr::Transform { arg, .. } => evaluation_phrase(arg),
285 _ => None,
286 }
287}
288
289fn explain_search(s: &SearchQuery) -> String {
292 let by = if s.group_by.is_empty() {
293 String::new()
294 } else {
295 format!(", grouped by {}", s.group_by.join(", "))
296 };
297 format!(
298 "Over {}, count {} matching `{}`{by} (rollup {}), alert {}.",
299 s.last,
300 source_noun(s.source),
301 s.raw_search,
302 s.rollup_method,
303 render_condition(&s.condition),
304 )
305}
306
307fn summarize_search(s: &SearchQuery, summary: &mut QuerySummary) {
308 summary.headline = format!("{} search", source_label(s.source));
309 summary.metric = Some(s.raw_search.clone());
310 summary.group_by = s.group_by.clone();
311 summary.window = Some(s.last.clone());
312 summary.evaluation = Some(format!("rollup {}", s.rollup_method));
313 summary.condition = Some(render_condition(&s.condition));
314}
315
316fn explain_check(c: &CheckQuery) -> String {
317 let by = if c.by.is_empty() {
318 String::new()
319 } else {
320 format!(" grouped by {}", c.by.join(", "))
321 };
322 format!(
323 "Evaluates the status of service check `{}` over its last {} runs{by}.",
324 c.check, c.last
325 )
326}
327
328fn explain_slo(s: &SloQuery) -> String {
329 format!(
330 "Alerts when the error budget for SLO `{}` over {} is {}.",
331 s.id,
332 s.over,
333 render_condition(&s.condition)
334 )
335}
336
337fn render_composite(c: &CompositeExpr) -> String {
338 match c {
339 CompositeExpr::Ref(r) => format!("#{}", r.id),
340 CompositeExpr::Not(inner) => format!("!{}", render_composite(inner)),
341 CompositeExpr::Binary { op, lhs, rhs } => {
342 format!(
343 "({} {} {})",
344 render_composite(lhs),
345 op,
346 render_composite(rhs)
347 )
348 }
349 }
350}
351
352fn render_condition(c: &Condition) -> String {
355 format!("{} {}", cmp_symbol(c.operator), c.critical)
356}
357
358fn cmp_symbol(op: CmpOp) -> &'static str {
359 match op {
360 CmpOp::Ge => "≥",
361 CmpOp::Le => "≤",
362 CmpOp::Eq => "=",
363 CmpOp::Gt => ">",
364 CmpOp::Lt => "<",
365 }
366}
367
368fn arith_word(op: ArithOp) -> &'static str {
369 match op {
370 ArithOp::Add => "+",
371 ArithOp::Sub => "-",
372 ArithOp::Mul => "×",
373 ArithOp::Div => "÷",
374 }
375}
376
377fn change_word(kind: ChangeKind, capitalized: bool) -> &'static str {
378 match (kind, capitalized) {
379 (ChangeKind::Change, true) => "Change",
380 (ChangeKind::Change, false) => "change",
381 (ChangeKind::PctChange, true) => "Percentage change",
382 (ChangeKind::PctChange, false) => "percentage change",
383 }
384}
385
386fn humanize_transform(name: &str) -> String {
387 if let Some(n) = name.strip_prefix("median_") {
388 return format!("{n}-point median");
389 }
390 if let Some(n) = name.strip_prefix("ewma_") {
391 return format!("{n}-point EWMA");
392 }
393 match name {
394 "rollup" => "rollup".to_string(),
395 "clamp_min" => "lower-clamped value".to_string(),
396 "clamp_max" => "upper-clamped value".to_string(),
397 "abs" => "absolute value".to_string(),
398 "count_nonzero" => "non-zero count".to_string(),
399 other => other.replace('_', " "),
400 }
401}
402
403fn source_noun(source: SearchSource) -> &'static str {
404 match source {
405 SearchSource::Logs => "log events",
406 SearchSource::Events => "events",
407 SearchSource::Rum => "RUM events",
408 SearchSource::ErrorTracking => "tracked errors",
409 }
410}
411
412fn source_label(source: SearchSource) -> &'static str {
413 match source {
414 SearchSource::Logs => "Log",
415 SearchSource::Events => "Event",
416 SearchSource::Rum => "RUM",
417 SearchSource::ErrorTracking => "Error-tracking",
418 }
419}
420
421fn filter_to_keyval(f: &Filter) -> Option<KeyVal> {
422 match f {
423 Filter::Tag {
424 negated,
425 key,
426 value,
427 } => Some(KeyVal {
428 key: key.clone(),
429 value: value.clone(),
430 negated: *negated,
431 }),
432 Filter::All | Filter::Glob(_) => None,
433 }
434}
435
436fn first_series(expr: &MetricExpr) -> Option<&Series> {
438 match expr {
439 MetricExpr::Series(s) => Some(s),
440 MetricExpr::Function { arg, .. }
441 | MetricExpr::Transform { arg, .. }
442 | MetricExpr::Change { arg, .. } => first_series(arg),
443 MetricExpr::Arith { lhs, rhs, .. } => first_series(lhs).or_else(|| first_series(rhs)),
444 MetricExpr::Scalar(_) => None,
445 }
446}
447
448fn top_function(expr: &MetricExpr) -> Option<FuncKind> {
450 match expr {
451 MetricExpr::Function { name, .. } => Some(*name),
452 MetricExpr::Transform { arg, .. } | MetricExpr::Change { arg, .. } => top_function(arg),
453 MetricExpr::Arith { lhs, rhs, .. } => top_function(lhs).or_else(|| top_function(rhs)),
454 _ => None,
455 }
456}
457
458fn positional_str(params: &[FuncParam], index: usize) -> Option<&str> {
459 params
460 .iter()
461 .filter(|p| p.key.is_none())
462 .nth(index)
463 .and_then(|p| match &p.value {
464 ParamValue::Str(s) => Some(s.as_str()),
465 _ => None,
466 })
467}
468
469fn param_count(params: &[FuncParam], index: usize) -> Option<f64> {
470 params
471 .iter()
472 .filter(|p| p.key.is_none())
473 .nth(index)
474 .and_then(|p| match &p.value {
475 ParamValue::Number(n) => Some(*n),
476 _ => None,
477 })
478}
479
480fn keyword_str<'a>(params: &'a [FuncParam], key: &str) -> Option<&'a str> {
481 params
482 .iter()
483 .find(|p| p.key.as_deref() == Some(key))
484 .map(|p| match &p.value {
485 ParamValue::Str(s) => s.as_str(),
486 ParamValue::Bool(true) => "true",
487 ParamValue::Bool(false) => "false",
488 ParamValue::Number(_) => "",
489 })
490 .filter(|s| !s.is_empty())
491}
492
493fn trim_num(n: f64) -> String {
494 if n.fract() == 0.0 && n.is_finite() {
495 format!("{}", n as i64)
496 } else {
497 format!("{n}")
498 }
499}