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 TimeAgg::Percentile => "percentile",
157 }
158}
159
160fn summarize_metric(m: &MetricQuery, summary: &mut QuerySummary) {
161 if let Some(series) = first_series(&m.expr) {
162 summary.metric = Some(series.metric.clone());
163 summary.scope = series.filter.iter().filter_map(filter_to_keyval).collect();
164 summary.group_by = series.group_by.clone();
165 }
166 summary.window = Some(m.window.display.clone());
167 summary.evaluation = evaluation_phrase(&m.expr);
168 summary.condition = Some(render_condition(&m.condition));
169 summary.headline = metric_headline(&m.expr, summary.metric.as_deref());
170}
171
172fn metric_headline(expr: &MetricExpr, metric: Option<&str>) -> String {
173 let subject = metric.unwrap_or("metric");
174 match top_function(expr) {
175 Some(FuncKind::Anomalies) => format!("Anomaly detection on {subject}"),
176 Some(FuncKind::Outliers) => format!("Outlier detection on {subject}"),
177 Some(FuncKind::Forecast) => format!("Forecast alert on {subject}"),
178 None => match expr {
179 MetricExpr::Change { kind, .. } => format!("{} on {subject}", change_word(*kind, true)),
180 _ => format!("Threshold alert on {subject}"),
181 },
182 }
183}
184
185fn describe_expr(expr: &MetricExpr) -> String {
187 match expr {
188 MetricExpr::Series(s) => describe_series(s),
189 MetricExpr::Function { name, arg, params } => {
190 let inner = describe_expr(arg);
191 format!("{} ({inner})", function_phrase(*name, params))
192 }
193 MetricExpr::Transform { name, arg, .. } => {
194 format!("{} of {}", humanize_transform(name), describe_expr(arg))
195 }
196 MetricExpr::Combine { name, args } => {
197 let parts: Vec<String> = args.iter().map(describe_expr).collect();
198 format!("{name} of ({})", parts.join(", "))
199 }
200 MetricExpr::Arith { op, lhs, rhs } => {
201 format!(
202 "({} {} {})",
203 describe_expr(lhs),
204 arith_word(*op),
205 describe_expr(rhs)
206 )
207 }
208 MetricExpr::Change {
209 kind, shift, arg, ..
210 } => format!(
211 "{} over {} of {}",
212 change_word(*kind, false),
213 shift.display,
214 describe_expr(arg)
215 ),
216 MetricExpr::Scalar(s) => s.to_string(),
217 }
218}
219
220fn describe_series(s: &Series) -> String {
221 let mut text = format!("{} of `{}`", s.space_aggregation, s.metric);
222 let scope: Vec<String> = s
223 .filter
224 .iter()
225 .filter_map(filter_to_keyval)
226 .map(|kv| {
227 if kv.negated {
228 format!("not {}:{}", kv.key, kv.value)
229 } else {
230 format!("{}:{}", kv.key, kv.value)
231 }
232 })
233 .collect();
234 if !scope.is_empty() {
235 let _ = write!(text, " ({})", scope.join(", "));
236 }
237 if !s.group_by.is_empty() {
238 let _ = write!(text, " by {}", s.group_by.join(", "));
239 }
240 text
241}
242
243fn function_phrase(name: FuncKind, params: &[FuncParam]) -> String {
244 match name {
245 FuncKind::Anomalies => {
246 let algo = positional_str(params, 0).unwrap_or("basic");
247 let bound = param_count(params, 1);
248 let direction = keyword_str(params, "direction").unwrap_or("both");
249 let seasonality = keyword_str(params, "seasonality");
250 let mut phrase = format!("anomaly detection (algorithm {algo}");
251 if let Some(n) = bound {
252 let _ = write!(phrase, ", ~{}σ", trim_num(n));
253 }
254 let _ = write!(phrase, ", direction {direction}");
255 if let Some(season) = seasonality {
256 let _ = write!(phrase, ", {season} seasonality");
257 }
258 phrase.push(')');
259 phrase
260 }
261 FuncKind::Outliers => {
262 let algo = positional_str(params, 0).unwrap_or("DBSCAN");
263 let tolerance = param_count(params, 1);
264 let mut phrase = format!("outlier detection (algorithm {algo}");
265 if let Some(n) = tolerance {
266 let _ = write!(phrase, ", tolerance {}", trim_num(n));
267 }
268 phrase.push(')');
269 phrase
270 }
271 FuncKind::Forecast => {
272 let algo = positional_str(params, 0).unwrap_or("linear");
273 format!("forecast (algorithm {algo})")
274 }
275 }
276}
277
278fn evaluation_phrase(expr: &MetricExpr) -> Option<String> {
279 match expr {
280 MetricExpr::Function { name, params, .. } => Some(function_phrase(*name, params)),
281 MetricExpr::Change { kind, shift, .. } => Some(format!(
282 "{} over {}",
283 change_word(*kind, false),
284 shift.display
285 )),
286 MetricExpr::Arith { lhs, rhs, .. } => {
287 evaluation_phrase(lhs).or_else(|| evaluation_phrase(rhs))
288 }
289 MetricExpr::Transform { arg, .. } => evaluation_phrase(arg),
290 _ => None,
291 }
292}
293
294fn explain_search(s: &SearchQuery) -> String {
297 let by = if s.group_by.is_empty() {
298 String::new()
299 } else {
300 format!(", grouped by {}", s.group_by.join(", "))
301 };
302 format!(
303 "Over {}, count {} matching `{}`{by} (rollup {}), alert {}.",
304 s.last,
305 source_noun(s.source),
306 s.raw_search,
307 s.rollup_method,
308 render_condition(&s.condition),
309 )
310}
311
312fn summarize_search(s: &SearchQuery, summary: &mut QuerySummary) {
313 summary.headline = format!("{} search", source_label(s.source));
314 summary.metric = Some(s.raw_search.clone());
315 summary.group_by = s.group_by.clone();
316 summary.window = Some(s.last.clone());
317 summary.evaluation = Some(format!("rollup {}", s.rollup_method));
318 summary.condition = Some(render_condition(&s.condition));
319}
320
321fn explain_check(c: &CheckQuery) -> String {
322 let by = if c.by.is_empty() {
323 String::new()
324 } else {
325 format!(" grouped by {}", c.by.join(", "))
326 };
327 format!(
328 "Evaluates the status of service check `{}` over its last {} runs{by}.",
329 c.check, c.last
330 )
331}
332
333fn explain_slo(s: &SloQuery) -> String {
334 format!(
335 "Alerts when the error budget for SLO `{}` over {} is {}.",
336 s.id,
337 s.over,
338 render_condition(&s.condition)
339 )
340}
341
342fn render_composite(c: &CompositeExpr) -> String {
343 match c {
344 CompositeExpr::Ref(r) => format!("#{}", r.id),
345 CompositeExpr::Not(inner) => format!("!{}", render_composite(inner)),
346 CompositeExpr::Binary { op, lhs, rhs } => {
347 format!(
348 "({} {} {})",
349 render_composite(lhs),
350 op,
351 render_composite(rhs)
352 )
353 }
354 }
355}
356
357fn render_condition(c: &Condition) -> String {
360 format!("{} {}", cmp_symbol(c.operator), c.critical)
361}
362
363fn cmp_symbol(op: CmpOp) -> &'static str {
364 match op {
365 CmpOp::Ge => "≥",
366 CmpOp::Le => "≤",
367 CmpOp::Eq => "=",
368 CmpOp::Ne => "≠",
369 CmpOp::Gt => ">",
370 CmpOp::Lt => "<",
371 }
372}
373
374fn arith_word(op: ArithOp) -> &'static str {
375 match op {
376 ArithOp::Add => "+",
377 ArithOp::Sub => "-",
378 ArithOp::Mul => "×",
379 ArithOp::Div => "÷",
380 }
381}
382
383fn change_word(kind: ChangeKind, capitalized: bool) -> &'static str {
384 match (kind, capitalized) {
385 (ChangeKind::Change, true) => "Change",
386 (ChangeKind::Change, false) => "change",
387 (ChangeKind::PctChange, true) => "Percentage change",
388 (ChangeKind::PctChange, false) => "percentage change",
389 }
390}
391
392fn humanize_transform(name: &str) -> String {
393 if let Some(n) = name.strip_prefix("median_") {
394 return format!("{n}-point median");
395 }
396 if let Some(n) = name.strip_prefix("ewma_") {
397 return format!("{n}-point EWMA");
398 }
399 match name {
400 "rollup" => "rollup".to_string(),
401 "clamp_min" => "lower-clamped value".to_string(),
402 "clamp_max" => "upper-clamped value".to_string(),
403 "abs" => "absolute value".to_string(),
404 "count_nonzero" => "non-zero count".to_string(),
405 other => other.replace('_', " "),
406 }
407}
408
409fn source_noun(source: SearchSource) -> &'static str {
410 match source {
411 SearchSource::Logs => "log events",
412 SearchSource::Events => "events",
413 SearchSource::Rum => "RUM events",
414 SearchSource::ErrorTracking => "tracked errors",
415 }
416}
417
418fn source_label(source: SearchSource) -> &'static str {
419 match source {
420 SearchSource::Logs => "Log",
421 SearchSource::Events => "Event",
422 SearchSource::Rum => "RUM",
423 SearchSource::ErrorTracking => "Error-tracking",
424 }
425}
426
427fn filter_to_keyval(f: &Filter) -> Option<KeyVal> {
428 match f {
429 Filter::Tag {
430 negated,
431 key,
432 value,
433 } => Some(KeyVal {
434 key: key.clone(),
435 value: value.clone(),
436 negated: *negated,
437 }),
438 Filter::All | Filter::Glob(_) => None,
439 }
440}
441
442fn first_series(expr: &MetricExpr) -> Option<&Series> {
444 match expr {
445 MetricExpr::Series(s) => Some(s),
446 MetricExpr::Function { arg, .. }
447 | MetricExpr::Transform { arg, .. }
448 | MetricExpr::Change { arg, .. } => first_series(arg),
449 MetricExpr::Arith { lhs, rhs, .. } => first_series(lhs).or_else(|| first_series(rhs)),
450 MetricExpr::Combine { args, .. } => args.iter().find_map(first_series),
451 MetricExpr::Scalar(_) => None,
452 }
453}
454
455fn top_function(expr: &MetricExpr) -> Option<FuncKind> {
457 match expr {
458 MetricExpr::Function { name, .. } => Some(*name),
459 MetricExpr::Transform { arg, .. } | MetricExpr::Change { arg, .. } => top_function(arg),
460 MetricExpr::Arith { lhs, rhs, .. } => top_function(lhs).or_else(|| top_function(rhs)),
461 _ => None,
462 }
463}
464
465fn positional_str(params: &[FuncParam], index: usize) -> Option<&str> {
466 params
467 .iter()
468 .filter(|p| p.key.is_none())
469 .nth(index)
470 .and_then(|p| match &p.value {
471 ParamValue::Str(s) => Some(s.as_str()),
472 _ => None,
473 })
474}
475
476fn param_count(params: &[FuncParam], index: usize) -> Option<f64> {
477 params
478 .iter()
479 .filter(|p| p.key.is_none())
480 .nth(index)
481 .and_then(|p| match &p.value {
482 ParamValue::Number(n) => Some(*n),
483 _ => None,
484 })
485}
486
487fn keyword_str<'a>(params: &'a [FuncParam], key: &str) -> Option<&'a str> {
488 params
489 .iter()
490 .find(|p| p.key.as_deref() == Some(key))
491 .map(|p| match &p.value {
492 ParamValue::Str(s) => s.as_str(),
493 ParamValue::Bool(true) => "true",
494 ParamValue::Bool(false) => "false",
495 ParamValue::Number(_) => "",
496 })
497 .filter(|s| !s.is_empty())
498}
499
500fn trim_num(n: f64) -> String {
501 if n.fract() == 0.0 && n.is_finite() {
502 format!("{}", n as i64)
503 } else {
504 format!("{n}")
505 }
506}