Skip to main content

cloud/envoy/
observability.rs

1//! `observability.*` verb signatures — monitoring and alerting catalog (R409-T12).
2//!
3//! Five verbs covering the alerting and incident-management plane.
4//! Exemplar tier-A providers: PagerDuty, Grafana Cloud, Datadog.
5//!
6//! - `observability.alert.list`         — enumerate active/all alerts
7//! - `observability.alert.ack`          — acknowledge an alert
8//! - `observability.incident.open`      — open a new incident
9//! - `observability.incident.close`     — resolve/close an incident
10//! - `observability.dashboard.snapshot` — capture a dashboard snapshot URL
11//!
12//! The verb shapes are deliberately narrow — adapters map provider-specific
13//! state to the closed enums here. Free-form detail rides in optional `detail`
14//! fields rather than proliferating variants.
15
16use serde::{Deserialize, Serialize};
17
18use super::{InternalVerb, VerbCategory};
19
20// ── observability.alert.list ──────────────────────────────────────────────
21
22/// Marker type for `observability.alert.list`.
23pub struct ObservabilityAlertList;
24
25/// Request body for `observability.alert.list`.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
28pub struct ObservabilityAlertListInput {
29    /// Severity filter: `"critical"`, `"warning"`, `"info"`. `None` returns
30    /// alerts of all severities.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub severity: Option<String>,
33    /// When `true` (default), only return currently firing alerts. Set to
34    /// `false` to include resolved and silenced alerts.
35    #[serde(default = "bool_true")]
36    pub active_only: bool,
37}
38
39fn bool_true() -> bool {
40    true
41}
42
43/// One alert entry in the list response.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
46pub struct AlertEntry {
47    /// Provider-issued alert or rule ID.
48    pub id: String,
49    /// Human-readable alert title.
50    pub title: String,
51    /// Severity bucket: `"critical"`, `"warning"`, `"info"`, `"unknown"`.
52    pub severity: String,
53    /// Lifecycle state: `"firing"`, `"resolved"`, `"silenced"`, `"unknown"`.
54    pub state: String,
55    /// RFC 3339 timestamp of when this alert began firing. `None` when
56    /// the provider does not report it.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub started_at: Option<String>,
59    /// Provider-specific detail string — reason text, runbook link, etc.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub detail: Option<String>,
62}
63
64/// Response body for `observability.alert.list`.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
67pub struct ObservabilityAlertListOutput {
68    pub alerts: Vec<AlertEntry>,
69}
70
71impl InternalVerb for ObservabilityAlertList {
72    type Input = ObservabilityAlertListInput;
73    type Output = ObservabilityAlertListOutput;
74    const ID: &'static str = "observability.alert.list";
75    const CATEGORY: VerbCategory = VerbCategory::Observability;
76}
77
78// ── observability.alert.ack ───────────────────────────────────────────────
79
80/// Marker type for `observability.alert.ack`.
81pub struct ObservabilityAlertAck;
82
83/// Request body for `observability.alert.ack`.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
86pub struct ObservabilityAlertAckInput {
87    /// Provider-issued alert ID.
88    pub id: String,
89    /// Acknowledgement message or reason. Encouraged but optional.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub message: Option<String>,
92}
93
94/// Response body for `observability.alert.ack`.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
97pub struct ObservabilityAlertAckOutput {
98    /// `true` if the alert transitioned to acknowledged; `false` when it was
99    /// already acknowledged before this call (idempotent).
100    pub acknowledged: bool,
101}
102
103impl InternalVerb for ObservabilityAlertAck {
104    type Input = ObservabilityAlertAckInput;
105    type Output = ObservabilityAlertAckOutput;
106    const ID: &'static str = "observability.alert.ack";
107    const CATEGORY: VerbCategory = VerbCategory::Observability;
108}
109
110// ── observability.incident.open ───────────────────────────────────────────
111
112/// Marker type for `observability.incident.open`.
113pub struct ObservabilityIncidentOpen;
114
115/// Request body for `observability.incident.open`.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
118pub struct ObservabilityIncidentOpenInput {
119    pub title: String,
120    /// Severity: `"critical"`, `"major"`, `"minor"`, `"info"`. Adapters
121    /// map to provider-specific levels (e.g. PagerDuty P1–P5).
122    pub severity: String,
123    /// Optional incident body — description, timeline, runbook link.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub body: Option<String>,
126}
127
128/// Response body for `observability.incident.open`.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
131pub struct ObservabilityIncidentOpenOutput {
132    /// Provider-issued incident ID.
133    pub id: String,
134    /// URL to the incident page in the provider's UI. `None` when the
135    /// provider doesn't return one at creation time.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub url: Option<String>,
138}
139
140impl InternalVerb for ObservabilityIncidentOpen {
141    type Input = ObservabilityIncidentOpenInput;
142    type Output = ObservabilityIncidentOpenOutput;
143    const ID: &'static str = "observability.incident.open";
144    const CATEGORY: VerbCategory = VerbCategory::Observability;
145}
146
147// ── observability.incident.close ──────────────────────────────────────────
148
149/// Marker type for `observability.incident.close`.
150pub struct ObservabilityIncidentClose;
151
152/// Request body for `observability.incident.close`.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
155pub struct ObservabilityIncidentCloseInput {
156    /// Provider-issued incident ID from a prior `observability.incident.open`.
157    pub id: String,
158    /// Resolution summary. Encouraged but optional.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub resolution: Option<String>,
161}
162
163/// Response body for `observability.incident.close`.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
166pub struct ObservabilityIncidentCloseOutput {
167    /// `true` if the incident was open and is now closed; `false` if it was
168    /// already resolved (idempotent).
169    pub closed: bool,
170}
171
172impl InternalVerb for ObservabilityIncidentClose {
173    type Input = ObservabilityIncidentCloseInput;
174    type Output = ObservabilityIncidentCloseOutput;
175    const ID: &'static str = "observability.incident.close";
176    const CATEGORY: VerbCategory = VerbCategory::Observability;
177}
178
179// ── observability.dashboard.snapshot ─────────────────────────────────────
180
181/// Marker type for `observability.dashboard.snapshot`.
182pub struct ObservabilityDashboardSnapshot;
183
184/// Request body for `observability.dashboard.snapshot`.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
187pub struct ObservabilityDashboardSnapshotInput {
188    /// Provider-specific dashboard identifier (UID, ID, or slug).
189    pub dashboard_id: String,
190    /// Time range for the snapshot. Provider-specific formats are accepted
191    /// (e.g. `"last_1h"`, `"last_24h"`, `"2026-06-01T00:00Z/2026-06-02T00:00Z"`).
192    /// `None` uses the provider's default view.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub time_range: Option<String>,
195}
196
197/// Response body for `observability.dashboard.snapshot`.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
200pub struct ObservabilityDashboardSnapshotOutput {
201    /// Publicly-accessible (or token-authenticated) URL to the snapshot.
202    pub url: String,
203    /// RFC 3339 expiry timestamp. `None` when the provider does not expire
204    /// snapshots automatically.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub expires_at: Option<String>,
207}
208
209impl InternalVerb for ObservabilityDashboardSnapshot {
210    type Input = ObservabilityDashboardSnapshotInput;
211    type Output = ObservabilityDashboardSnapshotOutput;
212    const ID: &'static str = "observability.dashboard.snapshot";
213    const CATEGORY: VerbCategory = VerbCategory::Observability;
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn verb_ids_match_canonical_namespace() {
222        let ids = [
223            ObservabilityAlertList::ID,
224            ObservabilityAlertAck::ID,
225            ObservabilityIncidentOpen::ID,
226            ObservabilityIncidentClose::ID,
227            ObservabilityDashboardSnapshot::ID,
228        ];
229        for id in ids {
230            assert!(id.starts_with("observability."), "{id}");
231        }
232    }
233
234    #[test]
235    fn verbs_are_under_observability_category() {
236        assert_eq!(
237            ObservabilityAlertList::CATEGORY,
238            VerbCategory::Observability
239        );
240        assert_eq!(ObservabilityAlertAck::CATEGORY, VerbCategory::Observability);
241        assert_eq!(
242            ObservabilityIncidentOpen::CATEGORY,
243            VerbCategory::Observability
244        );
245        assert_eq!(
246            ObservabilityIncidentClose::CATEGORY,
247            VerbCategory::Observability
248        );
249        assert_eq!(
250            ObservabilityDashboardSnapshot::CATEGORY,
251            VerbCategory::Observability
252        );
253    }
254
255    #[test]
256    fn alert_list_input_defaults_active_only_true() {
257        let wire = r#"{"severity":"critical"}"#;
258        let parsed: ObservabilityAlertListInput = serde_json::from_str(wire).unwrap();
259        assert!(parsed.active_only, "active_only should default to true");
260        assert_eq!(parsed.severity.as_deref(), Some("critical"));
261    }
262
263    #[test]
264    fn alert_list_input_no_severity_returns_all() {
265        let wire = r#"{}"#;
266        let parsed: ObservabilityAlertListInput = serde_json::from_str(wire).unwrap();
267        assert!(parsed.severity.is_none());
268        assert!(parsed.active_only);
269    }
270
271    #[test]
272    fn alert_entry_round_trips() {
273        let entry = AlertEntry {
274            id: "a1".into(),
275            title: "CPU high".into(),
276            severity: "critical".into(),
277            state: "firing".into(),
278            started_at: Some("2026-06-06T00:00:00Z".into()),
279            detail: None,
280        };
281        let wire = serde_json::to_string(&entry).unwrap();
282        let back: AlertEntry = serde_json::from_str(&wire).unwrap();
283        assert_eq!(back.severity, "critical");
284        assert!(back.detail.is_none());
285    }
286
287    #[test]
288    fn incident_open_output_omits_url_when_absent() {
289        let out = ObservabilityIncidentOpenOutput {
290            id: "INC-1".into(),
291            url: None,
292        };
293        let wire = serde_json::to_value(&out).unwrap();
294        assert!(!wire.as_object().unwrap().contains_key("url"));
295    }
296
297    #[test]
298    fn dashboard_snapshot_time_range_optional() {
299        let no_range = r#"{"dashboard_id":"abc123"}"#;
300        let parsed: ObservabilityDashboardSnapshotInput = serde_json::from_str(no_range).unwrap();
301        assert!(parsed.time_range.is_none());
302
303        let with_range = r#"{"dashboard_id":"abc123","time_range":"last_24h"}"#;
304        let parsed: ObservabilityDashboardSnapshotInput = serde_json::from_str(with_range).unwrap();
305        assert_eq!(parsed.time_range.as_deref(), Some("last_24h"));
306    }
307
308    #[cfg(feature = "json-schema")]
309    #[test]
310    fn verbs_emit_schemas_via_for_verb() {
311        use super::super::VerbDescriptor;
312
313        let alert_list = VerbDescriptor::for_verb::<ObservabilityAlertList>();
314        assert_eq!(alert_list.id, "observability.alert.list");
315        assert!(alert_list.output_schema.to_string().contains("alerts"));
316
317        let snapshot = VerbDescriptor::for_verb::<ObservabilityDashboardSnapshot>();
318        assert_eq!(snapshot.id, "observability.dashboard.snapshot");
319        assert!(snapshot.output_schema.to_string().contains("url"));
320    }
321}