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:
sktrpathi
2025-11-26 12:09:10 +05:30
committed by GitHub
co-authored by Ashish Kolhe Shrinath Rao
parent b3c63ed35b
commit 44011fbebc
33 changed files with 3541 additions and 103 deletions
+2 -2
View File
@@ -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."
+568 -18
View File
@@ -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());
}
}
}
+20
View File
@@ -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();
+136
View File
@@ -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()))
+61 -18
View File
@@ -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");
+2
View File
@@ -46,6 +46,8 @@ use crate::{
},
};
pub mod dedup_stats;
pub mod deduplication;
#[allow(deprecated)]
pub mod deprecated;
pub mod destinations;
+7
View File
@@ -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)
+256
View File
@@ -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))
}
}
}
+1 -2
View File
@@ -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!(
+4
View File
@@ -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 });
+3 -4
View File
@@ -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());
+105 -22
View File
@@ -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);
}
}
+249
View File
@@ -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);
}
}
+4
View File
@@ -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;
+81
View File
@@ -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");
}
}
+132 -24
View File
@@ -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
+1 -1
View File
@@ -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) => {
+5
View File
@@ -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",
+89 -7
View File
@@ -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>
+194
View File
@@ -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>
+1
View File
@@ -1246,6 +1246,7 @@
},
"settings": {
"header": "Management",
"alertCorrelation": "Alert Deduplication",
"userKeyLabel": "User Key",
"rumKeyLabel": "RUM Key",
"newUserKeyBtnLbl": "Add User Key",
+12
View File
@@ -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;