Skip to content
Merged
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: 49 additions & 46 deletions keep/api/bl/dismissal_expiry_bl.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,41 +12,41 @@
from sqlmodel import Session, select
from keep.api.core.db import get_session_sync
from keep.api.core.db_utils import get_json_extract_field
from keep.api.core.elastic import ElasticClient
from keep.api.core.elastic import ElasticClient
from keep.api.core.dependencies import get_pusher_client
from keep.api.models.action_type import ActionType
from keep.api.models.alert import AlertDto
from keep.api.models.db.alert import Alert, AlertAudit, AlertEnrichment


class DismissalExpiryBl:

@staticmethod
def get_alerts_with_expired_dismissals(session: Session) -> List[AlertEnrichment]:
"""
Get all AlertEnrichment records that have expired dismissedUntil timestamps.

Returns enrichment records where:
1. dismissed = true
1. dismissed = true
2. dismissedUntil is not null and not "forever"
3. dismissedUntil timestamp is in the past

Args:
session: Database session

Returns:
List of AlertEnrichment objects with expired dismissals
"""
logger = logging.getLogger(__name__)
now = datetime.datetime.now(datetime.timezone.utc)

logger.info("Searching for enrichments with expired dismissals")

# Query for enrichments with dismissed=true and dismissedUntil set
# Use the proper helper function for cross-database compatibility
dismissed_field = get_json_extract_field(session, AlertEnrichment.enrichments, "dismissed")
dismissed_until_field = get_json_extract_field(session, AlertEnrichment.enrichments, "dismissUntil")

# Build cross-database compatible boolean comparison
# Different databases store/extract JSON booleans differently:
# - SQLite: json_extract can return 1/0 for true/false OR "True"/"False"/"true"/"false" strings depending on how data was stored
Expand All @@ -61,7 +61,7 @@ def get_alerts_with_expired_dismissals(session: Session) -> List[AlertEnrichment
else:
# For MySQL, compare with lowercase string "true"
dismissed_condition = dismissed_field == "true"

query = session.exec(
select(AlertEnrichment).where(
dismissed_condition,
Expand All @@ -71,24 +71,24 @@ def get_alerts_with_expired_dismissals(session: Session) -> List[AlertEnrichment
dismissed_until_field != "forever",
)
)

candidate_enrichments = query.all()

logger.info(f"Found {len(candidate_enrichments)} candidate enrichments with dismissals")

# Filter in Python for safety and clarity (parsing ISO timestamps)
expired_enrichments = []
for enrichment in candidate_enrichments:
dismiss_until_str = enrichment.enrichments.get("dismissUntil")
if not dismiss_until_str or dismiss_until_str == "forever":
continue

try:
# Parse the dismissedUntil timestamp
# Parse the dismissedUntil timestamp
dismiss_until = datetime.datetime.strptime(
dismiss_until_str, "%Y-%m-%dT%H:%M:%S.%fZ"
).replace(tzinfo=datetime.timezone.utc)

# Check if it's expired (current time > dismissedUntil)
if now > dismiss_until:
logger.info(
Expand All @@ -101,54 +101,55 @@ def get_alerts_with_expired_dismissals(session: Session) -> List[AlertEnrichment
}
)
expired_enrichments.append(enrichment)

except (ValueError, TypeError) as e:
# Log invalid timestamp but don't fail
logger.warning(
f"Invalid dismissedUntil timestamp for fingerprint {enrichment.alert_fingerprint}: {dismiss_until_str}",
extra={
"tenant_id": enrichment.tenant_id,
"tenant_id": enrichment.tenant_id,
"fingerprint": enrichment.alert_fingerprint,
"error": str(e)
}
)
continue

logger.info(f"Found {len(expired_enrichments)} enrichments with expired dismissals")
return expired_enrichments

@staticmethod
def check_dismissal_expiry(logger: logging.Logger, session: Optional[Session] = None):
"""
Check for alerts with expired dismissedUntil and restore them.

This function:
1. Finds AlertEnrichment records with expired dismissedUntil timestamps
2. Updates their enrichments to set dismissed=false and dismissedUntil=null
3. Cleans up disposable fields
3. Cleans up disposable fields
4. Updates Elasticsearch indexes
5. Notifies UI of changes
6. Adds audit trail

Args:
logger: Logger instance for detailed logging
session: Optional database session (creates new if None)
"""
logger.info("Starting dismissal expiry check")


_owns_session = session is None
if session is None:
session = get_session_sync()

try:
# Find enrichments with expired dismissedUntil
expired_enrichments = DismissalExpiryBl.get_alerts_with_expired_dismissals(session)

if not expired_enrichments:
logger.info("No enrichments with expired dismissals found")
return

logger.info(f"Processing {len(expired_enrichments)} expired dismissal enrichments")

# Process each expired enrichment
for enrichment in expired_enrichments:
logger.info(
Expand All @@ -159,16 +160,16 @@ def check_dismissal_expiry(logger: logging.Logger, session: Optional[Session] =
"dismissed_until": enrichment.enrichments.get("dismissedUntil")
}
)

# Store original values for audit
original_dismissed = enrichment.enrichments.get("dismissed", False)
original_dismissed_until = enrichment.enrichments.get("dismissedUntil")

# Update enrichment - set back to not dismissed
new_enrichments = enrichment.enrichments.copy()
new_enrichments["dismissed"] = False
new_enrichments["dismissUntil"] = None # Clear the original field

# Reset status if it was set to suppressed during dismissal
enrichment_status = enrichment.enrichments.get("status")
if enrichment_status == "suppressed":
Expand All @@ -183,19 +184,19 @@ def check_dismissal_expiry(logger: logging.Logger, session: Optional[Session] =
"removed_status": enrichment_status
}
)

# Clean up ALL disposable fields (use pattern matching instead of hardcoded list)
cleaned_fields = []
keys_to_remove = []
for field_name in new_enrichments.keys():
if field_name.startswith("disposable_"):
keys_to_remove.append(field_name)
cleaned_fields.append(field_name)

# Remove the disposable fields
for field_name in keys_to_remove:
new_enrichments.pop(field_name)

if cleaned_fields:
logger.info(
f"Cleaned up disposable fields: {cleaned_fields}",
Expand All @@ -204,11 +205,11 @@ def check_dismissal_expiry(logger: logging.Logger, session: Optional[Session] =
"fingerprint": enrichment.alert_fingerprint
}
)

# Update the enrichment record
enrichment.enrichments = new_enrichments
session.add(enrichment)

# Add audit trail
try:
audit = AlertAudit(
Expand Down Expand Up @@ -237,7 +238,7 @@ def check_dismissal_expiry(logger: logging.Logger, session: Optional[Session] =
"fingerprint": enrichment.alert_fingerprint
}
)

# Update Elasticsearch index
try:
# Get the latest alert for this fingerprint to create AlertDto
Expand All @@ -248,11 +249,11 @@ def check_dismissal_expiry(logger: logging.Logger, session: Optional[Session] =
.order_by(Alert.timestamp.desc())
.limit(1)
).first()

if latest_alert:
# Create AlertDto with updated enrichments
alert_data = latest_alert.event.copy()

# Only update specific enrichment fields, don't override alert event data with None values
enrichment_fields = ['dismissed', 'dismissUntil', 'note', 'assignee', 'status']
for field in enrichment_fields:
Expand All @@ -261,9 +262,9 @@ def check_dismissal_expiry(logger: logging.Logger, session: Optional[Session] =
elif field in new_enrichments and new_enrichments[field] is None and field in ['dismissed', 'dismissUntil']:
# For dismissal fields, None is a valid value (means not dismissed)
alert_data[field] = new_enrichments[field]

alert_dto = AlertDto(**alert_data)

elastic_client = ElasticClient(enrichment.tenant_id)
elastic_client.index_alert(alert_dto)
logger.info(
Expand All @@ -281,7 +282,7 @@ def check_dismissal_expiry(logger: logging.Logger, session: Optional[Session] =
"fingerprint": enrichment.alert_fingerprint
}
)

except Exception as e:
logger.error(
f"Failed to update Elasticsearch for fingerprint {enrichment.alert_fingerprint}: {e}",
Expand All @@ -290,7 +291,7 @@ def check_dismissal_expiry(logger: logging.Logger, session: Optional[Session] =
"fingerprint": enrichment.alert_fingerprint
}
)

# Notify UI of change
try:
pusher_client = get_pusher_client()
Expand All @@ -299,7 +300,7 @@ def check_dismissal_expiry(logger: logging.Logger, session: Optional[Session] =
f"private-{enrichment.tenant_id}",
"alert-update",
{
"fingerprint": enrichment.alert_fingerprint,
"fingerprint": enrichment.alert_fingerprint,
"action": "dismissal_expired"
}
)
Expand All @@ -318,17 +319,19 @@ def check_dismissal_expiry(logger: logging.Logger, session: Optional[Session] =
"fingerprint": enrichment.alert_fingerprint
}
)

# Commit all changes
session.commit()
logger.info(
f"Successfully processed {len(expired_enrichments)} expired dismissal enrichments",
extra={"processed_count": len(expired_enrichments)}
)

except Exception as e:
logger.error(f"Error during dismissal expiry check: {e}", exc_info=True)
session.rollback()
raise
finally:
if _owns_session:
session.close()
logger.info("Dismissal expiry check completed")
Loading
Loading