Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 93 additions & 2 deletions keep-ui/app/(keep)/deduplication/DeduplicationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,36 @@ interface DeduplicationSidebarProps {
onSubmit: (data: Partial<DeduplicationRule>) => Promise<void>;
mutateDeduplicationRules: KeyedMutator<DeduplicationRule[]>;
providers: { installed_providers: Providers; linked_providers: Providers };
deduplicationRules: DeduplicationRule[];
}

const RULE_TYPE_OPTIONS: {
value: "split" | "correlate";
label: string;
description: string;
}[] = [
{
value: "split",
label: "Split",
description:
"Each unique combination of these fields is a distinct alert event. Use this to prevent old resolved alerts from being reused when the same alert re-fires.",
},
{
value: "correlate",
label: "Correlate",
description:
"Alerts sharing the same values for these fields are correlated together, without merging them. Use this to group related alerts and notify only once per correlation group.",
},
];

const DeduplicationSidebar: React.FC<DeduplicationSidebarProps> = ({
isOpen,
toggle,
selectedDeduplicationRule,
onSubmit,
mutateDeduplicationRules,
providers,
deduplicationRules,
}) => {
const {
control,
Expand All @@ -68,6 +89,7 @@ const DeduplicationSidebar: React.FC<DeduplicationSidebarProps> = ({
fingerprint_fields: [],
full_deduplication: false,
ignore_fields: [],
rule_type: "split" as "split" | "correlate",
},
});

Expand All @@ -92,6 +114,26 @@ const DeduplicationSidebar: React.FC<DeduplicationSidebarProps> = ({
const selectedProviderId = watch("provider_id");
const fingerprintFields = watch("fingerprint_fields");
const ignoreFields = watch("ignore_fields");
const ruleType = watch("rule_type");

const typeConflict = useMemo(() => {
if (!selectedProviderType || !ruleType) return null;
return (
deduplicationRules.find(
(r) =>
r.id !== selectedDeduplicationRule?.id &&
r.rule_type === ruleType &&
r.provider_type === selectedProviderType &&
(r.provider_id ?? null) === (selectedProviderId ?? null)
) ?? null
);
}, [
ruleType,
selectedProviderType,
selectedProviderId,
deduplicationRules,
selectedDeduplicationRule,
]);

const availableFields = useMemo(() => {
const defaultFields = [
Expand Down Expand Up @@ -125,7 +167,7 @@ const DeduplicationSidebar: React.FC<DeduplicationSidebarProps> = ({

useEffect(() => {
if (isOpen && selectedDeduplicationRule) {
reset(selectedDeduplicationRule);
reset({ ...selectedDeduplicationRule, rule_type: selectedDeduplicationRule.rule_type ?? "split" });
} else if (isOpen) {
reset({
name: "",
Expand All @@ -135,6 +177,7 @@ const DeduplicationSidebar: React.FC<DeduplicationSidebarProps> = ({
fingerprint_fields: [],
full_deduplication: false,
ignore_fields: [],
rule_type: "split",
});
}
}, [isOpen, selectedDeduplicationRule, reset]);
Expand Down Expand Up @@ -374,9 +417,57 @@ const DeduplicationSidebar: React.FC<DeduplicationSidebarProps> = ({
</p>
)}
</div>
<div>
<Text className="block text-sm font-medium text-gray-700 mb-2">
Rule type
</Text>
<Controller
name="rule_type"
control={control}
render={({ field }) => (
<div className="grid grid-cols-2 gap-2">
{RULE_TYPE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
disabled={!!selectedDeduplicationRule?.is_provisioned}
onClick={() => field.onChange(option.value)}
className={`text-left p-3 rounded-tremor-default border text-sm transition-colors ${
field.value === option.value
? "border-orange-500 bg-orange-50 text-orange-900"
: "border-tremor-border bg-white text-gray-700 hover:bg-gray-50"
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<span className="font-semibold block mb-1">
{option.label}
</span>
<span className="text-xs text-gray-500 leading-snug">
{option.description}
</span>
</button>
))}
</div>
)}
/>
{typeConflict && (
<Callout
className="mt-2"
title={`A ${ruleType} rule already exists for this provider`}
icon={ExclamationTriangleIcon}
color="yellow"
>
Rule &quot;{typeConflict.name}&quot; is already a{" "}
<strong>{ruleType}</strong> rule for this provider. Only one
rule of each type is allowed per provider. Delete or update
the existing rule first.
</Callout>
)}
</div>
<div>
<span className="text-sm font-medium text-gray-700 flex items-center mb-2">
Fields to use for fingerprint
{ruleType === "correlate"
? "Fields to use for correlation"
: "Fields to use for fingerprint"}
<span className="ml-1 relative inline-flex items-center">
<span className="group relative flex items-center">
<Icon
Expand Down
1 change: 1 addition & 0 deletions keep-ui/app/(keep)/deduplication/DeduplicationTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ export const DeduplicationTable: React.FC<DeduplicationTableProps> = ({
selectedDeduplicationRule={selectedDeduplicationRule}
onSubmit={handleSubmitDeduplicationRule}
providers={providers}
deduplicationRules={deduplicationRules}
/>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions keep-ui/app/(keep)/deduplication/models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ export interface DeduplicationRule {
full_deduplication: boolean;
ignore_fields: string[];
is_provisioned: boolean;
rule_type: "split" | "correlate";
}
24 changes: 24 additions & 0 deletions keep/api/alert_deduplicator/alert_deduplicator.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
get_all_deduplication_stats,
get_custom_deduplication_rule,
get_deduplication_rule_by_id,
get_last_alert_by_correlation_fingerprint,
get_last_alert_hashes_by_fingerprints,
update_deduplication_rule,
)
Expand Down Expand Up @@ -175,6 +176,26 @@ def apply_deduplication(
provider_type=alert.providerType,
)

if alert.correlation_fingerprint:
representative_fingerprint = get_last_alert_by_correlation_fingerprint(
self.tenant_id, alert.correlation_fingerprint
)
if (
representative_fingerprint
and representative_fingerprint != alert.fingerprint
):
alert.is_correlated = True
alert.correlated_to = representative_fingerprint
self.logger.info(
"Alert correlated to existing alert",
extra={
"alert_id": alert.id,
"correlated_to": representative_fingerprint,
"correlation_fingerprint": alert.correlation_fingerprint,
"tenant_id": self.tenant_id,
},
)

return alert

def _remove_field(self, field, alert: AlertDto) -> AlertDto:
Expand Down Expand Up @@ -335,6 +356,7 @@ def get_deduplications(self) -> list[DeduplicationRuleDto]:
dedup_ratio=0.0,
enabled=rule.enabled,
is_provisioned=rule.is_provisioned,
rule_type=rule.rule_type,
)
for rule in custom_deduplications
]
Expand Down Expand Up @@ -515,6 +537,7 @@ def create_deduplication_rule(
full_deduplication=rule.full_deduplication,
ignore_fields=rule.ignore_fields or [],
priority=0,
rule_type=rule.rule_type,
)

return new_rule
Expand Down Expand Up @@ -572,6 +595,7 @@ def update_deduplication_rule(
full_deduplication=rule.full_deduplication,
ignore_fields=rule.ignore_fields or [],
priority=0,
rule_type=rule.rule_type,
)

return updated_rule
Expand Down
46 changes: 46 additions & 0 deletions keep/api/core/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -2568,6 +2568,19 @@ def get_custom_deduplication_rule(tenant_id, provider_id, provider_type):
.where(AlertDeduplicationRule.tenant_id == tenant_id)
.where(AlertDeduplicationRule.provider_id == provider_id)
.where(AlertDeduplicationRule.provider_type == provider_type)
.where(AlertDeduplicationRule.rule_type == "split")
).first()
return rule


def get_correlation_deduplication_rule(tenant_id, provider_id, provider_type):
with Session(engine) as session:
rule = session.exec(
select(AlertDeduplicationRule)
.where(AlertDeduplicationRule.tenant_id == tenant_id)
.where(AlertDeduplicationRule.provider_id == provider_id)
.where(AlertDeduplicationRule.provider_type == provider_type)
.where(AlertDeduplicationRule.rule_type == "correlate")
).first()
return rule

Expand All @@ -2585,6 +2598,7 @@ def create_deduplication_rule(
ignore_fields: list[str] = [],
priority: int = 0,
is_provisioned: bool = False,
rule_type: str = "split",
):
with Session(engine) as session:
new_rule = AlertDeduplicationRule(
Expand All @@ -2601,6 +2615,7 @@ def create_deduplication_rule(
ignore_fields=ignore_fields,
priority=priority,
is_provisioned=is_provisioned,
rule_type=rule_type,
)
session.add(new_rule)
session.commit()
Expand All @@ -2621,6 +2636,7 @@ def update_deduplication_rule(
full_deduplication: bool = False,
ignore_fields: list[str] = [],
priority: int = 0,
rule_type: str = "split",
):
rule_uuid = __convert_to_uuid(rule_id)
if not rule_uuid:
Expand All @@ -2645,6 +2661,7 @@ def update_deduplication_rule(
rule.full_deduplication = full_deduplication
rule.ignore_fields = ignore_fields
rule.priority = priority
rule.rule_type = rule_type

session.add(rule)
session.commit()
Expand Down Expand Up @@ -5683,6 +5700,29 @@ def get_last_alert_by_fingerprint(
return session.exec(query).first()


def get_last_alert_by_correlation_fingerprint(
tenant_id: str, correlation_fingerprint: str
) -> Optional[str]:
"""Return the fingerprint of the oldest active alert in a correlation group.

Ordering by first_timestamp ASC gives a stable representative even after
subsequent group members are stored. Resolved and suppressed alerts are
excluded so they don't block new firings from starting a fresh group.
"""
with Session(engine) as session:
status_field = get_json_extract_field(session, Alert.event, "status")
last_alert = session.exec(
select(LastAlert)
.join(Alert, Alert.id == LastAlert.alert_id)
.where(LastAlert.tenant_id == tenant_id)
.where(LastAlert.correlation_fingerprint == correlation_fingerprint)
.where(status_field.notin_(["resolved", "suppressed"]))
.order_by(LastAlert.first_timestamp)
.limit(1)
).first()
return last_alert.fingerprint if last_alert else None


def set_last_alert(
tenant_id: str, alert: Alert, session: Optional[Session] = None, max_retries=3
) -> None:
Expand Down Expand Up @@ -5721,6 +5761,9 @@ def set_last_alert(
last_alert.timestamp = alert.timestamp
last_alert.alert_id = alert.id
last_alert.alert_hash = alert.alert_hash
last_alert.correlation_fingerprint = alert.event.get(
"correlation_fingerprint"
)
session.add(last_alert)

elif not last_alert:
Expand All @@ -5732,6 +5775,9 @@ def set_last_alert(
first_timestamp=alert.timestamp,
alert_id=alert.id,
alert_hash=alert.alert_hash,
correlation_fingerprint=alert.event.get(
"correlation_fingerprint"
),
)

session.add(last_alert)
Expand Down
5 changes: 5 additions & 0 deletions keep/api/models/alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ class AlertDto(BaseModel):
fingerprint: str | None = (
None # The fingerprint of the alert (used for alert de-duplication)
)
correlation_fingerprint: str | None = None # Set when a "correlate" deduplication rule matches
is_correlated: bool = False # True when another alert with the same correlation_fingerprint already exists
correlated_to: str | None = None # fingerprint of the representative alert in the correlation group
deleted: bool = (
False # @tal: Obselete field since we have dismissed, but kept for backwards compatibility
)
Expand Down Expand Up @@ -399,6 +402,7 @@ class DeduplicationRuleDto(BaseModel):
full_deduplication: bool
ignore_fields: list[str]
is_provisioned: bool
rule_type: str = "split" # "split" or "correlate"


class DeduplicationRuleRequestDto(BaseModel):
Expand All @@ -409,6 +413,7 @@ class DeduplicationRuleRequestDto(BaseModel):
fingerprint_fields: list[str]
full_deduplication: bool = False
ignore_fields: Optional[list[str]] = None
rule_type: str = "split" # "split" or "correlate"


class EnrichIncidentRequestBody(BaseModel):
Expand Down
7 changes: 7 additions & 0 deletions keep/api/models/db/alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class LastAlert(SQLModel, table=True):
timestamp: datetime = Field(nullable=False, index=True)
first_timestamp: datetime = Field(nullable=False, index=True)
alert_hash: str | None = Field(nullable=True, index=True)
correlation_fingerprint: str | None = Field(default=None, nullable=True)

__table_args__ = (
# Original indexes from MySQL
Expand All @@ -60,6 +61,11 @@ class LastAlert(SQLModel, table=True):
"alert_id",
"fingerprint",
),
Index(
"idx_lastalert_tenant_correlation_fingerprint",
"tenant_id",
"correlation_fingerprint",
),
{},
)

Expand Down Expand Up @@ -219,6 +225,7 @@ class AlertDeduplicationRule(SQLModel, table=True):
ignore_fields: list[str] = Field(sa_column=Column(JSON), default=[])
priority: int = Field(default=0) # for future use
is_provisioned: bool = Field(default=False)
rule_type: str = Field(default="split") # "split" or "correlate"

class Config:
arbitrary_types_allowed = True
Expand Down
Loading
Loading