Skip to content

Commit

Permalink
feat: Storing information about who authored the link between Alert a…
Browse files Browse the repository at this point in the history
…nd an Incident (#2195)
  • Loading branch information
Matvey-Kuk authored Oct 16, 2024
1 parent ac9cc2c commit eb5f97c
Show file tree
Hide file tree
Showing 11 changed files with 270 additions and 32 deletions.
6 changes: 4 additions & 2 deletions keep-ui/app/alerts/alert-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Fragment } from "react";
import Image from "next/image";
import { Dialog, Transition } from "@headlessui/react";
import { AlertDto } from "./models";
import { Button, Title, Card, Badge } from "@tremor/react";
Expand Down Expand Up @@ -83,10 +84,11 @@ const AlertSidebar = ({ isOpen, toggle, alert }: AlertSidebarProps) => {
<strong>Severity:</strong> {alert.severity}
</p>
<p>
<strong>Source:</strong>{" "}
<img
<Image
src={`/icons/${alert.source![0]}-icon.png`}
alt={alert.source![0]}
width={24}
height={24}
className="inline-block w-6 h-6"
/>
</p>
Expand Down
3 changes: 3 additions & 0 deletions keep-ui/app/alerts/models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ note?: string;
isNoisy?: boolean;
enriched_fields: string[];
incident?: string;

// From AlertWithIncidentLinkMetadataDto
is_created_by_ai?: boolean;
}

interface Option {
Expand Down
14 changes: 14 additions & 0 deletions keep-ui/app/incidents/[id]/incident-alerts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,20 @@ export default function IncidentAlerts({ incident }: Props) {
minSize: 100,
header: "Status",
}),
columnHelper.accessor("is_created_by_ai", {
id: "is_created_by_ai",
header: "🔗",
minSize: 50,
cell: (context) => (
<>
{context.getValue() ? (
<div title="Correlated with AI">🤖</div>
) : (
<div title="Correlated manually">👨‍💻</div>
)}
</>
),
}),
columnHelper.accessor("lastReceived", {
id: "lastReceived",
header: "Last Received",
Expand Down
54 changes: 44 additions & 10 deletions keep/api/core/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1231,7 +1231,8 @@ def get_last_alerts(
if with_incidents:
query = query.add_columns(AlertToIncident.incident_id.label("incident"))
query = query.outerjoin(
AlertToIncident, AlertToIncident.alert_id == Alert.id,
AlertToIncident,
and_(AlertToIncident.alert_id == Alert.id, AlertToIncident.deleted_at == NULL_FOR_DELETED_AT),
)

if provider_id:
Expand Down Expand Up @@ -1693,7 +1694,10 @@ def get_rule_distribution(tenant_id, minute=False):
)
.join(Incident, Rule.id == Incident.rule_id)
.join(AlertToIncident, Incident.id == AlertToIncident.incident_id)
.filter(AlertToIncident.timestamp >= seven_days_ago)
.filter(
AlertToIncident.deleted_at == NULL_FOR_DELETED_AT,
AlertToIncident.timestamp >= seven_days_ago
)
.filter(Rule.tenant_id == tenant_id) # Filter by tenant_id
.group_by(
"rule_id", "rule_name", "incident_id", "rule_fingerprint", "time"
Expand Down Expand Up @@ -2387,6 +2391,7 @@ def is_alert_assigned_to_incident(
.where(AlertToIncident.alert_id == alert_id)
.where(AlertToIncident.incident_id == incident_id)
.where(AlertToIncident.tenant_id == tenant_id)
.where(AlertToIncident.deleted_at == NULL_FOR_DELETED_AT)
).first()
return assigned is not None

Expand Down Expand Up @@ -2829,17 +2834,19 @@ def get_incidents_count(
)


def get_incident_alerts_by_incident_id(
def get_incident_alerts_and_links_by_incident_id(
tenant_id: str,
incident_id: UUID | str,
limit: Optional[int] = None,
offset: Optional[int] = None,
session: Optional[Session] = None,
) -> (List[Alert], int):
include_unlinked: bool = False,
) -> tuple[List[tuple[Alert, AlertToIncident]], int]:
with existed_or_new_session(session) as session:
query = (
session.query(
Alert,
AlertToIncident,
)
.join(AlertToIncident, AlertToIncident.alert_id == Alert.id)
.join(Incident, AlertToIncident.incident_id == Incident.id)
Expand All @@ -2849,6 +2856,10 @@ def get_incident_alerts_by_incident_id(
)
.order_by(col(Alert.timestamp).desc())
)
if not include_unlinked:
query = query.filter(
AlertToIncident.deleted_at == NULL_FOR_DELETED_AT,
)

total_count = query.count()

Expand All @@ -2858,12 +2869,20 @@ def get_incident_alerts_by_incident_id(
return query.all(), total_count


def get_incident_alerts_by_incident_id(*args, **kwargs) -> tuple[List[Alert], int]:
"""
Unpacking (List[(Alert, AlertToIncident)], int) to (List[Alert], int).
"""
alerts_and_links, total_alerts = get_incident_alerts_and_links_by_incident_id(*args, **kwargs)
alerts = [alert_and_link[0] for alert_and_link in alerts_and_links]
return alerts, total_alerts


def get_future_incidents_by_incident_id(
incident_id: str,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> (List[Incident], int):
) -> tuple[List[Incident], int]:
with Session(engine) as session:
query = (
session.query(
Expand Down Expand Up @@ -2936,6 +2955,7 @@ def add_alerts_to_incident_by_incident_id(
tenant_id: str,
incident_id: str | UUID,
alert_ids: List[UUID],
is_created_by_ai: bool = False,
session: Optional[Session] = None,
) -> Optional[Incident]:
with existed_or_new_session(session) as session:
Expand All @@ -2947,13 +2967,14 @@ def add_alerts_to_incident_by_incident_id(

if not incident:
return None
return add_alerts_to_incident(tenant_id, incident, alert_ids, session)
return add_alerts_to_incident(tenant_id, incident, alert_ids, is_created_by_ai, session)


def add_alerts_to_incident(
tenant_id: str,
incident: Incident,
alert_ids: List[UUID],
is_created_by_ai: bool = False,
session: Optional[Session] = None,
) -> Optional[Incident]:
logger.info(
Expand All @@ -2967,6 +2988,7 @@ def add_alerts_to_incident(
existing_alert_ids = set(
session.exec(
select(AlertToIncident.alert_id).where(
AlertToIncident.deleted_at == NULL_FOR_DELETED_AT,
AlertToIncident.tenant_id == tenant_id,
AlertToIncident.incident_id == incident.id,
col(AlertToIncident.alert_id).in_(alert_ids),
Expand All @@ -2993,7 +3015,7 @@ def add_alerts_to_incident(

alert_to_incident_entries = [
AlertToIncident(
alert_id=alert_id, incident_id=incident.id, tenant_id=tenant_id
alert_id=alert_id, incident_id=incident.id, tenant_id=tenant_id, is_created_by_ai=is_created_by_ai
)
for alert_id in new_alert_ids
]
Expand All @@ -3014,6 +3036,7 @@ def add_alerts_to_incident(
select(func.min(Alert.timestamp), func.max(Alert.timestamp))
.join(AlertToIncident, AlertToIncident.alert_id == Alert.id)
.where(
AlertToIncident.deleted_at == NULL_FOR_DELETED_AT,
AlertToIncident.tenant_id == tenant_id,
AlertToIncident.incident_id == incident.id,
)
Expand All @@ -3036,6 +3059,7 @@ def get_incident_unique_fingerprint_count(tenant_id: str, incident_id: str) -> i
.select_from(AlertToIncident)
.join(Alert, AlertToIncident.alert_id == Alert.id)
.where(
AlertToIncident.deleted_at == NULL_FOR_DELETED_AT,
Alert.tenant_id == tenant_id,
AlertToIncident.incident_id == incident_id,
)
Expand All @@ -3053,6 +3077,7 @@ def get_last_alerts_for_incidents(
)
.join(AlertToIncident, Alert.id == AlertToIncident.alert_id)
.filter(
AlertToIncident.deleted_at == NULL_FOR_DELETED_AT,
AlertToIncident.incident_id.in_(incident_ids),
)
.order_by(Alert.timestamp.desc())
Expand Down Expand Up @@ -3085,11 +3110,13 @@ def remove_alerts_to_incident_by_incident_id(
deleted = (
session.query(AlertToIncident)
.where(
AlertToIncident.deleted_at == NULL_FOR_DELETED_AT,
AlertToIncident.tenant_id == tenant_id,
AlertToIncident.incident_id == incident.id,
col(AlertToIncident.alert_id).in_(alert_ids),
)
.delete()
).update({
"deleted_at": datetime.now(datetime.now().astimezone().tzinfo),
})
)
session.commit()

Expand All @@ -3104,6 +3131,7 @@ def remove_alerts_to_incident_by_incident_id(
select(func.distinct(service_field))
.join(AlertToIncident, Alert.id == AlertToIncident.alert_id)
.filter(
AlertToIncident.deleted_at == NULL_FOR_DELETED_AT,
AlertToIncident.incident_id == incident_id,
service_field.in_(alerts_data_for_incident["services"]),
)
Expand All @@ -3116,6 +3144,7 @@ def remove_alerts_to_incident_by_incident_id(
select(col(Alert.provider_type).distinct())
.join(AlertToIncident, Alert.id == AlertToIncident.alert_id)
.filter(
AlertToIncident.deleted_at == NULL_FOR_DELETED_AT,
AlertToIncident.incident_id == incident_id,
col(Alert.provider_type).in_(alerts_data_for_incident["sources"]),
)
Expand Down Expand Up @@ -3522,7 +3551,10 @@ def get_workflow_executions_for_incident_or_alert(
Alert, WorkflowToAlertExecution.alert_fingerprint == Alert.fingerprint
)
.join(AlertToIncident, Alert.id == AlertToIncident.alert_id)
.where(AlertToIncident.incident_id == incident_id)
.where(
AlertToIncident.deleted_at == NULL_FOR_DELETED_AT,
AlertToIncident.incident_id == incident_id
)
)

# Combine both queries
Expand Down Expand Up @@ -3564,6 +3596,7 @@ def is_all_incident_alerts_resolved(incident: Incident, session: Optional[Sessio
.outerjoin(AlertEnrichment, Alert.fingerprint == AlertEnrichment.alert_fingerprint)
.join(AlertToIncident, AlertToIncident.alert_id == Alert.id)
.where(
AlertToIncident.deleted_at == NULL_FOR_DELETED_AT,
AlertToIncident.incident_id == incident.id,
)
.group_by(Alert.fingerprint)
Expand Down Expand Up @@ -3620,6 +3653,7 @@ def is_edge_incident_alert_resolved(incident: Incident, direction: Callable, ses
.outerjoin(AlertEnrichment, Alert.fingerprint == AlertEnrichment.alert_fingerprint)
.join(AlertToIncident, AlertToIncident.alert_id == Alert.id)
.where(
AlertToIncident.deleted_at == NULL_FOR_DELETED_AT,
AlertToIncident.incident_id == incident.id
)
.group_by(Alert.fingerprint)
Expand Down
11 changes: 11 additions & 0 deletions keep/api/models/alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,17 @@ class Config:
}


class AlertWithIncidentLinkMetadataDto(AlertDto):
is_created_by_ai: bool = False

@classmethod
def from_db_instance(cls, db_alert, db_alert_to_incident):
return cls(
is_created_by_ai=db_alert_to_incident.is_created_by_ai,
**db_alert.event,
)


class DeleteRequestBody(BaseModel):
fingerprint: str
lastReceived: str
Expand Down
62 changes: 56 additions & 6 deletions keep/api/models/db/alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,33 @@
datetime_column_type = DateTime


# We want to include the deleted_at field in the primary key,
# but we also want to allow it to be nullable. MySQL doesn't allow nullable fields in primary keys, so:
NULL_FOR_DELETED_AT = datetime(1000, 1, 1, 0, 0)

class AlertToIncident(SQLModel, table=True):
tenant_id: str = Field(foreign_key="tenant.id")
alert_id: UUID = Field(foreign_key="alert.id", primary_key=True)
timestamp: datetime = Field(default_factory=datetime.utcnow)

alert_id: UUID = Field(foreign_key="alert.id", primary_key=True)
incident_id: UUID = Field(
sa_column=Column(
UUIDType(binary=False),
ForeignKey("incident.id", ondelete="CASCADE"),
primary_key=True,
)
)

alert: "Alert" = Relationship(back_populates="alert_to_incident_link")
incident: "Incident" = Relationship(back_populates="alert_to_incident_link")

is_created_by_ai: bool = Field(default=False)

deleted_at: datetime = Field(
default_factory=None,
nullable=True,
primary_key=True,
default=NULL_FOR_DELETED_AT,
)

class Incident(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
Expand All @@ -82,7 +97,24 @@ class Incident(SQLModel, table=True):

# map of attributes to values
alerts: List["Alert"] = Relationship(
back_populates="incidents", link_model=AlertToIncident
back_populates="incidents", link_model=AlertToIncident,
# primaryjoin is used to filter out deleted links for various DB dialects
sa_relationship_kwargs={
"primaryjoin": f"""and_(AlertToIncident.incident_id == Incident.id,
or_(
AlertToIncident.deleted_at == '{NULL_FOR_DELETED_AT.strftime('%Y-%m-%d %H:%M:%S.%f')}',
AlertToIncident.deleted_at == '{NULL_FOR_DELETED_AT.strftime('%Y-%m-%d %H:%M:%S')}'
))""",
"uselist": True,
"overlaps": "alert,incident",
}

)
alert_to_incident_link: List[AlertToIncident] = Relationship(
back_populates="incident",
sa_relationship_kwargs={
"overlaps": "alerts,incidents"
}
)

is_predicted: bool = Field(default=False)
Expand Down Expand Up @@ -150,9 +182,6 @@ class Alert(SQLModel, table=True):
event: dict = Field(sa_column=Column(JSON))
fingerprint: str = Field(index=True) # Add the fingerprint field with an index

incidents: List["Incident"] = Relationship(
back_populates="alerts", link_model=AlertToIncident
)
# alert_hash is different than fingerprint, it is a hash of the alert itself
# and it is used for deduplication.
# alert can be different but have the same fingerprint (e.g. different "firing" and "resolved" will have the same fingerprint but not the same alert_hash)
Expand All @@ -166,6 +195,27 @@ class Alert(SQLModel, table=True):
}
)

incidents: List["Incident"] = Relationship(
back_populates="alerts",
link_model=AlertToIncident,
sa_relationship_kwargs={
# primaryjoin is used to filter out deleted links for various DB dialects
"primaryjoin": f"""and_(AlertToIncident.alert_id == Alert.id,
or_(
AlertToIncident.deleted_at == '{NULL_FOR_DELETED_AT.strftime('%Y-%m-%d %H:%M:%S.%f')}',
AlertToIncident.deleted_at == '{NULL_FOR_DELETED_AT.strftime('%Y-%m-%d %H:%M:%S')}'
))""",
"uselist": True,
"overlaps": "alert,incident",
}
)
alert_to_incident_link: List[AlertToIncident] = Relationship(
back_populates="alert",
sa_relationship_kwargs={
"overlaps": "alerts,incidents"
}
)

class Config:
arbitrary_types_allowed = True

Expand Down
Loading

0 comments on commit eb5f97c

Please sign in to comment.