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    ///
56    /// `default` pairs with `skip_serializing_if`: the field is omitted from JSON
57    /// when `false`, so deserialization must also tolerate its absence — otherwise
58    /// a serialize→deserialize round-trip of a non-negated filter fails with
59    /// `missing field negated`.
60    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
61    pub negated: bool,
62}
63
64/// Produce a one-paragraph, plain-language summary of a parsed query.
65///
66/// # Examples
67///
68/// ```
69/// use ddquery_core::{explain, parse};
70///
71/// let q = parse("avg(last_5m):avg:system.cpu.user{env:prod} > 90").unwrap();
72/// let text = explain(&q);
73/// assert!(text.contains("system.cpu.user"));
74/// ```
75#[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/// Produce a structured, render-ready summary of a parsed query.
95///
96/// The `raw` argument is the original query string, preserved verbatim in the
97/// summary so the front-end can offer a "show raw" toggle.
98#[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
134// ----- metric --------------------------------------------------------------
135
136fn 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
184/// A best-effort English description of the expression tree.
185fn 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
289// ----- search / check / slo / composite ------------------------------------
290
291fn 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
352// ----- shared helpers -------------------------------------------------------
353
354fn 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
436/// The first [`Series`] reachable in the expression tree, if any.
437fn 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
448/// The top-most evaluation function in the tree, if any.
449fn 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}