diff --git a/CHANGELOG.md b/CHANGELOG.md index 88e022a..97b59e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.2.0] - unreleased +## [1.2.1] - unreleased + +### Fixed +- **`{{ rule_name }}` available in top-level templates and throttle key** (issue #31). `rule_name` is now injected into the render context of `templates..title`, `body`, and `email_body_html`, and also into the `throttle.key` template, not just the notifier-level `subject_template` / `body_template`. Configs that referenced `{{ rule_name }}` in a top-level template previously rendered an empty string; they now render the rule name. If an event field happens to be literally named `rule_name`, the synthetic rule name wins, matching the collision policy of the existing notifier-level contexts. +- **Dotted event fields resolve in `throttle.key`**. The unflatten step introduced in v1.2.0 for template rendering (issue #25) now also applies to the throttle key template, so `throttle.key: "{{ nginx.http.status_code }}-{{ hostname }}"` works consistently with `title` / `body`. + +## [1.2.0] - 2026-04-15 ### Breaking changes - **Template field `body_html` renamed to `email_body_html`** to reflect that diff --git a/Cargo.lock b/Cargo.lock index ed2bc47..7c3e0a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2334,7 +2334,7 @@ dependencies = [ [[package]] name = "valerter" -version = "1.2.0" +version = "1.2.1" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index e397f04..d960ad6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "valerter" -version = "1.2.0" +version = "1.2.1" edition = "2024" description = "Real-time log alerting for VictoriaLogs" license = "Apache-2.0" diff --git a/config/config.example.yaml b/config/config.example.yaml index ae1bc1d..b1474e9 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -106,8 +106,15 @@ defaults: # streams configured server-side) # JSON field or named regex group # rule_name name of the triggered rule +# (available in `title`, `body`, +# `email_body_html`, `throttle.key`, +# and in notifier-level +# `subject_template` / +# `body_template`) # log_timestamp event timestamp (ISO8601) +# (notifier-level templates only) # log_timestamp_formatted human timestamp (defaults.timestamp_timezone) +# (notifier-level templates only) # # 2. Template OUTPUT KEYS that you define below (left-hand side). These are # the slots valerter fills and hands off to notifiers: diff --git a/docs/configuration.md b/docs/configuration.md index a4f0fca..077e931 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -206,11 +206,18 @@ Variables come from the parser output plus built-in fields: | `log_timestamp_formatted` | Human-readable timestamp (respects `timestamp_timezone` setting) | | Custom fields | Extracted by regex/JSON parser | +**Note:** `rule_name` is available in all template contexts: the top-level +template fields (`title`, `body`, `email_body_html`), the `throttle.key`, and +the notifier-level templates (`subject_template`, `body_template`). If an +event field happens to be named `rule_name`, the synthetic rule name wins. + **Note:** `log_timestamp` and `log_timestamp_formatted` are available in: - Email subject and body templates - Webhook `body_template` - Mattermost footer (automatically includes `log_timestamp_formatted`) +These timestamps are computed **after** the top-level template renders, so they are only accessible in notifier-level templates. If you need a timestamp at the top-level, reference `{{ _time }}` (raw VictoriaLogs field) directly. + ### email_body_html Requirement **Important:** Templates used with email destinations MUST include `email_body_html`. Valerter validates this at startup and will fail if missing. diff --git a/src/template.rs b/src/template.rs index af5b147..48dc02e 100644 --- a/src/template.rs +++ b/src/template.rs @@ -19,7 +19,7 @@ //! let engine = TemplateEngine::new(templates); //! let fields = json!({"host": "server-01", "severity": "critical"}); //! -//! match engine.render("alert_template", &fields) { +//! match engine.render("alert_template", &fields, "my_rule") { //! Ok(msg) => println!("Title: {}", msg.title), //! Err(e) => eprintln!("Render failed: {}", e), //! } @@ -105,6 +105,10 @@ impl TemplateEngine { /// /// * `template_name` - Name of the template to render. /// * `fields` - Extracted log fields as JSON value. + /// * `rule_name` - Name of the rule that triggered this render. Injected + /// into the render context as `rule_name` so templates can reference + /// `{{ rule_name }}` (issue #31). Overrides any event field with the + /// same name. /// /// # Returns /// @@ -116,12 +120,13 @@ impl TemplateEngine { /// /// ```ignore /// let fields = json!({"host": "server-01", "message": "Alert!"}); - /// let msg = engine.render("alert", &fields)?; + /// let msg = engine.render("alert", &fields, "my_rule")?; /// ``` pub fn render( &self, template_name: &str, fields: &Value, + rule_name: &str, ) -> Result { tracing::trace!(template_name = %template_name, "Starting template render"); @@ -133,13 +138,15 @@ impl TemplateEngine { name: template_name.to_string(), })?; - // Render each field - let title = self.render_string(&template.title, fields)?; - let body = self.render_string(&template.body, fields)?; + // Render each field. rule_name is injected in the render helpers so + // it is available at layer 1 (title, body, email_body_html), matching + // the existing layer 2 notifier-level contexts (issue #31). + let title = self.render_string(&template.title, fields, rule_name)?; + let body = self.render_string(&template.body, fields, rule_name)?; // Render email_body_html with HTML auto-escape if present let email_body_html = if let Some(email_body_html_template) = &template.email_body_html { - Some(self.render_string_html_escaped(email_body_html_template, fields)?) + Some(self.render_string_html_escaped(email_body_html_template, fields, rule_name)?) } else { None }; @@ -161,8 +168,14 @@ impl TemplateEngine { } /// Render a single template string with fields (no auto-escape). - fn render_string(&self, template_str: &str, fields: &Value) -> Result { - let ctx = crate::parser::unflatten_dotted_keys(fields); + fn render_string( + &self, + template_str: &str, + fields: &Value, + rule_name: &str, + ) -> Result { + let mut ctx = crate::parser::unflatten_dotted_keys(fields); + inject_rule_name(&mut ctx, rule_name); self.env .render_str(template_str, &ctx) .map_err(|e| TemplateError::RenderFailed { @@ -176,8 +189,10 @@ impl TemplateEngine { &self, template_str: &str, fields: &Value, + rule_name: &str, ) -> Result { - let ctx = crate::parser::unflatten_dotted_keys(fields); + let mut ctx = crate::parser::unflatten_dotted_keys(fields); + inject_rule_name(&mut ctx, rule_name); self.html_env .render_str(template_str, &ctx) .map_err(|e| TemplateError::RenderFailed { @@ -206,7 +221,7 @@ impl TemplateEngine { fields: &Value, rule_name: &str, ) -> RenderedMessage { - match self.render(template_name, fields) { + match self.render(template_name, fields, rule_name) { Ok(msg) => { tracing::trace!(rule_name = %rule_name, "Template render successful"); msg @@ -233,6 +248,21 @@ impl TemplateEngine { } } +/// Inject the synthetic `rule_name` key into a render context (issue #31). +/// +/// The synthetic value wins over any event field literally named `rule_name` +/// so operators can rely on `{{ rule_name }}` consistently across layer 1 +/// and layer 2 templates. If `ctx` is not a JSON object (should not happen +/// in practice — VL events are always objects), injection is skipped. +fn inject_rule_name(ctx: &mut Value, rule_name: &str) { + if let Some(obj) = ctx.as_object_mut() { + obj.insert( + "rule_name".to_string(), + Value::String(rule_name.to_string()), + ); + } +} + impl std::fmt::Debug for TemplateEngine { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("TemplateEngine") @@ -290,7 +320,7 @@ mod tests { "message": "CPU usage high" }); - let result = engine.render("alert", &fields).unwrap(); + let result = engine.render("alert", &fields, "test_rule").unwrap(); assert_eq!(result.title, "Alert: server-01"); assert_eq!(result.body, "Host server-01 reported: CPU usage high"); @@ -315,12 +345,16 @@ mod tests { // Test critical severity let fields_critical = json!({"severity": "critical"}); - let result = engine.render("alert", &fields_critical).unwrap(); + let result = engine + .render("alert", &fields_critical, "test_rule") + .unwrap(); assert_eq!(result.title, "🚨 CRITICAL"); // Test non-critical severity let fields_warning = json!({"severity": "warning"}); - let result = engine.render("alert", &fields_warning).unwrap(); + let result = engine + .render("alert", &fields_warning, "test_rule") + .unwrap(); assert_eq!(result.title, "⚠️ Warning"); } @@ -350,7 +384,7 @@ mod tests { } }); - let result = engine.render("alert", &fields).unwrap(); + let result = engine.render("alert", &fields, "test_rule").unwrap(); assert_eq!(result.title, "Server: prod-server-01"); assert_eq!(result.body, "Region: us-east-1, Status: alert"); @@ -372,7 +406,7 @@ mod tests { let fields = json!({"host": "server-01"}); // Should NOT return an error, missing field renders as empty string - let result = engine.render("alert", &fields).unwrap(); + let result = engine.render("alert", &fields, "test_rule").unwrap(); assert_eq!(result.title, "Host: server-01"); assert_eq!(result.body, "Missing: "); // Empty string for missing field @@ -396,7 +430,7 @@ mod tests { "body": "Something went wrong" }); - let result = engine.render("full_alert", &fields).unwrap(); + let result = engine.render("full_alert", &fields, "test_rule").unwrap(); assert_eq!(result.title, "Critical Alert"); assert_eq!(result.body, "Something went wrong"); @@ -413,7 +447,7 @@ mod tests { let engine = TemplateEngine::new(templates); let fields = json!({"host": "server-01"}); - let result = engine.render("nonexistent", &fields); + let result = engine.render("nonexistent", &fields, "test_rule"); assert!(result.is_err()); match result.unwrap_err() { @@ -437,7 +471,7 @@ mod tests { let fields = json!({"host": "server-01"}); // render() should return error - let result = engine.render("bad_template", &fields); + let result = engine.render("bad_template", &fields, "test_rule"); assert!(result.is_err()); // render_with_fallback() should return fallback message @@ -466,11 +500,15 @@ mod tests { // Render for "rule 1" let fields1 = json!({"host": "server-01", "message": "Error A"}); - let result1 = engine.render("shared_template", &fields1).unwrap(); + let result1 = engine + .render("shared_template", &fields1, "rule_1") + .unwrap(); // Render for "rule 2" with different data let fields2 = json!({"host": "server-02", "message": "Error B"}); - let result2 = engine.render("shared_template", &fields2).unwrap(); + let result2 = engine + .render("shared_template", &fields2, "rule_2") + .unwrap(); // Both should render correctly with their own data assert_eq!(result1.title, "Alert from server-01"); @@ -495,7 +533,7 @@ mod tests { let engine = TemplateEngine::new(templates); let fields = json!({}); - let result = engine.render("alert", &fields).unwrap(); + let result = engine.render("alert", &fields, "test_rule").unwrap(); assert_eq!(result.title, "Static Title"); assert_eq!(result.body, "Static Body"); } @@ -583,7 +621,7 @@ mod tests { "items": ["apple", "banana", "cherry"] }); - let result = engine.render("list", &fields).unwrap(); + let result = engine.render("list", &fields, "test_rule").unwrap(); assert_eq!(result.title, "Items (3)"); assert!(result.body.contains("- apple")); assert!(result.body.contains("- banana")); @@ -599,7 +637,7 @@ mod tests { let engine = TemplateEngine::new(templates); let fields = json!({"host": "server-01"}); - let result = engine.render("empty", &fields).unwrap(); + let result = engine.render("empty", &fields, "test_rule").unwrap(); assert_eq!(result.title, ""); assert_eq!(result.body, ""); } @@ -625,7 +663,7 @@ mod tests { } }); - let result = engine.render("deep", &fields).unwrap(); + let result = engine.render("deep", &fields, "test_rule").unwrap(); assert_eq!(result.title, "deep_value"); } @@ -661,7 +699,7 @@ mod tests { let engine = TemplateEngine::new(templates); let fields = json!({"host": "server-01"}); - let result = engine.render("email_alert", &fields).unwrap(); + let result = engine.render("email_alert", &fields, "test_rule").unwrap(); assert_eq!(result.title, "Alert: server-01"); assert_eq!(result.body, "Host server-01 down"); @@ -684,7 +722,7 @@ mod tests { let engine = TemplateEngine::new(templates); let fields = json!({"hostname": ""}); - let result = engine.render("email_alert", &fields).unwrap(); + let result = engine.render("email_alert", &fields, "test_rule").unwrap(); let email_body_html = result.email_body_html.unwrap(); // HTML should be escaped @@ -718,7 +756,7 @@ mod tests { let engine = TemplateEngine::new(templates); let fields = json!({"nginx.http.request_id": "abc"}); - let result = engine.render("alert", &fields).unwrap(); + let result = engine.render("alert", &fields, "test_rule").unwrap(); assert_eq!(result.title, "abc"); assert_eq!(result.body, "id=abc"); } @@ -746,7 +784,7 @@ mod tests { "nginx.http.status_code": "400" }); - let result = engine.render("my_template", &fields).unwrap(); + let result = engine.render("my_template", &fields, "test_rule").unwrap(); assert_eq!(result.title, "T"); assert_eq!(result.body, "B"); let email_body_html = result.email_body_html.unwrap(); @@ -759,6 +797,88 @@ mod tests { ); } + // =================================================================== + // Issue #31: rule_name available in layer 1 templates (title, body, + // email_body_html) and in the throttle key, not only in layer 2 + // notifier-level subject_template / body_template contexts. + // =================================================================== + + #[test] + fn render_injects_rule_name_in_title() { + let mut templates = HashMap::new(); + templates.insert( + "alert".to_string(), + make_template("Alert {{ rule_name }}", "body"), + ); + + let engine = TemplateEngine::new(templates); + let fields = json!({"host": "server-01"}); + + let result = engine.render("alert", &fields, "VM_OFF").unwrap(); + assert_eq!(result.title, "Alert VM_OFF"); + } + + #[test] + fn render_injects_rule_name_in_body() { + let mut templates = HashMap::new(); + templates.insert( + "alert".to_string(), + make_template( + "title", + "rule={{ rule_name }}\nhost={{ host }}\nrule_again={{ rule_name }}", + ), + ); + + let engine = TemplateEngine::new(templates); + let fields = json!({"host": "server-01"}); + + let result = engine.render("alert", &fields, "VM_OFF").unwrap(); + assert_eq!( + result.body, + "rule=VM_OFF\nhost=server-01\nrule_again=VM_OFF" + ); + } + + #[test] + fn render_injects_rule_name_in_email_body_html() { + let mut templates = HashMap::new(); + templates.insert( + "email_alert".to_string(), + make_template_with_email_body_html( + "t", + "b", + "

Rule: {{ rule_name }} on {{ host }}

", + ), + ); + + let engine = TemplateEngine::new(templates); + let fields = json!({"host": "server-01"}); + + let result = engine.render("email_alert", &fields, "VM_OFF").unwrap(); + assert_eq!( + result.email_body_html.unwrap(), + "

Rule: VM_OFF on server-01

" + ); + } + + #[test] + fn render_rule_name_synthetic_overrides_event_field() { + // Collision policy: synthetic rule_name wins over any event field + // literally named "rule_name". + let mut templates = HashMap::new(); + templates.insert( + "alert".to_string(), + make_template("{{ rule_name }}", "{{ rule_name }}"), + ); + + let engine = TemplateEngine::new(templates); + let fields = json!({"rule_name": "event-value", "host": "server-01"}); + + let result = engine.render("alert", &fields, "VM_OFF").unwrap(); + assert_eq!(result.title, "VM_OFF"); + assert_eq!(result.body, "VM_OFF"); + } + #[test] fn render_email_body_html_none_when_template_has_no_email_body_html() { let mut templates = HashMap::new(); @@ -770,7 +890,9 @@ mod tests { let engine = TemplateEngine::new(templates); let fields = json!({"host": "server-01"}); - let result = engine.render("mattermost_alert", &fields).unwrap(); + let result = engine + .render("mattermost_alert", &fields, "test_rule") + .unwrap(); assert!( result.email_body_html.is_none(), diff --git a/src/throttle.rs b/src/throttle.rs index 85da9a9..c9260e5 100644 --- a/src/throttle.rs +++ b/src/throttle.rs @@ -201,8 +201,13 @@ impl Throttler { fn render_key(&self, fields: &Value) -> String { match &self.key_template { Some(template) => { + // Inject synthetic `rule_name` (issue #31) so users can write + // `{{ rule_name }}` in throttle.key. Synthetic wins over any + // event field with the same name, matching layer 1/2 template + // behavior. + let enriched_ctx = enrich_with_rule_name(fields, &self.rule_name); // H1 fix: Use pre-created jinja_env instead of creating new one - match self.jinja_env.render_str(template, fields) { + match self.jinja_env.render_str(template, &enriched_ctx) { Ok(key) => { tracing::trace!(rendered_key = %key, "Throttle key rendered"); key @@ -236,6 +241,25 @@ impl Throttler { } } +/// Unflatten dotted event keys (issue #25) then inject the synthetic +/// `rule_name` key (issue #31). Matches the layer 1 template rendering path +/// so users can reference both dotted event fields (`{{ nginx.http.status }}`) +/// and `{{ rule_name }}` inside a `throttle.key` template consistently. +/// +/// Returns the original value unchanged if it is not a JSON object (should +/// not happen in practice, VL events are always objects). The synthetic +/// value wins over any event field literally named `rule_name`. +fn enrich_with_rule_name(fields: &Value, rule_name: &str) -> Value { + let mut ctx = crate::parser::unflatten_dotted_keys(fields); + if let Some(obj) = ctx.as_object_mut() { + obj.insert( + "rule_name".to_string(), + Value::String(rule_name.to_string()), + ); + } + ctx +} + impl std::fmt::Debug for Throttler { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Throttler") @@ -557,6 +581,50 @@ mod tests { assert!(debug.contains("test_rule")); } + // =================================================================== + // Issue #31: rule_name injected into throttle key render context so + // users can write `{{ rule_name }}` in throttle.key and get per-rule + // buckets even when sharing a key_template across multiple rules. + // =================================================================== + + #[test] + fn render_key_includes_rule_name() { + let config = make_config(Some("{{ rule_name }}-{{ host }}"), 3, 60); + let throttler = Throttler::new(Some(&config), "VM_OFF"); + + let fields = json!({"host": "SW-01"}); + let key = throttler.render_key(&fields); + + assert_eq!(key, "VM_OFF-SW-01"); + } + + #[test] + fn render_key_resolves_dotted_event_fields_via_unflatten() { + // Issue #25 + #31: the throttle key must see dotted event keys unflat- + // tened the same way template rendering does, so `{{ nginx.http.status }}` + // works here too (not just in `title`/`body`). + let config = make_config(Some("{{ rule_name }}-{{ nginx.http.status_code }}"), 3, 60); + let throttler = Throttler::new(Some(&config), "VM_OFF"); + + let fields = json!({"nginx.http.status_code": "404"}); + let key = throttler.render_key(&fields); + + assert_eq!(key, "VM_OFF-404"); + } + + #[test] + fn render_key_rule_name_synthetic_overrides_event_field() { + // Collision policy: synthetic rule_name wins over any event field + // literally named "rule_name". + let config = make_config(Some("{{ rule_name }}"), 3, 60); + let throttler = Throttler::new(Some(&config), "VM_OFF"); + + let fields = json!({"rule_name": "event-value", "host": "SW-01"}); + let key = throttler.render_key(&fields); + + assert_eq!(key, "VM_OFF"); + } + #[test] fn template_error_uses_fallback_key() { // Invalid template syntax that minijinja can't render