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.