1use crate::delivery::Provider;
2use crate::signal::{Severity, Signal, SignalError};
3use crate::template::{self, Templates};
4
5pub const DEFAULT_ALERTS_URL: &str = "https://api.opsgenie.com/v2/alerts";
8
9const 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
19pub 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 pub fn with_templates(mut self, templates: Option<Templates>) -> Self {
43 self.templates = templates;
44 self
45 }
46
47 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 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 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 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 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}