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    /// `moving_rollup`.
90    #[serde(rename_all = "camelCase")]
91    Transform {
92        /// Transform name (kept verbatim, e.g. `median_5`).
93        name: String,
94        /// The inner expression being transformed.
95        arg: Box<MetricExpr>,
96        /// Arguments to the transform. Most are numeric (e.g. `rollup(60)`), but
97        /// some carry strings — e.g. `moving_rollup(expr, 60, 'avg')`.
98        args: Vec<ParamValue>,
99    },
100    /// A spatial combiner over several sub-expressions, e.g.
101    /// `max(avg:a{*}, avg:b{*})`. `name` is the aggregator token
102    /// (`max`/`min`/`sum`/`avg`/`count`).
103    #[serde(rename_all = "camelCase")]
104    Combine {
105        /// The aggregator token combining the operands.
106        name: String,
107        /// The combined sub-expressions (two or more in practice).
108        args: Vec<MetricExpr>,
109    },
110    /// Arithmetic: `a + b`, `a / b * 100`, etc.
111    #[serde(rename_all = "camelCase")]
112    Arith {
113        /// The operator.
114        op: ArithOp,
115        /// Left operand.
116        lhs: Box<MetricExpr>,
117        /// Right operand.
118        rhs: Box<MetricExpr>,
119    },
120    /// `change()` / `pct_change()` over a window shift.
121    #[serde(rename_all = "camelCase")]
122    Change {
123        /// Whether this is an absolute or percentage change.
124        kind: ChangeKind,
125        /// The inner time aggregation applied before the shift.
126        inner_agg: TimeAgg,
127        /// The window shift to compare against.
128        shift: Window,
129        /// The inner expression.
130        arg: Box<MetricExpr>,
131    },
132    /// A literal scalar operand (for arithmetic).
133    Scalar(Scalar),
134}
135
136/// A scoped metric series.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct Series {
140    /// Spatial aggregation across reporting sources.
141    pub space_aggregation: SpaceAgg,
142    /// The metric name, e.g. `system.cpu.user`.
143    pub metric: String,
144    /// Scope filters, e.g. `env:production`, `!result:ok`.
145    pub filter: Vec<Filter>,
146    /// `by {tag}` grouping tags.
147    pub group_by: Vec<String>,
148    /// Postfix modifiers, e.g. `as_count()`, `fill(zero)`.
149    pub modifiers: Vec<Modifier>,
150}
151
152/// A scope filter inside `{ … }`.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(tag = "type", rename_all = "camelCase")]
155pub enum Filter {
156    /// `key:value`, optionally negated with a leading `!`.
157    #[serde(rename_all = "camelCase")]
158    Tag {
159        /// Whether the filter is negated (`!key:value`).
160        negated: bool,
161        /// The tag key.
162        key: String,
163        /// The tag value.
164        value: String,
165    },
166    /// The `*` match-all filter.
167    All,
168    /// A bare tag glob such as `service:web-*`.
169    Glob(String),
170}
171
172/// A postfix series modifier.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct Modifier {
176    /// Modifier name, e.g. `as_count`, `fill`, `rollup`.
177    pub name: String,
178    /// Modifier arguments kept verbatim, e.g. `["zero"]` for `fill(zero)`.
179    pub args: Vec<String>,
180}
181
182/// The alerting condition: comparison operator plus thresholds.
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184#[serde(rename_all = "camelCase")]
185pub struct Condition {
186    /// Comparison operator.
187    pub operator: CmpOp,
188    /// The critical threshold.
189    pub critical: Scalar,
190    /// Optional critical-recovery threshold.
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub critical_recovery: Option<Scalar>,
193    /// Optional warning threshold.
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub warning: Option<Scalar>,
196    /// Optional warning-recovery threshold.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub warning_recovery: Option<Scalar>,
199}
200
201/// A log/event/rum/error-tracking search-source query.
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct SearchQuery {
205    /// The search source.
206    pub source: SearchSource,
207    /// The verbatim inner search string (Datadog's search mini-language is
208    /// intentionally **not** parsed — stored as-is).
209    pub raw_search: String,
210    /// Optional `.index("…")`.
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub index: Option<String>,
213    /// `.rollup` method (e.g. `count`, `avg`).
214    pub rollup_method: String,
215    /// Optional `.rollup` interval / second argument.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub rollup_arg: Option<String>,
218    /// `.by("…")` grouping tags.
219    pub group_by: Vec<String>,
220    /// The `.last("…")` evaluation window argument.
221    pub last: String,
222    /// The alerting condition.
223    pub condition: Condition,
224}
225
226/// The source of a [`SearchQuery`].
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "kebab-case")]
229pub enum SearchSource {
230    /// `logs(...)`.
231    Logs,
232    /// `events(...)`.
233    Events,
234    /// `rum(...)`.
235    Rum,
236    /// `error-tracking(...)`.
237    ErrorTracking,
238}
239
240/// A service-check query.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(rename_all = "camelCase")]
243pub struct CheckQuery {
244    /// The check name.
245    pub check: String,
246    /// `.over(...)` scope tags.
247    pub over: Vec<String>,
248    /// `.by(...)` grouping tags.
249    pub by: Vec<String>,
250    /// `.last(n)` count of recent check runs.
251    pub last: u32,
252}
253
254/// An SLO error-budget query.
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct SloQuery {
258    /// The SLO identifier.
259    pub id: String,
260    /// The `.over("…")` time window argument.
261    pub over: String,
262    /// The alerting condition.
263    pub condition: Condition,
264}
265
266/// A boolean tree over monitor-id references (composite monitors).
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "camelCase")]
269pub enum CompositeExpr {
270    /// A reference to another monitor by id.
271    Ref(MonitorRef),
272    /// Logical negation of a sub-expression.
273    Not(Box<CompositeExpr>),
274    /// A binary boolean combination.
275    #[serde(rename_all = "camelCase")]
276    Binary {
277        /// `&&` or `||`.
278        op: BoolOp,
279        /// Left operand.
280        lhs: Box<CompositeExpr>,
281        /// Right operand.
282        rhs: Box<CompositeExpr>,
283    },
284}
285
286/// A reference to another monitor by its numeric id (kept as a string to
287/// preserve the verbatim token).
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct MonitorRef {
291    /// The referenced monitor id.
292    pub id: String,
293}
294
295/// A normalized evaluation window.
296///
297/// `last_5m` normalizes to `{ raw: "last_5m", seconds: 300, display: "last 5
298/// minutes" }`. Used for sorting, comparison, and human display.
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300#[serde(rename_all = "camelCase")]
301pub struct Window {
302    /// The verbatim window token.
303    pub raw: String,
304    /// The window length in seconds.
305    pub seconds: u64,
306    /// A human-readable rendering, e.g. `last 5 minutes`.
307    pub display: String,
308}
309
310impl Window {
311    /// Parse and normalize a window token such as `last_5m` or `5m`.
312    ///
313    /// Recognized prefixes are `last_` (the default) and `current_` (the
314    /// in-progress period, e.g. `current_1d`). Recognized units are `s`, `m`,
315    /// `h`, `d`, `w`, and `mo` (month, normalized to 30 days). Returns `None`
316    /// if the token is not a recognized `<prefix><number><unit>` form.
317    #[must_use]
318    pub fn parse(raw: &str) -> Option<Self> {
319        // `current_<n><unit>` describes the in-progress period; for length
320        // purposes it normalizes identically to `last_<n><unit>`. We keep the
321        // verbatim `raw` so the distinction survives for display.
322        let (body, prefix_word) = if let Some(rest) = raw.strip_prefix("last_") {
323            (rest, "last")
324        } else if let Some(rest) = raw.strip_prefix("current_") {
325            (rest, "current")
326        } else {
327            (raw, "last")
328        };
329        let (digits, unit) = body.split_at(body.find(|c: char| !c.is_ascii_digit())?);
330        if digits.is_empty() {
331            return None;
332        }
333        let count: u64 = digits.parse().ok()?;
334        let (secs_per, unit_name) = match unit {
335            "s" => (1, "second"),
336            "m" => (60, "minute"),
337            "h" => (3600, "hour"),
338            "d" => (86_400, "day"),
339            "w" => (604_800, "week"),
340            "mo" => (2_592_000, "month"), // 30 days, matching Datadog
341            _ => return None,
342        };
343        let seconds = count.checked_mul(secs_per)?;
344        let plural = if count == 1 { "" } else { "s" };
345        Some(Self {
346            raw: raw.to_string(),
347            seconds,
348            display: format!("{prefix_word} {count} {unit_name}{plural}"),
349        })
350    }
351}
352
353/// A numeric scalar literal (threshold or arithmetic operand).
354#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
355#[serde(transparent)]
356pub struct Scalar {
357    /// The numeric value.
358    pub value: f64,
359}
360
361impl Scalar {
362    /// Construct a scalar from a value.
363    #[must_use]
364    pub fn new(value: f64) -> Self {
365        Self { value }
366    }
367}
368
369impl fmt::Display for Scalar {
370    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
371        if self.value.fract() == 0.0 && self.value.is_finite() {
372            write!(f, "{}", self.value as i64)
373        } else {
374            write!(f, "{}", self.value)
375        }
376    }
377}
378
379/// A parameter to an evaluation function.
380///
381/// A `None` `key` is a positional parameter (`'agile'`, `5`); a `Some` `key`
382/// is a keyword parameter (`direction='below'`).
383#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
384#[serde(rename_all = "camelCase")]
385pub struct FuncParam {
386    /// The keyword name, if this is a keyword parameter.
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub key: Option<String>,
389    /// The parameter value.
390    pub value: ParamValue,
391}
392
393impl FuncParam {
394    /// A positional parameter.
395    #[must_use]
396    pub fn positional(value: ParamValue) -> Self {
397        Self { key: None, value }
398    }
399
400    /// A keyword parameter.
401    #[must_use]
402    pub fn keyword(key: impl Into<String>, value: ParamValue) -> Self {
403        Self {
404            key: Some(key.into()),
405            value,
406        }
407    }
408}
409
410/// The value of a [`FuncParam`].
411#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
412#[serde(untagged)]
413pub enum ParamValue {
414    /// A boolean literal.
415    Bool(bool),
416    /// A numeric literal.
417    Number(f64),
418    /// A string literal (quotes stripped).
419    Str(String),
420}
421
422impl fmt::Display for ParamValue {
423    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
424        match self {
425            ParamValue::Bool(b) => write!(f, "{b}"),
426            ParamValue::Number(n) => write!(f, "{n}"),
427            ParamValue::Str(s) => write!(f, "{s}"),
428        }
429    }
430}
431
432macro_rules! str_enum {
433    (
434        $(#[$meta:meta])*
435        $name:ident { $( $variant:ident => $text:literal ),+ $(,)? }
436    ) => {
437        $(#[$meta])*
438        #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
439        #[serde(rename_all = "camelCase")]
440        pub enum $name {
441            $(
442                #[doc = concat!("`", $text, "`")]
443                $variant,
444            )+
445        }
446
447        impl $name {
448            /// Parse from the source token, returning `None` if unrecognized.
449            #[must_use]
450            pub fn from_token(s: &str) -> Option<Self> {
451                match s {
452                    $( $text => Some(Self::$variant), )+
453                    _ => None,
454                }
455            }
456
457            /// The canonical source token.
458            #[must_use]
459            pub fn as_token(self) -> &'static str {
460                match self {
461                    $( Self::$variant => $text, )+
462                }
463            }
464        }
465
466        impl ::std::fmt::Display for $name {
467            fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
468                f.write_str(self.as_token())
469            }
470        }
471    };
472}
473
474str_enum! {
475    /// Evaluation-time aggregation.
476    TimeAgg {
477        Avg => "avg",
478        Sum => "sum",
479        Min => "min",
480        Max => "max",
481        Count => "count",
482        Percentile => "percentile",
483    }
484}
485
486/// Spatial aggregation across reporting sources.
487///
488/// Besides the named aggregators, Datadog allows a percentile selector such as
489/// `p95:` / `p99.9:`, which we keep as [`SpaceAgg::Percentile`] carrying the
490/// percentile rank verbatim (e.g. `"95"`, `"99.9"`).
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
492#[serde(rename_all = "camelCase")]
493pub enum SpaceAgg {
494    /// `avg`
495    Avg,
496    /// `sum`
497    Sum,
498    /// `min`
499    Min,
500    /// `max`
501    Max,
502    /// `count`
503    Count,
504    /// A percentile selector, e.g. `p95` → `Percentile("95")`. The rank is kept
505    /// as text so fractional percentiles (`p99.9`) round-trip losslessly.
506    Percentile(String),
507}
508
509impl SpaceAgg {
510    /// Parse from the source token, returning `None` if unrecognized.
511    ///
512    /// Recognizes the named aggregators and percentile tokens `p<rank>`, where
513    /// `<rank>` is a number 0–100 (optionally fractional), e.g. `p95`, `p99.9`.
514    #[must_use]
515    pub fn from_token(s: &str) -> Option<Self> {
516        match s {
517            "avg" => Some(Self::Avg),
518            "sum" => Some(Self::Sum),
519            "min" => Some(Self::Min),
520            "max" => Some(Self::Max),
521            "count" => Some(Self::Count),
522            _ => {
523                let rank = s.strip_prefix('p')?;
524                let value: f64 = rank.parse().ok()?;
525                (0.0..=100.0)
526                    .contains(&value)
527                    .then(|| Self::Percentile(rank.to_string()))
528            }
529        }
530    }
531}
532
533impl ::std::fmt::Display for SpaceAgg {
534    fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
535        match self {
536            Self::Avg => f.write_str("avg"),
537            Self::Sum => f.write_str("sum"),
538            Self::Min => f.write_str("min"),
539            Self::Max => f.write_str("max"),
540            Self::Count => f.write_str("count"),
541            Self::Percentile(rank) => write!(f, "p{rank}"),
542        }
543    }
544}
545
546str_enum! {
547    /// An evaluation function.
548    FuncKind {
549        Anomalies => "anomalies",
550        Outliers => "outliers",
551        Forecast => "forecast",
552    }
553}
554
555str_enum! {
556    /// An arithmetic operator.
557    ArithOp {
558        Add => "+",
559        Sub => "-",
560        Mul => "*",
561        Div => "/",
562    }
563}
564
565str_enum! {
566    /// A change function kind.
567    ChangeKind {
568        Change => "change",
569        PctChange => "pct_change",
570    }
571}
572
573str_enum! {
574    /// A boolean operator in a composite query.
575    BoolOp {
576        And => "&&",
577        Or => "||",
578    }
579}
580
581str_enum! {
582    /// A comparison operator in a condition.
583    CmpOp {
584        Ge => ">=",
585        Le => "<=",
586        Eq => "==",
587        Ne => "!=",
588        Gt => ">",
589        Lt => "<",
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use rstest::rstest;
596
597    use super::*;
598
599    #[rstest]
600    #[case("last_5m", 300, "last 5 minutes")]
601    #[case("last_1m", 60, "last 1 minute")]
602    #[case("last_1d", 86_400, "last 1 day")]
603    #[case("last_1w", 604_800, "last 1 week")]
604    #[case("last_30s", 30, "last 30 seconds")]
605    #[case("4h", 14_400, "last 4 hours")]
606    fn test_should_normalize_window(
607        #[case] raw: &str,
608        #[case] seconds: u64,
609        #[case] display: &str,
610    ) {
611        let window = Window::parse(raw).expect("valid window");
612        assert_eq!(window.seconds, seconds);
613        assert_eq!(window.display, display);
614        assert_eq!(window.raw, raw);
615    }
616
617    #[rstest]
618    #[case("")]
619    #[case("last_")]
620    #[case("5x")]
621    #[case("m")]
622    #[case("last_5mm")]
623    #[case("abc")]
624    fn test_should_reject_invalid_window(#[case] raw: &str) {
625        assert!(Window::parse(raw).is_none());
626    }
627
628    #[test]
629    fn test_should_reject_overflowing_window() {
630        assert!(Window::parse("18446744073709551615w").is_none());
631    }
632
633    #[test]
634    fn test_should_render_scalar_without_trailing_zero() {
635        assert_eq!(Scalar::new(90.0).to_string(), "90");
636        assert_eq!(Scalar::new(0.5).to_string(), "0.5");
637        assert_eq!(Scalar::new(-3.0).to_string(), "-3");
638    }
639
640    #[test]
641    fn test_should_roundtrip_through_json() {
642        let agg = TimeAgg::Avg;
643        let json = serde_json::to_string(&agg).expect("serialize");
644        assert_eq!(json, "\"avg\"");
645        let back: TimeAgg = serde_json::from_str(&json).expect("deserialize");
646        assert_eq!(back, agg);
647    }
648}