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        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
185/// A best-effort English description of the expression tree.
186fn 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
294// ----- search / check / slo / composite ------------------------------------
295
296fn 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
357// ----- shared helpers -------------------------------------------------------
358
359fn 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
442/// The first [`Series`] reachable in the expression tree, if any.
443fn 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
455/// The top-most evaluation function in the tree, if any.
456fn 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}