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