Skip to main content

videosdk/resources/
alert_rules.rs

1//! The alert-rules API: metric-threshold alerts over your project's telemetry.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use futures_util::Stream;
7use reqwest::Method;
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10
11use crate::client::{CallOptions, Client};
12use crate::common::{string_enum, MessageResponse};
13use crate::error::Result;
14use crate::pagination::{auto_page, paginate, ListParams, Page, PageFetcher};
15use crate::query::QueryBuilder;
16use crate::resources::escape;
17
18const PATH: &str = "/v2/alertRules";
19
20string_enum! {
21    /// An alert rule's severity.
22    AlertSeverity {
23        /// Informational.
24        INFO => "info",
25        /// A warning.
26        WARNING => "warning",
27        /// Critical.
28        CRITICAL => "critical",
29    }
30}
31
32string_enum! {
33    /// The product domain an alert rule is scoped to.
34    ///
35    /// The server defaults to [`AlertDomain::RTC`] when omitted — pass
36    /// [`AlertDomain::AGENT`] to see agent-domain rules.
37    AlertDomain {
38        /// Real-time communication.
39        RTC => "rtc",
40        /// AI agents.
41        AGENT => "agent",
42    }
43}
44
45string_enum! {
46    /// Whether an alert rule is active or paused.
47    AlertRuleStatus {
48        /// The rule is evaluating.
49        ACTIVE => "active",
50        /// The rule is paused.
51        PAUSED => "paused",
52    }
53}
54
55string_enum! {
56    /// The comparison operator in an alert rule condition.
57    AlertConditionOperator {
58        /// Greater than.
59        GREATER_THAN => ">",
60        /// Less than.
61        LESS_THAN => "<",
62        /// Equal to.
63        EQUAL => "=",
64        /// Not equal to.
65        NOT_EQUAL => "!=",
66    }
67}
68
69string_enum! {
70    /// How a condition must be met over the evaluation window.
71    AlertMatchType {
72        /// Fire if the threshold is crossed at least once. The default.
73        AT_LEAST_ONCE => "at_least_once",
74        /// Fire only if crossed for the whole window.
75        ALL_THE_TIME => "all_the_time",
76        /// Fire on the window average.
77        ON_AVERAGE => "on_average",
78        /// Fire on the window total.
79        IN_TOTAL => "in_total",
80        /// Fire on the last value.
81        LAST => "last",
82    }
83}
84
85string_enum! {
86    /// Collapses each series to a single value before the threshold check.
87    AlertReduceTo {
88        /// The last value.
89        LAST => "last",
90        /// The sum.
91        SUM => "sum",
92        /// The average.
93        AVG => "avg",
94        /// The minimum.
95        MIN => "min",
96        /// The maximum.
97        MAX => "max",
98    }
99}
100
101/* -------------------------------- config types -------------------------------- */
102
103/// Alerts when a metric stops reporting data.
104#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase")]
106pub struct AbsentDataAlert {
107    /// Whether absent-data alerting is on.
108    pub enabled: bool,
109    /// Fire after the metric has been absent this long.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub for_seconds: Option<u32>,
112}
113
114/// Narrows a metric query. `value` is a string, number, or array of them.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct AlertRuleFilter {
117    /// The attribute to filter on.
118    pub category: String,
119    /// The filter operator, e.g. `=`, `in`, `regex`, `exists`.
120    pub condition: String,
121    /// The value(s) to compare against.
122    pub value: Value,
123}
124
125/// Groups a metric query by an attribute key.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct AlertRuleGroupBy {
129    /// The attribute key.
130    pub key: String,
131    /// The attribute type: `string`, `int64`, `float64`, or `bool`.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub data_type: Option<String>,
134    /// The attribute source: `tag`, `resource`, or `scope`.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub key_type: Option<String>,
137}
138
139/// Filters on an aggregated column.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct AlertRuleHaving {
143    /// The aggregated column.
144    pub column_name: String,
145    /// One of `=`, `!=`, `>`, `>=`, `<`, `<=`.
146    pub operator: String,
147    /// The value to compare against.
148    pub value: f64,
149}
150
151/// Selects and aggregates a metric to alert on.
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct AlertRuleQuery {
155    /// The metric name, e.g. `stat_consumer_video_bitrate`. Required.
156    pub metric: String,
157    /// The metric's category, e.g. `bitrate`, `packet_loss`.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub metric_category: Option<String>,
160    /// Reduces a series over time: `latest`, `sum`, `avg`, `min`, `max`, `count`,
161    /// `count_distinct`, `rate`, `increase`, or a percentile `p05`..`p99`.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub time_aggregation: Option<String>,
164    /// Reduces across series: `sum`, `avg`, `min`, `max`, `count`, or `p50`..`p99`.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub space_aggregation: Option<String>,
167    /// The metric's value type: `float64`, `int64`, or `string`.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub data_type: Option<String>,
170    /// The query step interval, in seconds.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub step_interval_seconds: Option<u32>,
173    /// Filters narrowing the query.
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub filter: Vec<AlertRuleFilter>,
176    /// Attributes to group by.
177    #[serde(default, skip_serializing_if = "Vec::is_empty")]
178    pub group_by: Vec<AlertRuleGroupBy>,
179    /// Post-aggregation filters.
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub having: Vec<AlertRuleHaving>,
182}
183
184impl AlertRuleQuery {
185    /// A query over a single metric, with no aggregation or filters.
186    pub fn metric(metric: impl Into<String>) -> Self {
187        Self {
188            metric: metric.into(),
189            ..Default::default()
190        }
191    }
192}
193
194/// Compares the query's aggregated value against a threshold.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct AlertRuleCondition {
198    /// The comparison operator. Required.
199    pub operator: AlertConditionOperator,
200    /// The threshold to compare against. Required.
201    pub threshold: f64,
202    /// Overrides the query's time aggregation for the check.
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub time_aggregation: Option<String>,
205    /// Overrides the query's space aggregation for the check.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub space_aggregation: Option<String>,
208    /// Require at least this many sessions before the rule can fire.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub minimum_session_gate: Option<f64>,
211    /// The threshold's unit.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub unit: Option<String>,
214    /// How the threshold must be met over the window. Defaults to
215    /// [`AlertMatchType::AT_LEAST_ONCE`].
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub match_type: Option<AlertMatchType>,
218    /// Require a minimum number of data points.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub require_min_points: Option<bool>,
221    /// The minimum number of data points required.
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub required_num_points: Option<u32>,
224    /// How often the condition is evaluated, in seconds. Defaults to 60.
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub frequency_seconds: Option<u32>,
227}
228
229impl AlertRuleCondition {
230    /// A condition comparing the aggregated value against `threshold`.
231    pub fn new(operator: AlertConditionOperator, threshold: f64) -> Self {
232        Self {
233            operator,
234            threshold,
235            time_aggregation: None,
236            space_aggregation: None,
237            minimum_session_gate: None,
238            unit: None,
239            match_type: None,
240            require_min_points: None,
241            required_num_points: None,
242            frequency_seconds: None,
243        }
244    }
245}
246
247/// Controls how often and over what window the rule runs.
248#[derive(Debug, Clone, Default, Serialize, Deserialize)]
249#[serde(rename_all = "camelCase")]
250pub struct AlertRuleEvaluation {
251    /// How often the rule is evaluated, in seconds.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub frequency_seconds: Option<u32>,
254    /// The evaluation window, in seconds.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub window_seconds: Option<u32>,
257}
258
259/// An alert rule's config: a metric `query` and a threshold `condition` (both
260/// required), plus optional evaluation, absent-data alerting, and a formula over
261/// `additional_queries`.
262#[derive(Debug, Clone, Serialize, Deserialize)]
263#[serde(rename_all = "camelCase")]
264pub struct AlertRuleConfig {
265    /// The metric to alert on. Required.
266    pub query: AlertRuleQuery,
267    /// The threshold check. Required.
268    pub condition: AlertRuleCondition,
269    /// Free-form labels attached to fired incidents.
270    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
271    pub labels: HashMap<String, String>,
272    /// Evaluation frequency and window.
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub evaluation: Option<AlertRuleEvaluation>,
275    /// Alert when the metric stops reporting.
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub absent_data_alert: Option<AbsentDataAlert>,
278    /// Extra named queries (keys `B`..`Z`) referenced by `formula`.
279    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
280    pub additional_queries: HashMap<String, AlertRuleQuery>,
281    /// A formula combining `query` and `additional_queries`.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub formula: Option<String>,
284    /// Collapses each series to a single value before the threshold check.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub reduce_to: Option<AlertReduceTo>,
287}
288
289impl AlertRuleConfig {
290    /// A config from a query and a condition, leaving everything else default.
291    pub fn new(query: AlertRuleQuery, condition: AlertRuleCondition) -> Self {
292        Self {
293            query,
294            condition,
295            labels: HashMap::new(),
296            evaluation: None,
297            absent_data_alert: None,
298            additional_queries: HashMap::new(),
299            formula: None,
300            reduce_to: None,
301        }
302    }
303}
304
305/* --------------------------------- response ---------------------------------- */
306
307/// A configured alert rule.
308#[derive(Debug, Clone, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct AlertRule {
311    /// The rule's database id.
312    pub id: Option<String>,
313    /// The rule id.
314    #[serde(default, deserialize_with = "crate::common::null_to_default")]
315    pub alert_rule_id: String,
316    /// The rule's display name.
317    pub name: Option<String>,
318    /// The rule's severity.
319    pub severity: Option<AlertSeverity>,
320    /// The rule's description.
321    pub description: Option<String>,
322    /// A short summary applied to fired incidents.
323    pub summary: Option<String>,
324    /// The rule's domain.
325    pub domain: Option<AlertDomain>,
326    /// The rule's config.
327    pub rule_config: Option<AlertRuleConfig>,
328    /// The notification channels this rule delivers to.
329    #[serde(default, deserialize_with = "crate::common::null_to_default")]
330    pub notification_channel_ids: Vec<String>,
331    /// Whether the rule is active or paused.
332    pub status: Option<AlertRuleStatus>,
333    /// The rule's version.
334    pub version: Option<i64>,
335    /// When the rule last fired.
336    pub last_triggered: Option<String>,
337    /// When the rule was created.
338    pub created_at: Option<String>,
339    /// When the rule was last updated.
340    pub updated_at: Option<String>,
341    /// Any fields the server returned that this SDK does not model yet.
342    #[serde(flatten)]
343    pub extra: Map<String, Value>,
344}
345
346/* --------------------------------- params ------------------------------------ */
347
348/// The parameters for [`AlertRulesResource::create`].
349#[derive(Debug, Clone, Serialize)]
350#[serde(rename_all = "camelCase")]
351pub struct CreateAlertRuleParams {
352    /// The rule's display name. Required.
353    pub name: String,
354    /// The rule's severity. Required.
355    pub severity: AlertSeverity,
356    /// The rule's config. Required.
357    pub rule_config: AlertRuleConfig,
358    /// The rule's description.
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub description: Option<String>,
361    /// A summary applied to the underlying alert. Not stored on the rule record.
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub summary: Option<String>,
364    /// Whether the alert is active. Surfaces as `status`, not a returned
365    /// `enabled` field.
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub enabled: Option<bool>,
368    /// The rule's domain.
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub domain: Option<AlertDomain>,
371    /// Used when building the alert query. Not persisted on the rule record.
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub time_aggregation: Option<String>,
374    /// The notification channels to deliver to.
375    #[serde(skip_serializing_if = "Vec::is_empty")]
376    pub notification_channel_ids: Vec<String>,
377}
378
379impl CreateAlertRuleParams {
380    /// The required fields, leaving everything else unset.
381    pub fn new(
382        name: impl Into<String>,
383        severity: AlertSeverity,
384        rule_config: AlertRuleConfig,
385    ) -> Self {
386        Self {
387            name: name.into(),
388            severity,
389            rule_config,
390            description: None,
391            summary: None,
392            enabled: None,
393            domain: None,
394            time_aggregation: None,
395            notification_channel_ids: Vec::new(),
396        }
397    }
398}
399
400/// The parameters for [`AlertRulesResource::update`]. Only the set fields change.
401#[derive(Debug, Clone, Default, Serialize)]
402#[serde(rename_all = "camelCase")]
403pub struct UpdateAlertRuleParams {
404    /// A new display name.
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub name: Option<String>,
407    /// A new severity.
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub severity: Option<AlertSeverity>,
410    /// A new config.
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub rule_config: Option<AlertRuleConfig>,
413    /// A new description.
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub description: Option<String>,
416    /// A new summary.
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub summary: Option<String>,
419    /// Activate or pause the rule.
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub enabled: Option<bool>,
422    /// A new domain.
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub domain: Option<AlertDomain>,
425    /// A new query-time aggregation.
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub time_aggregation: Option<String>,
428    /// New notification channels.
429    #[serde(skip_serializing_if = "Vec::is_empty")]
430    pub notification_channel_ids: Vec<String>,
431}
432
433/// The query parameters for [`AlertRulesResource::list`].
434#[derive(Debug, Clone, Default)]
435pub struct ListAlertRulesParams {
436    /// The 1-based page number.
437    pub page: Option<u32>,
438    /// Items per page.
439    pub per_page: Option<u32>,
440    /// An opaque cursor from a previous page.
441    pub cursor: Option<String>,
442    /// Filters by region.
443    pub region: Option<String>,
444    /// Filters by status.
445    pub status: Option<AlertRuleStatus>,
446    /// Filters by metric category.
447    pub metric_category: Option<String>,
448    /// Filters by domain. The server defaults to [`AlertDomain::RTC`] when
449    /// omitted — pass [`AlertDomain::AGENT`] to see agent-domain rules.
450    pub domain: Option<AlertDomain>,
451}
452
453impl ListAlertRulesParams {
454    fn pagination(&self) -> ListParams {
455        ListParams {
456            page: self.page,
457            per_page: self.per_page,
458            cursor: self.cursor.clone(),
459        }
460    }
461}
462
463/* -------------------------------- resource ----------------------------------- */
464
465/// The alert-rules API. Reached via [`Client::alert_rules`].
466#[derive(Debug, Clone, Copy)]
467pub struct AlertRulesResource<'a> {
468    client: &'a Client,
469}
470
471impl<'a> AlertRulesResource<'a> {
472    pub(crate) fn new(client: &'a Client) -> Self {
473        Self { client }
474    }
475
476    /// Creates an alert rule.
477    pub async fn create(&self, params: CreateAlertRuleParams) -> Result<AlertRule> {
478        self.client
479            .wrapped(Method::POST, PATH, "alertRule", CallOptions::json(&params)?)
480            .await
481    }
482
483    /// Lists alert rules, one page at a time.
484    pub async fn list(&self, params: ListAlertRulesParams) -> Result<Page<AlertRule>> {
485        paginate(self.fetcher(&params), &params.pagination(), "data", None).await
486    }
487
488    /// Lists alert rules, transparently fetching every page.
489    pub fn list_stream(
490        &self,
491        params: ListAlertRulesParams,
492    ) -> impl Stream<Item = Result<AlertRule>> + Send {
493        auto_page(self.fetcher(&params), params.pagination(), "data", None)
494    }
495
496    /// Fetches an alert rule by id.
497    pub async fn get(&self, alert_rule_id: &str) -> Result<AlertRule> {
498        let path = format!("{PATH}/{}", escape(alert_rule_id));
499        self.client
500            .wrapped(Method::GET, &path, "alertRule", CallOptions::new())
501            .await
502    }
503
504    /// Updates an alert rule.
505    pub async fn update(
506        &self,
507        alert_rule_id: &str,
508        params: UpdateAlertRuleParams,
509    ) -> Result<AlertRule> {
510        let path = format!("{PATH}/{}", escape(alert_rule_id));
511        self.client
512            .wrapped(Method::PUT, &path, "alertRule", CallOptions::json(&params)?)
513            .await
514    }
515
516    /// Deletes an alert rule.
517    pub async fn delete(&self, alert_rule_id: &str) -> Result<MessageResponse> {
518        let path = format!("{PATH}/{}", escape(alert_rule_id));
519        self.client
520            .json(Method::DELETE, &path, CallOptions::new())
521            .await
522    }
523
524    fn fetcher(&self, params: &ListAlertRulesParams) -> PageFetcher {
525        let client = self.client.clone();
526        let params = params.clone();
527        Arc::new(move |page, per_page| {
528            let client = client.clone();
529            let params = params.clone();
530            Box::pin(async move {
531                let query = QueryBuilder::new()
532                    .opt("page", page)
533                    .opt("perPage", per_page)
534                    .opt_str("region", params.region.as_deref())
535                    .opt_str(
536                        "status",
537                        params.status.as_ref().map(AlertRuleStatus::as_str),
538                    )
539                    .opt_str("metricCategory", params.metric_category.as_deref())
540                    .opt_str("domain", params.domain.as_ref().map(AlertDomain::as_str))
541                    .into_pairs();
542                client
543                    .json::<Value>(Method::GET, PATH, CallOptions::new().query(query))
544                    .await
545            })
546        })
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use serde_json::json;
554
555    #[test]
556    fn create_serializes_the_typed_config() {
557        let params = CreateAlertRuleParams::new(
558            "High bitrate",
559            AlertSeverity::WARNING,
560            AlertRuleConfig::new(
561                AlertRuleQuery::metric("stat_consumer_video_bitrate"),
562                AlertRuleCondition::new(AlertConditionOperator::GREATER_THAN, 2500.0),
563            ),
564        );
565        assert_eq!(
566            serde_json::to_value(&params).unwrap(),
567            json!({
568                "name": "High bitrate",
569                "severity": "warning",
570                "ruleConfig": {
571                    "query": {"metric": "stat_consumer_video_bitrate"},
572                    "condition": {"operator": ">", "threshold": 2500.0},
573                },
574            })
575        );
576    }
577
578    #[test]
579    fn an_unknown_severity_on_a_response_does_not_fail() {
580        // The config + enums ride on the response, so a value this SDK predates
581        // must round-trip rather than break the whole rule.
582        let rule: AlertRule = serde_json::from_value(json!({
583            "alertRuleId": "ar-1",
584            "severity": "catastrophic",
585            "notificationChannelIds": null,
586        }))
587        .unwrap();
588        assert_eq!(rule.severity.unwrap().as_str(), "catastrophic");
589        assert!(rule.notification_channel_ids.is_empty());
590    }
591}