Skip to content
49 changes: 30 additions & 19 deletions posthog/temporal/alerts/investigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,22 @@ def decide_investigation(alert: AlertConfiguration, alert_check: AlertCheck) ->
Cooldown is enforced by `claim_investigation_slot`, which does the read-then-write
inside the caller's transaction.
"""
if not alert.investigation_agent_enabled:
if not investigates(alert, firing=alert_check.state == AlertState.FIRING):
return InvestigationDecision()
if not alert.detector_config:
return InvestigationDecision()
if alert_check.state != AlertState.FIRING:
return InvestigationDecision()

episode = episode_investigations(alert, alert_check)
if episode.started >= MAX_INVESTIGATIONS_PER_EPISODE:
return InvestigationDecision(previous_verdict=episode.previous_verdict)
return InvestigationDecision(should_investigate=True, previous_verdict=episode.previous_verdict)
return decide_within_budget(started=episode.started, previous_verdict=episode.previous_verdict)


def investigates(alert: AlertConfiguration, *, firing: bool) -> bool:
"""Whether a check of `alert` is one the agent investigates at all, before its budget."""
return bool(alert.investigation_agent_enabled and alert.detector_config and firing)


def decide_within_budget(*, started: int, previous_verdict: str | None) -> InvestigationDecision:
"""The decision for an eligible check, given what its episode already started."""
return InvestigationDecision(
should_investigate=started < MAX_INVESTIGATIONS_PER_EPISODE, previous_verdict=previous_verdict
)


def carried_verdict_suppresses(alert: AlertConfiguration, decision: InvestigationDecision) -> bool:
Expand All @@ -111,22 +116,28 @@ def claim_investigation_slot(alert: AlertConfiguration, alert_check: AlertCheck)

Caller must run this inside a transaction so the read-then-write is atomic.
"""
now = datetime.now(UTC)
in_flight = Q(
investigation_status__in=[InvestigationStatus.PENDING, InvestigationStatus.RUNNING],
created_at__gte=now - IN_FLIGHT_INVESTIGATION_HORIZON,
)
within_cooldown = Q(
investigation_status=InvestigationStatus.DONE,
created_at__gte=now - investigation_cooldown(alert),
)
recent = (
AlertCheck.objects.filter(alert_configuration=alert)
.exclude(id=alert_check.id)
.filter(in_flight | within_cooldown)
.filter(refusing_investigations(alert, datetime.now(UTC), status_field="investigation_status"))
)
if recent.exists():
AlertCheck.objects.filter(id=alert_check.id).update(investigation_status=InvestigationStatus.SKIPPED)
return False
AlertCheck.objects.filter(id=alert_check.id).update(investigation_status=InvestigationStatus.PENDING)
return True


def refusing_investigations(alert: AlertConfiguration, now: datetime, *, status_field: str) -> Q:
"""Earlier investigations of `alert` that refuse a new one: in flight, or done inside the cooldown.

`status_field` names the status column, because each stack keeps its investigations in its own table.
"""
cooldown = investigation_cooldown(alert)
in_flight = Q(
**{f"{status_field}__in": [InvestigationStatus.PENDING, InvestigationStatus.RUNNING]},
created_at__gte=now - IN_FLIGHT_INVESTIGATION_HORIZON,
)
within_cooldown = Q(**{status_field: InvestigationStatus.DONE}, created_at__gte=now - cooldown)
# The outer bound on its own lets the read scan one short range of the creation-time index.
return Q(created_at__gte=now - max(IN_FLIGHT_INVESTIGATION_HORIZON, cooldown)) & (in_flight | within_cooldown)
4 changes: 2 additions & 2 deletions products/alerts/backend/investigation_episode.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

# Statuses of a check whose investigation was started. SKIPPED never ran, so it costs no
# budget; FAILED did run and costs one, which keeps a broken agent from retrying forever.
_STARTED_INVESTIGATION_STATUSES = [
STARTED_INVESTIGATION_STATUSES = [
InvestigationStatus.PENDING,
InvestigationStatus.RUNNING,
InvestigationStatus.DONE,
Expand Down Expand Up @@ -57,7 +57,7 @@ def episode_investigations(alert: AlertConfiguration, alert_check: AlertCheck) -
.first()
)
return EpisodeInvestigations(
started=earlier.filter(investigation_status__in=_STARTED_INVESTIGATION_STATUSES).count(),
started=earlier.filter(investigation_status__in=STARTED_INVESTIGATION_STATUSES).count(),
first_check_id=str(first_check_id or alert_check.id),
previous_verdict=previous_verdict,
)
125 changes: 125 additions & 0 deletions products/alerts/backend/migrations/0015_insight_alert_investigation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Generated by Django 5.2.18 on 2026-10-26 19:46

import django.db.models.deletion
from django.db import migrations, models

import posthog.uuidt


class Migration(migrations.Migration):
dependencies = [
("alerts", "0014_strip_llm_detector_threshold_and_window"),
("notebooks", "0027_notebook_unlisted_visibility"),
("posthog", "1413_event_filter_pipeline_override"),
]

operations = [
migrations.CreateModel(
name="InsightAlertInvestigation",
fields=[
(
"id",
models.UUIDField(
default=posthog.uuidt.uuid7,
editable=False,
primary_key=True,
serialize=False,
),
),
("configuration_id", models.UUIDField()),
("evaluation_key", models.CharField(max_length=255)),
("episode_started_at", models.DateTimeField(blank=True, null=True)),
(
"kind",
models.CharField(
choices=[("agent", "Agent"), ("metrics", "Metrics")],
max_length=16,
),
),
(
"status",
models.CharField(
choices=[
("pending", "pending"),
("running", "running"),
("done", "done"),
("failed", "failed"),
("skipped", "skipped"),
],
max_length=10,
),
),
(
"verdict",
models.CharField(
blank=True,
choices=[
("true_positive", "true_positive"),
("false_positive", "false_positive"),
("inconclusive", "inconclusive"),
],
max_length=20,
null=True,
),
),
("summary", models.TextField(blank=True, null=True)),
("error", models.JSONField(blank=True, null=True)),
("evaluated", models.JSONField(default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"alert_configuration",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="alerts.alertconfiguration",
),
),
(
"notebook",
models.ForeignKey(
blank=True,
db_constraint=False,
db_index=False,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="notebooks.notebook",
),
),
(
"team",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="posthog.team",
),
),
],
options={
"db_table": "alerts_insightalertinvestigation",
"indexes": [
models.Index(
fields=["configuration_id", "episode_started_at", "created_at"],
name="insight_alert_inv_episode_idx",
),
models.Index(
condition=models.Q(("notebook__isnull", False)),
fields=["notebook"],
name="insight_alert_inv_notebook_idx",
),
models.Index(
fields=["configuration_id", "created_at"],
name="insight_alert_inv_recent_idx",
),
],
"constraints": [
models.UniqueConstraint(
fields=("configuration_id", "evaluation_key"),
name="insight_alert_investigation_one_per_evaluation",
)
],
},
),
]
2 changes: 1 addition & 1 deletion products/alerts/backend/migrations/max_migration.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0014_strip_llm_detector_threshold_and_window
0015_insight_alert_investigation
2 changes: 2 additions & 0 deletions products/alerts/backend/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from .alert import Alert, AlertCheck, AlertConfiguration, AlertSubscription, Threshold
from .investigation import InsightAlertInvestigation

__all__ = [
"Alert",
"AlertCheck",
"AlertConfiguration",
"AlertSubscription",
"InsightAlertInvestigation",
"Threshold",
]
69 changes: 69 additions & 0 deletions products/alerts/backend/models/investigation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from django.db import models

from posthog.models.scoping.root_mixin import TeamScopedRootMixin
from posthog.models.utils import UUIDModel

from products.alerts.backend.models.alert import AlertConfiguration, InvestigationStatus, InvestigationVerdict


class InsightAlertInvestigation(TeamScopedRootMixin, UUIDModel):
"""One investigation of one platform evaluation of an insight alert.

The platform never writes `AlertCheck`, so an alert the platform evaluates keeps its
investigations here. A row exists for every evaluation that asked for one, including one the
claim refused, which is recorded as SKIPPED and spends no budget.
"""

class Kind(models.TextChoices):
AGENT = "agent", "Agent"
METRICS = "metrics", "Metrics"

team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE, db_constraint=False, related_name="+")
alert_configuration = models.ForeignKey(AlertConfiguration, on_delete=models.CASCADE, related_name="+")
# The platform's configuration. Not a foreign key, because that model belongs to another product.
configuration_id = models.UUIDField()
evaluation_key = models.CharField(max_length=255)
# The firing the evaluation belongs to, which is what the per-episode budget counts. Null for
# the firing that was already open when the platform took the alert over.
episode_started_at = models.DateTimeField(null=True, blank=True)

kind = models.CharField(max_length=16, choices=Kind.choices)
status = models.CharField(max_length=10, choices=InvestigationStatus.choices)
verdict = models.CharField(max_length=20, choices=InvestigationVerdict.choices, null=True, blank=True)
summary = models.TextField(null=True, blank=True)
notebook = models.ForeignKey(
"notebooks.Notebook",
on_delete=models.SET_NULL,
db_constraint=False,
db_index=False,
Comment thread
jzhu13 marked this conversation as resolved.
Resolved
null=True,
blank=True,
related_name="+",
)
error = models.JSONField(null=True, blank=True)
# What the evaluation measured, as the agent reads it: value, triggered dates and points,
# anomaly scores, interval and triggered metadata.
evaluated = models.JSONField(default=dict)

created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

class Meta:
db_table = "alerts_insightalertinvestigation"
constraints = [
models.UniqueConstraint(
fields=["configuration_id", "evaluation_key"], name="insight_alert_investigation_one_per_evaluation"
)
]
indexes = [
# The episode's budget and its last verdict.
models.Index(
fields=["configuration_id", "episode_started_at", "created_at"], name="insight_alert_inv_episode_idx"
),
# Deleting a notebook nulls the link through this, and most rows have no notebook.
models.Index(
fields=["notebook"], name="insight_alert_inv_notebook_idx", condition=models.Q(notebook__isnull=False)
),
# The claim's in-flight and cooldown checks.
models.Index(fields=["configuration_id", "created_at"], name="insight_alert_inv_recent_idx"),
]
Loading
Loading