Skip to main content

ddquery_core/
ast.rs

1//! The typed monitor-query AST.
2//!
3//! Every public type here derives `Serialize`/`Deserialize` (camelCase) and
4//! `PartialEq`, so a parsed query round-trips cleanly to JSON for a front-end
5//! and compares structurally in tests. Dialect-tagged enums carry a `type`
6//! discriminant so the JSON is self-describing.
7
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::ParseError;
13
14/// A parsed Datadog monitor query, tagged by dialect.
15///
16/// The [`MonitorQuery::Unparsed`] variant is the graceful-degradation path: a
17/// query the grammar does not yet model is preserved verbatim rather than
18/// dropped, so a consumer can always render *something*.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21#[non_exhaustive]
22pub enum MonitorQuery {
23    /// `time-agg(window): expr op threshold` — the primary dialect.
24    Metric(MetricQuery),
25    /// `logs/events/rum/error-tracking` search-source query.
26    Search(SearchQuery),
27    /// `"check".over(...).by(...).last(n).count_by_status()`.
28    ServiceCheck(CheckQuery),
29    /// `error_budget("<id>").over("7d") op n`.
30    Slo(SloQuery),
31    /// Boolean tree of monitor-id references.
32    Composite(CompositeExpr),
33    /// A query that could not be parsed; preserved verbatim with a reason.
34    #[serde(rename_all = "camelCase")]
35    Unparsed {
36        /// The verbatim query string.
37        raw: String,
38        /// One-line reason the query did not parse.
39        reason: String,
40        /// Byte offset into `raw` where parsing failed.
41        offset: usize,
42    },
43}
44
45impl MonitorQuery {
46    /// Build an [`MonitorQuery::Unparsed`] from a raw query and a [`ParseError`].
47    #[must_use]
48    pub fn unparsed(raw: impl Into<String>, err: &ParseError) -> Self {
49        Self::Unparsed {
50            raw: raw.into(),
51            reason: err.reason().to_string(),
52            offset: err.offset(),
53        }
54    }
55}
56
57/// A metric monitor query: `time_agg(window): expr op threshold`.
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct MetricQuery {
61    /// Evaluation time aggregation (`avg`, `sum`, `min`, `max`, `count`).
62    pub time_aggregation: TimeAgg,
63    /// The evaluation window (`last_5m`, `last_1d`, …).
64    pub window: Window,
65    /// The recursive metric expression being evaluated.
66    pub expr: MetricExpr,
67    /// The alerting condition (operator + thresholds).
68    pub condition: Condition,
69}
70
71/// The recursive metric expression — the tree a visualization renders.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74#[non_exhaustive]
75pub enum MetricExpr {
76    /// A scoped metric: `space_agg:metric.name{filter} by {tags}.modifiers`.
77    Series(Series),
78    /// An evaluation function: `anomalies` / `outliers` / `forecast`.
79    #[serde(rename_all = "camelCase")]
80    Function {
81        /// Which evaluation function.
82        name: FuncKind,
83        /// The inner expression the function evaluates.
84        arg: Box<MetricExpr>,
85        /// Positional and keyword parameters.
86        params: Vec<FuncParam>,
87    },
88    /// A transform such as `rollup` / `clamp_min` / `median_N` / `abs`.
89    #[serde(rename_all = "camelCase")]
90    Transform {
91        /// Transform name (kept verbatim, e.g. `median_5`).
92        name: String,
93        /// The inner expression being transformed.
94        arg: Box<MetricExpr>,
95        /// Scalar arguments to the transform.
96        args: Vec<Scalar>,
97    },
98    /// Arithmetic: `a + b`, `a / b * 100`, etc.
99    #[serde(rename_all = "camelCase")]
100    Arith {
101        /// The operator.
102        op: ArithOp,
103        /// Left operand.
104        lhs: Box<MetricExpr>,
105        /// Right operand.
106        rhs: Box<MetricExpr>,
107    },
108    /// `change()` / `pct_change()` over a window shift.
109    #[serde(rename_all = "camelCase")]
110    Change {
111        /// Whether this is an absolute or percentage change.
112        kind: ChangeKind,
113        /// The inner time aggregation applied before the shift.
114        inner_agg: TimeAgg,
115        /// The window shift to compare against.
116        shift: Window,
117        /// The inner expression.
118        arg: Box<MetricExpr>,
119    },
120    /// A literal scalar operand (for arithmetic).
121    Scalar(Scalar),
122}
123
124/// A scoped metric series.
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126#[serde(rename_all = "camelCase")]
127pub struct Series {
128    /// Spatial aggregation across reporting sources.
129    pub space_aggregation: SpaceAgg,
130    /// The metric name, e.g. `system.cpu.user`.
131    pub metric: String,
132    /// Scope filters, e.g. `env:production`, `!result:ok`.
133    pub filter: Vec<Filter>,
134    /// `by {tag}` grouping tags.
135    pub group_by: Vec<String>,
136    /// Postfix modifiers, e.g. `as_count()`, `fill(zero)`.
137    pub modifiers: Vec<Modifier>,
138}
139
140/// A scope filter inside `{ … }`.
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(tag = "type", rename_all = "camelCase")]
143pub enum Filter {
144    /// `key:value`, optionally negated with a leading `!`.
145    #[serde(rename_all = "camelCase")]
146    Tag {
147        /// Whether the filter is negated (`!key:value`).
148        negated: bool,
149        /// The tag key.
150        key: String,
151        /// The tag value.
152        value: String,
153    },
154    /// The `*` match-all filter.
155    All,
156    /// A bare tag glob such as `service:web-*`.
157    Glob(String),
158}
159
160/// A postfix series modifier.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct Modifier {
164    /// Modifier name, e.g. `as_count`, `fill`, `rollup`.
165    pub name: String,
166    /// Modifier arguments kept verbatim, e.g. `["zero"]` for `fill(zero)`.
167    pub args: Vec<String>,
168}
169
170/// The alerting condition: comparison operator plus thresholds.
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct Condition {
174    /// Comparison operator.
175    pub operator: CmpOp,
176    /// The critical threshold.
177    pub critical: Scalar,
178    /// Optional critical-recovery threshold.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub critical_recovery: Option<Scalar>,
181    /// Optional warning threshold.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub warning: Option<Scalar>,
184    /// Optional warning-recovery threshold.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub warning_recovery: Option<Scalar>,
187}
188
189/// A log/event/rum/error-tracking search-source query.
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase")]
192pub struct SearchQuery {
193    /// The search source.
194    pub source: SearchSource,
195    /// The verbatim inner search string (Datadog's search mini-language is
196    /// intentionally **not** parsed — stored as-is).
197    pub raw_search: String,
198    /// Optional `.index("…")`.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub index: Option<String>,
201    /// `.rollup` method (e.g. `count`, `avg`).
202    pub rollup_method: String,
203    /// Optional `.rollup` interval / second argument.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub rollup_arg: Option<String>,
206    /// `.by("…")` grouping tags.
207    pub group_by: Vec<String>,
208    /// The `.last("…")` evaluation window argument.
209    pub last: String,
210    /// The alerting condition.
211    pub condition: Condition,
212}
213
214/// The source of a [`SearchQuery`].
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "kebab-case")]
217pub enum SearchSource {
218    /// `logs(...)`.
219    Logs,
220    /// `events(...)`.
221    Events,
222    /// `rum(...)`.
223    Rum,
224    /// `error-tracking(...)`.
225    ErrorTracking,
226}
227
228/// A service-check query.
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct CheckQuery {
232    /// The check name.
233    pub check: String,
234    /// `.over(...)` scope tags.
235    pub over: Vec<String>,
236    /// `.by(...)` grouping tags.
237    pub by: Vec<String>,
238    /// `.last(n)` count of recent check runs.
239    pub last: u32,
240}
241
242/// An SLO error-budget query.
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub struct SloQuery {
246    /// The SLO identifier.
247    pub id: String,
248    /// The `.over("…")` time window argument.
249    pub over: String,
250    /// The alerting condition.
251    pub condition: Condition,
252}
253
254/// A boolean tree over monitor-id references (composite monitors).
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub enum CompositeExpr {
258    /// A reference to another monitor by id.
259    Ref(MonitorRef),
260    /// Logical negation of a sub-expression.
261    Not(Box<CompositeExpr>),
262    /// A binary boolean combination.
263    #[serde(rename_all = "camelCase")]
264    Binary {
265        /// `&&` or `||`.
266        op: BoolOp,
267        /// Left operand.
268        lhs: Box<CompositeExpr>,
269        /// Right operand.
270        rhs: Box<CompositeExpr>,
271    },
272}
273
274/// A reference to another monitor by its numeric id (kept as a string to
275/// preserve the verbatim token).
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct MonitorRef {
279    /// The referenced monitor id.
280    pub id: String,
281}
282
283/// A normalized evaluation window.
284///
285/// `last_5m` normalizes to `{ raw: "last_5m", seconds: 300, display: "last 5
286/// minutes" }`. Used for sorting, comparison, and human display.
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "camelCase")]
289pub struct Window {
290    /// The verbatim window token.
291    pub raw: String,
292    /// The window length in seconds.
293    pub seconds: u64,
294    /// A human-readable rendering, e.g. `last 5 minutes`.
295    pub display: String,
296}
297
298impl Window {
299    /// Parse and normalize a window token such as `last_5m` or `5m`.
300    ///
301    /// Returns `None` if the token is not a recognized `<number><unit>` form
302    /// (with units `s`, `m`, `h`, `d`, `w`).
303    #[must_use]
304    pub fn parse(raw: &str) -> Option<Self> {
305        let body = raw.strip_prefix("last_").unwrap_or(raw);
306        let (digits, unit) = body.split_at(body.find(|c: char| !c.is_ascii_digit())?);
307        if digits.is_empty() || unit.len() != 1 {
308            return None;
309        }
310        let count: u64 = digits.parse().ok()?;
311        let (secs_per, unit_name) = match unit {
312            "s" => (1, "second"),
313            "m" => (60, "minute"),
314            "h" => (3600, "hour"),
315            "d" => (86_400, "day"),
316            "w" => (604_800, "week"),
317            _ => return None,
318        };
319        let seconds = count.checked_mul(secs_per)?;
320        let plural = if count == 1 { "" } else { "s" };
321        Some(Self {
322            raw: raw.to_string(),
323            seconds,
324            display: format!("last {count} {unit_name}{plural}"),
325        })
326    }
327}
328
329/// A numeric scalar literal (threshold or arithmetic operand).
330#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
331#[serde(transparent)]
332pub struct Scalar {
333    /// The numeric value.
334    pub value: f64,
335}
336
337impl Scalar {
338    /// Construct a scalar from a value.
339    #[must_use]
340    pub fn new(value: f64) -> Self {
341        Self { value }
342    }
343}
344
345impl fmt::Display for Scalar {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        if self.value.fract() == 0.0 && self.value.is_finite() {
348            write!(f, "{}", self.value as i64)
349        } else {
350            write!(f, "{}", self.value)
351        }
352    }
353}
354
355/// A parameter to an evaluation function.
356///
357/// A `None` `key` is a positional parameter (`'agile'`, `5`); a `Some` `key`
358/// is a keyword parameter (`direction='below'`).
359#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
360#[serde(rename_all = "camelCase")]
361pub struct FuncParam {
362    /// The keyword name, if this is a keyword parameter.
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub key: Option<String>,
365    /// The parameter value.
366    pub value: ParamValue,
367}
368
369impl FuncParam {
370    /// A positional parameter.
371    #[must_use]
372    pub fn positional(value: ParamValue) -> Self {
373        Self { key: None, value }
374    }
375
376    /// A keyword parameter.
377    #[must_use]
378    pub fn keyword(key: impl Into<String>, value: ParamValue) -> Self {
379        Self {
380            key: Some(key.into()),
381            value,
382        }
383    }
384}
385
386/// The value of a [`FuncParam`].
387#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
388#[serde(untagged)]
389pub enum ParamValue {
390    /// A boolean literal.
391    Bool(bool),
392    /// A numeric literal.
393    Number(f64),
394    /// A string literal (quotes stripped).
395    Str(String),
396}
397
398impl fmt::Display for ParamValue {
399    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400        match self {
401            ParamValue::Bool(b) => write!(f, "{b}"),
402            ParamValue::Number(n) => write!(f, "{n}"),
403            ParamValue::Str(s) => write!(f, "{s}"),
404        }
405    }
406}
407
408macro_rules! str_enum {
409    (
410        $(#[$meta:meta])*
411        $name:ident { $( $variant:ident => $text:literal ),+ $(,)? }
412    ) => {
413        $(#[$meta])*
414        #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
415        #[serde(rename_all = "camelCase")]
416        pub enum $name {
417            $(
418                #[doc = concat!("`", $text, "`")]
419                $variant,
420            )+
421        }
422
423        impl $name {
424            /// Parse from the source token, returning `None` if unrecognized.
425            #[must_use]
426            pub fn from_token(s: &str) -> Option<Self> {
427                match s {
428                    $( $text => Some(Self::$variant), )+
429                    _ => None,
430                }
431            }
432
433            /// The canonical source token.
434            #[must_use]
435            pub fn as_token(self) -> &'static str {
436                match self {
437                    $( Self::$variant => $text, )+
438                }
439            }
440        }
441
442        impl ::std::fmt::Display for $name {
443            fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
444                f.write_str(self.as_token())
445            }
446        }
447    };
448}
449
450str_enum! {
451    /// Evaluation-time aggregation.
452    TimeAgg {
453        Avg => "avg",
454        Sum => "sum",
455        Min => "min",
456        Max => "max",
457        Count => "count",
458    }
459}
460
461str_enum! {
462    /// Spatial aggregation across reporting sources.
463    SpaceAgg {
464        Avg => "avg",
465        Sum => "sum",
466        Min => "min",
467        Max => "max",
468        Count => "count",
469    }
470}
471
472str_enum! {
473    /// An evaluation function.
474    FuncKind {
475        Anomalies => "anomalies",
476        Outliers => "outliers",
477        Forecast => "forecast",
478    }
479}
480
481str_enum! {
482    /// An arithmetic operator.
483    ArithOp {
484        Add => "+",
485        Sub => "-",
486        Mul => "*",
487        Div => "/",
488    }
489}
490
491str_enum! {
492    /// A change function kind.
493    ChangeKind {
494        Change => "change",
495        PctChange => "pct_change",
496    }
497}
498
499str_enum! {
500    /// A boolean operator in a composite query.
501    BoolOp {
502        And => "&&",
503        Or => "||",
504    }
505}
506
507str_enum! {
508    /// A comparison operator in a condition.
509    CmpOp {
510        Ge => ">=",
511        Le => "<=",
512        Eq => "==",
513        Gt => ">",
514        Lt => "<",
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use rstest::rstest;
521
522    use super::*;
523
524    #[rstest]
525    #[case("last_5m", 300, "last 5 minutes")]
526    #[case("last_1m", 60, "last 1 minute")]
527    #[case("last_1d", 86_400, "last 1 day")]
528    #[case("last_1w", 604_800, "last 1 week")]
529    #[case("last_30s", 30, "last 30 seconds")]
530    #[case("4h", 14_400, "last 4 hours")]
531    fn test_should_normalize_window(
532        #[case] raw: &str,
533        #[case] seconds: u64,
534        #[case] display: &str,
535    ) {
536        let window = Window::parse(raw).expect("valid window");
537        assert_eq!(window.seconds, seconds);
538        assert_eq!(window.display, display);
539        assert_eq!(window.raw, raw);
540    }
541
542    #[rstest]
543    #[case("")]
544    #[case("last_")]
545    #[case("5x")]
546    #[case("m")]
547    #[case("last_5mm")]
548    #[case("abc")]
549    fn test_should_reject_invalid_window(#[case] raw: &str) {
550        assert!(Window::parse(raw).is_none());
551    }
552
553    #[test]
554    fn test_should_reject_overflowing_window() {
555        assert!(Window::parse("18446744073709551615w").is_none());
556    }
557
558    #[test]
559    fn test_should_render_scalar_without_trailing_zero() {
560        assert_eq!(Scalar::new(90.0).to_string(), "90");
561        assert_eq!(Scalar::new(0.5).to_string(), "0.5");
562        assert_eq!(Scalar::new(-3.0).to_string(), "-3");
563    }
564
565    #[test]
566    fn test_should_roundtrip_through_json() {
567        let agg = TimeAgg::Avg;
568        let json = serde_json::to_string(&agg).expect("serialize");
569        assert_eq!(json, "\"avg\"");
570        let back: TimeAgg = serde_json::from_str(&json).expect("deserialize");
571        assert_eq!(back, agg);
572    }
573}