mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
203 lines
7.6 KiB
Python
203 lines
7.6 KiB
Python
"""Django persistence and dispatch for alert notification destinations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Collection
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from django.db import transaction
|
|
from django.db.models import Q
|
|
|
|
import structlog
|
|
from prometheus_client import Counter
|
|
from rest_framework.exceptions import ValidationError
|
|
|
|
from insights.cdp.internal_events import InternalEventEvent, flush_internal_events_producer, produce_internal_event
|
|
from insights.exceptions_capture import capture_exception
|
|
from insights.kafka_client.client import ProduceResult
|
|
from insights.plugins.plugin_server_api import reload_insights_functions_on_workers
|
|
|
|
from products.alerts.backend.destination_configs import DESTINATION_TEMPLATE_IDS, AlertDestinationConfig
|
|
from products.cdp.backend.api.insights_function import InsightsFunctionSerializer
|
|
from products.cdp.backend.models.insights_functions.insights_function import InsightsFunction
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
ALERT_NOTIFICATION_FLUSH_TIMEOUT_SECONDS = 10.0
|
|
|
|
ALERT_INTERNAL_EVENT_DELIVERY_FAILURES = Counter(
|
|
"insights_alert_internal_event_delivery_failures_total",
|
|
"Number of alert internal events that failed delivery",
|
|
labelnames=["event_name"],
|
|
)
|
|
|
|
|
|
def create_alert_destination_insights_functions(configs: list[AlertDestinationConfig], *, request: Any) -> list[InsightsFunction]:
|
|
created: list[InsightsFunction] = []
|
|
insights_function_ids_by_team: dict[int, list[UUID]] = {}
|
|
with transaction.atomic():
|
|
for config in configs:
|
|
team = config.team
|
|
serializer = InsightsFunctionSerializer(
|
|
data=config.payload,
|
|
context={"request": request, "get_team": lambda team=team: team, "is_create": True},
|
|
)
|
|
serializer.is_valid(raise_exception=True)
|
|
insights_function = serializer.save(team=team)
|
|
created.append(insights_function)
|
|
insights_function_ids_by_team.setdefault(team.id, []).append(insights_function.id)
|
|
for team_id, insights_function_ids in insights_function_ids_by_team.items():
|
|
_reload_insights_functions_after_commit(team_id=team_id, insights_function_ids=insights_function_ids)
|
|
return created
|
|
|
|
|
|
def soft_delete_alert_destinations(
|
|
*,
|
|
team_id: int,
|
|
alert_id: str,
|
|
allowed_event_ids: Collection[str],
|
|
insights_function_ids: list[UUID],
|
|
) -> None:
|
|
unique_ids = set(insights_function_ids)
|
|
with transaction.atomic():
|
|
event_filter = _allowed_event_filter(allowed_event_ids)
|
|
owned_ids = set(
|
|
InsightsFunction.objects.select_for_update()
|
|
.filter(
|
|
event_filter,
|
|
team_id=team_id,
|
|
id__in=unique_ids,
|
|
template_id__in=DESTINATION_TEMPLATE_IDS.values(),
|
|
filters__properties__contains=[{"key": "alert_id", "value": alert_id}],
|
|
)
|
|
.values_list("id", flat=True)
|
|
)
|
|
invalid_ids = unique_ids - owned_ids
|
|
if invalid_ids:
|
|
formatted_ids = ", ".join(str(insights_function_id) for insights_function_id in sorted(invalid_ids, key=str))
|
|
raise ValidationError(
|
|
{
|
|
"insights_function_ids": [
|
|
f"These InsightsFunctions do not belong to this alert: {formatted_ids}. Refresh the alert and try again."
|
|
]
|
|
}
|
|
)
|
|
InsightsFunction.objects.filter(team_id=team_id, id__in=owned_ids).update(deleted=True, enabled=False)
|
|
_reload_insights_functions_after_commit(team_id=team_id, insights_function_ids=owned_ids)
|
|
|
|
|
|
def soft_delete_all_alert_destinations(*, team_id: int, alert_id: str, allowed_event_ids: Collection[str]) -> int:
|
|
with transaction.atomic():
|
|
owned_ids = set(
|
|
InsightsFunction.objects.select_for_update()
|
|
.filter(
|
|
_allowed_event_filter(allowed_event_ids),
|
|
team_id=team_id,
|
|
deleted=False,
|
|
template_id__in=DESTINATION_TEMPLATE_IDS.values(),
|
|
filters__properties__contains=[{"key": "alert_id", "value": alert_id}],
|
|
)
|
|
.values_list("id", flat=True)
|
|
)
|
|
deleted_count = InsightsFunction.objects.filter(team_id=team_id, id__in=owned_ids).update(
|
|
deleted=True, enabled=False
|
|
)
|
|
_reload_insights_functions_after_commit(team_id=team_id, insights_function_ids=owned_ids)
|
|
return deleted_count
|
|
|
|
|
|
def count_active_alert_destinations(*, team_id: int, alert_id: str, allowed_event_ids: Collection[str]) -> int:
|
|
return InsightsFunction.objects.filter(
|
|
_allowed_event_filter(allowed_event_ids),
|
|
team_id=team_id,
|
|
deleted=False,
|
|
enabled=True,
|
|
template_id__in=DESTINATION_TEMPLATE_IDS.values(),
|
|
filters__properties__contains=[{"key": "alert_id", "value": alert_id}],
|
|
).count()
|
|
|
|
|
|
def _allowed_event_filter(allowed_event_ids: Collection[str]) -> Q:
|
|
if not allowed_event_ids:
|
|
raise ValueError("allowed_event_ids must not be empty")
|
|
|
|
event_filter = Q()
|
|
for event_id in allowed_event_ids:
|
|
event_filter |= Q(filters__events__contains=[{"id": event_id, "type": "events"}])
|
|
return event_filter
|
|
|
|
|
|
def _reload_insights_functions_after_commit(*, team_id: int, insights_function_ids: Collection[UUID]) -> None:
|
|
serialized_ids = sorted(str(insights_function_id) for insights_function_id in insights_function_ids)
|
|
if serialized_ids:
|
|
transaction.on_commit(
|
|
lambda: reload_insights_functions_on_workers(team_id=team_id, insights_function_ids=serialized_ids),
|
|
robust=True,
|
|
)
|
|
|
|
|
|
def produce_alert_internal_event(
|
|
*,
|
|
team_id: int,
|
|
event_name: str,
|
|
properties: dict[str, Any],
|
|
timestamp: datetime | None = None,
|
|
uuid: str | None = None,
|
|
) -> ProduceResult | None:
|
|
try:
|
|
return produce_internal_event(
|
|
team_id=team_id,
|
|
event=InternalEventEvent(
|
|
event=event_name,
|
|
distinct_id=f"team_{team_id}",
|
|
properties=properties,
|
|
timestamp=timestamp.isoformat() if timestamp else None,
|
|
uuid=uuid,
|
|
),
|
|
)
|
|
except Exception as error:
|
|
context = {
|
|
"alert_id": properties.get("alert_id"),
|
|
"event_name": event_name,
|
|
"feature": "alerts",
|
|
"team_id": team_id,
|
|
}
|
|
capture_exception(error, context)
|
|
logger.exception("Failed to enqueue alert internal event", **context)
|
|
return None
|
|
|
|
|
|
def flush_alert_internal_events(timeout_seconds: float) -> None:
|
|
try:
|
|
remaining = flush_internal_events_producer(timeout_seconds)
|
|
if remaining:
|
|
logger.warning("Alert internal event flush timed out", remaining=remaining)
|
|
except Exception as error:
|
|
context = {"feature": "alerts", "phase": "notification_flush"}
|
|
capture_exception(error, context)
|
|
logger.exception("Failed to flush alert internal events", **context)
|
|
|
|
|
|
def alert_internal_event_delivered(
|
|
produce_result: ProduceResult,
|
|
*,
|
|
team_id: int,
|
|
alert_id: str,
|
|
event_name: str,
|
|
) -> bool:
|
|
try:
|
|
produce_result.get(timeout=0)
|
|
return True
|
|
except Exception as error:
|
|
context = {
|
|
"alert_id": alert_id,
|
|
"event_name": event_name,
|
|
"feature": "alerts",
|
|
"team_id": team_id,
|
|
}
|
|
ALERT_INTERNAL_EVENT_DELIVERY_FAILURES.labels(event_name=event_name).inc()
|
|
logger.warning("Alert internal event was not delivered", error=str(error), **context)
|
|
return False
|