Skip to main content

opssignal_core/providers/
opsgenie.rs

1use crate::delivery::Provider;
2use crate::signal::{Severity, Signal, SignalError};
3use crate::template::{self, Templates};
4
5/// Alert API endpoint for US-hosted accounts. EU accounts use
6/// `https://api.eu.opsgenie.com/v2/alerts`, set through `url`.
7pub const DEFAULT_ALERTS_URL: &str = "https://api.opsgenie.com/v2/alerts";
8
9/// Opsgenie rejects longer values with a 422, which retrying cannot
10/// fix, so they are cut to fit instead.
11const MAX_MESSAGE_CHARS: usize = 130;
12const MAX_ALIAS_CHARS: usize = 512;
13const MAX_DESCRIPTION_CHARS: usize = 15000;
14const MAX_SOURCE_CHARS: usize = 100;
15const MAX_ENTITY_CHARS: usize = 512;
16const MAX_TAGS: usize = 20;
17const MAX_TAG_CHARS: usize = 50;
18
19/// Creates an Opsgenie alert through the Alert API. The signal's
20/// `dedup_key` becomes the alert `alias`, so a repeat of the same
21/// signal adds to the open alert's count instead of opening a new one.
22pub struct OpsgenieProvider {
23    api_key: String,
24    pub url: String,
25    tags: Vec<String>,
26    client: reqwest::Client,
27    templates: Option<Templates>,
28}
29
30impl OpsgenieProvider {
31    pub fn new(api_key: String, url: Option<String>, tags: Vec<String>) -> Self {
32        OpsgenieProvider {
33            api_key,
34            url: url.unwrap_or_else(|| DEFAULT_ALERTS_URL.to_string()),
35            tags,
36            client: reqwest::Client::new(),
37            templates: None,
38        }
39    }
40
41    /// Uses `templates` for the parts they set; see [`crate::template`].
42    pub fn with_templates(mut self, templates: Option<Templates>) -> Self {
43        self.templates = templates;
44        self
45    }
46
47    /// Opsgenie priorities run from P1 (critical) to P5
48    /// (informational); P4 is left for people to assign by hand.
49    fn priority_for(severity: Severity) -> &'static str {
50        match severity {
51            Severity::Critical => "P1",
52            Severity::Error => "P2",
53            Severity::Warning => "P3",
54            Severity::Info | Severity::Success => "P5",
55        }
56    }
57
58    fn alert_for(&self, signal: &Signal) -> serde_json::Value {
59        // Opsgenie's details are a flat string-to-string map.
60        let mut details = serde_json::Map::new();
61        details.insert("signal_id".into(), signal.id.to_string().into());
62        details.insert("event_type".into(), signal.event_type.clone().into());
63        details.insert("severity".into(), signal.severity.to_string().into());
64        details.insert("timestamp".into(), signal.timestamp.to_rfc3339().into());
65        let optional = [
66            ("environment", &signal.environment),
67            ("service", &signal.service),
68            ("component", &signal.component),
69            ("team", &signal.team),
70            ("run_id", &signal.run_id),
71            ("correlation_id", &signal.correlation_id),
72            ("trace_id", &signal.trace_id),
73            ("log_url", &signal.log_url),
74        ];
75        for (key, value) in optional {
76            if let Some(value) = value {
77                details.insert(key.into(), value.clone().into());
78            }
79        }
80        if let Some(metadata) = signal.metadata.as_object() {
81            for (key, value) in metadata {
82                let text = match value {
83                    serde_json::Value::String(s) => s.clone(),
84                    other => other.to_string(),
85                };
86                details
87                    .entry(format!("metadata.{key}"))
88                    .or_insert(text.into());
89            }
90        }
91
92        let mut tags: Vec<String> = self.tags.clone();
93        tags.push(signal.severity.to_string());
94        if let Some(environment) = &signal.environment {
95            tags.push(environment.clone());
96        }
97        let mut seen = std::collections::HashSet::new();
98        let tags: Vec<String> = tags
99            .into_iter()
100            .map(|t| truncate(&t, MAX_TAG_CHARS))
101            .filter(|t| !t.is_empty() && seen.insert(t.clone()))
102            .take(MAX_TAGS)
103            .collect();
104
105        let message = template::render(&self.templates, "message", signal, &[])
106            .unwrap_or_else(|| signal.title.clone());
107        let mut alert = serde_json::json!({
108            "message": truncate(&message, MAX_MESSAGE_CHARS),
109            "source": truncate(&signal.source, MAX_SOURCE_CHARS),
110            "priority": Self::priority_for(signal.severity),
111            "tags": tags,
112            "details": details,
113        });
114        if let Some(key) = &signal.dedup_key {
115            alert["alias"] = truncate(key, MAX_ALIAS_CHARS).into();
116        }
117        let description = template::render(&self.templates, "description", signal, &[])
118            .unwrap_or_else(|| {
119                let mut description = signal.message.clone().unwrap_or_default();
120                if let Some(log_url) = &signal.log_url {
121                    if !description.is_empty() {
122                        description.push_str("\n\n");
123                    }
124                    description.push_str("Logs: ");
125                    description.push_str(log_url);
126                }
127                description
128            });
129        if !description.is_empty() {
130            alert["description"] = truncate(&description, MAX_DESCRIPTION_CHARS).into();
131        }
132        let entity = match (&signal.service, &signal.component) {
133            (Some(service), Some(component)) => Some(format!("{service}/{component}")),
134            (Some(one), None) | (None, Some(one)) => Some(one.clone()),
135            (None, None) => None,
136        };
137        if let Some(entity) = entity {
138            alert["entity"] = truncate(&entity, MAX_ENTITY_CHARS).into();
139        }
140        alert
141    }
142}
143
144fn truncate(s: &str, max_chars: usize) -> String {
145    match s.char_indices().nth(max_chars) {
146        Some((end, _)) => s[..end].to_string(),
147        None => s.to_string(),
148    }
149}
150
151#[async_trait::async_trait]
152impl Provider for OpsgenieProvider {
153    fn name(&self) -> &'static str {
154        "opsgenie"
155    }
156
157    fn preview(&self, signal: &Signal) -> Option<serde_json::Value> {
158        Some(self.alert_for(signal))
159    }
160
161    async fn send(&self, signal: &Signal) -> Result<(), SignalError> {
162        let resp = self
163            .client
164            .post(&self.url)
165            .header("Authorization", format!("GenieKey {}", self.api_key))
166            .json(&self.alert_for(signal))
167            .send()
168            .await
169            .map_err(|e| SignalError::Delivery(e.to_string()))?;
170
171        if !resp.status().is_success() {
172            return Err(SignalError::Delivery(format!(
173                "opsgenie alert API returned status {}",
174                resp.status()
175            )));
176        }
177        Ok(())
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::signal::SignalInput;
185    use wiremock::matchers::{body_partial_json, header, method, path};
186    use wiremock::{Mock, MockServer, ResponseTemplate};
187
188    fn signal(severity: Severity) -> Signal {
189        Signal::from_input(SignalInput {
190            source: "airflow".into(),
191            service: Some("orders".into()),
192            component: Some("dbt".into()),
193            event_type: "task_failed".into(),
194            title: "dbt model failed".into(),
195            message: Some("model orders timed out".into()),
196            severity,
197            environment: Some("prod".into()),
198            log_url: Some("https://logs.example.test/run/1".into()),
199            metadata: Some(serde_json::json!({ "dag": "daily", "try": 2 })),
200            ..Default::default()
201        })
202    }
203
204    #[test]
205    fn severities_map_to_opsgenie_priorities() {
206        assert_eq!(OpsgenieProvider::priority_for(Severity::Critical), "P1");
207        assert_eq!(OpsgenieProvider::priority_for(Severity::Error), "P2");
208        assert_eq!(OpsgenieProvider::priority_for(Severity::Warning), "P3");
209        assert_eq!(OpsgenieProvider::priority_for(Severity::Info), "P5");
210        assert_eq!(OpsgenieProvider::priority_for(Severity::Success), "P5");
211    }
212
213    #[test]
214    fn defaults_to_the_us_alert_endpoint() {
215        let provider = OpsgenieProvider::new("key".into(), None, vec![]);
216        assert_eq!(provider.url, DEFAULT_ALERTS_URL);
217    }
218
219    #[test]
220    fn alert_carries_signal_fields() {
221        let provider = OpsgenieProvider::new("k".into(), None, vec!["data".into()]);
222        let signal = signal(Severity::Critical);
223        let alert = provider.alert_for(&signal);
224
225        assert_eq!(alert["message"], "dbt model failed");
226        assert_eq!(alert["alias"], signal.dedup_key.clone().unwrap());
227        assert_eq!(alert["source"], "airflow");
228        assert_eq!(alert["priority"], "P1");
229        assert_eq!(alert["entity"], "orders/dbt");
230        assert_eq!(
231            alert["description"],
232            "model orders timed out\n\nLogs: https://logs.example.test/run/1"
233        );
234        assert_eq!(
235            alert["tags"],
236            serde_json::json!(["data", "critical", "prod"])
237        );
238        let details = &alert["details"];
239        assert_eq!(details["signal_id"], signal.id.to_string());
240        assert_eq!(details["event_type"], "task_failed");
241        assert_eq!(details["environment"], "prod");
242        assert_eq!(details["log_url"], "https://logs.example.test/run/1");
243        assert_eq!(details["metadata.dag"], "daily");
244        // Non-string metadata is flattened to its JSON text.
245        assert_eq!(details["metadata.try"], "2");
246        assert!(details.get("team").is_none());
247    }
248
249    #[test]
250    fn minimal_signal_omits_optional_fields() {
251        let provider = OpsgenieProvider::new("k".into(), None, vec![]);
252        let signal = Signal::from_input(SignalInput {
253            source: "ci".into(),
254            event_type: "build_failed".into(),
255            title: "main is red".into(),
256            ..Default::default()
257        });
258        let alert = provider.alert_for(&signal);
259        assert!(alert.get("description").is_none());
260        assert!(alert.get("entity").is_none());
261        assert_eq!(alert["tags"], serde_json::json!(["info"]));
262    }
263
264    #[test]
265    fn long_fields_are_cut_to_opsgenie_limits() {
266        let tags = (0..30).map(|i| format!("{i}-{}", "x".repeat(60))).collect();
267        let provider = OpsgenieProvider::new("k".into(), None, tags);
268        let signal = Signal::from_input(SignalInput {
269            source: "s".repeat(200),
270            event_type: "build_failed".into(),
271            // Multi-byte characters, so a byte-based cut would panic.
272            title: "é".repeat(500),
273            dedup_key: Some("ü".repeat(600)),
274            ..Default::default()
275        });
276        let alert = provider.alert_for(&signal);
277        let chars = |v: &serde_json::Value| v.as_str().unwrap().chars().count();
278        assert_eq!(chars(&alert["message"]), MAX_MESSAGE_CHARS);
279        assert_eq!(chars(&alert["alias"]), MAX_ALIAS_CHARS);
280        assert_eq!(chars(&alert["source"]), MAX_SOURCE_CHARS);
281        let tags = alert["tags"].as_array().unwrap();
282        assert_eq!(tags.len(), MAX_TAGS);
283        assert!(tags.iter().all(|t| chars(t) <= MAX_TAG_CHARS));
284    }
285
286    #[test]
287    fn duplicate_tags_are_dropped() {
288        let provider = OpsgenieProvider::new("k".into(), None, vec!["prod".into(), "error".into()]);
289        let alert = provider.alert_for(&signal(Severity::Error));
290        assert_eq!(alert["tags"], serde_json::json!(["prod", "error"]));
291    }
292
293    #[tokio::test]
294    async fn posts_the_alert_with_the_genie_key() {
295        let server = MockServer::start().await;
296        Mock::given(method("POST"))
297            .and(path("/v2/alerts"))
298            .and(header("authorization", "GenieKey S3CRET"))
299            .and(header("content-type", "application/json"))
300            .and(body_partial_json(serde_json::json!({
301                "message": "dbt model failed",
302                "priority": "P2",
303            })))
304            .respond_with(ResponseTemplate::new(202))
305            .expect(1)
306            .mount(&server)
307            .await;
308
309        OpsgenieProvider::new(
310            "S3CRET".into(),
311            Some(format!("{}/v2/alerts", server.uri())),
312            vec![],
313        )
314        .send(&signal(Severity::Error))
315        .await
316        .expect("202 is success");
317    }
318
319    #[tokio::test]
320    async fn rejected_alert_is_a_delivery_error() {
321        let server = MockServer::start().await;
322        Mock::given(method("POST"))
323            .respond_with(ResponseTemplate::new(401))
324            .mount(&server)
325            .await;
326
327        let err = OpsgenieProvider::new("bad".into(), Some(server.uri()), vec![])
328            .send(&signal(Severity::Error))
329            .await
330            .expect_err("401 must fail");
331        assert!(matches!(err, SignalError::Delivery(_)));
332        assert!(err.to_string().contains("401"));
333        // The key must never end up in an error that gets logged.
334        assert!(!err.to_string().contains("bad"));
335    }
336
337    #[test]
338    fn message_and_description_templates_replace_the_defaults() {
339        let provider =
340            OpsgenieProvider::new("k".into(), None, vec![]).with_templates(template::for_test(
341                "opsgenie",
342                &[
343                    ("message", "{{ service }}: {{ title }}"),
344                    (
345                        "description",
346                        "DAG {{ metadata.dag }}, try {{ metadata.try }}",
347                    ),
348                ],
349            ));
350        let alert = provider.alert_for(&signal(Severity::Error));
351        assert_eq!(alert["message"], "orders: dbt model failed");
352        assert_eq!(alert["description"], "DAG daily, try 2");
353    }
354}