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
20 changes: 4 additions & 16 deletions posthog/tasks/alerts/investigation_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

from posthog.schema import AlertState

from posthog.tasks.alerts.investigation_wording import safety_net_breaches
from posthog.tasks.alerts.utils import (
dispatch_alert_notification,
prepare_alert_insight_chart_url,
Expand Down Expand Up @@ -109,7 +110,9 @@ def run_investigation_notification_safety_net() -> int:
locked = AlertCheck.objects.select_for_update().get(id=check.id)
if locked.notification_sent_at is not None or locked.notification_suppressed_by_agent:
continue
breaches = _fallback_breach_descriptions(locked)
breaches = safety_net_breaches(
triggered_dates=locked.triggered_dates or [], value=locked.calculated_value
)
deliveries = dispatch_alert_notification(
alert, locked, breaches, extra_properties=extra_properties, render_chart=False
)
Expand All @@ -135,18 +138,3 @@ def run_investigation_notification_safety_net() -> int:
notified += 1

return notified


def _fallback_breach_descriptions(alert_check: AlertCheck) -> list[str]:
"""Minimal breach description for a safety-net dispatch (no verdict yet)."""
triggered_dates = alert_check.triggered_dates or []
if triggered_dates:
if len(triggered_dates) == 1:
head = f"Anomaly detected on {triggered_dates[0]}."
else:
head = f"Anomaly detected from {triggered_dates[0]} to {triggered_dates[-1]}."
elif alert_check.calculated_value is not None:
head = f"Calculated value at fire: {alert_check.calculated_value}."
else:
head = "Anomaly detected."
return [head, "(Investigation agent did not complete before the notification grace period expired.)"]
81 changes: 81 additions & 0 deletions posthog/tasks/alerts/investigation_wording.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""The lines an investigated insight alert notification states.

The insight stack and the alerts platform both send these, so one definition keeps the two word
for word the same.
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import Final

from posthog.dataclasses import frozen
from posthog.tasks.alerts.utils import InvestigationSummaryLine, investigation_summary_teaser
from posthog.utils import absolute_uri

VERDICT_LABELS: Final = {
"true_positive": "True positive",
"false_positive": "False positive",
"inconclusive": "Inconclusive",
}

SAFETY_NET_LINE: Final = "(Investigation agent did not complete before the notification grace period expired.)"


@frozen
class InvestigationBreaches:
lines: list[str]
summary: InvestigationSummaryLine | None


def notebook_url(notebook_short_id: str) -> str:
return absolute_uri(f"/notebooks/{notebook_short_id}")


def _anomaly_head(triggered_dates: Sequence[str], value: float | None) -> str:
if triggered_dates:
if len(triggered_dates) == 1:
return f"Anomaly detected on {triggered_dates[0]}."
return f"Anomaly detected from {triggered_dates[0]} to {triggered_dates[-1]}."
if value is not None:
return f"Calculated value at fire: {value}."
return "Anomaly detected."


def verdict_breaches(
*,
triggered_dates: Sequence[str],
value: float | None,
verdict: str | None,
previous_verdict: str | None,
summary: str,
notebook_short_id: str | None,
) -> InvestigationBreaches:
"""The breach lines of a notification sent once the agent reached its verdict.

The summary line holds a plain-text teaser. Email renders the full Markdown in its place.
"""
lines: list[str] = []
if previous_verdict and verdict and verdict != previous_verdict:
lines.append(
f"Investigation verdict changed from {VERDICT_LABELS.get(previous_verdict, previous_verdict)} "
f"to {VERDICT_LABELS.get(verdict, verdict)} while this alert keeps firing."
)
lines.append(_anomaly_head(triggered_dates, value))

verdict_label = VERDICT_LABELS.get(verdict or "", "")
if verdict_label:
lines.append(f"Investigation verdict: {verdict_label}.")
summary_line = None
teaser = investigation_summary_teaser(summary)
if teaser:
summary_line = InvestigationSummaryLine(index=len(lines), markdown=summary)
lines.append(teaser)
if notebook_short_id:
lines.append(f"See {notebook_url(notebook_short_id)} for the full investigation.")
return InvestigationBreaches(lines=lines, summary=summary_line)


def safety_net_breaches(*, triggered_dates: Sequence[str], value: float | None) -> list[str]:
"""Minimal breach description for a notification the safety net sent without a verdict."""
return [_anomaly_head(triggered_dates, value), SAFETY_NET_LINE]
71 changes: 14 additions & 57 deletions posthog/temporal/ai/anomaly_investigation/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,14 @@
from temporalio import activity, workflow
from temporalio.common import RetryPolicy

from posthog.dataclasses import frozen
from posthog.models import Team, User
from posthog.tasks.alerts.charts import png_to_b64, render_series_chart
from posthog.tasks.alerts.investigation_wording import InvestigationBreaches, notebook_url, verdict_breaches
from posthog.tasks.alerts.metric_definition import describe_metric_definition
from posthog.tasks.alerts.utils import (
InvestigationSummaryLine,
_inconclusive_is_suppressed,
_should_suppress_notification,
dispatch_alert_notification,
investigation_summary_teaser,
prepare_alert_insight_chart_url,
record_alert_delivery,
)
Expand Down Expand Up @@ -72,12 +70,6 @@
ANOMALY_INVESTIGATION_ACTIVITY_HEARTBEAT_TIMEOUT = 5 * 60 # 5 minutes
ANOMALY_INVESTIGATION_ACTIVITY_MAX_ATTEMPTS = 2

_VERDICT_LABELS = {
"true_positive": "True positive",
"false_positive": "False positive",
"inconclusive": "Inconclusive",
}

# Marker on the check's delivery receipts: the follow-up for a changed verdict is sent once.
_VERDICT_CHANGE_FOLLOWUP_KEY = "investigation_verdict_change"

Expand Down Expand Up @@ -333,15 +325,15 @@ def _build_investigation_signal_extra(
) -> dict:
"""The `extra` payload for an anomaly-investigation signal — a plain dict validated against
`AnomalyInvestigationSignalExtra` inside `emit_signal`."""
notebook_url = absolute_uri(f"/notebooks/{notebook_short_id}") if notebook_short_id else absolute_uri("/alerts")
url = notebook_url(notebook_short_id) if notebook_short_id else absolute_uri("/alerts")
extra: dict = {
"alert_id": str(alert.id),
"alert_name": alert.name or "Unnamed alert",
"alert_check_id": str(alert_check.id),
"insight_id": str(insight.id),
"detector_type": detector_type,
"verdict": report.verdict,
"url": notebook_url,
"url": url,
}
# Optional fields — omit when absent so the schema's None defaults apply.
if insight.name:
Expand Down Expand Up @@ -513,7 +505,7 @@ def _deliver_investigation_outcome(
# block falls back to a divider when absent).
extra_properties: dict[str, str] = {}
if notebook_short_id:
extra_properties["investigation_notebook_url"] = absolute_uri(f"/notebooks/{notebook_short_id}")
extra_properties["investigation_notebook_url"] = notebook_url(notebook_short_id)
if insight_chart_url:
extra_properties["insight_chart_url"] = insight_chart_url
try:
Expand Down Expand Up @@ -577,9 +569,7 @@ def _dispatch_verdict_change_followup(
summary=summary,
notebook_short_id=notebook_short_id,
)
extra_properties = (
{"investigation_notebook_url": absolute_uri(f"/notebooks/{notebook_short_id}")} if notebook_short_id else None
)
extra_properties = {"investigation_notebook_url": notebook_url(notebook_short_id)} if notebook_short_id else None
try:
# The savepoint keeps a database failure inside the dispatch from poisoning the
# caller's transaction once the handler below swallows it.
Expand Down Expand Up @@ -634,55 +624,22 @@ def _dispatch_verdict_change_followup(
)


@frozen
class _BreachDescriptions:
lines: list[str]
summary: InvestigationSummaryLine | None


def _build_breach_descriptions(
*,
alert_check,
verdict: str | None,
previous_verdict: str | None,
summary: str,
notebook_short_id: str | None,
) -> _BreachDescriptions:
"""Compose the strings that populate the `match_descriptions` list in the
existing alert email template. Keeps the current template working while
giving gated notifications richer body content.

The summary line holds a plain-text teaser. Email renders the full Markdown in its place.
"""
lines: list[str] = []
if previous_verdict and verdict and verdict != previous_verdict:
lines.append(
f"Investigation verdict changed from {_VERDICT_LABELS.get(previous_verdict, previous_verdict)} "
f"to {_VERDICT_LABELS.get(verdict, verdict)} while this alert keeps firing."
)
triggered_dates = alert_check.triggered_dates or []
if triggered_dates:
if len(triggered_dates) == 1:
lines.append(f"Anomaly detected on {triggered_dates[0]}.")
else:
lines.append(f"Anomaly detected from {triggered_dates[0]} to {triggered_dates[-1]}.")
elif alert_check.calculated_value is not None:
lines.append(f"Calculated value at fire: {alert_check.calculated_value}.")
else:
lines.append("Anomaly detected.")

verdict_label = _VERDICT_LABELS.get(verdict or "", "")
if verdict_label:
lines.append(f"Investigation verdict: {verdict_label}.")
summary_line = None
teaser = investigation_summary_teaser(summary)
if teaser:
summary_line = InvestigationSummaryLine(index=len(lines), markdown=summary)
lines.append(teaser)
if notebook_short_id:
notebook_url = absolute_uri(f"/notebooks/{notebook_short_id}")
lines.append(f"See {notebook_url} for the full investigation.")
return _BreachDescriptions(lines=lines, summary=summary_line)
) -> InvestigationBreaches:
return verdict_breaches(
triggered_dates=alert_check.triggered_dates or [],
value=alert_check.calculated_value,
verdict=verdict,
previous_verdict=previous_verdict,
summary=summary,
notebook_short_id=notebook_short_id,
)


async def _update_status(alert_check, status: str) -> None:
Expand Down
22 changes: 17 additions & 5 deletions products/alerts/backend/platform_alert_legacy_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@
from products.alerts.backend.logic.destinations import emit_alert_internal_events
from products.alerts.backend.models.alert import AlertConfiguration
from products.alerts.backend.platform_alert_slack import insight_transition_properties
from products.alerts.backend.platform_alert_wording import announces_firing
from products.alerts.backend.platform_investigation_message import (
FiringInvestigation,
firing_breaches,
firing_investigation,
)
from products.alerts_platform.backend.facade.contracts import (
AlertDeliveryRequest,
AlertEventKind,
Expand Down Expand Up @@ -72,7 +78,8 @@ def emit_insight_legacy_events(


def _announces_firing(emission: LegacyEventEmission) -> bool:
return emission.sends_message and emission.transition.kind == AlertEventKind.FIRING
# A verdict-change follow-up goes out as a firing event, as the insight stack sends it.
return emission.sends_message and announces_firing(emission.transition)


def insight_chart_url_of(team_id: int, source_detail: dict[str, Any]) -> str | None:
Expand All @@ -94,14 +101,16 @@ def insight_chart_image(*, team_id: int, transition: AnnouncedTransition) -> Mes
return MessageImage(url=url, alt_text="Insight chart") if url else None


def _formatting_properties(transition: AnnouncedTransition) -> dict[str, Any]:
def _formatting_properties(
transition: AnnouncedTransition, investigation: FiringInvestigation | None
) -> dict[str, Any]:
"""The Slack card fields, or none of them.

These enrich the event; the destinations it reaches do not depend on them. A stored detail the
formatter cannot read must cost the firing its card, never its delivery.
"""
try:
return insight_transition_properties(transition)
return insight_transition_properties(transition, investigation)
except Exception:
logger.exception("alerts_insight_legacy_event_formatting_failed")
return {}
Expand All @@ -124,16 +133,19 @@ def _incident_event(

def _firing_properties(alert: AlertConfiguration, transition: AnnouncedTransition) -> dict[str, Any]:
detail = transition.source_detail
investigation = firing_investigation(alert.team_id, transition)
properties: dict[str, Any] = {
# From the transition, not the alert row: the platform never writes `state` or
# `last_checked_at`, so a platform-owned alert past its shadow window would report the
# frozen `not_firing` default on the very event that announces a firing.
**alert.internal_event_properties(state=AlertState.FIRING.value, last_checked_at=transition.occurred_at),
# Joined with newlines so each breach renders on its own line, as the insight stack sends it.
"breaches": "\n".join(detail.get("breaches") or []),
"breaches": "\n".join(firing_breaches(transition, investigation)),
**detector_verdict_event_fields(detail.get("triggered_metadata")),
**_formatting_properties(transition),
**_formatting_properties(transition, investigation),
}
if investigation is not None and investigation.notebook_url:
properties["investigation_notebook_url"] = investigation.notebook_url
if chart_url := insight_chart_url_of(alert.team_id, detail):
properties["insight_chart_url"] = chart_url
return properties
Expand Down
31 changes: 19 additions & 12 deletions products/alerts/backend/platform_alert_slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@

from products.alerts.backend.logic.insight_alert_destinations import _slack_attachments, _slack_blocks
from products.alerts.backend.logic.insight_alert_message import insight_alert_slack_properties
from products.alerts.backend.platform_alert_wording import announces_firing
from products.alerts.backend.platform_investigation_message import (
FiringInvestigation,
firing_breaches,
firing_investigation,
)
from products.alerts_platform.backend.facade.contracts import (
AlertEventKind,
AnnouncedTransition,
MessageImage,
MessageLink,
Expand All @@ -33,9 +38,11 @@ def _zone(name: str) -> ZoneInfo:
return ZoneInfo("UTC")


def insight_transition_properties(transition: AnnouncedTransition) -> dict[str, Any]:
def insight_transition_properties(
transition: AnnouncedTransition, investigation: FiringInvestigation | None
) -> dict[str, Any]:
detail = transition.source_detail
breaches = detail.get("breaches") or []
breaches = firing_breaches(transition, investigation)
# Rows come from a stored detail, so their shape is checked rather than assumed. The formatter
# reads `observed` directly, so a row without it would raise there.
rows = [row for row in (detail.get("breach_details") or []) if isinstance(row, dict)]
Expand All @@ -58,11 +65,9 @@ def insight_transition_properties(transition: AnnouncedTransition) -> dict[str,
metric_name=detail.get("metric_name") or "Insight",
is_anomaly=bool(detail.get("is_anomaly")),
tags=detail.get("tags") or [],
# Native delivery sends as the check finishes, before any anomaly investigation runs, so it
# has no summary, verdict or next step to show. The HogFunction path sends later and does.
summary=None,
verdict=None,
investigation_status=None,
summary=investigation.chat_summary if investigation else None,
Comment thread
jzhu13 marked this conversation as resolved.
Resolved
verdict=investigation.verdict if investigation else None,
investigation_status=investigation.status if investigation else None,
detected_at=detected_at,
allowed_root=absolute_uri("/"),
)
Expand All @@ -77,12 +82,14 @@ def render_insight_slack_message(
project: MessageLink | None,
image: MessageImage | None,
) -> SlackMessageContent | None:
if transition.kind != AlertEventKind.FIRING:
if not announces_firing(transition):
return None
alert_id = transition.source_detail.get("alert_id")
if not alert_id:
return None
properties = insight_transition_properties(transition)
investigation = firing_investigation(team_id, transition)
properties = insight_transition_properties(transition, investigation)
notebook_url = investigation.notebook_url if investigation else None
kind = "Anomaly alert" if properties["slack_alert_type"] == "Anomaly detected" else "Insight alert"
project_url = project.url if project else absolute_uri(f"/project/{team_id}")
project_name = project.label if project else transition.source_detail.get("team_name") or ""
Expand All @@ -92,8 +99,8 @@ def render_insight_slack_message(
blocks = _slack_blocks(
chart={"type": "image", "image_url": image.url, "alt_text": image.alt_text} if image else {"type": "divider"},
alert_id=str(alert_id),
primary_url=alert_url,
primary_label="🔎 View alert",
primary_url=notebook_url or alert_url,
primary_label="🔎 View investigation" if notebook_url else "🔎 View alert",
insight_url=absolute_uri(f"/project/{team_id}/insights/{insight_short_id}") if insight_short_id else None,
)
attachments = _slack_attachments(
Expand Down
Loading
Loading