1use crate::signal::{Signal, SignalError};
20use minijinja::{Environment, Value};
21use std::collections::BTreeMap;
22
23pub type TemplateConfig = BTreeMap<String, String>;
25
26pub const SIGNAL_VARIABLES: &[&str] = &[
30 "id",
31 "source",
32 "service",
33 "component",
34 "event_type",
35 "severity",
36 "title",
37 "message",
38 "environment",
39 "team",
40 "run_id",
41 "correlation_id",
42 "trace_id",
43 "log_url",
44 "metadata",
45 "timestamp",
46 "dedup_key",
47];
48
49const PROVIDERS: &[(&str, &[&str], &[&str])] = &[
52 ("console", &["text"], &[]),
53 ("slack", &["text"], &["emoji"]),
54 ("webhook", &["body"], &[]),
55 ("pagerduty", &["summary"], &[]),
56 ("teams", &["title", "text"], &[]),
57 ("opsgenie", &["message", "description"], &[]),
58 ("email", &["subject", "body"], &[]),
59];
60
61fn provider_spec(provider: &str) -> (&'static [&'static str], &'static [&'static str]) {
62 PROVIDERS
63 .iter()
64 .find(|(name, _, _)| *name == provider)
65 .map(|(_, parts, extras)| (*parts, *extras))
66 .unwrap_or((&[], &[]))
67}
68
69pub struct Templates {
71 provider: &'static str,
72 env: Environment<'static>,
73}
74
75impl std::fmt::Debug for Templates {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 f.debug_struct("Templates")
78 .field("provider", &self.provider)
79 .finish_non_exhaustive()
80 }
81}
82
83impl Templates {
84 pub fn compile(
88 provider: &'static str,
89 config: &TemplateConfig,
90 ) -> Result<Option<Self>, SignalError> {
91 if config.is_empty() {
92 return Ok(None);
93 }
94 let (parts, extras) = provider_spec(provider);
95 let mut env = Environment::new();
98 for (part, source) in config {
99 let at = format!("providers.{provider}.template.{part}");
100 if !parts.contains(&part.as_str()) {
101 return Err(config_err(format!(
102 "{at}: {provider} has no part '{part}' (templatable: {})",
103 parts.join(", ")
104 )));
105 }
106 env.add_template_owned(part.clone(), source.clone())
107 .map_err(|e| config_err(format!("{at}: {e}")))?;
108 let template = env
109 .get_template(part)
110 .map_err(|e| config_err(format!("{at}: {e}")))?;
111 let mut unknown: Vec<String> = template
112 .undeclared_variables(false)
113 .into_iter()
114 .filter(|name| {
115 !SIGNAL_VARIABLES.contains(&name.as_str())
116 && !extras.contains(&name.as_str())
117 && !env.globals().any(|(global, _)| global == name)
118 })
119 .collect();
120 if !unknown.is_empty() {
121 unknown.sort();
122 let mut known: Vec<&str> = SIGNAL_VARIABLES.to_vec();
123 known.extend_from_slice(extras);
124 return Err(config_err(format!(
125 "{at}: unknown variable {} (available: {})",
126 unknown.join(", "),
127 known.join(", ")
128 )));
129 }
130 }
131 Ok(Some(Templates { provider, env }))
132 }
133
134 pub fn load(provider: &'static str, config: &TemplateConfig) -> Option<Self> {
139 match Self::compile(provider, config) {
140 Ok(templates) => templates,
141 Err(e) => {
142 tracing::warn!(provider, error = %e, "ignoring message templates");
143 None
144 }
145 }
146 }
147
148 pub fn render(&self, part: &str, signal: &Signal, extras: &[(&str, Value)]) -> Option<String> {
152 let template = self.env.get_template(part).ok()?;
153 let result = template.render(context(signal, extras));
154 match result {
155 Ok(text) if !text.trim().is_empty() => Some(text),
156 Ok(_) => {
157 tracing::warn!(
158 provider = self.provider,
159 part,
160 signal_id = %signal.id,
161 "template rendered empty; sending the default format"
162 );
163 None
164 }
165 Err(e) => {
166 tracing::warn!(
167 provider = self.provider,
168 part,
169 signal_id = %signal.id,
170 error = %e,
171 "template failed; sending the default format"
172 );
173 None
174 }
175 }
176 }
177}
178
179pub fn render(
181 templates: &Option<Templates>,
182 part: &str,
183 signal: &Signal,
184 extras: &[(&str, Value)],
185) -> Option<String> {
186 templates.as_ref()?.render(part, signal, extras)
187}
188
189fn context(signal: &Signal, extras: &[(&str, Value)]) -> Value {
190 let mut map: BTreeMap<String, Value> = BTreeMap::new();
191 if let Ok(serde_json::Value::Object(fields)) = serde_json::to_value(signal) {
192 for (key, value) in fields {
193 if !value.is_null() {
194 map.insert(key, Value::from_serialize(&value));
195 }
196 }
197 }
198 for (key, value) in extras {
199 map.insert((*key).to_string(), value.clone());
200 }
201 Value::from(map)
202}
203
204#[cfg(test)]
206pub(crate) fn for_test(provider: &'static str, parts: &[(&str, &str)]) -> Option<Templates> {
207 let config = parts
208 .iter()
209 .map(|(k, v)| (k.to_string(), v.to_string()))
210 .collect();
211 Templates::compile(provider, &config).expect("valid templates")
212}
213
214fn config_err(msg: impl Into<String>) -> SignalError {
215 SignalError::Config(msg.into())
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use crate::signal::{Severity, SignalInput};
222
223 fn config(pairs: &[(&str, &str)]) -> TemplateConfig {
224 pairs
225 .iter()
226 .map(|(k, v)| (k.to_string(), v.to_string()))
227 .collect()
228 }
229
230 fn signal() -> Signal {
231 Signal::from_input(SignalInput {
232 source: "airflow".into(),
233 event_type: "task_failed".into(),
234 title: "load failed".into(),
235 severity: Severity::Error,
236 environment: Some("prod".into()),
237 metadata: Some(serde_json::json!({ "dag_id": "orders", "tries": 3 })),
238 ..Default::default()
239 })
240 }
241
242 #[test]
243 fn no_templates_compiles_to_none() {
244 assert!(Templates::compile("slack", &TemplateConfig::new())
245 .expect("ok")
246 .is_none());
247 }
248
249 #[test]
250 fn renders_signal_fields_metadata_and_extras() {
251 let t = Templates::compile(
252 "slack",
253 &config(&[(
254 "text",
255 "{{ emoji }} [{{ environment|upper }}] {{ title }} ({{ metadata.dag_id }}, try {{ metadata.tries }})",
256 )]),
257 )
258 .expect("ok")
259 .expect("some");
260 let text = t
261 .render("text", &signal(), &[("emoji", Value::from(":x:"))])
262 .expect("rendered");
263 assert_eq!(text, ":x: [PROD] load failed (orders, try 3)");
264 }
265
266 #[test]
267 fn unset_optional_fields_are_undefined() {
268 let t = Templates::compile(
269 "slack",
270 &config(&[(
271 "text",
272 "{{ title }}{% if message %}: {{ message }}{% endif %}|{{ team }}|",
273 )]),
274 )
275 .expect("ok")
276 .expect("some");
277 assert_eq!(
278 t.render("text", &signal(), &[]).expect("rendered"),
279 "load failed||"
280 );
281 }
282
283 #[test]
284 fn unknown_part_is_a_config_error() {
285 let err = Templates::compile("slack", &config(&[("txt", "{{ title }}")]))
286 .expect_err("unknown part");
287 let msg = err.to_string();
288 assert!(msg.contains("providers.slack.template.txt"), "{msg}");
289 assert!(msg.contains("templatable: text"), "{msg}");
290 }
291
292 #[test]
293 fn unknown_variable_is_a_config_error() {
294 let err = Templates::compile("pagerduty", &config(&[("summary", "{{ tittle }}")]))
295 .expect_err("typo");
296 let msg = err.to_string();
297 assert!(msg.contains("unknown variable tittle"), "{msg}");
298 }
299
300 #[test]
301 fn provider_extras_are_not_shared() {
302 assert!(Templates::compile("teams", &config(&[("title", "{{ emoji }}")])).is_err());
304 }
305
306 #[test]
307 fn syntax_error_is_a_config_error() {
308 let err =
309 Templates::compile("email", &config(&[("subject", "{{ title ")])).expect_err("syntax");
310 assert!(err.to_string().contains("providers.email.template.subject"));
311 }
312
313 #[test]
314 fn globals_and_loop_variables_are_allowed() {
315 let t = Templates::compile(
316 "email",
317 &config(&[(
318 "body",
319 "{% for key, value in metadata|items %}{{ key }}={{ value }};{% endfor %}{% for i in range(2) %}{{ loop.index }}{% endfor %}",
320 )]),
321 )
322 .expect("ok")
323 .expect("some");
324 assert_eq!(
325 t.render("body", &signal(), &[]).expect("rendered"),
326 "dag_id=orders;tries=3;12"
327 );
328 }
329
330 #[test]
331 fn runtime_failure_and_blank_output_fall_back() {
332 let t = Templates::compile("console", &config(&[("text", "{{ metadata.dag_id + 1 }}")]))
333 .expect("ok")
334 .expect("some");
335 assert!(t.render("text", &signal(), &[]).is_none());
336
337 let blank = Templates::compile("console", &config(&[("text", "{{ message }} \n")]))
338 .expect("ok")
339 .expect("some");
340 assert!(blank.render("text", &signal(), &[]).is_none());
341 }
342
343 #[test]
344 fn missing_part_is_none() {
345 let t = Templates::compile("teams", &config(&[("title", "{{ title }}")]))
346 .expect("ok")
347 .expect("some");
348 assert!(t.render("text", &signal(), &[]).is_none());
349 }
350}