Skip to main content

opssignal_core/
template.rs

1//! Per-provider message templates.
2//!
3//! Each provider entry in `signal.yaml` may carry a `template` map that
4//! replaces the text of one or more parts of the message it sends, such
5//! as Slack's `text` or PagerDuty's `summary`. Templates use Jinja
6//! syntax (rendered with minijinja), and see every signal field by name:
7//! `{{ title }}`, `{{ severity }}`, `{{ metadata.dag_id }}`.
8//!
9//! Anything a template does not set keeps the built-in format, so a
10//! config without templates sends exactly what it always did.
11//!
12//! Mistakes are caught when the config loads: a syntax error, a part
13//! the provider doesn't have (`providers.slack.template.txt`) or an
14//! unknown variable (`{{ tittle }}`) is a config error. A template that
15//! still fails at send time, or renders to nothing but whitespace, is
16//! logged and the built-in format is sent instead: a broken template
17//! must never cost the alert.
18
19use crate::signal::{Signal, SignalError};
20use minijinja::{Environment, Value};
21use std::collections::BTreeMap;
22
23/// A provider's `template` map: part name to template source.
24pub type TemplateConfig = BTreeMap<String, String>;
25
26/// Signal fields a template can use. Optional fields the signal doesn't
27/// set are undefined, so `{% if message %}` works and `{{ message }}`
28/// renders as nothing.
29pub 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
49/// The parts each provider lets a template replace, and any variables
50/// it adds on top of the signal fields.
51const 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
69/// One provider's compiled templates.
70pub 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    /// Compiles and checks `provider`'s templates; `None` when it has
85    /// none. Used at config load, so every error names the offending
86    /// key.
87    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        // The default trims one trailing newline, the one a YAML `|`
96        // block adds.
97        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    /// Like [`Templates::compile`], for building a provider from a
135    /// config that was checked when it loaded. An error here can only
136    /// come from a config built by hand; it is logged and the provider
137    /// keeps its built-in format.
138    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    /// Renders `part` for `signal`, or `None` when this provider has no
149    /// template for it, it fails, or it renders to only whitespace; the
150    /// caller then uses its built-in format.
151    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
179/// Renders `part` with `templates` if there are any, else `None`.
180pub 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/// Compiled templates from `(part, source)` pairs, for provider tests.
205#[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        // `emoji` is Slack's; Teams doesn't render Slack shortcodes.
303        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}