Skip to main content

ddquery_core/
explain.rs

1//! Deterministic AST → English and AST → structured-summary rendering.
2//!
3//! Both [`explain`] and [`summarize`] are pure functions over the AST. There
4//! is no LLM and no I/O, so the output is stable and snapshot-testable. Richer
5//! narration can layer on top later, but this base layer is the contract.
6
7use 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/// A render-ready breakdown of a query for a front-end (labelled chips/rows).
18///
19/// Every field is optional except `raw` and `headline`, so a consumer can show
20/// just the chips that apply. `raw` is always the verbatim query.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct QuerySummary {
24    /// A short headline, e.g. `Anomaly detection on view time`.
25    pub headline: String,
26    /// The primary metric name, when there is one.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub metric: Option<String>,
29    /// Scope filters as key/value chips.
30    pub scope: Vec<KeyVal>,
31    /// `by {…}` grouping tags.
32    pub group_by: Vec<String>,
33    /// The evaluation window, humanized.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub window: Option<String>,
36    /// The evaluation method (anomaly/outlier/forecast/change phrasing).
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub evaluation: Option<String>,
39    /// The alerting condition, e.g. `≥ 1`.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub condition: Option<String>,
42    /// The verbatim query string. Always present.
43    pub raw: String,
44}
45
46/// A scope key/value pair (a filter chip).
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct KeyVal {
50    /// The tag key.
51    pub key: String,
52    /// The tag value.
53    pub value: String,
54    /// Whether the filter is negated (`!key:value`).
55    #[serde(skip_serializing_if = "std::ops::Not::not")]
56    pub negated: bool,
57}
58
59/// Produce a one-paragraph, plain-language summary of a parsed query.
60///
61/// # Examples
62///
63/// ```
64/// use ddquery_core::{explain, parse};
65///
66/// let q = parse("avg(last_5m):avg:system.cpu.user{env:prod} > 90").unwrap();
67/// let text = explain(&q);
68/// assert!(text.contains("system.cpu.user"));
69/// ```
70#[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/// Produce a structured, render-ready summary of a parsed query.
90///
91/// The `raw` argument is the original query string, preserved verbatim in the
92/// summary so the front-end can offer a "show raw" toggle.
93#[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
129// ----- metric --------------------------------------------------------------
130
131fn 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
179/// A best-effort English description of the expression tree.
180fn 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
284// ----- search / check / slo / composite ------------------------------------
285
286fn 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
347// ----- shared helpers -------------------------------------------------------
348
349fn 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
431/// The first [`Series`] reachable in the expression tree, if any.
432fn 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
443/// The top-most evaluation function in the tree, if any.
444fn 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}