From 2c8fb1949aaeca4699c6f476becb30436e57fa80 Mon Sep 17 00:00:00 2001 From: Loakesh Indiran Date: Mon, 2 Nov 2026 23:34:39 +0530 Subject: [PATCH] feat(alerts): shared notification send path, stage 1a core Add NotificationKind and the platform rule table, the kind, event, object id and cluster name on the notification context, template precedence with ordered level 1 candidates (D2 reserved rule and transport rule), the default_templates org key, compiled anomaly and incident cards, the replay record, and resolve, send and notify. Preview and test send accept a kind. Example fixtures under render/testdata/examples have golden tests. Nothing calls the path yet. Design assumptions checked against main: 1, 7, 8 and 9 hold. 2 holds (the table is alert_incidents). 3 holds in part (stage 3). 4 fails for HTTP, which has no timeout, so the shared path sets 120 s. 5 holds in effect. 6 fails: alert_composites.template also names a template. --- .../src/request/alerts/destinations.rs | 5 +- src/config/src/meta/alerts/mod.rs | 1 + .../src/meta/alerts/notification_kind.rs | 184 +++++ src/core/src/alerts/alert.rs | 128 ++- src/core/src/alerts/notifications/context.rs | 30 +- src/core/src/alerts/notifications/custom.rs | 4 + .../alerts/notifications/default_template.rs | 134 ++- src/core/src/alerts/notifications/examples.rs | 353 ++++++++ src/core/src/alerts/notifications/format.rs | 16 + src/core/src/alerts/notifications/mod.rs | 5 +- src/core/src/alerts/notifications/notify.rs | 763 ++++++++++++++++++ .../src/alerts/notifications/org_default.rs | 611 ++++++++++++-- src/core/src/alerts/notifications/platform.rs | 180 ++++- src/core/src/alerts/notifications/preview.rs | 29 +- src/core/src/alerts/notifications/record.rs | 364 +++++++++ .../src/alerts/notifications/render/mod.rs | 1 + .../render/testdata/examples/anomaly.json | 191 +++++ .../render/testdata/examples/incident.json | 216 +++++ .../alerts/notifications/render/webhook.rs | 8 + src/core/src/alerts/notifications/resolve.rs | 57 +- .../src/alerts/notifications/test_send.rs | 19 +- src/core/src/organization.rs | 9 + src/db/src/system_settings.rs | 3 +- 23 files changed, 3153 insertions(+), 158 deletions(-) create mode 100644 src/config/src/meta/alerts/notification_kind.rs create mode 100644 src/core/src/alerts/notifications/examples.rs create mode 100644 src/core/src/alerts/notifications/notify.rs create mode 100644 src/core/src/alerts/notifications/record.rs create mode 100644 src/core/src/alerts/notifications/render/testdata/examples/anomaly.json create mode 100644 src/core/src/alerts/notifications/render/testdata/examples/incident.json diff --git a/src/api/management/src/request/alerts/destinations.rs b/src/api/management/src/request/alerts/destinations.rs index 4771a2b98c..80e5593193 100644 --- a/src/api/management/src/request/alerts/destinations.rs +++ b/src/api/management/src/request/alerts/destinations.rs @@ -764,6 +764,9 @@ pub struct TestSendRequest { /// The name of an already-saved template to test-send. Exactly one of /// `definition` / `template_name` must be set. pub template_name: Option, + /// Which producer's sample to render with; absent means alert. + #[serde(default)] + pub kind: config::meta::alerts::notification_kind::NotificationKind, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] @@ -885,7 +888,7 @@ pub async fn test_send( return MetaHttpResponse::bad_request("Test-send is only supported for alert destinations"); }; - match core_test_send(&org_id, destination_type, &spec).await { + match core_test_send(&org_id, destination_type, &spec, req.kind).await { Ok(message) => MetaHttpResponse::json(TestSendResponse { success: true, message, diff --git a/src/config/src/meta/alerts/mod.rs b/src/config/src/meta/alerts/mod.rs index d03ec6682d..30fb3a8811 100644 --- a/src/config/src/meta/alerts/mod.rs +++ b/src/config/src/meta/alerts/mod.rs @@ -40,6 +40,7 @@ pub mod dispatch; pub mod grouping; pub mod incidents; pub mod level; +pub mod notification_kind; pub mod priority; pub mod prom_rule; pub mod rca_runs; diff --git a/src/config/src/meta/alerts/notification_kind.rs b/src/config/src/meta/alerts/notification_kind.rs new file mode 100644 index 0000000000..77d44091c6 --- /dev/null +++ b/src/config/src/meta/alerts/notification_kind.rs @@ -0,0 +1,184 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Which producer a notification comes from, and what each of its events does on a platform. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub const EVENT_RESOLVED: &str = "resolved"; +pub const EVENT_ACKNOWLEDGED: &str = "acknowledged"; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum NotificationKind { + #[default] + Alert, + Incident, + Anomaly, + Synthetics, + Downtime, + OnCall, +} + +impl NotificationKind { + pub const ALL: [NotificationKind; 6] = [ + NotificationKind::Alert, + NotificationKind::Incident, + NotificationKind::Anomaly, + NotificationKind::Synthetics, + NotificationKind::Downtime, + NotificationKind::OnCall, + ]; + + pub fn as_str(self) -> &'static str { + match self { + NotificationKind::Alert => "alert", + NotificationKind::Incident => "incident", + NotificationKind::Anomaly => "anomaly", + NotificationKind::Synthetics => "synthetics", + NotificationKind::Downtime => "downtime", + NotificationKind::OnCall => "on_call", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|k| k.as_str() == s) + } + + /// The reserved name of the kind's compiled default; alerts keep `o2_default_content`. + pub fn compiled_default_name(self) -> &'static str { + match self { + NotificationKind::Alert => "o2_default_content", + NotificationKind::Incident => "o2_default_incident", + NotificationKind::Anomaly => "o2_default_anomaly", + NotificationKind::Synthetics => "o2_default_synthetics", + NotificationKind::Downtime => "o2_default_downtime", + NotificationKind::OnCall => "o2_default_on_call", + } + } + + /// What `event` does to a PagerDuty, Opsgenie or ServiceNow record of this kind. + pub fn platform_event(self, event: &str) -> PlatformEvent { + let head = event.split(':').next().unwrap_or_default(); + match (self, head) { + (NotificationKind::Alert, EVENT_RESOLVED) => PlatformEvent::Resolve, + (NotificationKind::Alert, _) => PlatformEvent::Trigger, + (NotificationKind::Downtime, _) => PlatformEvent::NotForKind, + (_, EVENT_RESOLVED) => PlatformEvent::Resolve, + (NotificationKind::OnCall, EVENT_ACKNOWLEDGED) => PlatformEvent::NoAck, + (NotificationKind::Anomaly, "firing") + | (NotificationKind::Incident, "opened" | "updated") + | (NotificationKind::Synthetics, "firing" | "down") + | (NotificationKind::OnCall, "paged" | "handoff") => PlatformEvent::Trigger, + _ => PlatformEvent::NotForKind, + } + } + + /// True for the events that open or extend a platform record, the only ones closure refuses. + pub fn opens_episode(self, event: &str) -> bool { + self != NotificationKind::Alert && self.platform_event(event) == PlatformEvent::Trigger + } + + /// True for events whose episode has no lifecycle, so it closes at insert. + pub fn closed_at_insert(self, event: &str) -> bool { + let head = event.split(':').next().unwrap_or_default(); + match self { + NotificationKind::Downtime => true, + NotificationKind::Synthetics => { + matches!(head, "degraded" | "flaky" | "cert_expiring") + } + NotificationKind::OnCall => head == "test", + _ => false, + } + } +} + +impl std::fmt::Display for NotificationKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlatformEvent { + Trigger, + Resolve, + NoAck, + NotForKind, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_spelling_round_trips() { + for kind in NotificationKind::ALL { + assert_eq!(NotificationKind::parse(kind.as_str()), Some(kind)); + let json = serde_json::to_string(&kind).unwrap(); + assert_eq!(json, format!("\"{}\"", kind.as_str())); + } + assert_eq!(NotificationKind::OnCall.as_str(), "on_call"); + assert_eq!(NotificationKind::parse("pages"), None); + } + + #[test] + fn platform_rule_table() { + use NotificationKind::*; + use PlatformEvent::*; + let cases = [ + (Anomaly, "firing:1700", Trigger), + (Anomaly, "resolved", Resolve), + (Incident, "opened", Trigger), + (Incident, "updated:severity_changed:1", Trigger), + (Incident, "resolved", Resolve), + (Synthetics, "firing:r1", Trigger), + (Synthetics, "down", Trigger), + (Synthetics, "degraded:r1", NotForKind), + (Synthetics, "flaky:r1", NotForKind), + (Synthetics, "cert_expiring:r1", NotForKind), + (Synthetics, "resolved", Resolve), + (Downtime, "started", NotForKind), + (Downtime, "resolved", NotForKind), + (OnCall, "paged:1", Trigger), + (OnCall, "handoff:2:9", Trigger), + (OnCall, "acknowledged", NoAck), + (OnCall, "coverage_gap:1", NotForKind), + (OnCall, "verdict:5", NotForKind), + (OnCall, "test", NotForKind), + (OnCall, "resolved", Resolve), + ]; + for (kind, event, want) in cases { + assert_eq!(kind.platform_event(event), want, "{kind} {event}"); + } + } + + #[test] + fn opening_and_closed_at_insert_events() { + use NotificationKind::*; + assert!(Anomaly.opens_episode("firing:1")); + assert!(!Anomaly.opens_episode("resolved")); + assert!(Incident.opens_episode("updated:x:1")); + assert!(!OnCall.opens_episode("acknowledged")); + assert!(!Downtime.opens_episode("started")); + assert!(!Alert.opens_episode("firing")); + assert!(Downtime.closed_at_insert("started")); + assert!(Synthetics.closed_at_insert("degraded:r1")); + assert!(!Synthetics.closed_at_insert("firing:r1")); + assert!(OnCall.closed_at_insert("test")); + assert!(!Anomaly.closed_at_insert("firing:1")); + } +} diff --git a/src/core/src/alerts/alert.rs b/src/core/src/alerts/alert.rs index 5e74990e1e..28aa83522c 100644 --- a/src/core/src/alerts/alert.rs +++ b/src/core/src/alerts/alert.rs @@ -3255,7 +3255,7 @@ async fn build_chart_asset( /// A body we cannot parse is left alone and logged: saving the alert already /// refused this combination, so reaching here means the template changed after /// the fact, and dropping the firing would be the worse failure. -fn stamp_platform_key( +pub(crate) fn stamp_platform_key( dest_type: &DestinationType, ctx: &NotificationContext, msg: String, @@ -3571,18 +3571,52 @@ fn members_to_send(kept: Vec, skipped: &[String]) -> Result Result { +pub(crate) async fn send_http_notification( + endpoint: &Endpoint, + msg: String, +) -> Result { + match send_http_attempt(endpoint, msg, None).await? { + HttpAttempt::Answered { + note: Some(note), .. + } => Ok(note), + HttpAttempt::Answered { + status, + body, + hide_body, + note: None, + } => send_outcome(status, &body, hide_body), + HttpAttempt::Blocked(e) | HttpAttempt::NoAnswer(e) => Err(anyhow::anyhow!(e)), + } +} + +/// One HTTP send as the shared path classifies it; `note` marks a Slack resend without images. +pub(crate) enum HttpAttempt { + Answered { + status: reqwest::StatusCode, + body: String, + hide_body: bool, + note: Option, + }, + Blocked(String), + NoAnswer(String), +} + +pub(crate) async fn send_http_attempt( + endpoint: &Endpoint, + msg: String, + timeout: Option, +) -> Result { // A plain body posted at a Jira site root does nothing, so senders without a Jira branch fail. if jira::is_jira(endpoint) { - return Err(anyhow::anyhow!(jira::UNSUPPORTED_SENDER)); + return Ok(HttpAttempt::Blocked(jira::UNSUPPORTED_SENDER.to_string())); } // Block SSRF: validate the destination URL (including DNS resolution) before // making any outbound request. The client is built through `build_safe_destination_client` // so that redirect targets and per-connect DNS resolution are re-validated. if let Err(e) = SsrfGuard::validate_destination_url_with_config_async(&endpoint.url).await { - return Err(anyhow::anyhow!( + return Ok(HttpAttempt::Blocked(format!( "Destination URL blocked by SSRF guard: {e}" - )); + ))); } let builder = if endpoint.skip_tls_verify { @@ -3590,6 +3624,10 @@ async fn send_http_notification(endpoint: &Endpoint, msg: String) -> Result builder.timeout(t), + None => builder, + }; let client = common::utils::ssrf_guard::build_safe_destination_client(builder)?; let url = url::Url::parse(&endpoint.url)?; let build_req = |body: String| { @@ -3640,15 +3678,18 @@ async fn send_http_notification(endpoint: &Endpoint, msg: String) -> Result v, Err(e) => { log::error!("error sending request to {} with error {e:?}", endpoint.url); - return Err(anyhow::anyhow!( + return Ok(HttpAttempt::NoAnswer(format!( "error sending request : {:?}", e.without_url() - )); + ))); } }; let resp_status = resp.status(); let hide_body = config::utils::ssrf_guard::admitted_only_by_allowlist(&resp); - let resp_body = resp.text().await.map_err(reqwest::Error::without_url)?; + let resp_body = match resp.text().await { + Ok(body) => body, + Err(e) => return Ok(HttpAttempt::NoAnswer(e.without_url().to_string())), + }; log::debug!( "Alert sent to destination {} with status: {}, body:\n{}", @@ -3693,17 +3734,27 @@ async fn send_http_notification(endpoint: &Endpoint, msg: String) -> Result>, ) -> Result { + match send_discord_attempt(endpoint, msg, png, None).await? { + HttpAttempt::Answered { + status, + body, + hide_body, + .. + } => send_outcome(status, &body, hide_body), + HttpAttempt::Blocked(e) => Err(anyhow::anyhow!(e)), + HttpAttempt::NoAnswer(e) => Err(anyhow::anyhow!(e)), + } +} + +pub(crate) async fn send_discord_attempt( + endpoint: &Endpoint, + msg: String, + png: std::sync::Arc>, + timeout: Option, +) -> Result { if let Err(e) = SsrfGuard::validate_destination_url_with_config_async(&endpoint.url).await { - return Err(anyhow::anyhow!( + return Ok(HttpAttempt::Blocked(format!( "Destination URL blocked by SSRF guard: {e}" - )); + ))); } let builder = if endpoint.skip_tls_verify { reqwest::Client::builder().danger_accept_invalid_certs(true) } else { reqwest::Client::builder() }; + let builder = match timeout { + Some(t) => builder.timeout(t), + None => builder, + }; let client = common::utils::ssrf_guard::build_safe_destination_client(builder)?; let url = url::Url::parse(&endpoint.url)?; @@ -3825,21 +3898,27 @@ async fn send_discord_with_attachment( .mime_str("image/png")?, ); - let resp = client - .post(url) - .multipart(form) - .send() - .await - .map_err(reqwest::Error::without_url)?; + let resp = match client.post(url).multipart(form).send().await { + Ok(resp) => resp, + Err(e) => return Ok(HttpAttempt::NoAnswer(e.without_url().to_string())), + }; let resp_status = resp.status(); let hide_body = config::utils::ssrf_guard::admitted_only_by_allowlist(&resp); - let resp_body = resp.text().await.map_err(reqwest::Error::without_url)?; + let resp_body = match resp.text().await { + Ok(body) => body, + Err(e) => return Ok(HttpAttempt::NoAnswer(e.without_url().to_string())), + }; if !resp_status.is_success() { log::error!( "Alert discord notification failed with status: {resp_status}, body: {resp_body}, payload: {msg}" ); } - send_outcome(resp_status, &resp_body, hide_body) + Ok(HttpAttempt::Answered { + status: resp_status, + body: resp_body, + hide_body, + note: None, + }) } /// What the caller of a send sees; a peer only the operator allowlist admits shows its status only. @@ -3856,7 +3935,7 @@ fn send_outcome( } } -async fn send_sns_notification( +pub(crate) async fn send_sns_notification( alert_name: &str, aws_sns: &AwsSns, msg: String, @@ -4510,6 +4589,7 @@ async fn build_notification_context( level, chart_url: None, chart_png: None, + ..Default::default() } } pub fn get_row_column_map(rows: &[Map]) -> HashMap> { diff --git a/src/core/src/alerts/notifications/context.rs b/src/core/src/alerts/notifications/context.rs index 6b4135d1fd..e97f7d38de 100644 --- a/src/core/src/alerts/notifications/context.rs +++ b/src/core/src/alerts/notifications/context.rs @@ -23,7 +23,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use config::{ - meta::alerts::level::AlertLevel, + meta::alerts::{level::AlertLevel, notification_kind::NotificationKind}, utils::json::{Map, Value}, }; @@ -99,6 +99,34 @@ pub struct NotificationContext { pub metadata: Vec<(String, String)>, pub group_labels: Option>, pub level: Option, + pub kind: NotificationKind, + /// The owning object a delivery row is listed under, such as the anomaly or incident id. + pub object_id: String, + /// The ledger event, e.g. `firing:{window_end_us}` or `resolved`; empty on the alert path. + pub event: String, + pub cluster_name: String, + /// Today's body for a generic webhook while no user template applies (D1). + pub legacy_payload: Option, +} + +/// Builds a producer's pre-template JSON body only when the legacy rule selects it. +#[derive(Clone)] +pub struct LegacyPayload(pub std::sync::Arc Value + Send + Sync>); + +impl LegacyPayload { + pub fn new(f: impl Fn() -> Value + Send + Sync + 'static) -> Self { + Self(std::sync::Arc::new(f)) + } + + pub fn build(&self) -> Value { + (self.0)() + } +} + +impl std::fmt::Debug for LegacyPayload { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("LegacyPayload") + } } /// Collect row columns in deterministic order. diff --git a/src/core/src/alerts/notifications/custom.rs b/src/core/src/alerts/notifications/custom.rs index 81a728d6c1..0ea38a70a9 100644 --- a/src/core/src/alerts/notifications/custom.rs +++ b/src/core/src/alerts/notifications/custom.rs @@ -88,6 +88,9 @@ pub(crate) fn apply_custom_template_plain( ctx.episode_id.as_deref().unwrap_or_default(), ) .replace("{recovery_reason}", &ctx.recovery_reason) + .replace("{notification_kind}", ctx.kind.as_str()) + .replace("{notification_event}", &ctx.event) + .replace("{cluster_name}", &ctx.cluster_name) // Feature 2 (PT-4 / PT-9). Scope is DESTINATION TEMPLATES ONLY (D25): // incident notifications build custom JSON and workflows carry // hard-coded metadata; neither is wired here in v1. Unset priority and @@ -564,6 +567,7 @@ mod golden { chart_png: None, alert_id: String::new(), incident_id: String::new(), + ..Default::default() } } diff --git a/src/core/src/alerts/notifications/default_template.rs b/src/core/src/alerts/notifications/default_template.rs index 76d22afe94..ef51a89334 100644 --- a/src/core/src/alerts/notifications/default_template.rs +++ b/src/core/src/alerts/notifications/default_template.rs @@ -26,7 +26,10 @@ use config::{ DEFAULT_ORG, meta::{ - alerts::content_spec::{ChartSpec, ContentField, ContentSpec, RowsSpec}, + alerts::{ + content_spec::{ChartSpec, ContentField, ContentSpec, RowsSpec}, + notification_kind::NotificationKind, + }, destinations::{Template, TemplateKind, TemplateType}, }, }; @@ -73,6 +76,76 @@ pub fn compiled_default_content() -> ContentSpec { } } +/// The compiled default for `kind`; kinds whose stage has not shipped use the alert card. +pub fn compiled_content_for(kind: NotificationKind) -> ContentSpec { + match kind { + NotificationKind::Incident => compiled_incident_content(), + NotificationKind::Anomaly => compiled_anomaly_content(), + _ => compiled_default_content(), + } +} + +/// The reserved name that `compiled_content_for(kind)` is seeded under. +pub fn compiled_name_for(kind: NotificationKind) -> &'static str { + match kind { + NotificationKind::Incident | NotificationKind::Anomaly => kind.compiled_default_name(), + _ => DEFAULT_CONTENT_TEMPLATE_NAME, + } +} + +pub fn compiled_anomaly_content() -> ContentSpec { + ContentSpec { + title: "Anomaly {alert_status}: {anomaly_name} on {stream_name}".into(), + title_overrides: Default::default(), + body: "{alert_description}".into(), + fields: vec![ + field("Worst value", "{worst_actual_value}"), + field("Expected", "{worst_expected}"), + field("Window", "{window_start} to {window_end}"), + field("Score", "{score} vs {threshold}"), + ], + rows: RowsSpec { + enabled: false, + max: 5, + columns: None, + format: None, + }, + links: vec![], + chart: ChartSpec { enabled: false }, + } +} + +pub fn compiled_incident_content() -> ContentSpec { + ContentSpec { + title: "Incident {incident_severity}: {incident_title}".into(), + title_overrides: Default::default(), + body: "Incident **{incident_event}** for service **{incident_service}**.\n\n{alert_description}" + .into(), + fields: vec![ + field("Event", "{incident_event}"), + field("Severity", "{incident_severity}"), + field("Service", "{incident_service}"), + field("Alert", "{alert_name}"), + ], + rows: RowsSpec { + enabled: true, + max: 5, + columns: None, + format: None, + }, + links: vec![], + chart: ChartSpec { enabled: false }, + } +} + +fn field(label: &str, value: &str) -> ContentField { + ContentField { + label: label.into(), + value: value.into(), + show_when: None, + } +} + /// True for template names the system reserves and manages: the /// `prebuilt_*` family, plus `o2_default_content`. Users can never /// create/update/delete these (root-only, same enforcement path as @@ -80,31 +153,50 @@ pub fn compiled_default_content() -> ContentSpec { pub fn is_reserved_template_name(name: &str) -> bool { config::prebuilt_loader::is_prebuilt_template_name(name) || name == DEFAULT_CONTENT_TEMPLATE_NAME + || name == NotificationKind::Incident.compiled_default_name() + || name == NotificationKind::Anomaly.compiled_default_name() || crate::alerts::templates::is_downtime_template_name(name) } -/// Create-if-missing (and overwrite under the same `should_apply_prebuilt` -/// rule as the prebuilt loop) the seeded DB copy of the compiled default in -/// DEFAULT_ORG. Called from `ensure_system_templates`, inside the same -/// dist_lock + revision gate, after the prebuilt loop. +/// Seed the read only DEFAULT_ORG copies of the compiled defaults under `should_apply_prebuilt`. pub(crate) async fn ensure_default_content_template( shipped_rev: u32, applied_rev: u32, ) -> Result<(), anyhow::Error> { - let spec = compiled_default_content(); - let body = serde_json::to_string(&spec)?; + for kind in [ + NotificationKind::Alert, + NotificationKind::Incident, + NotificationKind::Anomaly, + ] { + ensure_compiled_row( + compiled_name_for(kind), + &compiled_content_for(kind), + shipped_rev, + applied_rev, + ) + .await?; + } + Ok(()) +} +async fn ensure_compiled_row( + name: &str, + spec: &ContentSpec, + shipped_rev: u32, + applied_rev: u32, +) -> Result<(), anyhow::Error> { + let body = serde_json::to_string(spec)?; let mut template = Template { id: None, org_id: DEFAULT_ORG.to_string(), - name: DEFAULT_CONTENT_TEMPLATE_NAME.to_string(), + name: name.to_string(), is_default: false, template_type: TemplateType::Http, body: body.clone(), kind: TemplateKind::Content, }; - match db::alerts::templates::get(DEFAULT_ORG, DEFAULT_CONTENT_TEMPLATE_NAME).await { + match db::alerts::templates::get(DEFAULT_ORG, name).await { Ok(existing) => { let drifted = existing.body != body || existing.template_type != TemplateType::Http; if super::super::templates::should_apply_prebuilt(shipped_rev, applied_rev, drifted) @@ -112,20 +204,16 @@ pub(crate) async fn ensure_default_content_template( { template.id = existing.id; db::alerts::templates::set(template).await?; - log::info!( - "[TEMPLATES] Updated system template '{DEFAULT_CONTENT_TEMPLATE_NAME}' in {DEFAULT_ORG}" - ); + log::info!("[TEMPLATES] Updated system template '{name}' in {DEFAULT_ORG}"); } } Err(TemplateError::NotFound) => { db::alerts::templates::set(template).await?; - log::info!( - "[TEMPLATES] Created system template '{DEFAULT_CONTENT_TEMPLATE_NAME}' in {DEFAULT_ORG}" - ); + log::info!("[TEMPLATES] Created system template '{name}' in {DEFAULT_ORG}"); } Err(e) => { log::error!( - "[TEMPLATES] Error checking system template '{DEFAULT_CONTENT_TEMPLATE_NAME}' in {DEFAULT_ORG}: {e}" + "[TEMPLATES] Error checking system template '{name}' in {DEFAULT_ORG}: {e}" ); } } @@ -144,8 +232,14 @@ mod tests { #[test] fn compiled_default_parses_and_renders_everywhere() { - let spec = compiled_default_content(); - assert!(!spec.title.is_empty()); + for kind in NotificationKind::ALL { + let spec = compiled_content_for(kind); + assert!(!spec.title.is_empty()); + renders_everywhere(&spec); + } + } + + fn renders_everywhere(spec: &ContentSpec) { let ctx = synthetic_context(None); // Every channel format must render the compiled-in default without // error — this is the terminal fallback (see module doc comment), @@ -162,7 +256,7 @@ mod tests { ChannelFormat::ServiceNow, ChannelFormat::Sns, ] { - let content = resolve_content(&spec, &ctx, f.channel_family()); + let content = resolve_content(spec, &ctx, f.channel_family()); assert!(render(f, &content, &ctx).is_ok(), "{f:?}"); } } @@ -189,6 +283,8 @@ mod tests { #[test] fn o2_default_content_is_reserved() { assert!(is_reserved_template_name("o2_default_content")); + assert!(is_reserved_template_name("o2_default_anomaly")); + assert!(is_reserved_template_name("o2_default_incident")); assert!(is_reserved_template_name("prebuilt_slack")); assert!(!is_reserved_template_name("my_template")); } diff --git a/src/core/src/alerts/notifications/examples.rs b/src/core/src/alerts/notifications/examples.rs new file mode 100644 index 0000000000..b8faea88b2 --- /dev/null +++ b/src/core/src/alerts/notifications/examples.rs @@ -0,0 +1,353 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Per kind sample contexts and variable lists, shared by preview and the example goldens. + +use config::meta::alerts::{level::AlertLevel, notification_kind::NotificationKind}; + +use super::{ + STATUS_FIRING, STATUS_RESOLVED, + context::{NotificationContext, build_row_columns}, +}; + +pub const ANOMALY_VARIABLES: [&str; 13] = [ + "anomaly_id", + "anomaly_name", + "anomaly_kind", + "anomaly_count", + "max_deviation_percent", + "deviation_kind", + "worst_actual_value", + "worst_expected", + "score", + "threshold", + "window_start", + "window_end", + "anomaly_url", +]; + +pub const INCIDENT_VARIABLES: [&str; 6] = [ + "incident_id", + "incident_title", + "incident_event", + "incident_severity", + "incident_service", + "incident_url", +]; + +const COMMON_VARIABLES: [&str; 27] = [ + "org_name", + "stream_type", + "stream_name", + "alert_name", + "alert_type", + "alert_period", + "alert_operator", + "alert_threshold", + "alert_count", + "alert_agg_value", + "alert_level", + "alert_status", + "episode_id", + "recovery_reason", + "alert_priority", + "alert_tags", + "alert_threshold_crit", + "alert_threshold_warn", + "alert_warning_threshold", + "alert_start_time", + "alert_end_time", + "alert_url", + "alert_trigger_time_str", + "alert_time", + "alert_description", + "rows", + "group.