mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
feat: add organization-level alert deduplication with semantic field groups (#9209)
## Summary
Implements org-level alert deduplication with semantic field groups for
intelligent alert suppression and batched notifications. This feature
prevents alert storms by deduplicating alerts based on configurable
fingerprints and grouping related alerts together.
### Core Capabilities
- **Semantic Field Groups**: Map field name variations (`hostname`,
`host`, `node`) to canonical dimensions for consistent deduplication
across different data sources
- **Organization-Level Configuration**: Global deduplication settings
with cross-alert support to suppress alerts from different rules sharing
semantic dimensions
- **Alert Grouping & Batching**: Wait-and-collect logic with three send
strategies (`FirstWithCount`, `Summary`, `All`) for batched
notifications
- **Per-Alert Control**: Individual alert deduplication settings with
optional fingerprint field customization
### Features
**Deduplication Modes:**
- Per-alert exact matching using fingerprint-based TTL expiration
- Cross-alert semantic matching using org-defined dimension groups
- Configurable time windows (per-alert override or org-level default)
**Alert Grouping:**
- Batch alerts with same fingerprint before sending notifications
- Three strategies: first alert with count, summary with names, or all
details
- Configurable wait time (1-300 seconds) and max batch size (1-100
alerts)
- Background processor runs every 1 second for expired batches
**Semantic Field Groups:**
- Built-in presets for common fields (host, IP address, service)
- Kubernetes resource groups (cluster, namespace, pod, node, container)
- Field normalization support (lowercase + trim)
- Overlap handling with first-defined precedence
### Configuration Types
- `SemanticFieldGroup`: Field name equivalence definitions with
normalization
- `GlobalDeduplicationConfig`: Org-level settings with semantic groups
and cross-alert dedup
- `DeduplicationConfig`: Per-alert fingerprint fields and grouping
options
### API Endpoints
- `GET /{org_id}/alerts/deduplication/config` - Retrieve org-level dedup
config
- `POST /{org_id}/alerts/deduplication/config` - Set org-level dedup
config
- `DELETE /{org_id}/alerts/deduplication/config` - Delete org-level
dedup config
### Implementation Details
- Pure business logic algorithms in enterprise layer (fingerprinting,
matching, calculations)
- OSS service layer orchestrates database operations with enterprise
delegation
- Feature-gated with `#[cfg(feature = "enterprise")]` for
enterprise-only functionality
- Background job architecture for async batch processing
- Storage: Org config in KV DB at `/alert_config/{org_id}/deduplication`
### Architecture
```
Alert Triggered
↓
Query Evaluation
↓
Deduplication Check (fingerprint + TTL)
↓
Grouping Enabled? → Add to Batch → Wait/Collect → Send Grouped
↓ No
Send Individual Notification
```
---------
Co-authored-by: Ashish Kolhe <ashish.j.kolhe@gmail.com>
Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
This commit is contained in:
co-authored by
Ashish Kolhe
Shrinath Rao
parent
b3c63ed35b
commit
44011fbebc
@@ -327,8 +327,8 @@ jobs:
|
||||
# Try to get last failed tests from TestDino
|
||||
# Redirect stderr to /dev/null to avoid capturing npm warnings
|
||||
if LAST_FAILED_FLAGS="$(npx tdpw last-failed --cache-id "$CACHE_ID" 2>/dev/null)"; then
|
||||
# Trim whitespace (original working logic - no filtering)
|
||||
LAST_FAILED_FLAGS="$(echo "$LAST_FAILED_FLAGS" | xargs)"
|
||||
# Trim whitespace using parameter expansion (safer than xargs for quoted strings)
|
||||
LAST_FAILED_FLAGS="$(echo "$LAST_FAILED_FLAGS" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
|
||||
if [ -n "$LAST_FAILED_FLAGS" ] && [ "$LAST_FAILED_FLAGS" != "No failed tests found" ]; then
|
||||
echo "::notice::✅ Found failed tests from previous run."
|
||||
|
||||
@@ -13,9 +13,216 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Semantic field group - defines field names that represent the same entity
|
||||
///
|
||||
/// # Field Name Overlaps
|
||||
///
|
||||
/// Field names can appear in multiple semantic groups. When extracting dimensions,
|
||||
/// the **first-defined group** takes precedence. This allows flexible configurations
|
||||
/// where a field like "service" can have different meanings in different contexts.
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// // Group 1: service-primary (defined first)
|
||||
/// // field_names: ["service", "primary_service"]
|
||||
/// //
|
||||
/// // Group 2: service-backup (defined second)
|
||||
/// // field_names: ["service", "backup_service"]
|
||||
/// //
|
||||
/// // When extracting dimensions from {"service": "api"}:
|
||||
/// // → Matches "service-primary" group (first occurrence wins)
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, PartialEq)]
|
||||
pub struct SemanticFieldGroup {
|
||||
/// Unique identifier (lowercase, dash-separated, e.g., "host", "k8s-cluster")
|
||||
pub id: String,
|
||||
|
||||
/// Human-readable display name (e.g., "Host", "K8s Cluster")
|
||||
pub display: String,
|
||||
|
||||
/// List of field names that are equivalent (e.g., ["host", "hostname", "node"])
|
||||
///
|
||||
/// Note: Field names can overlap with other groups. First-defined group wins.
|
||||
pub fields: Vec<String>,
|
||||
|
||||
/// Whether to normalize values (lowercase + trim)
|
||||
#[serde(default)]
|
||||
pub normalize: bool,
|
||||
}
|
||||
|
||||
impl SemanticFieldGroup {
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
display: impl Into<String>,
|
||||
fields: &[&str],
|
||||
normalize: bool,
|
||||
) -> Self {
|
||||
let fields = fields.iter().map(|v| v.to_string()).collect_vec();
|
||||
|
||||
Self {
|
||||
id: id.into(),
|
||||
display: display.into(),
|
||||
fields,
|
||||
normalize,
|
||||
}
|
||||
}
|
||||
/// Get default semantic groups for common use cases
|
||||
pub fn default_presets() -> Vec<Self> {
|
||||
vec![
|
||||
Self::new(
|
||||
"host",
|
||||
"Host",
|
||||
&["host", "hostname", "node", "node_name"],
|
||||
true,
|
||||
),
|
||||
Self::new(
|
||||
"ip-address",
|
||||
"IP Address",
|
||||
&[
|
||||
"ip",
|
||||
"ipaddr",
|
||||
"ip_address",
|
||||
"ip_addr",
|
||||
"client_ip",
|
||||
"source_ip",
|
||||
"host_ip",
|
||||
],
|
||||
true,
|
||||
),
|
||||
Self::new(
|
||||
"service",
|
||||
"Service",
|
||||
&[
|
||||
"service",
|
||||
"service_name",
|
||||
"svc",
|
||||
"app",
|
||||
"application",
|
||||
"app_name",
|
||||
],
|
||||
true,
|
||||
),
|
||||
// Kubernetes groups
|
||||
Self::new(
|
||||
"k8s-cluster",
|
||||
"K8s Cluster",
|
||||
&["k8s_cluster", "cluster", "cluster_name", "cluster_id"],
|
||||
false,
|
||||
),
|
||||
Self::new(
|
||||
"k8s-namespace",
|
||||
"K8s Namespace",
|
||||
&["k8s_namespace", "namespace", "k8s_ns", "ns"],
|
||||
false,
|
||||
),
|
||||
Self::new(
|
||||
"k8s-pod",
|
||||
"K8s Pod",
|
||||
&["k8s_pod", "pod", "pod_name", "pod_id"],
|
||||
false,
|
||||
),
|
||||
Self::new(
|
||||
"k8s-node",
|
||||
"K8s Node",
|
||||
&["k8s_node", "node", "node_name", "kubernetes_node"],
|
||||
false,
|
||||
),
|
||||
Self::new(
|
||||
"k8s-container",
|
||||
"K8s Container",
|
||||
&[
|
||||
"container",
|
||||
"container_name",
|
||||
"container_id",
|
||||
"k8s_container",
|
||||
],
|
||||
false,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Validate semantic field group ID format
|
||||
pub fn validate_id(id: &str) -> bool {
|
||||
if id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Lowercase, alphanumeric, dash-separated
|
||||
id.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
&& !id.starts_with('-')
|
||||
&& !id.ends_with('-')
|
||||
&& !id.contains("--")
|
||||
}
|
||||
}
|
||||
|
||||
/// Organization-level deduplication configuration (Global settings)
|
||||
///
|
||||
/// This configuration is stored at the organization level and defines:
|
||||
/// - Whether deduplication is enabled globally
|
||||
/// - Semantic field groups that map field name variations to canonical dimensions
|
||||
/// - Cross-alert deduplication based on shared semantic dimensions
|
||||
/// - Default time window for deduplication
|
||||
///
|
||||
/// Per-alert fingerprint fields are stored in the Alert.deduplication field.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, PartialEq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct GlobalDeduplicationConfig {
|
||||
/// Enable/disable deduplication globally for this organization
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Semantic field groups - defines field name equivalences
|
||||
///
|
||||
/// Defines how field names from different data sources map to canonical dimensions.
|
||||
/// Example: `["host", "hostname", "server"]` all map to semantic group ID "host".
|
||||
///
|
||||
/// These groups are used for:
|
||||
/// 1. Mapping per-alert fingerprint_fields to semantic dimensions
|
||||
/// 2. Cross-alert deduplication (if enabled)
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub semantic_field_groups: Vec<SemanticFieldGroup>,
|
||||
|
||||
/// Enable cross-alert deduplication based on shared semantic dimensions
|
||||
///
|
||||
/// When true, alerts from DIFFERENT alert rules that share semantic dimensions
|
||||
/// will be deduplicated if one already fired recently.
|
||||
///
|
||||
/// Example:
|
||||
/// - Alert A fires: {host: "srv01", service: "api"}
|
||||
/// - Alert B fires 30s later: {host: "srv01", region: "us-east"}
|
||||
/// - If cross_alert_dedup=true: Alert B suppressed (shares "host" dimension)
|
||||
/// - If cross_alert_dedup=false: Both sent (per-alert dedup only)
|
||||
#[serde(default)]
|
||||
pub alert_dedup_enabled: bool,
|
||||
|
||||
/// Semantic group IDs to use for cross-alert fingerprinting
|
||||
///
|
||||
/// When cross_alert_dedup is enabled, these semantic group IDs are used
|
||||
/// to generate fingerprints across all alerts (instead of per-alert fingerprint_fields).
|
||||
/// Must reference IDs from semantic_field_groups.
|
||||
///
|
||||
/// Example: ["host", "service"] means fingerprint = hash(host_value + service_value)
|
||||
/// Required when cross_alert_dedup is true.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub alert_fingerprint_groups: Vec<String>,
|
||||
|
||||
/// Default time window in minutes for deduplication
|
||||
///
|
||||
/// Alerts with the same fingerprint within this window are suppressed.
|
||||
/// If None, defaults to 2x the alert evaluation frequency.
|
||||
/// Can be overridden per-alert.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub time_window_minutes: Option<i64>,
|
||||
}
|
||||
|
||||
/// Per-alert deduplication configuration (from main branch)
|
||||
///
|
||||
/// This is stored on each Alert and specifies which fields to use for fingerprinting.
|
||||
/// This matches the structure from the main branch exactly.
|
||||
/// Configuration for alert deduplication
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, PartialEq, Default)]
|
||||
#[serde(default)]
|
||||
@@ -110,12 +317,224 @@ fn default_group_wait() -> i64 {
|
||||
30
|
||||
}
|
||||
|
||||
impl GlobalDeduplicationConfig {
|
||||
/// Validate the organization-level deduplication configuration
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Validate cross-alert fingerprint groups reference existing semantic groups
|
||||
if self.alert_dedup_enabled && !self.alert_fingerprint_groups.is_empty() {
|
||||
let semantic_group_ids: std::collections::HashSet<_> =
|
||||
self.semantic_field_groups.iter().map(|g| &g.id).collect();
|
||||
|
||||
for group_id in &self.alert_fingerprint_groups {
|
||||
if !semantic_group_ids.contains(group_id) {
|
||||
return Err(format!(
|
||||
"Cross-alert fingerprint group '{}' not found in semantic_field_groups",
|
||||
group_id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Require at least one fingerprint group when cross-alert dedup is enabled
|
||||
if self.alert_dedup_enabled && self.alert_fingerprint_groups.is_empty() {
|
||||
return Err(
|
||||
"cross_alert_fingerprint_groups is required when cross_alert_dedup is enabled"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// Validate semantic groups
|
||||
if self.semantic_field_groups.len() > 50 {
|
||||
return Err("Maximum 50 semantic groups allowed per organization".to_string());
|
||||
}
|
||||
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
let mut field_name_to_group: std::collections::HashMap<&String, &String> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for group in &self.semantic_field_groups {
|
||||
// Validate ID format
|
||||
if !SemanticFieldGroup::validate_id(&group.id) {
|
||||
return Err(format!(
|
||||
"Invalid semantic group ID '{}': must be lowercase, alphanumeric, dash-separated",
|
||||
group.id
|
||||
));
|
||||
}
|
||||
|
||||
// Validate ID uniqueness
|
||||
if !seen_ids.insert(&group.id) {
|
||||
return Err(format!("Duplicate semantic group ID: {}", group.id));
|
||||
}
|
||||
|
||||
// Validate display name
|
||||
if group.display.is_empty() {
|
||||
return Err(format!(
|
||||
"Display name required for semantic group '{}'",
|
||||
group.id
|
||||
));
|
||||
}
|
||||
|
||||
// Validate field names
|
||||
if group.fields.is_empty() {
|
||||
return Err(format!(
|
||||
"Semantic group '{}' must have at least one field name",
|
||||
group.id
|
||||
));
|
||||
}
|
||||
|
||||
if group.fields.len() > 20 {
|
||||
return Err(format!(
|
||||
"Semantic group '{}' has too many field names (max 20)",
|
||||
group.id
|
||||
));
|
||||
}
|
||||
|
||||
// Track field name overlaps (warn but allow)
|
||||
// Precedence: first-defined group wins when extracting dimensions
|
||||
for field_name in &group.fields {
|
||||
if let Some(existing_group_id) = field_name_to_group.get(field_name) {
|
||||
log::warn!(
|
||||
"[deduplication] Field name '{field_name}' appears in multiple semantic groups: '{existing_group_id}' and '{}'. Using first occurrence (group '{existing_group_id}').",
|
||||
group.id
|
||||
);
|
||||
} else {
|
||||
field_name_to_group.insert(field_name, &group.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get default configuration with common semantic groups
|
||||
pub fn default_with_presets() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
semantic_field_groups: SemanticFieldGroup::default_presets(),
|
||||
alert_dedup_enabled: false,
|
||||
alert_fingerprint_groups: vec![],
|
||||
time_window_minutes: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a field name to its semantic group ID
|
||||
///
|
||||
/// Returns the first matching semantic group ID, or None if no match.
|
||||
/// Used for reverse lookup: actual field name → semantic dimension.
|
||||
pub fn get_semantic_group_id(&self, field_name: &str) -> Option<&str> {
|
||||
for group in &self.semantic_field_groups {
|
||||
if group.fields.iter().any(|f| f == field_name) {
|
||||
return Some(&group.id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract semantic dimensions from a result row
|
||||
///
|
||||
/// Maps actual field names to semantic group IDs and extracts their values.
|
||||
/// Returns a map of semantic_group_id → value.
|
||||
///
|
||||
/// Example:
|
||||
/// - Input fields: ["hostname", "service_name"]
|
||||
/// - Input row: {"hostname": "srv01", "service_name": "api", "other": "data"}
|
||||
/// - Output: {"host": "srv01", "service": "api"}
|
||||
pub fn extract_semantic_dimensions(
|
||||
&self,
|
||||
field_names: &[String],
|
||||
result_row: &std::collections::HashMap<String, String>,
|
||||
) -> std::collections::HashMap<String, String> {
|
||||
let mut dimensions = std::collections::HashMap::new();
|
||||
|
||||
for field_name in field_names {
|
||||
if let Some(semantic_id) = self.get_semantic_group_id(field_name) {
|
||||
if let Some(value) = result_row.get(field_name) {
|
||||
// Find the semantic group to check if normalization is needed
|
||||
if let Some(group) = self
|
||||
.semantic_field_groups
|
||||
.iter()
|
||||
.find(|g| g.id == semantic_id)
|
||||
{
|
||||
let normalized_value = if group.normalize {
|
||||
value.to_lowercase().trim().to_string()
|
||||
} else {
|
||||
value.clone()
|
||||
};
|
||||
dimensions.insert(semantic_id.to_string(), normalized_value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Field not in semantic groups - use as-is
|
||||
if let Some(value) = result_row.get(field_name) {
|
||||
dimensions.insert(field_name.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dimensions
|
||||
}
|
||||
}
|
||||
|
||||
impl DeduplicationConfig {
|
||||
/// Validate the per-alert deduplication configuration
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Fingerprint fields can be empty (will auto-detect)
|
||||
if self.fingerprint_fields.len() > 10 {
|
||||
return Err("Maximum 10 fingerprint fields allowed per alert".to_string());
|
||||
}
|
||||
|
||||
// Time window must be positive if set
|
||||
if let Some(window) = self.time_window_minutes
|
||||
&& window <= 0
|
||||
{
|
||||
return Err("Time window must be positive".to_string());
|
||||
}
|
||||
|
||||
// Validate grouping config if present
|
||||
if let Some(grouping) = &self.grouping
|
||||
&& grouping.enabled
|
||||
{
|
||||
if grouping.max_group_size == 0 {
|
||||
return Err("Max group size must be at least 1".to_string());
|
||||
}
|
||||
if grouping.group_wait_seconds < 0 {
|
||||
return Err("Group wait seconds must be non-negative".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_deduplication_config_default() {
|
||||
fn test_semantic_field_group_id_validation() {
|
||||
assert!(SemanticFieldGroup::validate_id("host"));
|
||||
assert!(SemanticFieldGroup::validate_id("k8s-cluster"));
|
||||
assert!(SemanticFieldGroup::validate_id("ip-address"));
|
||||
assert!(SemanticFieldGroup::validate_id("service-123"));
|
||||
|
||||
assert!(!SemanticFieldGroup::validate_id(""));
|
||||
assert!(!SemanticFieldGroup::validate_id("Host"));
|
||||
assert!(!SemanticFieldGroup::validate_id("k8s_cluster"));
|
||||
assert!(!SemanticFieldGroup::validate_id("-host"));
|
||||
assert!(!SemanticFieldGroup::validate_id("host-"));
|
||||
assert!(!SemanticFieldGroup::validate_id("host--name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_per_alert_deduplication_config_default() {
|
||||
let config = DeduplicationConfig::default();
|
||||
assert!(!config.enabled);
|
||||
assert!(config.fingerprint_fields.is_empty());
|
||||
@@ -124,31 +543,122 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grouping_config_default() {
|
||||
let config = GroupingConfig::default();
|
||||
fn test_organization_deduplication_config_default() {
|
||||
let config = GlobalDeduplicationConfig::default();
|
||||
assert!(!config.enabled);
|
||||
assert_eq!(config.max_group_size, 100);
|
||||
assert_eq!(config.send_strategy, SendStrategy::FirstWithCount);
|
||||
assert_eq!(config.group_wait_seconds, 30);
|
||||
assert!(!config.alert_dedup_enabled);
|
||||
assert!(config.semantic_field_groups.is_empty());
|
||||
assert_eq!(config.time_window_minutes, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_strategy_serialization() {
|
||||
let strategy = SendStrategy::FirstWithCount;
|
||||
let json = serde_json::to_string(&strategy).unwrap();
|
||||
assert_eq!(json, r#""first_with_count""#);
|
||||
fn test_organization_deduplication_config_validation() {
|
||||
let mut config = GlobalDeduplicationConfig {
|
||||
enabled: true,
|
||||
alert_dedup_enabled: false,
|
||||
semantic_field_groups: vec![
|
||||
SemanticFieldGroup::new("service", "Service", &["service", "service_name"], true),
|
||||
SemanticFieldGroup::new("host", "Host", &["host", "hostname"], true),
|
||||
],
|
||||
alert_fingerprint_groups: vec![],
|
||||
time_window_minutes: Some(10),
|
||||
};
|
||||
|
||||
let strategy = SendStrategy::Summary;
|
||||
let json = serde_json::to_string(&strategy).unwrap();
|
||||
assert_eq!(json, r#""summary""#);
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
let strategy = SendStrategy::All;
|
||||
let json = serde_json::to_string(&strategy).unwrap();
|
||||
assert_eq!(json, r#""all""#);
|
||||
// Test invalid ID
|
||||
config.semantic_field_groups[0].id = "Invalid_ID".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
config.semantic_field_groups[0].id = "service".to_string();
|
||||
|
||||
// Test duplicate ID
|
||||
config.semantic_field_groups.push(SemanticFieldGroup::new(
|
||||
"service",
|
||||
"Service 2",
|
||||
&["svc"],
|
||||
true,
|
||||
));
|
||||
assert!(config.validate().is_err());
|
||||
config.semantic_field_groups.pop();
|
||||
|
||||
// Test overlapping field names - allowed with warning
|
||||
config.semantic_field_groups[1]
|
||||
.fields
|
||||
.push("service".to_string());
|
||||
assert!(config.validate().is_ok()); // Should succeed, just warns
|
||||
config.semantic_field_groups[1].fields.pop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deduplication_config_serialization() {
|
||||
fn test_per_alert_deduplication_config_validation() {
|
||||
let mut config = DeduplicationConfig {
|
||||
enabled: true,
|
||||
fingerprint_fields: vec!["hostname".to_string(), "service".to_string()],
|
||||
time_window_minutes: Some(10),
|
||||
grouping: None,
|
||||
};
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
// Test too many fingerprint fields
|
||||
config.fingerprint_fields = (0..15).map(|i| format!("field{}", i)).collect();
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Test negative time window
|
||||
config.fingerprint_fields = vec!["hostname".to_string()];
|
||||
config.time_window_minutes = Some(-1);
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_organization_config_overlapping_field_names() {
|
||||
// Test that overlapping field names are allowed (with warning)
|
||||
// Precedence: first-defined group wins
|
||||
let config = GlobalDeduplicationConfig {
|
||||
enabled: true,
|
||||
alert_dedup_enabled: false,
|
||||
semantic_field_groups: vec![
|
||||
SemanticFieldGroup::new(
|
||||
"service-primary",
|
||||
"Primary Service",
|
||||
&["service", "primary_service"],
|
||||
true,
|
||||
),
|
||||
SemanticFieldGroup::new(
|
||||
"service-backup",
|
||||
"Backup Service",
|
||||
&["service", "backup_service"],
|
||||
true,
|
||||
), // "service" overlaps
|
||||
],
|
||||
alert_fingerprint_groups: vec![],
|
||||
time_window_minutes: Some(10),
|
||||
};
|
||||
|
||||
// Should succeed - overlaps are allowed, just logged as warnings
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_organization_deduplication_config_serialization() {
|
||||
let config = GlobalDeduplicationConfig {
|
||||
enabled: true,
|
||||
alert_dedup_enabled: true,
|
||||
semantic_field_groups: vec![
|
||||
SemanticFieldGroup::new("service", "Service", &["service", "service_name"], true),
|
||||
SemanticFieldGroup::new("host", "Host", &["host", "hostname"], true),
|
||||
],
|
||||
alert_fingerprint_groups: vec![],
|
||||
time_window_minutes: Some(10),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&config).unwrap();
|
||||
let deserialized: GlobalDeduplicationConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(config, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_per_alert_deduplication_config_serialization() {
|
||||
let config = DeduplicationConfig {
|
||||
enabled: true,
|
||||
fingerprint_fields: vec!["service".to_string(), "hostname".to_string()],
|
||||
@@ -167,7 +677,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deduplication_config_minimal_serialization() {
|
||||
fn test_per_alert_config_minimal_serialization() {
|
||||
let config = DeduplicationConfig {
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
@@ -179,5 +689,45 @@ mod tests {
|
||||
assert!(json.get("fingerprint_fields").is_none());
|
||||
// time_window_minutes should be omitted when None
|
||||
assert!(json.get("time_window_minutes").is_none());
|
||||
// grouping should be omitted when None
|
||||
assert!(json.get("grouping").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_organization_config_minimal_serialization() {
|
||||
let config = GlobalDeduplicationConfig {
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&config).unwrap();
|
||||
assert_eq!(json["enabled"], true);
|
||||
// semantic_field_groups should be omitted when empty
|
||||
assert!(json.get("semantic_field_groups").is_none());
|
||||
// time_window_minutes should be omitted when None
|
||||
assert!(json.get("time_window_minutes").is_none());
|
||||
// alert_dedup_enabled should be false by default
|
||||
assert_eq!(
|
||||
json.get("alert_dedup_enabled").and_then(|v| v.as_bool()),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_presets() {
|
||||
let presets = SemanticFieldGroup::default_presets();
|
||||
assert!(!presets.is_empty());
|
||||
|
||||
// Check that we have common groups
|
||||
assert!(presets.iter().any(|g| g.id == "host"));
|
||||
assert!(presets.iter().any(|g| g.id == "service"));
|
||||
assert!(presets.iter().any(|g| g.id == "k8s-cluster"));
|
||||
|
||||
// Validate all preset IDs
|
||||
for preset in &presets {
|
||||
assert!(SemanticFieldGroup::validate_id(&preset.id));
|
||||
assert!(!preset.display.is_empty());
|
||||
assert!(!preset.fields.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +372,11 @@ mod tests {
|
||||
query_took: None,
|
||||
scheduler_trace_id: None,
|
||||
time_in_queue_ms: None,
|
||||
dedup_enabled: None,
|
||||
dedup_suppressed: None,
|
||||
dedup_count: None,
|
||||
grouped: None,
|
||||
group_size: None,
|
||||
};
|
||||
|
||||
let result = queue
|
||||
@@ -893,6 +898,11 @@ mod tests {
|
||||
query_took: None,
|
||||
scheduler_trace_id: None,
|
||||
time_in_queue_ms: None,
|
||||
dedup_enabled: None,
|
||||
dedup_suppressed: None,
|
||||
dedup_count: None,
|
||||
grouped: None,
|
||||
group_size: None,
|
||||
};
|
||||
|
||||
let error_data = error::ErrorData {
|
||||
@@ -956,6 +966,11 @@ mod tests {
|
||||
query_took: None,
|
||||
scheduler_trace_id: None,
|
||||
time_in_queue_ms: None,
|
||||
dedup_enabled: None,
|
||||
dedup_suppressed: None,
|
||||
dedup_count: None,
|
||||
grouped: None,
|
||||
group_size: None,
|
||||
};
|
||||
|
||||
let trigger_data2 = TriggerData {
|
||||
@@ -1009,6 +1024,11 @@ mod tests {
|
||||
query_took: None,
|
||||
scheduler_trace_id: None,
|
||||
time_in_queue_ms: None,
|
||||
dedup_enabled: None,
|
||||
dedup_suppressed: None,
|
||||
dedup_count: None,
|
||||
grouped: None,
|
||||
group_size: None,
|
||||
};
|
||||
|
||||
// Should succeed when queue has space
|
||||
|
||||
@@ -79,6 +79,50 @@ pub struct TriggerData {
|
||||
pub query_took: Option<i64>,
|
||||
pub scheduler_trace_id: Option<String>,
|
||||
pub time_in_queue_ms: Option<i64>,
|
||||
// Deduplication tracking fields
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dedup_enabled: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dedup_suppressed: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dedup_count: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub grouped: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group_size: Option<i32>,
|
||||
}
|
||||
|
||||
impl Default for TriggerData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
_timestamp: 0,
|
||||
org: String::new(),
|
||||
module: TriggerDataType::Alert,
|
||||
key: String::new(),
|
||||
next_run_at: 0,
|
||||
is_realtime: false,
|
||||
is_silenced: false,
|
||||
status: TriggerDataStatus::Completed,
|
||||
start_time: 0,
|
||||
end_time: 0,
|
||||
retries: 0,
|
||||
skipped_alerts_count: None,
|
||||
error: None,
|
||||
success_response: None,
|
||||
is_partial: None,
|
||||
delay_in_secs: None,
|
||||
evaluation_took_in_secs: None,
|
||||
source_node: None,
|
||||
query_took: None,
|
||||
scheduler_trace_id: None,
|
||||
time_in_queue_ms: None,
|
||||
dedup_enabled: None,
|
||||
dedup_suppressed: None,
|
||||
dedup_count: None,
|
||||
grouped: None,
|
||||
group_size: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TriggerData {
|
||||
@@ -114,6 +158,11 @@ impl TriggerData {
|
||||
query_took: Some(0),
|
||||
scheduler_trace_id: Some(String::new()),
|
||||
time_in_queue_ms: Some(0),
|
||||
dedup_enabled: Some(false),
|
||||
dedup_suppressed: Some(false),
|
||||
dedup_count: Some(0),
|
||||
grouped: Some(false),
|
||||
group_size: Some(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,6 +764,11 @@ mod tests {
|
||||
query_took: Some(500),
|
||||
scheduler_trace_id: Some("trace123".to_string()),
|
||||
time_in_queue_ms: Some(100),
|
||||
dedup_enabled: None,
|
||||
dedup_suppressed: None,
|
||||
dedup_count: None,
|
||||
grouped: None,
|
||||
group_size: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&trigger_data).unwrap();
|
||||
|
||||
@@ -691,6 +691,114 @@ pub static META_NUM_ALERTS: Lazy<IntGaugeVec> = Lazy::new(|| {
|
||||
)
|
||||
.expect("Metric created")
|
||||
});
|
||||
|
||||
// Alert deduplication metrics
|
||||
pub static ALERT_DEDUP_SUPPRESSED_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
IntCounterVec::new(
|
||||
Opts::new(
|
||||
"alert_dedup_suppressed_total",
|
||||
"Total number of alerts suppressed by deduplication",
|
||||
)
|
||||
.namespace(NAMESPACE)
|
||||
.const_labels(create_const_labels()),
|
||||
&["organization", "alert_name", "dedup_type"],
|
||||
)
|
||||
.expect("Metric created")
|
||||
});
|
||||
|
||||
pub static ALERT_DEDUP_PASSED_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
IntCounterVec::new(
|
||||
Opts::new(
|
||||
"alert_dedup_passed_total",
|
||||
"Total number of alerts that passed deduplication",
|
||||
)
|
||||
.namespace(NAMESPACE)
|
||||
.const_labels(create_const_labels()),
|
||||
&["organization", "alert_name"],
|
||||
)
|
||||
.expect("Metric created")
|
||||
});
|
||||
|
||||
pub static ALERT_DEDUP_ERRORS_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
IntCounterVec::new(
|
||||
Opts::new(
|
||||
"alert_dedup_errors_total",
|
||||
"Total deduplication processing errors",
|
||||
)
|
||||
.namespace(NAMESPACE)
|
||||
.const_labels(create_const_labels()),
|
||||
&["organization", "error_type"],
|
||||
)
|
||||
.expect("Metric created")
|
||||
});
|
||||
|
||||
// Alert grouping/batching metrics
|
||||
pub static ALERT_GROUPING_BATCHES_PENDING: Lazy<IntGaugeVec> = Lazy::new(|| {
|
||||
IntGaugeVec::new(
|
||||
Opts::new(
|
||||
"alert_grouping_batches_pending",
|
||||
"Current number of pending alert batches",
|
||||
)
|
||||
.namespace(NAMESPACE)
|
||||
.const_labels(create_const_labels()),
|
||||
&["organization"],
|
||||
)
|
||||
.expect("Metric created")
|
||||
});
|
||||
|
||||
pub static ALERT_GROUPING_NOTIFICATIONS_SENT_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
IntCounterVec::new(
|
||||
Opts::new(
|
||||
"alert_grouping_notifications_sent_total",
|
||||
"Total number of grouped notifications sent",
|
||||
)
|
||||
.namespace(NAMESPACE)
|
||||
.const_labels(create_const_labels()),
|
||||
&["organization", "send_strategy", "reason"],
|
||||
)
|
||||
.expect("Metric created")
|
||||
});
|
||||
|
||||
pub static ALERT_GROUPING_BATCH_SIZE: Lazy<HistogramVec> = Lazy::new(|| {
|
||||
HistogramVec::new(
|
||||
HistogramOpts::new(
|
||||
"alert_grouping_batch_size",
|
||||
"Number of alerts per grouped notification",
|
||||
)
|
||||
.namespace(NAMESPACE)
|
||||
.const_labels(create_const_labels())
|
||||
.buckets(vec![1.0, 2.0, 3.0, 5.0, 10.0, 25.0, 50.0, 100.0]),
|
||||
&["organization", "send_strategy"],
|
||||
)
|
||||
.expect("Metric created")
|
||||
});
|
||||
|
||||
pub static ALERT_GROUPING_WAIT_TIME: Lazy<HistogramVec> = Lazy::new(|| {
|
||||
HistogramVec::new(
|
||||
HistogramOpts::new(
|
||||
"alert_grouping_wait_time_seconds",
|
||||
"Time batches waited before sending (seconds)",
|
||||
)
|
||||
.namespace(NAMESPACE)
|
||||
.const_labels(create_const_labels())
|
||||
.buckets(vec![5.0, 10.0, 15.0, 20.0, 30.0, 45.0, 60.0, 90.0, 120.0]),
|
||||
&["organization"],
|
||||
)
|
||||
.expect("Metric created")
|
||||
});
|
||||
|
||||
pub static ALERT_GROUPING_SEND_ERRORS_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
IntCounterVec::new(
|
||||
Opts::new(
|
||||
"alert_grouping_send_errors_total",
|
||||
"Total grouped notification send failures",
|
||||
)
|
||||
.namespace(NAMESPACE)
|
||||
.const_labels(create_const_labels()),
|
||||
&["organization", "error_type"],
|
||||
)
|
||||
.expect("Metric created")
|
||||
});
|
||||
pub static META_NUM_DASHBOARDS: Lazy<IntGaugeVec> = Lazy::new(|| {
|
||||
IntGaugeVec::new(
|
||||
Opts::new("meta_num_dashboards", "Metadata dashboard nums")
|
||||
@@ -1376,6 +1484,34 @@ fn register_metrics(registry: &Registry) {
|
||||
.register(Box::new(META_NUM_DASHBOARDS.clone()))
|
||||
.expect("Metric registered");
|
||||
|
||||
// alert deduplication metrics
|
||||
registry
|
||||
.register(Box::new(ALERT_DEDUP_SUPPRESSED_TOTAL.clone()))
|
||||
.expect("Metric registered");
|
||||
registry
|
||||
.register(Box::new(ALERT_DEDUP_PASSED_TOTAL.clone()))
|
||||
.expect("Metric registered");
|
||||
registry
|
||||
.register(Box::new(ALERT_DEDUP_ERRORS_TOTAL.clone()))
|
||||
.expect("Metric registered");
|
||||
|
||||
// alert grouping metrics
|
||||
registry
|
||||
.register(Box::new(ALERT_GROUPING_BATCHES_PENDING.clone()))
|
||||
.expect("Metric registered");
|
||||
registry
|
||||
.register(Box::new(ALERT_GROUPING_NOTIFICATIONS_SENT_TOTAL.clone()))
|
||||
.expect("Metric registered");
|
||||
registry
|
||||
.register(Box::new(ALERT_GROUPING_BATCH_SIZE.clone()))
|
||||
.expect("Metric registered");
|
||||
registry
|
||||
.register(Box::new(ALERT_GROUPING_WAIT_TIME.clone()))
|
||||
.expect("Metric registered");
|
||||
registry
|
||||
.register(Box::new(ALERT_GROUPING_SEND_ERRORS_TOTAL.clone()))
|
||||
.expect("Metric registered");
|
||||
|
||||
// db stats
|
||||
registry
|
||||
.register(Box::new(DB_QUERY_NUMS.clone()))
|
||||
|
||||
@@ -417,19 +417,62 @@ mod tests {
|
||||
]
|
||||
});
|
||||
|
||||
let expected_output = json!({
|
||||
"firstname": "John",
|
||||
"lastname": "Doe",
|
||||
"age": 25,
|
||||
"address_streetaddress": "123 Main St",
|
||||
"address_city": "Anytown",
|
||||
"address_state": "CA",
|
||||
"address_postalcode": "12345",
|
||||
"phonenumbers":"[{\"number\":\"555-555-1234\",\"type\":\"home\"},{\"number\":\"555-555-5678\",\"type\":\"work\"}]"
|
||||
});
|
||||
|
||||
let output = flatten(input).unwrap();
|
||||
assert_eq!(output, expected_output);
|
||||
|
||||
// Check all fields except phonenumbers
|
||||
assert_eq!(output["firstname"], "John");
|
||||
assert_eq!(output["lastname"], "Doe");
|
||||
assert_eq!(output["age"], 25);
|
||||
assert_eq!(output["address_streetaddress"], "123 Main St");
|
||||
assert_eq!(output["address_city"], "Anytown");
|
||||
assert_eq!(output["address_state"], "CA");
|
||||
assert_eq!(output["address_postalcode"], "12345");
|
||||
|
||||
// Parse and compare phonenumbers JSON to handle key ordering
|
||||
let phonenumbers_str = output["phonenumbers"].as_str().unwrap();
|
||||
let phonenumbers: serde_json::Value = serde_json::from_str(phonenumbers_str).unwrap();
|
||||
let expected_phonenumbers = json!([
|
||||
{"type": "home", "number": "555-555-1234"},
|
||||
{"type": "work", "number": "555-555-5678"}
|
||||
]);
|
||||
assert_eq!(phonenumbers, expected_phonenumbers);
|
||||
}
|
||||
|
||||
fn compare_flattened_json(actual: &serde_json::Value, expected: &serde_json::Value) {
|
||||
// Helper to compare JSON values, parsing embedded JSON strings
|
||||
let actual_obj = actual.as_object().unwrap();
|
||||
let expected_obj = expected.as_object().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
actual_obj.len(),
|
||||
expected_obj.len(),
|
||||
"Different number of fields"
|
||||
);
|
||||
|
||||
for (key, expected_val) in expected_obj {
|
||||
let actual_val = actual_obj
|
||||
.get(key)
|
||||
.unwrap_or_else(|| panic!("Missing key: {}", key));
|
||||
|
||||
if let (Some(actual_str), Some(expected_str)) =
|
||||
(actual_val.as_str(), expected_val.as_str())
|
||||
{
|
||||
// Both are strings, try to parse as JSON
|
||||
if let (Ok(actual_json), Ok(expected_json)) = (
|
||||
serde_json::from_str::<serde_json::Value>(actual_str),
|
||||
serde_json::from_str::<serde_json::Value>(expected_str),
|
||||
) {
|
||||
// Both strings contain valid JSON, compare them structurally
|
||||
assert_eq!(actual_json, expected_json, "JSON mismatch for key: {}", key);
|
||||
} else {
|
||||
// Not JSON or parse failed, compare as strings
|
||||
assert_eq!(actual_str, expected_str, "String mismatch for key: {}", key);
|
||||
}
|
||||
} else {
|
||||
// Non-string values, compare directly
|
||||
assert_eq!(actual_val, expected_val, "Value mismatch for key: {}", key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -512,17 +555,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let output = flatten_with_level(input.clone(), 0).unwrap();
|
||||
assert_eq!(output, expected_output_level0);
|
||||
compare_flattened_json(&output, &expected_output_level0);
|
||||
let output = flatten_with_level(input.clone(), 1).unwrap();
|
||||
assert_eq!(output, expected_output_level1);
|
||||
compare_flattened_json(&output, &expected_output_level1);
|
||||
let output = flatten_with_level(input.clone(), 2).unwrap();
|
||||
assert_eq!(output, expected_output_level2);
|
||||
compare_flattened_json(&output, &expected_output_level2);
|
||||
let output = flatten_with_level(input.clone(), 3).unwrap();
|
||||
assert_eq!(output, expected_output_level3);
|
||||
compare_flattened_json(&output, &expected_output_level3);
|
||||
let output = flatten_with_level(input.clone(), 4).unwrap();
|
||||
assert_eq!(output, expected_output_level4);
|
||||
compare_flattened_json(&output, &expected_output_level4);
|
||||
let output = flatten_with_level(input, 5).unwrap();
|
||||
assert_eq!(output, expected_output_level4);
|
||||
compare_flattened_json(&output, &expected_output_level4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright 2025 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
use actix_web::{HttpResponse, get, web};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::common::meta::http::HttpResponse as MetaHttpResponse;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct DedupSummaryResponse {
|
||||
/// Total number of alerts in organization
|
||||
pub total_alerts: usize,
|
||||
/// Number of alerts with deduplication enabled
|
||||
pub alerts_with_dedup: usize,
|
||||
/// Total alerts suppressed in time period
|
||||
pub suppressions_total: i64,
|
||||
/// Total alerts that passed dedup in time period
|
||||
pub passed_total: i64,
|
||||
/// Suppression rate (0.0 to 1.0)
|
||||
pub suppression_rate: f64,
|
||||
/// Current pending batches (grouping)
|
||||
pub pending_batches: i64,
|
||||
/// Timestamp when data was collected
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
/// Get deduplication summary statistics for an organization
|
||||
#[utoipa::path(
|
||||
context_path = "/api",
|
||||
tag = "Alerts",
|
||||
operation_id = "GetDedupSummary",
|
||||
security(
|
||||
("Authorization"= [])
|
||||
),
|
||||
params(
|
||||
("org_id" = String, Path, description = "Organization name"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Success", body = DedupSummaryResponse),
|
||||
)
|
||||
)]
|
||||
#[get("/{org_id}/alerts/dedup/summary")]
|
||||
pub async fn get_dedup_summary(
|
||||
org_id: web::Path<String>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let org_id = org_id.into_inner();
|
||||
|
||||
// Get all alerts for the organization
|
||||
let alerts_data = match crate::service::db::alerts::alert::list(&org_id, None, None).await {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
log::error!("Failed to list alerts for org {}: {}", org_id, e);
|
||||
return Ok(
|
||||
HttpResponse::InternalServerError().json(MetaHttpResponse::error(
|
||||
actix_web::http::StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
|
||||
e.to_string(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let total_alerts = alerts_data.len();
|
||||
let alerts_with_dedup: usize = alerts_data
|
||||
.iter()
|
||||
.filter(|alert| alert.deduplication.as_ref().is_some_and(|d| d.enabled))
|
||||
.count();
|
||||
|
||||
// Get current pending batches count from in-memory state (enterprise only)
|
||||
#[cfg(feature = "enterprise")]
|
||||
let pending_batches = crate::service::alerts::grouping::get_pending_batch_count(&org_id);
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
let pending_batches = 0;
|
||||
|
||||
// For suppressions/passed counts, we'll use database for now
|
||||
// In future, could query Prometheus metrics for time-series data
|
||||
let (suppressions_total, passed_total) = match get_dedup_counts_from_db(&org_id).await {
|
||||
Ok(counts) => counts,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to get dedup counts from database: {}", e);
|
||||
(0, 0)
|
||||
}
|
||||
};
|
||||
|
||||
let suppression_rate = if suppressions_total + passed_total > 0 {
|
||||
suppressions_total as f64 / (suppressions_total + passed_total) as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let response = DedupSummaryResponse {
|
||||
total_alerts,
|
||||
alerts_with_dedup,
|
||||
suppressions_total,
|
||||
passed_total,
|
||||
suppression_rate,
|
||||
pending_batches,
|
||||
timestamp: config::utils::time::now_micros(),
|
||||
};
|
||||
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
}
|
||||
|
||||
/// Get dedup counts from database
|
||||
/// Returns (suppressions, passed) counts based on occurrence_count
|
||||
async fn get_dedup_counts_from_db(_org_id: &str) -> Result<(i64, i64), anyhow::Error> {
|
||||
#[cfg(feature = "enterprise")]
|
||||
{
|
||||
use infra::table::entity::alert_dedup_state;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
let db = infra::db::ORM_CLIENT
|
||||
.get()
|
||||
.ok_or_else(|| anyhow::anyhow!("Database not initialized"))?;
|
||||
|
||||
// Get all dedup states for this org
|
||||
let states = alert_dedup_state::Entity::find()
|
||||
.filter(alert_dedup_state::Column::OrgId.eq(_org_id))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let mut suppressions = 0i64;
|
||||
let mut passed = 0i64;
|
||||
|
||||
for state in states {
|
||||
// If occurrence_count > 1, then (count - 1) were suppressed
|
||||
if state.occurrence_count > 1 {
|
||||
suppressions += state.occurrence_count - 1;
|
||||
}
|
||||
// Each fingerprint represents 1 passed alert
|
||||
passed += 1;
|
||||
}
|
||||
|
||||
Ok((suppressions, passed))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
{
|
||||
Ok((0, 0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2025 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Alert deduplication API endpoints (Enterprise feature)
|
||||
|
||||
use actix_web::{HttpResponse, delete, get, post, web};
|
||||
#[cfg(feature = "enterprise")]
|
||||
use config::meta::alerts::deduplication::GlobalDeduplicationConfig;
|
||||
|
||||
// ==================== ENTERPRISE IMPLEMENTATIONS ====================
|
||||
|
||||
/// Get deduplication configuration for an organization
|
||||
#[cfg(feature = "enterprise")]
|
||||
#[utoipa::path(
|
||||
context_path = "/api",
|
||||
tag = "Alerts",
|
||||
operation_id = "GetDeduplicationConfig",
|
||||
security(("Authorization" = [])),
|
||||
params(
|
||||
("org_id" = String, Path, description = "Organization ID"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Success", body = GlobalDeduplicationConfig),
|
||||
(status = 403, description = "Forbidden - Enterprise feature"),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
)]
|
||||
#[get("/{org_id}/alerts/deduplication/config")]
|
||||
pub async fn get_config(org_id: web::Path<String>) -> Result<HttpResponse, actix_web::Error> {
|
||||
let org_id = org_id.into_inner();
|
||||
|
||||
match crate::service::alerts::org_config::get_deduplication_config(&org_id).await {
|
||||
Ok(Some(config)) => Ok(HttpResponse::Ok().json(config)),
|
||||
Ok(None) => Ok(HttpResponse::Ok().json(GlobalDeduplicationConfig::default_with_presets())),
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Error getting deduplication config for org {}: {}",
|
||||
org_id,
|
||||
e
|
||||
);
|
||||
Ok(HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to get deduplication config: {}", e)
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get deduplication configuration for an organization (OSS - Not Supported)
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
#[utoipa::path(
|
||||
context_path = "/api",
|
||||
tag = "Alerts",
|
||||
operation_id = "GetDeduplicationConfig",
|
||||
security(("Authorization" = [])),
|
||||
params(
|
||||
("org_id" = String, Path, description = "Organization ID"),
|
||||
),
|
||||
responses(
|
||||
(status = 403, description = "Forbidden - Enterprise feature"),
|
||||
),
|
||||
)]
|
||||
#[get("/{org_id}/alerts/deduplication/config")]
|
||||
pub async fn get_config(_org_id: web::Path<String>) -> Result<HttpResponse, actix_web::Error> {
|
||||
Ok(HttpResponse::Forbidden().json("Enterprise feature not available"))
|
||||
}
|
||||
|
||||
/// Set deduplication configuration for an organization
|
||||
#[cfg(feature = "enterprise")]
|
||||
#[utoipa::path(
|
||||
context_path = "/api",
|
||||
tag = "Alerts",
|
||||
operation_id = "SetDeduplicationConfig",
|
||||
security(("Authorization" = [])),
|
||||
params(
|
||||
("org_id" = String, Path, description = "Organization ID"),
|
||||
),
|
||||
request_body(content = GlobalDeduplicationConfig, description = "Deduplication configuration", content_type = "application/json"),
|
||||
responses(
|
||||
(status = 200, description = "Success"),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 403, description = "Forbidden - Enterprise feature"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
)]
|
||||
#[post("/{org_id}/alerts/deduplication/config")]
|
||||
pub async fn set_config(
|
||||
org_id: web::Path<String>,
|
||||
config: web::Json<GlobalDeduplicationConfig>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let org_id = org_id.into_inner();
|
||||
let config = config.into_inner();
|
||||
|
||||
match crate::service::alerts::org_config::set_deduplication_config(&org_id, &config).await {
|
||||
Ok(()) => Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"message": "Deduplication config saved successfully"
|
||||
}))),
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Error setting deduplication config for org {}: {}",
|
||||
org_id,
|
||||
e
|
||||
);
|
||||
Ok(HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to set deduplication config: {}", e)
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set deduplication configuration for an organization (OSS - Not Supported)
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
#[utoipa::path(
|
||||
context_path = "/api",
|
||||
tag = "Alerts",
|
||||
operation_id = "SetDeduplicationConfig",
|
||||
security(("Authorization" = [])),
|
||||
params(
|
||||
("org_id" = String, Path, description = "Organization ID"),
|
||||
),
|
||||
responses(
|
||||
(status = 403, description = "Forbidden - Enterprise feature"),
|
||||
),
|
||||
)]
|
||||
#[post("/{org_id}/alerts/deduplication/config")]
|
||||
pub async fn set_config(
|
||||
_org_id: web::Path<String>,
|
||||
_config: web::Json<serde_json::Value>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
Ok(HttpResponse::Forbidden().json("Enterprise feature not available"))
|
||||
}
|
||||
|
||||
/// Delete deduplication configuration for an organization
|
||||
#[cfg(feature = "enterprise")]
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/{org_id}/alerts/deduplication/config",
|
||||
context_path = "/api",
|
||||
tag = "Alerts",
|
||||
operation_id = "DeleteDeduplicationConfig",
|
||||
security(("Authorization" = [])),
|
||||
params(
|
||||
("org_id" = String, Path, description = "Organization ID"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Success"),
|
||||
(status = 403, description = "Forbidden - Enterprise feature"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
)]
|
||||
#[delete("/{org_id}/alerts/deduplication/config")]
|
||||
pub async fn delete_config(org_id: web::Path<String>) -> Result<HttpResponse, actix_web::Error> {
|
||||
let org_id = org_id.into_inner();
|
||||
|
||||
match crate::service::alerts::org_config::delete_deduplication_config(&org_id).await {
|
||||
Ok(()) => Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"message": "Deduplication config deleted successfully"
|
||||
}))),
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Error deleting deduplication config for org {}: {}",
|
||||
org_id,
|
||||
e
|
||||
);
|
||||
Ok(HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to delete deduplication config: {}", e)
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete deduplication configuration for an organization (OSS - Not Supported)
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/{org_id}/alerts/deduplication/config",
|
||||
context_path = "/api",
|
||||
tag = "Alerts",
|
||||
operation_id = "DeleteDeduplicationConfig",
|
||||
security(("Authorization" = [])),
|
||||
params(
|
||||
("org_id" = String, Path, description = "Organization ID"),
|
||||
),
|
||||
responses(
|
||||
(status = 403, description = "Forbidden - Enterprise feature"),
|
||||
),
|
||||
)]
|
||||
#[delete("/{org_id}/alerts/deduplication/config")]
|
||||
pub async fn delete_config(_org_id: web::Path<String>) -> Result<HttpResponse, actix_web::Error> {
|
||||
Ok(HttpResponse::Forbidden().json("Enterprise feature not available"))
|
||||
}
|
||||
@@ -83,6 +83,17 @@ pub struct AlertHistoryEntry {
|
||||
pub evaluation_took_in_secs: Option<f64>,
|
||||
pub source_node: Option<String>,
|
||||
pub query_took: Option<i64>,
|
||||
// Deduplication information
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dedup_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dedup_suppressed: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dedup_count: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub grouped: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub group_size: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
@@ -520,6 +531,18 @@ pub async fn get_alert_history(
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
query_took: hit.get("query_took").and_then(|v| v.as_i64()),
|
||||
// Deduplication fields - populated from trigger data if available
|
||||
dedup_enabled: hit.get("dedup_enabled").and_then(|v| v.as_bool()),
|
||||
dedup_suppressed: hit.get("dedup_suppressed").and_then(|v| v.as_bool()),
|
||||
dedup_count: hit
|
||||
.get("dedup_count")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|v| v as i32),
|
||||
grouped: hit.get("grouped").and_then(|v| v.as_bool()),
|
||||
group_size: hit
|
||||
.get("group_size")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|v| v as i32),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -599,6 +622,11 @@ mod tests {
|
||||
evaluation_took_in_secs: Some(1.5),
|
||||
source_node: Some("node1".to_string()),
|
||||
query_took: Some(100),
|
||||
dedup_enabled: None,
|
||||
dedup_suppressed: None,
|
||||
dedup_count: None,
|
||||
grouped: None,
|
||||
group_size: None,
|
||||
};
|
||||
|
||||
assert_eq!(entry.alert_name, "test_alert");
|
||||
@@ -644,6 +672,11 @@ mod tests {
|
||||
evaluation_took_in_secs: None,
|
||||
source_node: None,
|
||||
query_took: None,
|
||||
dedup_enabled: None,
|
||||
dedup_suppressed: None,
|
||||
dedup_count: None,
|
||||
grouped: None,
|
||||
group_size: None,
|
||||
};
|
||||
|
||||
let response = AlertHistoryResponse {
|
||||
@@ -678,6 +711,11 @@ mod tests {
|
||||
evaluation_took_in_secs: Some(5.5),
|
||||
source_node: Some("node2".to_string()),
|
||||
query_took: Some(500),
|
||||
dedup_enabled: None,
|
||||
dedup_suppressed: None,
|
||||
dedup_count: None,
|
||||
grouped: None,
|
||||
group_size: None,
|
||||
};
|
||||
|
||||
assert_eq!(entry.status, "error");
|
||||
|
||||
@@ -46,6 +46,8 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
pub mod dedup_stats;
|
||||
pub mod deduplication;
|
||||
#[allow(deprecated)]
|
||||
pub mod deprecated;
|
||||
pub mod destinations;
|
||||
|
||||
@@ -522,6 +522,7 @@ pub fn get_service_routes(svc: &mut web::ServiceConfig) {
|
||||
.service(alerts::trigger_alert)
|
||||
.service(alerts::move_alerts)
|
||||
.service(alerts::history::get_alert_history)
|
||||
.service(alerts::dedup_stats::get_dedup_summary)
|
||||
.service(alerts::deprecated::save_alert)
|
||||
.service(alerts::deprecated::update_alert)
|
||||
.service(alerts::deprecated::get_alert)
|
||||
@@ -636,6 +637,12 @@ pub fn get_service_routes(svc: &mut web::ServiceConfig) {
|
||||
.service(traces::get_store_stats)
|
||||
.service(patterns::extract_patterns);
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
let service = service
|
||||
.service(alerts::deduplication::get_config)
|
||||
.service(alerts::deduplication::set_config)
|
||||
.service(alerts::deduplication::delete_config);
|
||||
|
||||
#[cfg(feature = "cloud")]
|
||||
let service = service
|
||||
.service(organization::org::get_org_invites)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright 2025 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Background job to process expired alert batches
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::time;
|
||||
|
||||
// Import trait for alert notification sending
|
||||
#[cfg(feature = "enterprise")]
|
||||
use crate::service::alerts::alert::AlertExt;
|
||||
|
||||
/// Background worker that processes expired alert batches
|
||||
pub async fn process_expired_batches() {
|
||||
log::info!("[alert_grouping_worker] Starting alert grouping background worker");
|
||||
|
||||
let mut interval = time::interval(Duration::from_secs(1));
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
{
|
||||
let batches = crate::service::alerts::grouping::get_expired_batches();
|
||||
|
||||
if !batches.is_empty() {
|
||||
log::debug!(
|
||||
"[alert_grouping_worker] Processing {} expired batches",
|
||||
batches.len()
|
||||
);
|
||||
|
||||
for batch in batches {
|
||||
if let Err(e) = send_grouped_notification(batch).await {
|
||||
log::error!(
|
||||
"[alert_grouping_worker] Error sending grouped notification: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a grouped notification for a batch of alerts (public wrapper for sync call)
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub async fn send_grouped_notification_sync(
|
||||
batch: crate::service::alerts::grouping::PendingBatch,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
send_grouped_notification(batch).await
|
||||
}
|
||||
|
||||
/// Send a grouped notification for a batch of alerts
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn send_grouped_notification(
|
||||
batch: crate::service::alerts::grouping::PendingBatch,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
use config::meta::alerts::deduplication::SendStrategy;
|
||||
|
||||
let elapsed_seconds =
|
||||
(chrono::Utc::now().timestamp_micros() - batch.timer_started_at) / 1_000_000;
|
||||
|
||||
log::info!(
|
||||
"[alert_grouping_worker] Sending grouped notification for {} alerts with fingerprint {} (waited {}s)",
|
||||
batch.alerts.len(),
|
||||
batch.fingerprint,
|
||||
elapsed_seconds
|
||||
);
|
||||
|
||||
// Record wait time metric
|
||||
config::metrics::ALERT_GROUPING_WAIT_TIME
|
||||
.with_label_values(&[batch.org_id.as_str()])
|
||||
.observe(elapsed_seconds as f64);
|
||||
|
||||
// Get the first alert (primary) and grouping config
|
||||
let primary_alert = &batch.alerts[0].alert;
|
||||
let grouping_config = primary_alert
|
||||
.deduplication
|
||||
.as_ref()
|
||||
.and_then(|d| d.grouping.as_ref())
|
||||
.ok_or_else(|| anyhow::anyhow!("Grouping config not found"))?;
|
||||
|
||||
let send_strategy = &grouping_config.send_strategy;
|
||||
|
||||
// Collect alert details
|
||||
let alert_names: Vec<String> = batch.alerts.iter().map(|a| a.alert.name.clone()).collect();
|
||||
|
||||
let alert_count = batch.alerts.len();
|
||||
let suppressed_count = alert_count.saturating_sub(1);
|
||||
|
||||
// Build notification context based on strategy
|
||||
let notification_context = match send_strategy {
|
||||
SendStrategy::FirstWithCount => {
|
||||
// "Alert a60 fired (2 others suppressed: a80, a90)"
|
||||
if suppressed_count > 0 {
|
||||
let suppressed_names = alert_names[1..].join(", ");
|
||||
format!(
|
||||
"Alert '{}' fired ({} other{} suppressed: {})",
|
||||
alert_names[0],
|
||||
suppressed_count,
|
||||
if suppressed_count == 1 { "" } else { "s" },
|
||||
suppressed_names
|
||||
)
|
||||
} else {
|
||||
format!("Alert '{}' fired", alert_names[0])
|
||||
}
|
||||
}
|
||||
SendStrategy::Summary => {
|
||||
// "3 alerts fired: a60, a80, a90"
|
||||
format!(
|
||||
"{} alert{} fired: {}",
|
||||
alert_count,
|
||||
if alert_count == 1 { "" } else { "s" },
|
||||
alert_names.join(", ")
|
||||
)
|
||||
}
|
||||
SendStrategy::All => {
|
||||
// Full details of each alert
|
||||
let mut details = vec![format!("Grouped Alerts ({} total)", alert_count)];
|
||||
for (i, batched) in batch.alerts.iter().enumerate() {
|
||||
details.push(format!(
|
||||
"\n{}. Alert: '{}'\n Triggered at: {}\n Rows matched: {}",
|
||||
i + 1,
|
||||
batched.alert.name,
|
||||
chrono::DateTime::<chrono::Utc>::from_timestamp(
|
||||
batched.timestamp / 1_000_000,
|
||||
0
|
||||
)
|
||||
.map(|dt| dt.to_rfc3339())
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
batched.rows.len()
|
||||
));
|
||||
}
|
||||
details.join("\n")
|
||||
}
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"[alert_grouping_worker] Strategy: {:?}, Sending notification with context: {}",
|
||||
send_strategy,
|
||||
notification_context.chars().take(200).collect::<String>() // Log first 200 chars
|
||||
);
|
||||
|
||||
// Prepare combined trigger data based on strategy
|
||||
let combined_rows = match send_strategy {
|
||||
SendStrategy::FirstWithCount | SendStrategy::Summary => {
|
||||
// For FirstWithCount and Summary, just use the first alert's data
|
||||
batch.alerts[0].rows.clone()
|
||||
}
|
||||
SendStrategy::All => {
|
||||
// For All, combine all rows from all alerts
|
||||
let mut all_rows = Vec::new();
|
||||
for batched in &batch.alerts {
|
||||
all_rows.extend(batched.rows.clone());
|
||||
}
|
||||
all_rows
|
||||
}
|
||||
};
|
||||
|
||||
// Use the primary alert for notification and inject grouped context
|
||||
let mut notification_alert = primary_alert.clone();
|
||||
|
||||
// Inject grouped context into context_attributes for template access
|
||||
let mut context_attrs = notification_alert.context_attributes.unwrap_or_default();
|
||||
context_attrs.insert("grouped_alerts".to_string(), alert_names.join(", "));
|
||||
context_attrs.insert("alert_count".to_string(), alert_count.to_string());
|
||||
context_attrs.insert("grouped_summary".to_string(), notification_context.clone());
|
||||
context_attrs.insert("is_grouped".to_string(), "true".to_string());
|
||||
notification_alert.context_attributes = Some(context_attrs);
|
||||
|
||||
// Get the timestamp for notification
|
||||
let rows_end_time = batch
|
||||
.alerts
|
||||
.iter()
|
||||
.map(|a| a.timestamp)
|
||||
.max()
|
||||
.unwrap_or_else(|| chrono::Utc::now().timestamp_micros());
|
||||
|
||||
let start_time = batch.alerts.iter().map(|a| a.timestamp).min();
|
||||
|
||||
let evaluation_timestamp = chrono::Utc::now().timestamp_micros();
|
||||
|
||||
// Send notification using alert's send_notification method
|
||||
match notification_alert
|
||||
.send_notification(
|
||||
&combined_rows,
|
||||
rows_end_time,
|
||||
start_time,
|
||||
evaluation_timestamp,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((success_msg, err_msg)) => {
|
||||
if !err_msg.is_empty() {
|
||||
log::error!(
|
||||
"[alert_grouping_worker] Some destinations failed for grouped notification (fingerprint: {}): {}",
|
||||
batch.fingerprint,
|
||||
err_msg
|
||||
);
|
||||
// Record error metric
|
||||
config::metrics::ALERT_GROUPING_SEND_ERRORS_TOTAL
|
||||
.with_label_values(&[batch.org_id.as_str(), "partial_failure"])
|
||||
.inc();
|
||||
return Err(anyhow::anyhow!("Partial failure: {}", err_msg));
|
||||
}
|
||||
|
||||
// Record successful send metrics
|
||||
let strategy_str = format!("{:?}", send_strategy).to_lowercase();
|
||||
let reason = if elapsed_seconds >= grouping_config.group_wait_seconds {
|
||||
"expired"
|
||||
} else {
|
||||
"max_size"
|
||||
};
|
||||
|
||||
config::metrics::ALERT_GROUPING_NOTIFICATIONS_SENT_TOTAL
|
||||
.with_label_values(&[batch.org_id.as_str(), strategy_str.as_str(), reason])
|
||||
.inc();
|
||||
|
||||
config::metrics::ALERT_GROUPING_BATCH_SIZE
|
||||
.with_label_values(&[batch.org_id.as_str(), strategy_str.as_str()])
|
||||
.observe(alert_count as f64);
|
||||
|
||||
log::info!(
|
||||
"[alert_grouping_worker] Successfully sent grouped notification (fingerprint: {}): {}",
|
||||
batch.fingerprint,
|
||||
success_msg
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[alert_grouping_worker] Failed to send grouped notification (fingerprint: {}): {}",
|
||||
batch.fingerprint,
|
||||
e
|
||||
);
|
||||
// Record error metric
|
||||
config::metrics::ALERT_GROUPING_SEND_ERRORS_TOTAL
|
||||
.with_label_values(&[batch.org_id.as_str(), "send_failed"])
|
||||
.inc();
|
||||
Err(anyhow::anyhow!("Send failed: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,8 +160,7 @@ async fn cleanup_alert_dedup_state() -> Result<(), anyhow::Error> {
|
||||
// This is conservative - most alerts have shorter time windows
|
||||
let older_than_minutes = 24 * 60; // 24 hours
|
||||
|
||||
match service::alerts::deduplication::cleanup_expired_state(db, None, older_than_minutes).await
|
||||
{
|
||||
match service::alerts::deduplication::cleanup_expired_state(db, older_than_minutes).await {
|
||||
Ok(deleted_count) => {
|
||||
if deleted_count > 0 {
|
||||
log::info!(
|
||||
|
||||
@@ -28,6 +28,8 @@ use crate::{
|
||||
service::{db, self_reporting, users},
|
||||
};
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub mod alert_grouping;
|
||||
mod alert_manager;
|
||||
#[cfg(feature = "enterprise")]
|
||||
mod cipher;
|
||||
@@ -308,6 +310,8 @@ pub async fn init() -> Result<(), anyhow::Error> {
|
||||
tokio::task::spawn(async move { metrics::run().await });
|
||||
let _ = promql::run();
|
||||
tokio::task::spawn(async move { alert_manager::run().await });
|
||||
#[cfg(feature = "enterprise")]
|
||||
tokio::task::spawn(async move { alert_grouping::process_expired_batches().await });
|
||||
tokio::task::spawn(async move { file_downloader::run().await });
|
||||
#[cfg(feature = "enterprise")]
|
||||
tokio::task::spawn(async move { pipeline::run().await });
|
||||
|
||||
@@ -2523,7 +2523,7 @@ mod tests {
|
||||
let json_str2 = r#"{"level": "warn", "job": "test2"}"#;
|
||||
|
||||
// Simulate rows_tpl_val containing Value::String with JSON content
|
||||
let rows_tpl_val = vec![
|
||||
let rows_tpl_val = [
|
||||
Value::String(json_str1.to_string()),
|
||||
Value::String(json_str2.to_string()),
|
||||
];
|
||||
@@ -2532,9 +2532,8 @@ mod tests {
|
||||
let mut parsed_values = Vec::new();
|
||||
for v in rows_tpl_val.iter() {
|
||||
if let Value::String(s) = v {
|
||||
match serde_json::from_str::<Value>(s) {
|
||||
Ok(parsed) => parsed_values.push(parsed),
|
||||
Err(_) => {}
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(s) {
|
||||
parsed_values.push(parsed)
|
||||
}
|
||||
} else {
|
||||
parsed_values.push(v.clone());
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
//! implementation. It is only compiled when the enterprise feature is enabled.
|
||||
|
||||
use config::{
|
||||
meta::alerts::{alert::Alert, deduplication::DeduplicationConfig},
|
||||
meta::alerts::{
|
||||
alert::Alert,
|
||||
deduplication::{DeduplicationConfig, GlobalDeduplicationConfig},
|
||||
},
|
||||
utils::json::{Map, Value},
|
||||
};
|
||||
use infra::table::entity::alert_dedup_state;
|
||||
@@ -28,12 +31,16 @@ use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, Qu
|
||||
/// Calculate fingerprint for an alert result row
|
||||
///
|
||||
/// Delegates to enterprise implementation.
|
||||
/// With org_config provided, uses semantic dimensions for cross-alert deduplication.
|
||||
pub fn calculate_fingerprint(
|
||||
alert: &Alert,
|
||||
result_row: &Map<String, Value>,
|
||||
config: &DeduplicationConfig,
|
||||
org_config: Option<&GlobalDeduplicationConfig>,
|
||||
) -> String {
|
||||
o2_enterprise::enterprise::alerts::dedup::calculate_fingerprint(alert, result_row, config)
|
||||
o2_enterprise::enterprise::alerts::dedup::calculate_fingerprint(
|
||||
alert, result_row, config, org_config,
|
||||
)
|
||||
}
|
||||
|
||||
/// Get or create deduplication state
|
||||
@@ -48,13 +55,12 @@ pub async fn get_dedup_state(
|
||||
|
||||
/// Parameters for saving deduplication state
|
||||
pub struct DedupStateParams<'a> {
|
||||
pub org_id: &'a str,
|
||||
pub fingerprint: &'a str,
|
||||
pub alert_id: &'a str,
|
||||
pub org_id: &'a str,
|
||||
pub first_seen_at: i64,
|
||||
pub last_seen_at: i64,
|
||||
pub occurrence_count: i64,
|
||||
pub notification_sent: bool,
|
||||
}
|
||||
|
||||
/// Save or update deduplication state
|
||||
@@ -62,26 +68,24 @@ pub async fn save_dedup_state(
|
||||
db: &DatabaseConnection,
|
||||
params: DedupStateParams<'_>,
|
||||
) -> Result<alert_dedup_state::Model, sea_orm::DbErr> {
|
||||
let now = o2_enterprise::enterprise::alerts::dedup::current_timestamp_micros();
|
||||
|
||||
// Try to find existing record
|
||||
if let Some(existing) = get_dedup_state(db, params.fingerprint).await? {
|
||||
// Update existing
|
||||
let mut active: alert_dedup_state::ActiveModel = existing.clone().into();
|
||||
active.last_seen_at = Set(params.last_seen_at);
|
||||
active.occurrence_count = Set(params.occurrence_count);
|
||||
active.notification_sent = Set(params.notification_sent);
|
||||
active.update(db).await
|
||||
} else {
|
||||
// Insert new
|
||||
let now = o2_enterprise::enterprise::alerts::dedup::current_timestamp_micros();
|
||||
let new_state = alert_dedup_state::ActiveModel {
|
||||
org_id: Set(params.org_id.to_string()),
|
||||
fingerprint: Set(params.fingerprint.to_string()),
|
||||
alert_id: Set(params.alert_id.to_string()),
|
||||
org_id: Set(params.org_id.to_string()),
|
||||
first_seen_at: Set(params.first_seen_at),
|
||||
last_seen_at: Set(params.last_seen_at),
|
||||
occurrence_count: Set(params.occurrence_count),
|
||||
notification_sent: Set(params.notification_sent),
|
||||
notification_sent: Set(false),
|
||||
created_at: Set(now),
|
||||
};
|
||||
new_state.insert(db).await
|
||||
@@ -99,38 +103,86 @@ pub fn is_within_window(state: &alert_dedup_state::Model, time_window_minutes: i
|
||||
/// Cleanup old deduplication state records
|
||||
pub async fn cleanup_expired_state(
|
||||
db: &DatabaseConnection,
|
||||
org_id: Option<&str>,
|
||||
older_than_minutes: i64,
|
||||
) -> Result<u64, sea_orm::DbErr> {
|
||||
let cutoff_time = o2_enterprise::enterprise::alerts::dedup::current_timestamp_micros()
|
||||
- (older_than_minutes * 60 * 1_000_000);
|
||||
|
||||
let mut query = alert_dedup_state::Entity::delete_many()
|
||||
let query = alert_dedup_state::Entity::delete_many()
|
||||
.filter(alert_dedup_state::Column::LastSeenAt.lt(cutoff_time));
|
||||
|
||||
if let Some(org) = org_id {
|
||||
query = query.filter(alert_dedup_state::Column::OrgId.eq(org));
|
||||
}
|
||||
|
||||
let result = query.exec(db).await?;
|
||||
Ok(result.rows_affected)
|
||||
}
|
||||
|
||||
/// Check for cross-alert semantic matches (when cross_alert_dedup is enabled)
|
||||
///
|
||||
/// Looks for any fingerprint containing the given semantic dimensions within the time window.
|
||||
/// Used to suppress alerts that share semantic dimensions with recently fired alerts.
|
||||
pub async fn find_matching_semantic_fingerprints(
|
||||
db: &DatabaseConnection,
|
||||
semantic_dimensions: &std::collections::HashMap<String, String>,
|
||||
time_window_minutes: i64,
|
||||
) -> Result<Vec<alert_dedup_state::Model>, sea_orm::DbErr> {
|
||||
let cutoff_time = o2_enterprise::enterprise::alerts::dedup::current_timestamp_micros()
|
||||
- (time_window_minutes * 60 * 1_000_000);
|
||||
|
||||
// Get all recent fingerprints
|
||||
let recent_states = alert_dedup_state::Entity::find()
|
||||
.filter(alert_dedup_state::Column::LastSeenAt.gt(cutoff_time))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
// Filter for matches with overlapping semantic dimensions
|
||||
// Fingerprint format: "dim1=val1,dim2=val2,..."
|
||||
let matching_states: Vec<_> = recent_states
|
||||
.into_iter()
|
||||
.filter(|state| {
|
||||
// Parse fingerprint to check for dimension overlap
|
||||
o2_enterprise::enterprise::alerts::dedup::fingerprint_matches_dimensions(
|
||||
&state.fingerprint,
|
||||
semantic_dimensions,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(matching_states)
|
||||
}
|
||||
|
||||
/// Apply deduplication to alert result rows before sending notifications
|
||||
///
|
||||
/// This is the main entry point for deduplication logic in the alert execution flow.
|
||||
/// Fetches org-level config for semantic groups and cross-alert deduplication.
|
||||
pub async fn apply_deduplication(
|
||||
db: &DatabaseConnection,
|
||||
alert: &Alert,
|
||||
result_rows: Vec<Map<String, Value>>,
|
||||
) -> Result<Vec<Map<String, Value>>, sea_orm::DbErr> {
|
||||
// Check if deduplication is enabled
|
||||
// Check if per-alert deduplication is enabled
|
||||
let dedup_config = match &alert.deduplication {
|
||||
Some(config) if config.enabled => config,
|
||||
_ => return Ok(result_rows), // Deduplication disabled, return all rows
|
||||
};
|
||||
|
||||
apply_deduplication_impl(db, alert, result_rows, dedup_config).await
|
||||
// Fetch org-level config for semantic groups
|
||||
let org_config = match super::org_config::get_deduplication_config(&alert.org_id).await {
|
||||
Ok(Some(config)) if config.enabled => Some(config),
|
||||
Ok(Some(_)) => {
|
||||
// Org config exists but disabled - still allow per-alert dedup
|
||||
None
|
||||
}
|
||||
Ok(None) => None, // No org config
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to fetch org dedup config for {}: {}, proceeding without semantic groups",
|
||||
alert.org_id,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
apply_deduplication_impl(db, alert, result_rows, dedup_config, org_config.as_ref()).await
|
||||
}
|
||||
|
||||
/// Enterprise implementation of apply_deduplication
|
||||
@@ -139,6 +191,7 @@ async fn apply_deduplication_impl(
|
||||
alert: &Alert,
|
||||
result_rows: Vec<Map<String, Value>>,
|
||||
dedup_config: &DeduplicationConfig,
|
||||
org_config: Option<&GlobalDeduplicationConfig>,
|
||||
) -> Result<Vec<Map<String, Value>>, sea_orm::DbErr> {
|
||||
let now = o2_enterprise::enterprise::alerts::dedup::current_timestamp_micros();
|
||||
let alert_id = alert.get_unique_key();
|
||||
@@ -153,7 +206,7 @@ async fn apply_deduplication_impl(
|
||||
let mut deduplicated_rows = Vec::new();
|
||||
|
||||
for row in result_rows {
|
||||
let fingerprint = calculate_fingerprint(alert, &row, dedup_config);
|
||||
let fingerprint = calculate_fingerprint(alert, &row, dedup_config, org_config);
|
||||
|
||||
// Check if this fingerprint exists and is within time window
|
||||
let should_send = match get_dedup_state(db, &fingerprint).await? {
|
||||
@@ -163,13 +216,12 @@ async fn apply_deduplication_impl(
|
||||
if let Err(e) = save_dedup_state(
|
||||
db,
|
||||
DedupStateParams {
|
||||
org_id: org_id.as_str(),
|
||||
fingerprint: &fingerprint,
|
||||
alert_id: &alert_id,
|
||||
org_id,
|
||||
first_seen_at: existing_state.first_seen_at,
|
||||
last_seen_at: now,
|
||||
occurrence_count: existing_state.occurrence_count + 1,
|
||||
notification_sent: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -180,6 +232,26 @@ async fn apply_deduplication_impl(
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Record suppression metric
|
||||
let dedup_type = if existing_state.alert_id == alert_id {
|
||||
"same_alert"
|
||||
} else {
|
||||
"cross_alert"
|
||||
};
|
||||
config::metrics::ALERT_DEDUP_SUPPRESSED_TOTAL
|
||||
.with_label_values(&[org_id.as_str(), &alert.name, dedup_type])
|
||||
.inc();
|
||||
|
||||
log::debug!(
|
||||
"[dedup] Suppressed alert '{}' for org: {}, fingerprint: {}, occurrence: {}, type: {}",
|
||||
alert.name,
|
||||
org_id,
|
||||
fingerprint,
|
||||
existing_state.occurrence_count + 1,
|
||||
dedup_type
|
||||
);
|
||||
|
||||
false
|
||||
} else {
|
||||
// Outside window - treat as new alert
|
||||
@@ -197,13 +269,12 @@ async fn apply_deduplication_impl(
|
||||
if let Err(e) = save_dedup_state(
|
||||
db,
|
||||
DedupStateParams {
|
||||
org_id: org_id.as_str(),
|
||||
fingerprint: &fingerprint,
|
||||
alert_id: &alert_id,
|
||||
org_id,
|
||||
first_seen_at: now,
|
||||
last_seen_at: now,
|
||||
occurrence_count: 1,
|
||||
notification_sent: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -215,6 +286,18 @@ async fn apply_deduplication_impl(
|
||||
);
|
||||
}
|
||||
|
||||
// Record passed metric
|
||||
config::metrics::ALERT_DEDUP_PASSED_TOTAL
|
||||
.with_label_values(&[org_id.as_str(), &alert.name])
|
||||
.inc();
|
||||
|
||||
log::debug!(
|
||||
"[dedup] Alert '{}' passed dedup check for org: {}, fingerprint: {}",
|
||||
alert.name,
|
||||
org_id,
|
||||
fingerprint
|
||||
);
|
||||
|
||||
deduplicated_rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// Copyright 2025 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Alert grouping and batching for deduplication
|
||||
//!
|
||||
//! Implements wait-and-collect logic to batch multiple alerts with the same
|
||||
//! fingerprint before sending a single grouped notification.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use config::{meta::alerts::alert::Alert, utils::json};
|
||||
use dashmap::DashMap;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
/// In-memory cache of pending alert batches
|
||||
/// Key: fingerprint, Value: PendingBatch
|
||||
static PENDING_BATCHES: Lazy<Arc<DashMap<String, PendingBatch>>> =
|
||||
Lazy::new(|| Arc::new(DashMap::new()));
|
||||
|
||||
/// A batch of alerts waiting to be sent together
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PendingBatch {
|
||||
pub fingerprint: String,
|
||||
pub org_id: String,
|
||||
pub alerts: Vec<BatchedAlert>,
|
||||
pub timer_started_at: i64,
|
||||
pub group_wait_seconds: i64,
|
||||
pub max_group_size: usize,
|
||||
}
|
||||
|
||||
/// An alert waiting in a batch
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BatchedAlert {
|
||||
pub alert: Alert,
|
||||
pub rows: Vec<json::Map<String, json::Value>>,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
impl PendingBatch {
|
||||
/// Create a new pending batch
|
||||
pub fn new(
|
||||
fingerprint: String,
|
||||
org_id: String,
|
||||
alert: Alert,
|
||||
rows: Vec<json::Map<String, json::Value>>,
|
||||
group_wait_seconds: i64,
|
||||
max_group_size: usize,
|
||||
) -> Self {
|
||||
let now = Utc::now().timestamp_micros();
|
||||
Self {
|
||||
fingerprint,
|
||||
org_id,
|
||||
alerts: vec![BatchedAlert {
|
||||
alert,
|
||||
rows,
|
||||
timestamp: now,
|
||||
}],
|
||||
timer_started_at: now,
|
||||
group_wait_seconds,
|
||||
max_group_size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an alert to this batch
|
||||
pub fn add_alert(&mut self, alert: Alert, rows: Vec<json::Map<String, json::Value>>) -> bool {
|
||||
if self.alerts.len() >= self.max_group_size {
|
||||
return false; // Batch full
|
||||
}
|
||||
|
||||
let now = Utc::now().timestamp_micros();
|
||||
self.alerts.push(BatchedAlert {
|
||||
alert,
|
||||
rows,
|
||||
timestamp: now,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if batch wait time has expired
|
||||
pub fn is_expired(&self) -> bool {
|
||||
let now = Utc::now().timestamp_micros();
|
||||
let elapsed_seconds = (now - self.timer_started_at) / 1_000_000;
|
||||
elapsed_seconds >= self.group_wait_seconds
|
||||
}
|
||||
|
||||
/// Check if batch is at max capacity
|
||||
pub fn is_full(&self) -> bool {
|
||||
self.alerts.len() >= self.max_group_size
|
||||
}
|
||||
}
|
||||
|
||||
/// Add alert to pending batch or create new batch
|
||||
/// Returns true if batch is ready to send (expired or full)
|
||||
pub fn add_to_batch(
|
||||
fingerprint: String,
|
||||
org_id: String,
|
||||
alert: Alert,
|
||||
rows: Vec<json::Map<String, json::Value>>,
|
||||
group_wait_seconds: i64,
|
||||
max_group_size: usize,
|
||||
) -> bool {
|
||||
let mut batch_ready = false;
|
||||
let mut is_new_batch = false;
|
||||
let mut batch_size = 0;
|
||||
|
||||
PENDING_BATCHES
|
||||
.entry(fingerprint.clone())
|
||||
.and_modify(|batch| {
|
||||
if batch.add_alert(alert.clone(), rows.clone()) {
|
||||
batch_size = batch.alerts.len();
|
||||
log::debug!(
|
||||
"[grouping] Added alert '{}' to existing batch {} (count: {}/{})",
|
||||
alert.name,
|
||||
fingerprint,
|
||||
batch.alerts.len(),
|
||||
batch.max_group_size
|
||||
);
|
||||
if batch.is_full() {
|
||||
batch_ready = true;
|
||||
log::info!(
|
||||
"[grouping] Batch {} reached max size ({}), ready to send",
|
||||
fingerprint,
|
||||
batch.max_group_size
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
"[grouping] Failed to add alert '{}' to batch {} (already full)",
|
||||
alert.name,
|
||||
fingerprint
|
||||
);
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| {
|
||||
is_new_batch = true;
|
||||
batch_size = 1;
|
||||
log::info!(
|
||||
"[grouping] Created new batch for fingerprint {}, alert: '{}', org: {}, wait_seconds: {}, max_size: {}",
|
||||
fingerprint,
|
||||
alert.name,
|
||||
org_id,
|
||||
group_wait_seconds,
|
||||
max_group_size
|
||||
);
|
||||
PendingBatch::new(
|
||||
fingerprint.clone(),
|
||||
org_id,
|
||||
alert,
|
||||
rows,
|
||||
group_wait_seconds,
|
||||
max_group_size,
|
||||
)
|
||||
});
|
||||
|
||||
if is_new_batch {
|
||||
let batch_count = PENDING_BATCHES.len();
|
||||
log::debug!("[grouping] Current pending batches count: {}", batch_count);
|
||||
|
||||
// Update gauge metric for pending batches
|
||||
// Use org_id from the batch we just inserted
|
||||
if let Some(batch) = PENDING_BATCHES.get(&fingerprint) {
|
||||
config::metrics::ALERT_GROUPING_BATCHES_PENDING
|
||||
.with_label_values(&[batch.org_id.as_str()])
|
||||
.set(batch_count as i64);
|
||||
}
|
||||
}
|
||||
|
||||
batch_ready
|
||||
}
|
||||
|
||||
/// Get and remove a batch if it's ready (expired or full)
|
||||
pub fn get_ready_batch(fingerprint: &str) -> Option<PendingBatch> {
|
||||
if let Some(entry) = PENDING_BATCHES.get(fingerprint)
|
||||
&& (entry.is_expired() || entry.is_full())
|
||||
{
|
||||
let batch = PENDING_BATCHES.remove(fingerprint).map(|(_, batch)| batch);
|
||||
if let Some(ref b) = batch {
|
||||
log::debug!(
|
||||
"[grouping] Retrieved ready batch for fingerprint {} ({} alerts)",
|
||||
fingerprint,
|
||||
b.alerts.len()
|
||||
);
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get all expired batches
|
||||
pub fn get_expired_batches() -> Vec<PendingBatch> {
|
||||
let mut expired = Vec::new();
|
||||
let now = Utc::now().timestamp_micros();
|
||||
|
||||
PENDING_BATCHES.retain(|fingerprint, batch| {
|
||||
if batch.is_expired() {
|
||||
let elapsed_seconds = (now - batch.timer_started_at) / 1_000_000;
|
||||
log::info!(
|
||||
"[grouping] Batch {} expired after {}s with {} alerts",
|
||||
fingerprint,
|
||||
elapsed_seconds,
|
||||
batch.alerts.len()
|
||||
);
|
||||
expired.push(batch.clone());
|
||||
false // Remove from map
|
||||
} else {
|
||||
true // Keep in map
|
||||
}
|
||||
});
|
||||
|
||||
if !expired.is_empty() {
|
||||
log::debug!(
|
||||
"[grouping] Found {} expired batches, {} still pending",
|
||||
expired.len(),
|
||||
PENDING_BATCHES.len()
|
||||
);
|
||||
}
|
||||
|
||||
expired
|
||||
}
|
||||
|
||||
/// Get count of pending batches for an organization
|
||||
pub fn get_pending_batch_count(org_id: &str) -> i64 {
|
||||
PENDING_BATCHES
|
||||
.iter()
|
||||
.filter(|entry| entry.org_id == org_id)
|
||||
.count() as i64
|
||||
}
|
||||
|
||||
/// Clear all pending batches (for testing)
|
||||
pub fn clear_all_batches() {
|
||||
let count = PENDING_BATCHES.len();
|
||||
PENDING_BATCHES.clear();
|
||||
if count > 0 {
|
||||
log::info!("[grouping] Cleared {} pending batches (testing)", count);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,10 @@ pub mod alert;
|
||||
pub mod deduplication;
|
||||
pub mod derived_streams;
|
||||
pub mod destinations;
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub mod grouping;
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub mod org_config;
|
||||
pub mod scheduler;
|
||||
pub mod templates;
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2025 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Organization-level alert configuration storage
|
||||
//!
|
||||
//! This module provides functions to store and retrieve org-level alert configs
|
||||
//! using the existing key-value DB interface.
|
||||
|
||||
use config::meta::alerts::deduplication::GlobalDeduplicationConfig;
|
||||
use infra::db;
|
||||
|
||||
const MODULE: &str = "alert_config";
|
||||
const DEDUPLICATION_KEY: &str = "deduplication";
|
||||
|
||||
/// Get deduplication config for an organization
|
||||
pub async fn get_deduplication_config(
|
||||
org_id: &str,
|
||||
) -> Result<Option<GlobalDeduplicationConfig>, anyhow::Error> {
|
||||
let key = db::build_key(MODULE, org_id, DEDUPLICATION_KEY, 0);
|
||||
let db = db::get_db().await;
|
||||
|
||||
match db.get(&key).await {
|
||||
Ok(bytes) => {
|
||||
let config: GlobalDeduplicationConfig = serde_json::from_slice(&bytes)?;
|
||||
Ok(Some(config))
|
||||
}
|
||||
Err(infra::errors::Error::DbError(infra::errors::DbError::KeyNotExists(_))) => Ok(None),
|
||||
Err(e) => Err(anyhow::anyhow!("Failed to get deduplication config: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set deduplication config for an organization
|
||||
pub async fn set_deduplication_config(
|
||||
org_id: &str,
|
||||
config: &GlobalDeduplicationConfig,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let key = db::build_key(MODULE, org_id, DEDUPLICATION_KEY, 0);
|
||||
let value = serde_json::to_vec(config)?;
|
||||
let db = db::get_db().await;
|
||||
|
||||
db.put(&key, value.into(), db::NO_NEED_WATCH, None)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to set deduplication config: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete deduplication config for an organization
|
||||
pub async fn delete_deduplication_config(org_id: &str) -> Result<(), anyhow::Error> {
|
||||
let key = db::build_key(MODULE, org_id, DEDUPLICATION_KEY, 0);
|
||||
let db = db::get_db().await;
|
||||
|
||||
db.delete_if_exists(&key, false, db::NO_NEED_WATCH)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to delete deduplication config: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_key() {
|
||||
let key = db::build_key(MODULE, "org123", DEDUPLICATION_KEY, 0);
|
||||
assert_eq!(key, "/alert_config/org123/deduplication");
|
||||
}
|
||||
}
|
||||
@@ -220,12 +220,7 @@ async fn handle_alert_triggers(
|
||||
start_time: now,
|
||||
end_time: now,
|
||||
next_run_at: now,
|
||||
skipped_alerts_count: None,
|
||||
delay_in_secs: None,
|
||||
evaluation_took_in_secs: None,
|
||||
query_took: None,
|
||||
success_response: None,
|
||||
is_partial: None,
|
||||
..Default::default()
|
||||
});
|
||||
return Err(anyhow::anyhow!("Alert not found"));
|
||||
}
|
||||
@@ -269,12 +264,7 @@ async fn handle_alert_triggers(
|
||||
start_time: now,
|
||||
end_time: now,
|
||||
next_run_at: now,
|
||||
skipped_alerts_count: None,
|
||||
delay_in_secs: None,
|
||||
evaluation_took_in_secs: None,
|
||||
query_took: None,
|
||||
success_response: None,
|
||||
is_partial: None,
|
||||
..Default::default()
|
||||
});
|
||||
return Err(anyhow::anyhow!("Error getting alert by id: {}", e));
|
||||
}
|
||||
@@ -469,15 +459,11 @@ async fn handle_alert_triggers(
|
||||
end_time: *skipped_last_timestamp,
|
||||
retries: trigger.retries,
|
||||
delay_in_secs: Some(Duration::microseconds(delay).num_seconds()),
|
||||
error: None,
|
||||
success_response: None,
|
||||
is_partial: None,
|
||||
evaluation_took_in_secs: None,
|
||||
source_node: Some(source_node.clone()),
|
||||
query_took: None,
|
||||
scheduler_trace_id: Some(scheduler_trace_id.clone()),
|
||||
time_in_queue_ms: Some(time_in_queue),
|
||||
skipped_alerts_count: Some(skipped_alerts_count as i64),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
log::info!(
|
||||
@@ -521,7 +507,7 @@ async fn handle_alert_triggers(
|
||||
query_took: None,
|
||||
scheduler_trace_id: Some(scheduler_trace_id.clone()),
|
||||
time_in_queue_ms: Some(time_in_queue),
|
||||
skipped_alerts_count: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let evaluation_took = Instant::now();
|
||||
@@ -643,6 +629,115 @@ async fn handle_alert_triggers(
|
||||
|
||||
// send notification
|
||||
if let Some(data) = trigger_results.data {
|
||||
// Check if grouping is enabled BEFORE deduplication (enterprise-only feature)
|
||||
#[cfg(feature = "enterprise")]
|
||||
let grouping_enabled = alert
|
||||
.deduplication
|
||||
.as_ref()
|
||||
.and_then(|d| d.grouping.as_ref())
|
||||
.map(|g| g.enabled)
|
||||
.unwrap_or(false);
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
let grouping_enabled = false;
|
||||
|
||||
if grouping_enabled {
|
||||
#[cfg(feature = "enterprise")]
|
||||
{
|
||||
let grouping_config = alert
|
||||
.deduplication
|
||||
.as_ref()
|
||||
.and_then(|d| d.grouping.as_ref())
|
||||
.unwrap();
|
||||
|
||||
// Calculate fingerprint for grouping
|
||||
let fingerprint = if let Some(dedup_config) = alert.deduplication.as_ref() {
|
||||
let org_config =
|
||||
match crate::service::alerts::org_config::get_deduplication_config(
|
||||
&new_trigger.org,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(config)) => Some(config),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(first_row) = data.first() {
|
||||
crate::service::alerts::deduplication::calculate_fingerprint(
|
||||
&alert,
|
||||
first_row,
|
||||
dedup_config,
|
||||
org_config.as_ref(),
|
||||
)
|
||||
} else {
|
||||
alert.get_unique_key()
|
||||
}
|
||||
} else {
|
||||
alert.get_unique_key()
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"[SCHEDULER trace_id {scheduler_trace_id}] Adding alert to batch, org: {}, alert: {}, fingerprint: {}, rows: {}",
|
||||
&new_trigger.org,
|
||||
&alert.name,
|
||||
fingerprint,
|
||||
data.len()
|
||||
);
|
||||
|
||||
// Add to batch
|
||||
let batch_ready = crate::service::alerts::grouping::add_to_batch(
|
||||
fingerprint.clone(),
|
||||
new_trigger.org.clone(),
|
||||
alert.clone(),
|
||||
data.clone(),
|
||||
grouping_config.group_wait_seconds,
|
||||
grouping_config.max_group_size,
|
||||
);
|
||||
|
||||
if batch_ready {
|
||||
log::info!(
|
||||
"[SCHEDULER trace_id {scheduler_trace_id}] Batch {} reached max size, sending immediately",
|
||||
fingerprint
|
||||
);
|
||||
if let Some(batch) =
|
||||
crate::service::alerts::grouping::get_ready_batch(&fingerprint)
|
||||
&& let Err(e) =
|
||||
crate::job::alert_grouping::send_grouped_notification_sync(batch).await
|
||||
{
|
||||
log::error!(
|
||||
"[SCHEDULER trace_id {scheduler_trace_id}] Failed to send grouped notification: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::debug!(
|
||||
"[SCHEDULER trace_id {scheduler_trace_id}] Alert added to batch, waiting for more alerts or timeout, fingerprint: {}",
|
||||
fingerprint
|
||||
);
|
||||
}
|
||||
|
||||
// Mark as grouped for history tracking
|
||||
trigger_data_stream.dedup_enabled = Some(true);
|
||||
trigger_data_stream.grouped = Some(true);
|
||||
trigger_data_stream.group_size = Some(if batch_ready {
|
||||
grouping_config.max_group_size as i32
|
||||
} else {
|
||||
1
|
||||
});
|
||||
|
||||
// Alert added to batch, don't send individual notification
|
||||
trigger_data.period_end_time = if should_store_last_end_time {
|
||||
Some(trigger_results.end_time)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
new_trigger.data = json::to_string(&trigger_data).unwrap();
|
||||
db::scheduler::update_trigger(new_trigger, true, &query_trace_id).await?;
|
||||
publish_triggers_usage(trigger_data_stream);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Apply deduplication if enabled (enterprise-only feature)
|
||||
#[cfg(feature = "enterprise")]
|
||||
let data = if let Some(db) = ORM_CLIENT.get() {
|
||||
@@ -660,6 +755,11 @@ async fn handle_alert_triggers(
|
||||
&new_trigger.org,
|
||||
&new_trigger.module_key
|
||||
);
|
||||
|
||||
// Mark as suppressed for history tracking
|
||||
trigger_data_stream.dedup_enabled = Some(true);
|
||||
trigger_data_stream.dedup_suppressed = Some(true);
|
||||
|
||||
// All results were deduplicated, skip notification
|
||||
// Still update the trigger timing
|
||||
trigger_data.period_end_time = if should_store_last_end_time {
|
||||
@@ -737,6 +837,14 @@ async fn handle_alert_triggers(
|
||||
);
|
||||
trigger_data_stream.start_time = alert_start_time;
|
||||
trigger_data_stream.end_time = alert_end_time;
|
||||
|
||||
// Mark dedup status for history (if dedup was enabled, alert passed through)
|
||||
if alert.deduplication.as_ref().is_some_and(|d| d.enabled) {
|
||||
trigger_data_stream.dedup_enabled = Some(true);
|
||||
trigger_data_stream.dedup_suppressed = Some(false);
|
||||
}
|
||||
|
||||
// No grouping - send individual notification
|
||||
match alert
|
||||
.send_notification(
|
||||
&data,
|
||||
@@ -1014,7 +1122,7 @@ async fn handle_report_triggers(
|
||||
query_took: None,
|
||||
scheduler_trace_id: Some(scheduler_trace_id.clone()),
|
||||
time_in_queue_ms: Some(Duration::microseconds(time_in_queue).num_milliseconds()),
|
||||
skipped_alerts_count: None,
|
||||
..Default::default()
|
||||
});
|
||||
return Err(anyhow::anyhow!(
|
||||
"Error getting report: {report_id}, error: {e}"
|
||||
@@ -1148,7 +1256,7 @@ async fn handle_report_triggers(
|
||||
query_took: None,
|
||||
scheduler_trace_id: Some(scheduler_trace_id.clone()),
|
||||
time_in_queue_ms: Some(Duration::microseconds(time_in_queue).num_milliseconds()),
|
||||
skipped_alerts_count: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if trigger.retries >= max_retries {
|
||||
@@ -1311,7 +1419,7 @@ async fn handle_derived_stream_triggers(
|
||||
query_took: None,
|
||||
scheduler_trace_id: Some(scheduler_trace_id.clone()),
|
||||
time_in_queue_ms: Some(time_in_queue),
|
||||
skipped_alerts_count: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
log::error!("[SCHEDULER trace_id {scheduler_trace_id}] {err_msg}");
|
||||
@@ -1381,7 +1489,7 @@ async fn handle_derived_stream_triggers(
|
||||
query_took: None,
|
||||
scheduler_trace_id: Some(scheduler_trace_id.clone()),
|
||||
time_in_queue_ms: Some(time_in_queue),
|
||||
skipped_alerts_count: None,
|
||||
..Default::default()
|
||||
};
|
||||
log::info!("[SCHEDULER trace_id {scheduler_trace_id}] {msg}");
|
||||
new_trigger_data.reset();
|
||||
@@ -1420,7 +1528,7 @@ async fn handle_derived_stream_triggers(
|
||||
query_took: None,
|
||||
scheduler_trace_id: Some(scheduler_trace_id.clone()),
|
||||
time_in_queue_ms: Some(time_in_queue),
|
||||
skipped_alerts_count: None,
|
||||
..Default::default()
|
||||
};
|
||||
log::error!("[SCHEDULER trace_id {scheduler_trace_id}] {err_msg}");
|
||||
new_trigger_data.reset();
|
||||
@@ -1569,7 +1677,7 @@ async fn handle_derived_stream_triggers(
|
||||
query_took: None,
|
||||
scheduler_trace_id: Some(scheduler_trace_id.clone()),
|
||||
time_in_queue_ms: Some(time_in_queue),
|
||||
skipped_alerts_count: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// conditionally modify supposed_to_be_run_at
|
||||
|
||||
@@ -260,7 +260,7 @@ pub async fn evaluate_trigger(triggers: TriggerAlertData) {
|
||||
query_took: None,
|
||||
scheduler_trace_id: None,
|
||||
time_in_queue_ms: None,
|
||||
skipped_alerts_count: None,
|
||||
..Default::default()
|
||||
};
|
||||
match alert.send_notification(val, now, None, now).await {
|
||||
Err(e) => {
|
||||
|
||||
@@ -414,6 +414,11 @@ mod tests {
|
||||
query_took: None,
|
||||
scheduler_trace_id: None,
|
||||
time_in_queue_ms: None,
|
||||
dedup_enabled: None,
|
||||
dedup_suppressed: None,
|
||||
dedup_count: None,
|
||||
grouped: None,
|
||||
group_size: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -159,15 +159,15 @@ mod tests {
|
||||
assert!(field_names.contains(&"status".to_string()));
|
||||
assert!(field_names.contains(&"next_run_at".to_string()));
|
||||
|
||||
// Verify count matches struct fields (21 total)
|
||||
assert_eq!(field_names.len(), 21);
|
||||
// Verify count matches struct fields (26 total: 21 original + 5 dedup/grouping fields)
|
||||
assert_eq!(field_names.len(), 26);
|
||||
|
||||
// Verify no duplicate fields
|
||||
let unique_count = field_names
|
||||
.iter()
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len();
|
||||
assert_eq!(unique_count, 21);
|
||||
assert_eq!(unique_count, 26);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -104,7 +104,7 @@ export class HomePage {
|
||||
|
||||
async clickDefaultOrg() {
|
||||
// Click the dropdown to open the list of organizations
|
||||
await this.page.getByText('arrow_drop_down').click();
|
||||
await this.page.getByText('arrow_drop_down').first().click();
|
||||
|
||||
// Wait for the dropdown options to be present using the correct selector
|
||||
const optionsSelector = '[data-test="organization-menu-item-label-item-label"]';
|
||||
@@ -133,7 +133,7 @@ export class HomePage {
|
||||
}
|
||||
|
||||
// Optionally, close the dropdown again if needed
|
||||
await this.page.getByText('arrow_drop_down').click();
|
||||
await this.page.getByText('arrow_drop_down').first().click();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -220,6 +220,52 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</q-td>
|
||||
</template> -->
|
||||
|
||||
<template #body-cell-dedup="props">
|
||||
<q-td :props="props">
|
||||
<!-- Not deduplicated or dedup not enabled -->
|
||||
<span v-if="!props.row.dedup_enabled" class="text-grey-5">
|
||||
-
|
||||
</span>
|
||||
|
||||
<!-- Suppressed by deduplication -->
|
||||
<div v-else-if="props.row.dedup_suppressed" class="text-negative">
|
||||
<q-icon name="block" size="sm" />
|
||||
<q-tooltip class="bg-grey-8">
|
||||
Suppressed by deduplication
|
||||
<div v-if="props.row.dedup_count" class="text-caption">
|
||||
{{ props.row.dedup_count }} occurrence{{ props.row.dedup_count > 1 ? 's' : '' }}
|
||||
</div>
|
||||
</q-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Grouped notification -->
|
||||
<div v-else-if="props.row.grouped" class="text-primary flex items-center justify-center">
|
||||
<q-icon name="group_work" size="sm" />
|
||||
<span class="text-caption q-ml-xs">×{{ props.row.group_size || 1 }}</span>
|
||||
<q-tooltip class="bg-grey-8">
|
||||
Grouped notification
|
||||
<div class="text-caption">
|
||||
{{ props.row.group_size }} alerts batched together
|
||||
</div>
|
||||
</q-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Sent (passed dedup) -->
|
||||
<div v-else class="text-positive flex items-center justify-center">
|
||||
<q-icon name="check_circle" size="sm" />
|
||||
<span v-if="props.row.dedup_count && props.row.dedup_count > 1" class="text-caption q-ml-xs">
|
||||
×{{ props.row.dedup_count }}
|
||||
</span>
|
||||
<q-tooltip class="bg-grey-8">
|
||||
Notification sent
|
||||
<div v-if="props.row.dedup_count && props.row.dedup_count > 1" class="text-caption">
|
||||
{{ props.row.dedup_count }} occurrences deduplicated
|
||||
</div>
|
||||
</q-tooltip>
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-actions="props">
|
||||
<q-td :props="props">
|
||||
<q-btn
|
||||
@@ -679,6 +725,14 @@ const columns = ref([
|
||||
sortable: true,
|
||||
style: "width: 50px;",
|
||||
},
|
||||
{
|
||||
name: "dedup",
|
||||
label: "Dedup",
|
||||
field: "dedup",
|
||||
align: "center",
|
||||
sortable: false,
|
||||
style: "width: 80px;",
|
||||
},
|
||||
// {
|
||||
// name: "error",
|
||||
// label: "Error",
|
||||
|
||||
@@ -88,13 +88,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
class="q-ml-sm o2-secondary-button tw-h-[36px]"
|
||||
no-caps
|
||||
flat
|
||||
label="View History"
|
||||
@click="goToAlertHistory"
|
||||
data-test="alert-history-btn"
|
||||
icon="history"
|
||||
>
|
||||
<q-tooltip>View alert execution history</q-tooltip>
|
||||
</q-btn>
|
||||
:label="t('settings.header')"
|
||||
@click="showCorrelationDrawer = true"
|
||||
data-test="correlation-settings-btn"
|
||||
icon="settings"
|
||||
/>
|
||||
<q-btn
|
||||
class="q-ml-sm o2-secondary-button tw-h-[36px]"
|
||||
no-caps
|
||||
@@ -249,6 +247,27 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
{{ props.row[col.field].name }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="col.name === 'dedup_status'">
|
||||
<div class="tw-flex tw-items-center tw-justify-center">
|
||||
<q-icon
|
||||
v-if="props.row.deduplication?.enabled"
|
||||
name="check_circle"
|
||||
size="sm"
|
||||
color="positive"
|
||||
>
|
||||
<q-tooltip class="bg-grey-8">
|
||||
Deduplication: Enabled
|
||||
<div v-if="props.row.deduplication.fingerprint_fields?.length">
|
||||
Fields: {{ props.row.deduplication.fingerprint_fields.join(', ') }}
|
||||
</div>
|
||||
<div v-if="props.row.deduplication.grouping?.enabled">
|
||||
Grouping: {{ props.row.deduplication.grouping.group_wait_seconds }}s wait
|
||||
</div>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
<span v-else class="text-grey-5">-</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="col.name == 'actions'">
|
||||
<div class="tw-flex tw-items-center actions-container"
|
||||
>
|
||||
@@ -536,6 +555,48 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
@update:templates="getTemplates"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Correlation Settings Drawer -->
|
||||
<q-drawer
|
||||
v-model="showCorrelationDrawer"
|
||||
side="right"
|
||||
:width="800"
|
||||
bordered
|
||||
overlay
|
||||
behavior="mobile"
|
||||
data-test="correlation-settings-drawer"
|
||||
>
|
||||
<div class="tw-h-full tw-flex tw-flex-col">
|
||||
<!-- Drawer Header -->
|
||||
<div class="tw-px-6 tw-py-4 tw-border-b tw-flex tw-items-center tw-justify-between">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<q-icon name="group_work" size="24px" class="tw-mr-2" />
|
||||
<h6 class="tw-text-lg tw-font-semibold tw-m-0">
|
||||
{{ t('settings.alertCorrelation') }}
|
||||
</h6>
|
||||
</div>
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="close"
|
||||
@click="showCorrelationDrawer = false"
|
||||
data-test="close-correlation-drawer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Drawer Content -->
|
||||
<div class="tw-flex-1 tw-overflow-y-auto">
|
||||
<OrganizationDeduplicationSettings
|
||||
:org-id="store.state.selectedOrganization.identifier"
|
||||
:config="store.state.organizationSettings?.deduplication_config"
|
||||
@saved="onCorrelationSettingsSaved"
|
||||
@cancel="showCorrelationDrawer = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-drawer>
|
||||
|
||||
<ConfirmDialog
|
||||
title="Delete Alert"
|
||||
message="Are you sure you want to delete this alert?"
|
||||
@@ -795,6 +856,8 @@ import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import segment from "@/services/segment_analytics";
|
||||
import config from "@/aws-exports";
|
||||
import ImportAlert from "@/components/alerts/ImportAlert.vue";
|
||||
import OrganizationDeduplicationSettings from "@/components/alerts/OrganizationDeduplicationSettings.vue";
|
||||
import DedupSummaryCards from "@/components/alerts/DedupSummaryCards.vue";
|
||||
import {
|
||||
getImageURL,
|
||||
getUUID,
|
||||
@@ -830,6 +893,8 @@ export default defineComponent({
|
||||
NoData,
|
||||
ConfirmDialog,
|
||||
ImportAlert,
|
||||
OrganizationDeduplicationSettings,
|
||||
DedupSummaryCards,
|
||||
FolderList,
|
||||
MoveAcrossFolders,
|
||||
AppTabs,
|
||||
@@ -866,6 +931,7 @@ export default defineComponent({
|
||||
const showHistoryDrawer = ref(false);
|
||||
const selectedHistoryAlertId = ref("");
|
||||
const selectedHistoryAlertName = ref("");
|
||||
const showCorrelationDrawer = ref(false);
|
||||
|
||||
const { getStreams } = useStreams();
|
||||
|
||||
@@ -1089,6 +1155,14 @@ export default defineComponent({
|
||||
sortable: true,
|
||||
style: "width: 150px",
|
||||
},
|
||||
{
|
||||
name: "dedup_status",
|
||||
field: "dedup_status",
|
||||
label: "Dedup",
|
||||
align: "center",
|
||||
sortable: false,
|
||||
style: "width: 80px",
|
||||
},
|
||||
{
|
||||
name: "actions",
|
||||
field: "actions",
|
||||
@@ -1987,6 +2061,12 @@ export default defineComponent({
|
||||
});
|
||||
};
|
||||
|
||||
const onCorrelationSettingsSaved = async () => {
|
||||
// Reload organization settings to get updated dedup config
|
||||
await store.dispatch("getDefaultOrganizationSettings");
|
||||
showCorrelationDrawer.value = false;
|
||||
};
|
||||
|
||||
const exportAlert = async (row: any) => {
|
||||
// Find the alert based on uuid
|
||||
const alertToBeExported = await getAlertById(row.alert_id);
|
||||
@@ -2478,6 +2558,8 @@ export default defineComponent({
|
||||
showHistoryDrawer,
|
||||
selectedHistoryAlertId,
|
||||
selectedHistoryAlertName,
|
||||
showCorrelationDrawer,
|
||||
onCorrelationSettingsSaved,
|
||||
refreshImportedAlerts,
|
||||
folderIdToBeCloned,
|
||||
updateFolderIdToBeCloned,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<!-- Copyright 2025 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 <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="dedup-summary-cards tw-grid tw-grid-cols-4 tw-gap-4 tw-mb-4">
|
||||
<!-- Card 1: Total Alerts -->
|
||||
<q-card class="summary-card">
|
||||
<q-card-section class="tw-p-4">
|
||||
<div class="tw-text-2xl tw-font-semibold">{{ summary.total_alerts }}</div>
|
||||
<div class="tw-text-sm tw-text-gray-600">Total Alerts</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Card 2: Alerts with Dedup -->
|
||||
<q-card class="summary-card">
|
||||
<q-card-section class="tw-p-4">
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<div class="tw-text-2xl tw-font-semibold">{{ summary.alerts_with_dedup }}</div>
|
||||
<q-icon name="filter_alt" size="sm" color="primary" />
|
||||
</div>
|
||||
<div class="tw-text-sm tw-text-gray-600 tw-flex tw-items-center tw-gap-1">
|
||||
Using Deduplication
|
||||
<q-icon name="info_outline" size="xs" class="tw-cursor-pointer">
|
||||
<q-tooltip class="bg-grey-8">
|
||||
Alerts with deduplication configured
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Card 3: Suppression Rate -->
|
||||
<q-card
|
||||
class="summary-card"
|
||||
:class="{
|
||||
'tw-bg-green-50': summary.suppression_rate > 0.5,
|
||||
'tw-bg-yellow-50': summary.suppression_rate > 0 && summary.suppression_rate <= 0.5
|
||||
}"
|
||||
>
|
||||
<q-card-section class="tw-p-4">
|
||||
<div class="tw-text-2xl tw-font-semibold" :class="{
|
||||
'tw-text-green-700': summary.suppression_rate > 0.5,
|
||||
'tw-text-yellow-700': summary.suppression_rate > 0 && summary.suppression_rate <= 0.5
|
||||
}">
|
||||
{{ formatPercentage(summary.suppression_rate) }}
|
||||
</div>
|
||||
<div class="tw-text-sm tw-text-gray-600 tw-flex tw-items-center tw-gap-1">
|
||||
Suppression Rate (24h)
|
||||
<q-icon name="info_outline" size="xs" class="tw-cursor-pointer">
|
||||
<q-tooltip class="bg-grey-8">
|
||||
{{ summary.suppressions_total }} suppressed /
|
||||
{{ summary.suppressions_total + summary.passed_total }} total
|
||||
<div class="tw-mt-1">
|
||||
Passed: {{ summary.passed_total }}
|
||||
</div>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Card 4: Pending Batches -->
|
||||
<q-card class="summary-card">
|
||||
<q-card-section class="tw-p-4">
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<div class="tw-text-2xl tw-font-semibold">{{ summary.pending_batches }}</div>
|
||||
<q-icon name="group_work" size="sm" color="amber" />
|
||||
</div>
|
||||
<div class="tw-text-sm tw-text-gray-600 tw-flex tw-items-center tw-gap-1">
|
||||
Pending Batches
|
||||
<q-icon name="info_outline" size="xs" class="tw-cursor-pointer">
|
||||
<q-tooltip class="bg-grey-8">
|
||||
Alerts waiting to be grouped together
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import alertsService from '@/services/alerts';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
interface DedupSummary {
|
||||
total_alerts: number;
|
||||
alerts_with_dedup: number;
|
||||
suppressions_total: number;
|
||||
passed_total: number;
|
||||
suppression_rate: number;
|
||||
pending_batches: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const summary = ref<DedupSummary>({
|
||||
total_alerts: 0,
|
||||
alerts_with_dedup: 0,
|
||||
suppressions_total: 0,
|
||||
passed_total: 0,
|
||||
suppression_rate: 0,
|
||||
pending_batches: 0,
|
||||
timestamp: 0,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSummary();
|
||||
});
|
||||
|
||||
async function fetchSummary() {
|
||||
try {
|
||||
const orgId = store.state.selectedOrganization.identifier;
|
||||
const response = await alertsService.get_dedup_summary(orgId);
|
||||
if (response.data) {
|
||||
summary.value = response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dedup summary:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function formatPercentage(rate: number): string {
|
||||
if (rate === 0) return '0%';
|
||||
return `${(rate * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
// Expose fetch method for parent to refresh
|
||||
defineExpose({
|
||||
fetchSummary
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.summary-card {
|
||||
border: 1px solid #e0e0e0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,334 @@
|
||||
<!-- Copyright 2025 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 <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<q-card flat class="tw-p-6">
|
||||
<div class="tw-mb-6">
|
||||
<div class="text-h6 tw-mb-2">Alert Deduplication</div>
|
||||
<div class="text-body2 text-grey-7">
|
||||
Configure organization-wide semantic field groups and default time windows.
|
||||
Semantic groups define which field name variations represent the same dimension
|
||||
(e.g., "host", "hostname", "node" all map to "host"). Each alert specifies which
|
||||
fields to use for fingerprinting in its own configuration.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-separator class="tw-mb-6" />
|
||||
|
||||
<!-- Enable Deduplication -->
|
||||
<div class="tw-mb-6">
|
||||
<q-checkbox
|
||||
v-model="localConfig.enabled"
|
||||
label="Enable Organization-Level Deduplication"
|
||||
dense
|
||||
@update:model-value="emitUpdate"
|
||||
>
|
||||
<q-tooltip>
|
||||
Enable deduplication and correlation features for all alerts in this organization
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- Cross-Alert Deduplication -->
|
||||
<div class="tw-mb-6" v-if="localConfig.enabled">
|
||||
<q-checkbox
|
||||
v-model="localConfig.alert_dedup_enabled"
|
||||
label="Enable Cross-Alert Deduplication"
|
||||
dense
|
||||
@update:model-value="emitUpdate"
|
||||
>
|
||||
<q-tooltip>
|
||||
Allow different alerts to deduplicate each other when they share the same fingerprint
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- Semantic Field Groups Configuration -->
|
||||
<div class="tw-mb-6" v-if="localConfig.enabled">
|
||||
<SemanticFieldGroupsConfig
|
||||
v-model:semantic-field-groups="localSemanticGroups"
|
||||
@update:semantic-field-groups="handleSemanticGroupsUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Cross-Alert Fingerprint Groups -->
|
||||
<div class="tw-mb-6" v-if="localConfig.alert_dedup_enabled">
|
||||
<div class="tw-font-semibold tw-pb-2 tw-flex tw-items-center">
|
||||
Cross-Alert Fingerprint Groups <span class="tw-text-red-500 tw-ml-1">*</span>
|
||||
<q-icon
|
||||
:name="outlinedInfo"
|
||||
size="17px"
|
||||
class="q-ml-xs cursor-pointer"
|
||||
:class="store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'"
|
||||
>
|
||||
<q-tooltip
|
||||
anchor="center right"
|
||||
self="center left"
|
||||
max-width="300px"
|
||||
style="font-size: 12px"
|
||||
>
|
||||
Select which semantic groups to use for cross-alert fingerprinting.
|
||||
Alerts will be deduplicated if they share the same values for these dimensions.
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
<div class="tw-text-sm tw-text-gray-600 dark:tw-text-gray-400 tw-mb-2">
|
||||
Select at least one semantic group for cross-alert deduplication
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-col tw-gap-2">
|
||||
<q-checkbox
|
||||
v-for="group in localSemanticGroups"
|
||||
:key="group.id"
|
||||
:model-value="localConfig.alert_fingerprint_groups?.includes(group.id)"
|
||||
@update:model-value="(val) => toggleFingerprintGroup(group.id, val)"
|
||||
:label="`${group.display} (${group.id})`"
|
||||
dense
|
||||
/>
|
||||
<div
|
||||
v-if="!localConfig.alert_fingerprint_groups || localConfig.alert_fingerprint_groups.length === 0"
|
||||
class="tw-text-red-500 tw-text-sm tw-mt-1"
|
||||
>
|
||||
At least one semantic group must be selected
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time Window -->
|
||||
<div class="tw-mb-6">
|
||||
<div class="tw-font-semibold tw-pb-2 tw-flex tw-items-center">
|
||||
Default Correlation Window (minutes)
|
||||
<q-icon
|
||||
:name="outlinedInfo"
|
||||
size="17px"
|
||||
class="q-ml-xs cursor-pointer"
|
||||
:class="store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'"
|
||||
>
|
||||
<q-tooltip
|
||||
anchor="center right"
|
||||
self="center left"
|
||||
max-width="300px"
|
||||
style="font-size: 12px"
|
||||
>
|
||||
In absence of semantic field match, alerts and data from given
|
||||
duration is considered related
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
<div class="tw-text-sm tw-text-gray-600 dark:tw-text-gray-400 tw-mb-2">
|
||||
In absence of semantic field match, alerts and data from given duration
|
||||
is considered related
|
||||
</div>
|
||||
<q-input
|
||||
v-model.number="localConfig.time_window_minutes"
|
||||
type="number"
|
||||
dense
|
||||
filled
|
||||
min="1"
|
||||
placeholder="Use alert period by default"
|
||||
:class="
|
||||
store.state.theme === 'dark'
|
||||
? 'input-box-bg-dark input-border-dark'
|
||||
: 'input-box-bg-light input-border-light'
|
||||
"
|
||||
@update:model-value="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-justify-end tw-gap-3">
|
||||
<q-btn outline label="Cancel" @click="$emit('cancel')" class="tw-px-4" />
|
||||
<q-btn
|
||||
label="Save Settings"
|
||||
color="primary"
|
||||
@click="saveSettings"
|
||||
:loading="saving"
|
||||
class="tw-px-4"
|
||||
/>
|
||||
</div>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useQuasar } from "quasar";
|
||||
import { outlinedInfo } from "@quasar/extras/material-icons-outlined";
|
||||
import SemanticFieldGroupsConfig from "./SemanticFieldGroupsConfig.vue";
|
||||
import alertsService from "@/services/alerts";
|
||||
|
||||
const store = useStore();
|
||||
const $q = useQuasar();
|
||||
|
||||
interface SemanticFieldGroup {
|
||||
id: string;
|
||||
display: string;
|
||||
fields: string[];
|
||||
normalize: boolean;
|
||||
}
|
||||
|
||||
interface OrganizationDeduplicationConfig {
|
||||
enabled: boolean;
|
||||
semantic_field_groups?: SemanticFieldGroup[];
|
||||
alert_dedup_enabled?: boolean;
|
||||
alert_fingerprint_groups?: string[];
|
||||
time_window_minutes?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
orgId: string;
|
||||
config?: OrganizationDeduplicationConfig | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
config: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "saved"): void;
|
||||
(e: "cancel"): void;
|
||||
}>();
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
const localConfig = ref<OrganizationDeduplicationConfig>({
|
||||
enabled: true,
|
||||
alert_dedup_enabled: props.config?.alert_dedup_enabled ?? true,
|
||||
alert_fingerprint_groups: props.config?.alert_fingerprint_groups ?? [],
|
||||
time_window_minutes: props.config?.time_window_minutes ?? undefined,
|
||||
semantic_field_groups: props.config?.semantic_field_groups ?? [],
|
||||
});
|
||||
|
||||
const localSemanticGroups = ref<SemanticFieldGroup[]>(
|
||||
props.config?.semantic_field_groups ?? [],
|
||||
);
|
||||
|
||||
const handleSemanticGroupsUpdate = (groups: SemanticFieldGroup[]) => {
|
||||
localConfig.value.semantic_field_groups = groups;
|
||||
localSemanticGroups.value = groups;
|
||||
};
|
||||
|
||||
const toggleFingerprintGroup = (groupId: string, checked: boolean) => {
|
||||
if (!localConfig.value.alert_fingerprint_groups) {
|
||||
localConfig.value.alert_fingerprint_groups = [];
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
if (!localConfig.value.alert_fingerprint_groups.includes(groupId)) {
|
||||
localConfig.value.alert_fingerprint_groups.push(groupId);
|
||||
}
|
||||
} else {
|
||||
localConfig.value.alert_fingerprint_groups =
|
||||
localConfig.value.alert_fingerprint_groups.filter((id) => id !== groupId);
|
||||
}
|
||||
};
|
||||
|
||||
const emitUpdate = () => {
|
||||
// Just update local state, don't auto-save
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
// Validate cross-alert dedup requires fingerprint groups
|
||||
if (localConfig.value.alert_dedup_enabled) {
|
||||
if (!localConfig.value.alert_fingerprint_groups ||
|
||||
localConfig.value.alert_fingerprint_groups.length === 0) {
|
||||
$q.notify({
|
||||
type: "negative",
|
||||
message: "Please select at least one semantic group for cross-alert deduplication",
|
||||
timeout: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await alertsService.setOrganizationDeduplicationConfig(
|
||||
props.orgId,
|
||||
localConfig.value,
|
||||
);
|
||||
|
||||
$q.notify({
|
||||
type: "positive",
|
||||
message:
|
||||
"Organization deduplication settings saved successfully",
|
||||
timeout: 2000,
|
||||
});
|
||||
|
||||
emit("saved");
|
||||
} catch (error: any) {
|
||||
console.error("Error saving deduplication settings:", error);
|
||||
$q.notify({
|
||||
type: "negative",
|
||||
message: error?.message || "Failed to save settings",
|
||||
timeout: 3000,
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch config on mount if not provided
|
||||
const loadConfig = async () => {
|
||||
if (!props.config) {
|
||||
try {
|
||||
const response = await alertsService.getOrganizationDeduplicationConfig(props.orgId);
|
||||
const config = response.data;
|
||||
console.log("Loaded dedup config:", config);
|
||||
localConfig.value = {
|
||||
enabled: config.enabled ?? true,
|
||||
alert_dedup_enabled: config.alert_dedup_enabled ?? true,
|
||||
alert_fingerprint_groups: config.alert_fingerprint_groups ?? [],
|
||||
time_window_minutes: config.time_window_minutes ?? undefined,
|
||||
semantic_field_groups: config.semantic_field_groups ?? [],
|
||||
};
|
||||
localSemanticGroups.value = config.semantic_field_groups ?? [];
|
||||
} catch (error) {
|
||||
console.log("No existing config, using defaults", error);
|
||||
}
|
||||
} else {
|
||||
console.log("Using config from props:", props.config);
|
||||
}
|
||||
};
|
||||
|
||||
// Load config on mount
|
||||
loadConfig();
|
||||
|
||||
// Watch for external changes
|
||||
watch(
|
||||
() => props.config,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
console.log("Config changed from props:", newVal);
|
||||
localConfig.value = {
|
||||
enabled: newVal.enabled ?? true,
|
||||
alert_dedup_enabled: newVal.alert_dedup_enabled ?? true,
|
||||
alert_fingerprint_groups: newVal.alert_fingerprint_groups ?? [],
|
||||
time_window_minutes: newVal.time_window_minutes ?? undefined,
|
||||
semantic_field_groups: newVal.semantic_field_groups ?? [],
|
||||
};
|
||||
localSemanticGroups.value = newVal.semantic_field_groups ?? [];
|
||||
console.log("Updated localConfig:", localConfig.value);
|
||||
console.log("Updated localSemanticGroups:", localSemanticGroups.value);
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.q-card {
|
||||
max-width: 1200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,398 @@
|
||||
<!-- Copyright 2025 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 <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="semantic-field-groups-config">
|
||||
<div class="section-header q-mb-md">
|
||||
<div class="text-h6">Field Mappings</div>
|
||||
<div class="text-caption text-grey-7">
|
||||
Define field name mappings for correlation and deduplication (e.g.,
|
||||
host, hostname, node → "host" group)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preset Templates -->
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-select
|
||||
v-model="selectedPreset"
|
||||
:options="presetOptions"
|
||||
label="Load Preset Template"
|
||||
hint="Quick start with common field groups"
|
||||
dense
|
||||
filled
|
||||
color="input-border"
|
||||
bg-color="input-bg"
|
||||
emit-value
|
||||
map-options
|
||||
clearable
|
||||
@update:model-value="loadPreset"
|
||||
style="max-width: 100%"
|
||||
>
|
||||
<template v-slot:selected>
|
||||
<div
|
||||
class="ellipsis"
|
||||
style="max-width: 100%; overflow: hidden; text-overflow: ellipsis"
|
||||
>
|
||||
{{
|
||||
presetOptions.find((p) => p.value === selectedPreset)?.label ||
|
||||
"Select preset"
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 flex items-center">
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="Add Custom Group"
|
||||
icon="add"
|
||||
size="sm"
|
||||
@click="addGroup"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Semantic Groups List -->
|
||||
<div v-if="localGroups.length > 0" class="groups-list q-mb-md">
|
||||
<SemanticGroupItem
|
||||
v-for="(group, index) in localGroups"
|
||||
:key="`${group.id}-${index}`"
|
||||
:group="group"
|
||||
@update="updateGroup(index, $event)"
|
||||
@delete="removeGroup(index)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-center q-pa-lg text-grey-7">
|
||||
<q-icon name="info" size="md"
|
||||
class="q-mb-sm" />
|
||||
<div>
|
||||
No semantic groups defined. Add a group or load a preset template.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fingerprint Fields Selection (only for per-alert, not org-level) -->
|
||||
<div v-if="localGroups.length > 0 && showFingerprintFields" class="fingerprint-section q-mt-lg">
|
||||
<div class="text-subtitle1 q-mb-sm">
|
||||
Deduplication Fields *
|
||||
<q-tooltip
|
||||
>Select which field mappings to use for deduplication
|
||||
fingerprinting</q-tooltip
|
||||
>
|
||||
</div>
|
||||
<div class="text-caption text-grey-7 q-mb-md">
|
||||
Alerts with the same values for these fields will be deduplicated (e.g.,
|
||||
same fingerprint = same field values)
|
||||
</div>
|
||||
<div class="fingerprint-checkboxes">
|
||||
<q-checkbox
|
||||
v-for="group in localGroups"
|
||||
:key="group.id"
|
||||
v-model="localFingerprintFields"
|
||||
:val="group.id"
|
||||
:label="group.display"
|
||||
class="fingerprint-checkbox"
|
||||
@update:model-value="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="localFingerprintFields.length === 0"
|
||||
class="text-negative text-caption q-mt-sm"
|
||||
>
|
||||
At least one field mapping is required for deduplication
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import SemanticGroupItem from "./SemanticGroupItem.vue";
|
||||
|
||||
interface SemanticGroup {
|
||||
id: string;
|
||||
display: string;
|
||||
fields: string[];
|
||||
normalize: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
semanticFieldGroups?: SemanticGroup[];
|
||||
fingerprintFields?: string[];
|
||||
showFingerprintFields?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
semanticFieldGroups: () => [],
|
||||
fingerprintFields: () => [],
|
||||
showFingerprintFields: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:semanticFieldGroups", groups: SemanticGroup[]): void;
|
||||
(e: "update:fingerprintFields", fields: string[]): void;
|
||||
}>();
|
||||
|
||||
const localGroups = ref<SemanticGroup[]>([...props.semanticFieldGroups]);
|
||||
const localFingerprintFields = ref<string[]>([...props.fingerprintFields]);
|
||||
const selectedPreset = ref<string | null>(null);
|
||||
|
||||
// Watch for external changes
|
||||
watch(
|
||||
() => props.semanticFieldGroups,
|
||||
(newGroups) => {
|
||||
localGroups.value = [...newGroups];
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.fingerprintFields,
|
||||
(newFields) => {
|
||||
localFingerprintFields.value = [...newFields];
|
||||
},
|
||||
);
|
||||
|
||||
// Preset options
|
||||
const presetOptions = [
|
||||
{ label: "Common", value: "common" },
|
||||
{ label: "Kubernetes", value: "kubernetes" },
|
||||
{ label: "AWS", value: "aws" },
|
||||
{ label: "GCP", value: "gcp" },
|
||||
{ label: "Azure", value: "azure" },
|
||||
];
|
||||
|
||||
// Group ID options for fingerprint selection
|
||||
const groupIdOptions = computed(() => {
|
||||
return localGroups.value.map((group) => ({
|
||||
label: group.display,
|
||||
value: group.id,
|
||||
}));
|
||||
});
|
||||
|
||||
// Preset templates
|
||||
const presets: Record<
|
||||
string,
|
||||
{ groups: SemanticGroup[]; fingerprint: string[] }
|
||||
> = {
|
||||
common: {
|
||||
groups: [
|
||||
{
|
||||
id: "host",
|
||||
display: "Host",
|
||||
fields: ["host", "hostname", "node", "server"],
|
||||
normalize: true,
|
||||
},
|
||||
{
|
||||
id: "service",
|
||||
display: "Service",
|
||||
fields: ["service", "service_name", "application", "app"],
|
||||
normalize: true,
|
||||
},
|
||||
{
|
||||
id: "environment",
|
||||
display: "Environment",
|
||||
fields: ["environment", "env", "stage"],
|
||||
normalize: true,
|
||||
},
|
||||
{
|
||||
id: "ip-address",
|
||||
display: "IP Address",
|
||||
fields: ["ip", "ip_address", "ipaddr", "client_ip"],
|
||||
normalize: false,
|
||||
},
|
||||
],
|
||||
fingerprint: ["service", "host"],
|
||||
},
|
||||
kubernetes: {
|
||||
groups: [
|
||||
{
|
||||
id: "k8s-cluster",
|
||||
display: "K8s Cluster",
|
||||
fields: ["cluster", "k8s_cluster", "kubernetes_cluster"],
|
||||
normalize: true,
|
||||
},
|
||||
{
|
||||
id: "k8s-namespace",
|
||||
display: "K8s Namespace",
|
||||
fields: ["namespace", "k8s_namespace", "kubernetes_namespace"],
|
||||
normalize: true,
|
||||
},
|
||||
{
|
||||
id: "k8s-pod",
|
||||
display: "K8s Pod",
|
||||
fields: ["pod", "pod_name", "k8s_pod"],
|
||||
normalize: true,
|
||||
},
|
||||
{
|
||||
id: "k8s-container",
|
||||
display: "K8s Container",
|
||||
fields: ["container", "container_name", "k8s_container"],
|
||||
normalize: true,
|
||||
},
|
||||
],
|
||||
fingerprint: ["k8s-cluster", "k8s-namespace", "k8s-pod"],
|
||||
},
|
||||
aws: {
|
||||
groups: [
|
||||
{
|
||||
id: "aws-account",
|
||||
display: "AWS Account",
|
||||
fields: ["account_id", "aws_account", "account"],
|
||||
normalize: false,
|
||||
},
|
||||
{
|
||||
id: "aws-region",
|
||||
display: "AWS Region",
|
||||
fields: ["region", "aws_region"],
|
||||
normalize: true,
|
||||
},
|
||||
{
|
||||
id: "aws-service",
|
||||
display: "AWS Service",
|
||||
fields: ["service", "aws_service", "service_name"],
|
||||
normalize: true,
|
||||
},
|
||||
],
|
||||
fingerprint: ["aws-account", "aws-region", "aws-service"],
|
||||
},
|
||||
gcp: {
|
||||
groups: [
|
||||
{
|
||||
id: "gcp-project",
|
||||
display: "GCP Project",
|
||||
fields: ["project_id", "gcp_project", "project"],
|
||||
normalize: false,
|
||||
},
|
||||
{
|
||||
id: "gcp-zone",
|
||||
display: "GCP Zone",
|
||||
fields: ["zone", "gcp_zone", "availability_zone"],
|
||||
normalize: true,
|
||||
},
|
||||
{
|
||||
id: "gcp-service",
|
||||
display: "GCP Service",
|
||||
fields: ["service", "gcp_service", "service_name"],
|
||||
normalize: true,
|
||||
},
|
||||
],
|
||||
fingerprint: ["gcp-project", "gcp-zone", "gcp-service"],
|
||||
},
|
||||
azure: {
|
||||
groups: [
|
||||
{
|
||||
id: "azure-subscription",
|
||||
display: "Azure Subscription",
|
||||
fields: ["subscription_id", "azure_subscription"],
|
||||
normalize: false,
|
||||
},
|
||||
{
|
||||
id: "azure-resource-group",
|
||||
display: "Azure Resource Group",
|
||||
fields: ["resource_group", "azure_resource_group", "rg"],
|
||||
normalize: true,
|
||||
},
|
||||
{
|
||||
id: "azure-service",
|
||||
display: "Azure Service",
|
||||
fields: ["service", "azure_service", "service_name"],
|
||||
normalize: true,
|
||||
},
|
||||
],
|
||||
fingerprint: [
|
||||
"azure-subscription",
|
||||
"azure-resource-group",
|
||||
"azure-service",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const loadPreset = (presetKey: string | null) => {
|
||||
if (!presetKey) return;
|
||||
|
||||
const preset = presets[presetKey];
|
||||
if (preset) {
|
||||
localGroups.value = [...preset.groups];
|
||||
localFingerprintFields.value = [...preset.fingerprint];
|
||||
emitUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const addGroup = () => {
|
||||
const newGroup: SemanticGroup = {
|
||||
id: "",
|
||||
display: "",
|
||||
fields: [],
|
||||
normalize: true,
|
||||
};
|
||||
localGroups.value.push(newGroup);
|
||||
emitUpdate();
|
||||
};
|
||||
|
||||
const updateGroup = (index: number, updatedGroup: SemanticGroup) => {
|
||||
localGroups.value[index] = updatedGroup;
|
||||
emitUpdate();
|
||||
};
|
||||
|
||||
const removeGroup = (index: number) => {
|
||||
const removedId = localGroups.value[index].id;
|
||||
localGroups.value.splice(index, 1);
|
||||
|
||||
// Remove from fingerprint fields if present
|
||||
localFingerprintFields.value = localFingerprintFields.value.filter(
|
||||
(id) => id !== removedId,
|
||||
);
|
||||
|
||||
emitUpdate();
|
||||
};
|
||||
|
||||
const emitUpdate = () => {
|
||||
emit("update:semanticFieldGroups", [...localGroups.value]);
|
||||
emit("update:fingerprintFields", [...localFingerprintFields.value]);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.semantic-field-groups-config {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
border-bottom: 1px solid var(--q-separator-color);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.groups-list {
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.fingerprint-section {
|
||||
border-top: 1px solid var(--q-separator-color);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.fingerprint-checkboxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fingerprint-checkbox {
|
||||
min-width: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<!-- Copyright 2025 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 <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<q-card flat bordered
|
||||
class="semantic-group-item q-pa-md q-mb-sm">
|
||||
<div class="group-layout">
|
||||
<!-- Left Column: ID and Display Name stacked -->
|
||||
<div class="left-column">
|
||||
<div class="q-mb-sm">
|
||||
<q-input
|
||||
v-model="localGroup.id"
|
||||
label="Name *"
|
||||
hint="e.g., k8s-cluster"
|
||||
:rules="[validateId]"
|
||||
dense
|
||||
filled
|
||||
color="input-border"
|
||||
bg-color="input-bg"
|
||||
@update:model-value="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<q-input
|
||||
v-model="localGroup.display"
|
||||
label="Display *"
|
||||
hint="Human-readable"
|
||||
:rules="[(val) => !!val || 'Display name is required']"
|
||||
dense
|
||||
filled
|
||||
color="input-border"
|
||||
bg-color="input-bg"
|
||||
@update:model-value="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Field Names spanning both rows -->
|
||||
<div class="right-column">
|
||||
<div class="field-names-input">
|
||||
<TagInput
|
||||
v-model="localGroup.fields"
|
||||
placeholder=""
|
||||
label="Field names (comma-separated) *"
|
||||
@update:model-value="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Column: Normalize and Delete -->
|
||||
<div class="actions-column">
|
||||
<div class="q-mb-sm">
|
||||
<q-checkbox
|
||||
v-model="localGroup.normalize"
|
||||
label="Normalize"
|
||||
@update:model-value="emitUpdate"
|
||||
>
|
||||
<q-tooltip>Lowercase and trim values for matching</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
color="negative"
|
||||
icon="delete"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<q-tooltip>Remove this semantic group</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from "vue";
|
||||
import TagInput from "./TagInput.vue";
|
||||
|
||||
interface SemanticGroup {
|
||||
id: string;
|
||||
display: string;
|
||||
fields: string[];
|
||||
normalize: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
group: SemanticGroup;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
(e: "update", group: SemanticGroup): void;
|
||||
(e: "delete"): void;
|
||||
}>();
|
||||
|
||||
const localGroup = ref<SemanticGroup>({ ...props.group });
|
||||
|
||||
watch(
|
||||
() => props.group,
|
||||
(newGroup) => {
|
||||
localGroup.value = { ...newGroup };
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const validateId = (val: string) => {
|
||||
if (!val) return "ID is required";
|
||||
if (!/^[a-z0-9-]+$/.test(val)) {
|
||||
return "ID must be lowercase letters, numbers, and dashes only";
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const emitUpdate = () => {
|
||||
emit("update", { ...localGroup.value });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.semantic-group-item {
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.group-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.right-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.field-names-input {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.tag-input-container) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.tag-input-wrapper) {
|
||||
flex: 1;
|
||||
min-height: 100px;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.actions-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.text-subtitle2 {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--q-color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.group-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<!-- Copyright 2025 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 <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="tag-input-container">
|
||||
<div class="tag-input-wrapper" :class="{ 'has-content': hasContent }">
|
||||
<label v-if="label" class="tag-input-label">{{ label }}</label>
|
||||
<div class="tags-and-input">
|
||||
<q-chip
|
||||
v-for="(tag, index) in modelValue"
|
||||
:key="index"
|
||||
removable
|
||||
@remove="removeTag(index)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
size="sm"
|
||||
class="tag-chip"
|
||||
>
|
||||
{{ tag }}
|
||||
</q-chip>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
class="tag-input"
|
||||
@keydown.enter.prevent="addTag"
|
||||
@input="handleInput"
|
||||
@keydown.delete="handleBackspace"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
interface Props {
|
||||
modelValue: string[];
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: "Type and press Enter or comma",
|
||||
label: "",
|
||||
});
|
||||
|
||||
const hasContent = computed(
|
||||
() => props.modelValue.length > 0 || inputValue.value.length > 0,
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string[]): void;
|
||||
}>();
|
||||
|
||||
const inputValue = ref("");
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const addTag = () => {
|
||||
const trimmedValue = inputValue.value.trim();
|
||||
if (trimmedValue && !props.modelValue.includes(trimmedValue)) {
|
||||
const newTags = [...props.modelValue, trimmedValue];
|
||||
emit("update:modelValue", newTags);
|
||||
inputValue.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
// Check if the input contains a comma
|
||||
if (inputValue.value.includes(",")) {
|
||||
// Split by comma and process each value
|
||||
const values = inputValue.value.split(",");
|
||||
|
||||
// Process all complete values (all but the last one)
|
||||
for (let i = 0; i < values.length - 1; i++) {
|
||||
const trimmedValue = values[i].trim();
|
||||
if (trimmedValue && !props.modelValue.includes(trimmedValue)) {
|
||||
const newTags = [...props.modelValue, trimmedValue];
|
||||
emit("update:modelValue", newTags);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the last value (after the last comma) in the input
|
||||
inputValue.value = values[values.length - 1];
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (index: number) => {
|
||||
const newTags = props.modelValue.filter((_, i) => i !== index);
|
||||
emit("update:modelValue", newTags);
|
||||
};
|
||||
|
||||
const handleBackspace = () => {
|
||||
if (inputValue.value === "" && props.modelValue.length > 0) {
|
||||
removeTag(props.modelValue.length - 1);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tag-input-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tag-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--q-input-border-color, rgba(0, 0, 0, 0.24));
|
||||
border-radius: 4px;
|
||||
background-color: var(--q-input-bg-color, rgba(0, 0, 0, 0.05));
|
||||
min-height: 56px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
cursor: text;
|
||||
transition: border-color 0.3s;
|
||||
overflow: hidden;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--q-primary);
|
||||
|
||||
.tag-input-label {
|
||||
color: var(--q-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.has-content .tag-input-label {
|
||||
transform: translateY(-8px) scale(0.75);
|
||||
color: var(--q-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.tag-input-label {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 12px;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
pointer-events: none;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
|
||||
transform-origin: left top;
|
||||
background-color: transparent;
|
||||
padding: 0 4px;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.tags-and-input {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
margin: 0 !important;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tag-input {
|
||||
flex: 1 1 100px;
|
||||
min-width: 100px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
padding: 4px;
|
||||
font-size: 14px;
|
||||
color: var(--q-color-text-primary);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--q-color-text-hint);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1246,6 +1246,7 @@
|
||||
},
|
||||
"settings": {
|
||||
"header": "Management",
|
||||
"alertCorrelation": "Alert Deduplication",
|
||||
"userKeyLabel": "User Key",
|
||||
"rumKeyLabel": "RUM Key",
|
||||
"newUserKeyBtnLbl": "Add User Key",
|
||||
|
||||
@@ -195,6 +195,18 @@ const alerts = {
|
||||
if (query.sort_order) params.append("sort_order", query.sort_order);
|
||||
return http().get(`/api/${org_identifier}/alerts/history?${params}`);
|
||||
},
|
||||
getOrganizationDeduplicationConfig: (org_identifier: string) => {
|
||||
return http().get(`/api/${org_identifier}/alerts/deduplication/config`);
|
||||
},
|
||||
setOrganizationDeduplicationConfig: (org_identifier: string, data: any) => {
|
||||
return http().post(`/api/${org_identifier}/alerts/deduplication/config`, data);
|
||||
},
|
||||
deleteOrganizationDeduplicationConfig: (org_identifier: string) => {
|
||||
return http().delete(`/api/${org_identifier}/alerts/deduplication/config`);
|
||||
},
|
||||
get_dedup_summary: (org_identifier: string) => {
|
||||
return http().get(`/api/${org_identifier}/alerts/dedup/summary`);
|
||||
},
|
||||
};
|
||||
|
||||
export default alerts;
|
||||
|
||||
Reference in New Issue
Block a user