From 78263a9d4e98c15c15eca4744288be84b4f10381 Mon Sep 17 00:00:00 2001 From: hpouillot Date: Thu, 20 Aug 2026 11:49:25 +0200 Subject: [PATCH 1/2] feat(error-tracking): add severity rules API --- .../security/idor-team-scoped-models.yaml | 2 + posthog/hogql/database/schema/system.py | 27 +++ .../schema/test/test_system_tables.py | 9 + posthog/settings/web.py | 1 + .../error_tracking/backend/facade/__init__.py | 12 + products/error_tracking/backend/facade/api.py | 47 ++++ .../backend/facade/contracts.py | 12 + .../error_tracking/backend/logic/rules.py | 51 +++++ .../migrations/0027_add_severity_rules.py | 58 +++++ .../backend/migrations/max_migration.txt | 2 +- products/error_tracking/backend/models.py | 15 ++ .../backend/presentation/views/__init__.py | 2 + .../presentation/views/severity_rules.py | 190 ++++++++++++++++ products/error_tracking/backend/routes.py | 7 + .../backend/tests/api/test_severity_rules.py | 211 ++++++++++++++++++ .../frontend/generated/api.schemas.ts | 104 +++++++++ .../error_tracking/frontend/generated/api.ts | 120 ++++++++++ .../frontend/generated/api.zod.ts | 56 +++++ services/mcp/src/api/generated.ts | 104 +++++++++ 19 files changed, 1029 insertions(+), 1 deletion(-) create mode 100644 products/error_tracking/backend/migrations/0027_add_severity_rules.py create mode 100644 products/error_tracking/backend/presentation/views/severity_rules.py create mode 100644 products/error_tracking/backend/tests/api/test_severity_rules.py diff --git a/.semgrep/rules/security/idor-team-scoped-models.yaml b/.semgrep/rules/security/idor-team-scoped-models.yaml index 29a1194d73d2..9e7c8cb39729 100644 --- a/.semgrep/rules/security/idor-team-scoped-models.yaml +++ b/.semgrep/rules/security/idor-team-scoped-models.yaml @@ -161,6 +161,7 @@ rules: |ErrorTrackingRelease |ErrorTrackingRecommendation |ErrorTrackingSettings + |ErrorTrackingSeverityRule |ErrorTrackingSpikeDetectionConfig |ErrorTrackingSpikeEvent |ErrorTrackingStackFrame @@ -531,6 +532,7 @@ rules: |ErrorTrackingRelease |ErrorTrackingRecommendation |ErrorTrackingSettings + |ErrorTrackingSeverityRule |ErrorTrackingSpikeDetectionConfig |ErrorTrackingSpikeEvent |ErrorTrackingStackFrame diff --git a/posthog/hogql/database/schema/system.py b/posthog/hogql/database/schema/system.py index a85e9411a1e6..ffbb6a4b4793 100644 --- a/posthog/hogql/database/schema/system.py +++ b/posthog/hogql/database/schema/system.py @@ -1733,6 +1733,30 @@ def _notebook_markdown_expr() -> ast.Expr: }, ) +error_tracking_severity_rules: PostgresTable = PostgresTable( + name="error_tracking_severity_rules", + postgres_table_name="posthog_errortrackingseverityrule", + access_scope="error_tracking", + description="Ordered rules that assign severity to newly created error tracking issues; one row per rule.", + fields={ + "id": StringDatabaseField(name="id", description="Rule UUID."), + "team_id": IntegerDatabaseField(name="team_id"), + "severity": StringDatabaseField( + name="severity", description="Severity assigned when the rule is the first match." + ), + "order_key": IntegerDatabaseField(name="order_key", description="Evaluation order; lower runs first."), + "filters": StringJSONDatabaseField( + name="filters", description="JSON conditions a new issue's event must match for the rule to apply." + ), + "bytecode": StringJSONDatabaseField(name="bytecode", description="Compiled Hog bytecode for the filters."), + "disabled_data": StringJSONDatabaseField( + name="disabled_data", nullable=True, description="JSON state when the rule is disabled; NULL when active." + ), + "created_at": DateTimeDatabaseField(name="created_at", description="When the rule was created."), + "updated_at": DateTimeDatabaseField(name="updated_at", description="When the rule was last updated."), + }, +) + error_tracking_bypass_rules: PostgresTable = PostgresTable( name="error_tracking_bypass_rules", postgres_table_name="posthog_errortrackingbypassrule", @@ -2849,6 +2873,9 @@ class SystemTables(TableNode): ), "error_tracking_issues": TableNode(name="error_tracking_issues", table=error_tracking_issues), "error_tracking_releases": TableNode(name="error_tracking_releases", table=error_tracking_releases), + "error_tracking_severity_rules": TableNode( + name="error_tracking_severity_rules", table=error_tracking_severity_rules + ), "error_tracking_symbol_sets": TableNode(name="error_tracking_symbol_sets", table=error_tracking_symbol_sets), "error_tracking_suppression_rules": TableNode( name="error_tracking_suppression_rules", table=error_tracking_suppression_rules diff --git a/posthog/hogql/database/schema/test/test_system_tables.py b/posthog/hogql/database/schema/test/test_system_tables.py index 8429217f0e3d..eb9eff5642e3 100644 --- a/posthog/hogql/database/schema/test/test_system_tables.py +++ b/posthog/hogql/database/schema/test/test_system_tables.py @@ -76,6 +76,7 @@ ErrorTrackingIssueAssignment, ErrorTrackingIssueFingerprintV2, ErrorTrackingRelease, + ErrorTrackingSeverityRule, ErrorTrackingSuppressionRule, ErrorTrackingSymbolSet, ) @@ -93,6 +94,7 @@ ErrorTrackingBypassRule = apps.get_model("error_tracking", "ErrorTrackingBypassRule") ErrorTrackingSuppressionRule = apps.get_model("error_tracking", "ErrorTrackingSuppressionRule") ErrorTrackingRelease = apps.get_model("error_tracking", "ErrorTrackingRelease") + ErrorTrackingSeverityRule = apps.get_model("error_tracking", "ErrorTrackingSeverityRule") # Only directly-queryable tables are team-scoped via a WHERE clause. Namespace nodes such as # `information_schema` carry no `table` of their own (just child catalog tables computed per-query), @@ -430,6 +432,12 @@ def _create_error_tracking_bypass_rule(team: Team, label: str): ) +def _create_error_tracking_severity_rule(team: Team, label: str): + return ErrorTrackingSeverityRule.objects.unscoped().create( + team=team, filters={"type": "AND", "values": []}, bytecode=[], severity="high", order_key=0 + ) + + def _create_error_tracking_suppression_rule(team: Team, label: str): return ErrorTrackingSuppressionRule.objects.create( team=team, filters={"type": "AND", "values": []}, bytecode=[], order_key=0, sampling_rate=1.0 @@ -838,6 +846,7 @@ def _create_business_knowledge_chunk(team: Team, label: str): ("source_sync_jobs", _create_source_sync_job), ("error_tracking_issues", _create_error_tracking_issue), ("error_tracking_releases", _create_error_tracking_release), + ("error_tracking_severity_rules", _create_error_tracking_severity_rule), ("error_tracking_symbol_sets", _create_error_tracking_symbol_set), ("error_tracking_suppression_rules", _create_error_tracking_suppression_rule), ("evaluation_directories", _create_evaluation_directory), diff --git a/posthog/settings/web.py b/posthog/settings/web.py index 85d2a92b09d3..bc5a00c0cd4d 100644 --- a/posthog/settings/web.py +++ b/posthog/settings/web.py @@ -887,6 +887,7 @@ def static_varies_origin(headers, path, url): "WoWChangeDirectionEnum": ["Up", "Down"], "BatchExportIntervalEnum": ["hour", "day", "week", "every 5 minutes", "every 15 minutes"], "ErrorTrackingIssueOrderByEnum": ["last_seen", "first_seen", "occurrences", "users", "sessions"], + "ErrorTrackingIssueSeverityRuleEnum": ["low", "medium", "high", "critical"], "ErrorTrackingIssueStatusEnum": ["archived", "active", "resolved", "pending_release", "suppressed", "all"], # Dashboard widget polymorphic OpenAPI: each per-type serializer uses a singleton # widget_type ChoiceField (one value). drf-spectacular hashes enum value sets — without diff --git a/products/error_tracking/backend/facade/__init__.py b/products/error_tracking/backend/facade/__init__.py index 69c09c656fc2..d1211fc1f535 100644 --- a/products/error_tracking/backend/facade/__init__.py +++ b/products/error_tracking/backend/facade/__init__.py @@ -12,10 +12,12 @@ create_external_reference, create_grouping_rule, create_release, + create_severity_rule, create_suppression_rule, delete_assignment_rule, delete_grouping_rule, delete_release, + delete_severity_rule, delete_suppression_rule, get_assignment_rule, get_client_safe_suppression_rules, @@ -36,6 +38,7 @@ get_release, get_release_by_hash, get_settings, + get_severity_rule, get_spike_detection_config, get_stack_frame, get_suppression_rule, @@ -51,12 +54,14 @@ list_grouping_rules, list_issues, list_releases, + list_severity_rules, list_spike_events, list_stack_frames, list_suppression_rules, override_error_tracking_issue_fingerprint, reorder_assignment_rules, reorder_grouping_rules, + reorder_severity_rules, reorder_suppression_rules, search_external_issues, sync_issues_to_clickhouse, @@ -64,6 +69,7 @@ update_grouping_rule, update_release, update_settings, + update_severity_rule, update_spike_detection_config, update_suppression_rule, ) @@ -82,10 +88,12 @@ "create_external_reference", "create_grouping_rule", "create_release", + "create_severity_rule", "create_suppression_rule", "delete_assignment_rule", "delete_grouping_rule", "delete_release", + "delete_severity_rule", "delete_suppression_rule", "get_assignment_rule", "get_client_safe_suppression_rules", @@ -105,6 +113,7 @@ "get_org_ids_with_exceptions", "get_release", "get_release_by_hash", + "get_severity_rule", "get_settings", "get_spike_detection_config", "get_stack_frame", @@ -121,18 +130,21 @@ "list_grouping_rules", "list_issues", "list_releases", + "list_severity_rules", "list_spike_events", "list_stack_frames", "list_suppression_rules", "override_error_tracking_issue_fingerprint", "reorder_assignment_rules", "reorder_grouping_rules", + "reorder_severity_rules", "reorder_suppression_rules", "search_external_issues", "sync_issues_to_clickhouse", "update_assignment_rule", "update_grouping_rule", "update_release", + "update_severity_rule", "update_settings", "update_spike_detection_config", "update_suppression_rule", diff --git a/products/error_tracking/backend/facade/api.py b/products/error_tracking/backend/facade/api.py index f588f28f70e9..07dc26448de1 100644 --- a/products/error_tracking/backend/facade/api.py +++ b/products/error_tracking/backend/facade/api.py @@ -406,6 +406,53 @@ def reorder_assignment_rules(team_id: int, orders: dict[str, int]) -> None: rules.reorder_assignment_rules(team_id, orders) +def _to_severity_rule(rule) -> contracts.ErrorTrackingSeverityRule: + return contracts.ErrorTrackingSeverityRule( + id=rule.id, + filters=rule.filters, + severity=rule.severity, + order_key=rule.order_key, + disabled_data=rule.disabled_data, + created_at=rule.created_at, + updated_at=rule.updated_at, + ) + + +def list_severity_rules(team_id: int) -> list[contracts.ErrorTrackingSeverityRule]: + return [_to_severity_rule(rule) for rule in rules.list_severity_rules(team_id)] + + +def get_severity_rule(team_id: int, rule_id: str) -> contracts.ErrorTrackingSeverityRule | None: + rule = rules.get_severity_rule(team_id, rule_id) + return _to_severity_rule(rule) if rule is not None else None + + +def create_severity_rule( + team_id: int, *, filters: dict, severity: str, order_key: int = 0 +) -> contracts.ErrorTrackingSeverityRule: + rule = rules.create_severity_rule(team_id, filters=filters, severity=severity, order_key=order_key) + return _to_severity_rule(rule) + + +def update_severity_rule( + team_id: int, + rule_id: str, + *, + filters: dict | None = None, + severity: str | None = None, +) -> contracts.ErrorTrackingSeverityRule | None: + rule = rules.update_severity_rule(team_id, rule_id, filters=filters, severity=severity) + return _to_severity_rule(rule) if rule is not None else None + + +def delete_severity_rule(team_id: int, rule_id: str) -> bool: + return rules.delete_severity_rule(team_id, rule_id) + + +def reorder_severity_rules(team_id: int, orders: dict[str, int]) -> None: + rules.reorder_severity_rules(team_id, orders) + + def _to_grouping_rule(rule, issue: tuple[UUID, str | None] | None = None) -> contracts.ErrorTrackingGroupingRule: return contracts.ErrorTrackingGroupingRule( id=rule.id, diff --git a/products/error_tracking/backend/facade/contracts.py b/products/error_tracking/backend/facade/contracts.py index c4cd2a689b39..1625ff3432a3 100644 --- a/products/error_tracking/backend/facade/contracts.py +++ b/products/error_tracking/backend/facade/contracts.py @@ -11,6 +11,7 @@ from dataclasses import field from datetime import datetime +from typing import Literal from uuid import UUID from pydantic.dataclasses import dataclass @@ -202,6 +203,17 @@ class ErrorTrackingAssignmentRule: updated_at: datetime +@dataclass(frozen=True) +class ErrorTrackingSeverityRule: + id: UUID + filters: dict + severity: Literal["low", "medium", "high", "critical"] + order_key: int + disabled_data: dict | None + created_at: datetime + updated_at: datetime + + @dataclass(frozen=True) class ErrorTrackingGroupingRuleIssue: id: UUID diff --git a/products/error_tracking/backend/logic/rules.py b/products/error_tracking/backend/logic/rules.py index b32f26f914c3..b579ee3e5470 100644 --- a/products/error_tracking/backend/logic/rules.py +++ b/products/error_tracking/backend/logic/rules.py @@ -8,6 +8,7 @@ ErrorTrackingBypassRule, ErrorTrackingGroupingRule, ErrorTrackingIssueFingerprintV2, + ErrorTrackingSeverityRule, ErrorTrackingSuppressionRule, ) @@ -65,6 +66,7 @@ def match_all_bytecode() -> list[Any]: ErrorTrackingAssignmentRule, ErrorTrackingBypassRule, ErrorTrackingGroupingRule, + ErrorTrackingSeverityRule, ErrorTrackingSuppressionRule, ) @@ -150,6 +152,55 @@ def reorder_assignment_rules(team_id: int, orders: dict[str, int]) -> None: _reorder_rules(ErrorTrackingAssignmentRule, team_id, orders) +def list_severity_rules(team_id: int) -> QuerySet[ErrorTrackingSeverityRule]: + return ErrorTrackingSeverityRule.objects.filter(team_id=team_id).order_by("order_key", "created_at", "id") + + +def get_severity_rule(team_id: int, rule_id: str) -> ErrorTrackingSeverityRule | None: + return ErrorTrackingSeverityRule.objects.filter(team_id=team_id, id=rule_id).first() + + +def create_severity_rule( + team_id: int, *, filters: dict, severity: str, order_key: int = 0 +) -> ErrorTrackingSeverityRule: + return ErrorTrackingSeverityRule.objects.create( + team_id=team_id, + filters=filters, + bytecode=_rule_bytecode(team_id, filters), + severity=severity, + order_key=order_key, + ) + + +def update_severity_rule( + team_id: int, + rule_id: str, + *, + filters: dict | None = None, + severity: str | None = None, +) -> ErrorTrackingSeverityRule | None: + rule = get_severity_rule(team_id, rule_id) + if rule is None: + return None + if filters is not None: + rule.filters = filters + rule.bytecode = _rule_bytecode(team_id, filters) + rule.disabled_data = None + if severity is not None: + rule.severity = severity + rule.save() + return rule + + +def delete_severity_rule(team_id: int, rule_id: str) -> bool: + deleted, _ = ErrorTrackingSeverityRule.objects.filter(team_id=team_id, id=rule_id).delete() + return deleted > 0 + + +def reorder_severity_rules(team_id: int, orders: dict[str, int]) -> None: + _reorder_rules(ErrorTrackingSeverityRule, team_id, orders) + + def list_grouping_rules(team_id: int) -> QuerySet[ErrorTrackingGroupingRule]: return ErrorTrackingGroupingRule.objects.filter(team_id=team_id).order_by("order_key") diff --git a/products/error_tracking/backend/migrations/0027_add_severity_rules.py b/products/error_tracking/backend/migrations/0027_add_severity_rules.py new file mode 100644 index 000000000000..4e0ebf3e9e45 --- /dev/null +++ b/products/error_tracking/backend/migrations/0027_add_severity_rules.py @@ -0,0 +1,58 @@ +# Generated by Django 5.2.17 on 2026-08-19 16:48 + +import django.db.models.deletion +from django.db import migrations, models + +import posthog.uuidt + + +class Migration(migrations.Migration): + dependencies = [ + ("error_tracking", "0026_issue_state_updated_at_index"), + ("posthog", "1309_integration_kind_ext_idx"), + ] + + operations = [ + migrations.CreateModel( + name="ErrorTrackingSeverityRule", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.uuidt.UUIDT, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("filters", models.JSONField()), + ("bytecode", models.JSONField()), + ( + "severity", + models.TextField( + choices=[ + ("low", "Low"), + ("medium", "Medium"), + ("high", "High"), + ("critical", "Critical"), + ] + ), + ), + ("order_key", models.IntegerField()), + ("disabled_data", models.JSONField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "team", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.CASCADE, + to="posthog.team", + ), + ), + ], + options={ + "db_table": "posthog_errortrackingseverityrule", + }, + ), + ] diff --git a/products/error_tracking/backend/migrations/max_migration.txt b/products/error_tracking/backend/migrations/max_migration.txt index bfa4f7221e2a..dd0b2c6853df 100644 --- a/products/error_tracking/backend/migrations/max_migration.txt +++ b/products/error_tracking/backend/migrations/max_migration.txt @@ -1 +1 @@ -0026_issue_state_updated_at_index +0027_add_severity_rules diff --git a/products/error_tracking/backend/models.py b/products/error_tracking/backend/models.py index 9b134b0f0b29..2ebb554198af 100644 --- a/products/error_tracking/backend/models.py +++ b/products/error_tracking/backend/models.py @@ -20,6 +20,7 @@ ) from posthog.models.event.util import format_clickhouse_timestamp from posthog.models.integration import Integration +from posthog.models.scoping.root_mixin import TeamScopedRootMixin from posthog.models.utils import UUIDModel, UUIDTModel from posthog.storage import object_storage @@ -425,6 +426,20 @@ class Meta: # ] +class ErrorTrackingSeverityRule(TeamScopedRootMixin, UUIDTModel): + team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE, db_constraint=False) + filters = models.JSONField(null=False, blank=False) + bytecode = models.JSONField(null=False, blank=False) + severity = models.TextField(choices=ErrorTrackingIssue.Severity) + order_key = models.IntegerField(null=False, blank=False) + disabled_data = models.JSONField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "posthog_errortrackingseverityrule" + + # A custom grouping rule works as follows: # - Events are run against the filter code # - If an event matches, the fingerprint of the event is set as "custom-rule:" diff --git a/products/error_tracking/backend/presentation/views/__init__.py b/products/error_tracking/backend/presentation/views/__init__.py index 76796ba5f9f7..67979c332808 100644 --- a/products/error_tracking/backend/presentation/views/__init__.py +++ b/products/error_tracking/backend/presentation/views/__init__.py @@ -11,6 +11,7 @@ from products.error_tracking.backend.presentation.views.recommendations import ErrorTrackingRecommendationViewSet from products.error_tracking.backend.presentation.views.releases import ErrorTrackingReleaseViewSet from products.error_tracking.backend.presentation.views.settings import ErrorTrackingSettingsViewSet +from products.error_tracking.backend.presentation.views.severity_rules import ErrorTrackingSeverityRuleViewSet from products.error_tracking.backend.presentation.views.spike_detection_config import ( ErrorTrackingSpikeDetectionConfigViewSet, ) @@ -30,6 +31,7 @@ "ErrorTrackingGroupingRuleViewSet", "ErrorTrackingReleaseViewSet", "ErrorTrackingSettingsViewSet", + "ErrorTrackingSeverityRuleViewSet", "ErrorTrackingSpikeDetectionConfigViewSet", "ErrorTrackingSpikeEventViewSet", "ErrorTrackingSuppressionRuleViewSet", diff --git a/products/error_tracking/backend/presentation/views/severity_rules.py b/products/error_tracking/backend/presentation/views/severity_rules.py new file mode 100644 index 000000000000..8b19fe3702ef --- /dev/null +++ b/products/error_tracking/backend/presentation/views/severity_rules.py @@ -0,0 +1,190 @@ +import structlog +import posthoganalytics +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_field, extend_schema_serializer +from pydantic import ValidationError as PydanticValidationError +from rest_framework import serializers, status, viewsets +from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.response import Response + +from posthog.schema import PropertyGroupFilterValue + +from posthog.api.mixins import ValidatedRequest, validated_request +from posthog.api.routing import TeamAndOrgViewSetMixin +from posthog.api.utils import action +from posthog.event_usage import groups + +from products.error_tracking.backend.facade import api as error_tracking_api +from products.error_tracking.backend.facade.contracts import ERROR_TRACKING_ISSUE_SEVERITIES + +logger = structlog.get_logger(__name__) + + +@extend_schema_field(PropertyGroupFilterValue) # type: ignore[arg-type] +class ErrorTrackingSeverityRuleFiltersField(serializers.JSONField): + def to_internal_value(self, data): + value = super().to_internal_value(data) + if not isinstance(value, dict): + raise serializers.ValidationError("Expected a JSON object.") + try: + PropertyGroupFilterValue(**value) + except (PydanticValidationError, TypeError) as err: + logger.warning("Invalid severity rule filters payload", exc_info=err) + raise serializers.ValidationError("Invalid filters payload.") from err + return value + + +@extend_schema_field( + { + "type": "object", + "nullable": True, + "properties": { + "message": {"type": "string"}, + "issue": {"type": "object", "additionalProperties": True}, + "properties": {"type": "object", "additionalProperties": True}, + }, + } +) +class ErrorTrackingSeverityRuleDisabledDataField(serializers.JSONField): + pass + + +class ErrorTrackingSeverityRuleSerializer(serializers.Serializer): + id = serializers.UUIDField(read_only=True, help_text="Unique identifier of the severity rule.") + filters = ErrorTrackingSeverityRuleFiltersField( + help_text="Property-group filters evaluated against the event that creates an issue." + ) + severity = serializers.ChoiceField( + choices=ERROR_TRACKING_ISSUE_SEVERITIES, + help_text="Severity assigned to a newly created issue when this rule is the first match.", + ) + order_key = serializers.IntegerField( + help_text="Evaluation priority. Lower values run first, and the first matching rule wins." + ) + disabled_data = ErrorTrackingSeverityRuleDisabledDataField( + allow_null=True, + help_text="Diagnostic details when ingestion automatically disables the rule, otherwise null.", + ) + created_at = serializers.DateTimeField(read_only=True, help_text="When the rule was created.") + updated_at = serializers.DateTimeField(read_only=True, help_text="When the rule was last updated.") + + +@extend_schema_serializer(many=False) +class ErrorTrackingSeverityRuleListResponseSerializer(serializers.Serializer): + results = ErrorTrackingSeverityRuleSerializer(many=True, help_text="Severity rules in ascending evaluation order.") + + +class ErrorTrackingSeverityRuleCreateRequestSerializer(serializers.Serializer): + filters = ErrorTrackingSeverityRuleFiltersField( + help_text="Property-group filters evaluated against the event that creates an issue." + ) + severity = serializers.ChoiceField( + choices=ERROR_TRACKING_ISSUE_SEVERITIES, + help_text="Severity assigned when this rule is the first match.", + ) + order_key = serializers.IntegerField( + required=False, + default=0, + help_text="Evaluation priority. Lower values run first. Defaults to 0.", + ) + + +class ErrorTrackingSeverityRuleUpdateRequestSerializer(serializers.Serializer): + filters = ErrorTrackingSeverityRuleFiltersField( + required=False, + help_text="Replacement property-group filters. Omit to preserve the existing filters.", + ) + severity = serializers.ChoiceField( + choices=ERROR_TRACKING_ISSUE_SEVERITIES, + required=False, + help_text="Replacement severity. Omit to preserve the existing severity.", + ) + + +class ErrorTrackingSeverityRuleReorderRequestSerializer(serializers.Serializer): + orders = serializers.DictField( + child=serializers.IntegerField(), + help_text="Mapping from severity rule UUID to its new evaluation order.", + ) + + +class ErrorTrackingSeverityRuleViewSet(TeamAndOrgViewSetMixin, viewsets.GenericViewSet): + scope_object = "error_tracking" + scope_object_write_actions = ["create", "update", "partial_update", "destroy", "reorder"] + requires_resource_level_access = True + serializer_class = ErrorTrackingSeverityRuleSerializer + pagination_class = None + + @extend_schema(responses={200: OpenApiResponse(response=ErrorTrackingSeverityRuleListResponseSerializer)}) + def list(self, request, *args, **kwargs) -> Response: + rules = error_tracking_api.list_severity_rules(self.team.id) + return Response({"results": self.get_serializer(rules, many=True).data}) + + def retrieve(self, request, *args, pk=None, **kwargs) -> Response: + rule = error_tracking_api.get_severity_rule(self.team.id, pk) + if rule is None: + raise NotFound() + return Response(self.get_serializer(rule).data) + + def _apply_rule_update(self, request: ValidatedRequest, pk: str) -> Response: + try: + rule = error_tracking_api.update_severity_rule( + self.team.id, + pk, + filters=request.validated_data.get("filters"), + severity=request.validated_data.get("severity"), + ) + except error_tracking_api.InvalidBytecodeError as err: + raise ValidationError(str(err)) from err + if rule is None: + raise NotFound() + posthoganalytics.capture( + "error_tracking_severity_rule_edited", + distinct_id=request.user.pk, + groups=groups(self.team.organization, self.team), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + @validated_request(request_serializer=ErrorTrackingSeverityRuleUpdateRequestSerializer, responses={204: None}) + def update(self, request: ValidatedRequest, *args, pk=None, **kwargs) -> Response: + return self._apply_rule_update(request, pk) + + @validated_request(request_serializer=ErrorTrackingSeverityRuleUpdateRequestSerializer, responses={204: None}) + def partial_update(self, request: ValidatedRequest, *args, pk=None, **kwargs) -> Response: + return self._apply_rule_update(request, pk) + + def destroy(self, request, *args, pk=None, **kwargs) -> Response: + if not error_tracking_api.delete_severity_rule(self.team.id, pk): + raise NotFound() + posthoganalytics.capture( + "error_tracking_severity_rule_deleted", + distinct_id=request.user.pk, + groups=groups(self.team.organization, self.team), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + @validated_request( + request_serializer=ErrorTrackingSeverityRuleCreateRequestSerializer, + responses={201: OpenApiResponse(response=ErrorTrackingSeverityRuleSerializer)}, + ) + def create(self, request: ValidatedRequest, *args, **kwargs) -> Response: + try: + rule = error_tracking_api.create_severity_rule( + self.team.id, + filters=request.validated_data["filters"], + severity=request.validated_data["severity"], + order_key=request.validated_data["order_key"], + ) + except error_tracking_api.InvalidBytecodeError as err: + raise ValidationError(str(err)) from err + posthoganalytics.capture( + "error_tracking_severity_rule_created", + distinct_id=request.user.pk, + groups=groups(self.team.organization, self.team), + ) + return Response(self.get_serializer(rule).data, status=status.HTTP_201_CREATED) + + @validated_request(request_serializer=ErrorTrackingSeverityRuleReorderRequestSerializer, responses={204: None}) + @action(methods=["PATCH"], detail=False) + def reorder(self, request: ValidatedRequest, **kwargs) -> Response: + error_tracking_api.reorder_severity_rules(self.team.id, request.validated_data["orders"]) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/products/error_tracking/backend/routes.py b/products/error_tracking/backend/routes.py index 3e27cd64f000..4244b3cb1e2c 100644 --- a/products/error_tracking/backend/routes.py +++ b/products/error_tracking/backend/routes.py @@ -11,6 +11,7 @@ ErrorTrackingRecommendationViewSet, ErrorTrackingReleaseViewSet, ErrorTrackingSettingsViewSet, + ErrorTrackingSeverityRuleViewSet, ErrorTrackingSpikeDetectionConfigViewSet, ErrorTrackingSpikeEventViewSet, ErrorTrackingStackFrameViewSet, @@ -36,6 +37,12 @@ def register_routes(routers: RouterRegistry) -> None: "project_error_tracking_assignment_rule", ["team_id"], ) + routers.projects.register( + r"error_tracking/severity_rules", + ErrorTrackingSeverityRuleViewSet, + "project_error_tracking_severity_rule", + ["team_id"], + ) routers.projects.register( r"error_tracking/grouping_rules", ErrorTrackingGroupingRuleViewSet, diff --git a/products/error_tracking/backend/tests/api/test_severity_rules.py b/products/error_tracking/backend/tests/api/test_severity_rules.py new file mode 100644 index 000000000000..6846bd110e25 --- /dev/null +++ b/products/error_tracking/backend/tests/api/test_severity_rules.py @@ -0,0 +1,211 @@ +from datetime import UTC, datetime +from typing import Any + +import pytest +from posthog.test.base import APIBaseTest + +from parameterized import parameterized +from rest_framework import status + +from posthog.constants import AvailableFeature +from posthog.models.organization import OrganizationMembership +from posthog.models.user import User + +from products.error_tracking.backend.models import ErrorTrackingSeverityRule + +try: + from ee.models.rbac.access_control import AccessControl +except ImportError: + pass + +VALID_FILTERS = { + "type": "AND", + "values": [ + { + "type": "AND", + "values": [ + { + "key": "$exception_type", + "type": "event", + "value": ["TypeError"], + "operator": "exact", + } + ], + } + ], +} + + +class TestSeverityRuleAPI(APIBaseTest): + def _url(self, rule_id: str | None = None) -> str: + base = f"/api/environments/{self.team.id}/error_tracking/severity_rules/" + return f"{base}{rule_id}/" if rule_id else base + + def test_create_persists_compiled_ordered_rule(self) -> None: + response = self.client.post( + self._url(), + data={"filters": VALID_FILTERS, "severity": "critical", "order_key": 7}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED, response.json() + data = response.json() + assert data["filters"] == VALID_FILTERS + assert data["severity"] == "critical" + assert data["order_key"] == 7 + + rule = ErrorTrackingSeverityRule.objects.unscoped().get(id=data["id"]) + assert rule.team_id == self.team.id + assert rule.bytecode + + @parameterized.expand( + [ + ("unsupported_severity", {"filters": VALID_FILTERS, "severity": "urgent"}, "severity"), + ("invalid_filters", {"filters": {"not": "valid"}, "severity": "high"}, "filters"), + ("missing_filters", {"severity": "high"}, "filters"), + ("missing_severity", {"filters": VALID_FILTERS}, "severity"), + ] + ) + def test_create_rejects_invalid_payload(self, _name: str, payload: dict[str, Any], attr: str) -> None: + response = self.client.post(self._url(), data=payload, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["attr"] == attr + assert not ErrorTrackingSeverityRule.objects.unscoped().exists() + + def test_update_recompiles_and_clears_disabled_data(self) -> None: + rule = ErrorTrackingSeverityRule.objects.unscoped().create( + team=self.team, + filters={"type": "AND", "values": []}, + bytecode=[], + severity="low", + order_key=0, + disabled_data={"message": "invalid program"}, + ) + + response = self.client.patch( + self._url(str(rule.id)), + data={"filters": VALID_FILTERS, "severity": "high"}, + format="json", + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT, response.json() + rule.refresh_from_db() + assert rule.filters == VALID_FILTERS + assert rule.severity == "high" + assert rule.bytecode + assert rule.disabled_data is None + + def test_severity_only_update_preserves_disabled_data(self) -> None: + disabled_data = {"message": "invalid program"} + rule = ErrorTrackingSeverityRule.objects.unscoped().create( + team=self.team, + filters=VALID_FILTERS, + bytecode=[], + severity="low", + order_key=0, + disabled_data=disabled_data, + ) + + response = self.client.patch(self._url(str(rule.id)), data={"severity": "high"}, format="json") + + assert response.status_code == status.HTTP_204_NO_CONTENT, response.json() + rule.refresh_from_db() + assert rule.severity == "high" + assert rule.disabled_data == disabled_data + + def test_list_uses_rule_id_to_break_exact_order_ties(self) -> None: + high_id = "ffffffff-ffff-ffff-ffff-ffffffffffff" + low_id = "00000000-0000-0000-0000-000000000001" + for rule_id in (high_id, low_id): + ErrorTrackingSeverityRule.objects.unscoped().create( + id=rule_id, + team=self.team, + filters=VALID_FILTERS, + bytecode=[], + severity="low", + order_key=0, + ) + ErrorTrackingSeverityRule.objects.unscoped().filter(id__in=(high_id, low_id)).update( + created_at=datetime(2026, 1, 1, tzinfo=UTC) + ) + + response = self.client.get(self._url()) + + assert response.status_code == status.HTTP_200_OK + assert [rule["id"] for rule in response.json()["results"]] == [low_id, high_id] + + def test_list_and_reorder_are_project_scoped(self) -> None: + first = self.client.post( + self._url(), data={"filters": VALID_FILTERS, "severity": "low", "order_key": 0}, format="json" + ).json()["id"] + second = self.client.post( + self._url(), data={"filters": VALID_FILTERS, "severity": "high", "order_key": 1}, format="json" + ).json()["id"] + other_team = self.create_team_with_organization(organization=self.organization) + ErrorTrackingSeverityRule.objects.unscoped().create( + team=other_team, filters=VALID_FILTERS, bytecode=[], severity="critical", order_key=0 + ) + + reorder_response = self.client.patch( + f"{self._url()}reorder/", data={"orders": {first: 1, second: 0}}, format="json" + ) + list_response = self.client.get(self._url()) + + assert reorder_response.status_code == status.HTTP_204_NO_CONTENT + assert list_response.status_code == status.HTTP_200_OK + assert [rule["id"] for rule in list_response.json()["results"]] == [second, first] + + def test_cannot_access_or_delete_another_projects_rule(self) -> None: + other_team = self.create_team_with_organization(organization=self.organization) + other_rule = ErrorTrackingSeverityRule.objects.unscoped().create( + team=other_team, filters=VALID_FILTERS, bytecode=[], severity="medium", order_key=0 + ) + + assert self.client.get(self._url(str(other_rule.id))).status_code == status.HTTP_404_NOT_FOUND + assert self.client.delete(self._url(str(other_rule.id))).status_code == status.HTTP_404_NOT_FOUND + assert ErrorTrackingSeverityRule.objects.unscoped().filter(id=other_rule.id).exists() + + def test_delete_removes_rule(self) -> None: + response = self.client.post(self._url(), data={"filters": VALID_FILTERS, "severity": "medium"}, format="json") + rule_id = response.json()["id"] + + delete_response = self.client.delete(self._url(rule_id)) + + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + assert not ErrorTrackingSeverityRule.objects.unscoped().filter(id=rule_id).exists() + + @pytest.mark.ee + def test_project_wide_rules_require_resource_level_access(self) -> None: + self.organization.available_product_features = [ + {"key": AvailableFeature.ACCESS_CONTROL, "name": AvailableFeature.ACCESS_CONTROL} + ] + self.organization.save() + restricted_user = User.objects.create_and_join(self.organization, "restricted@posthog.com", "testtest") + membership = OrganizationMembership.objects.get(user=restricted_user, organization=self.organization) + AccessControl.objects.create( + team=self.team, + resource="error_tracking", + access_level="none", + ) + AccessControl.objects.create( + team=self.team, + resource="error_tracking", + resource_id="00000000-0000-0000-0000-000000000001", + access_level="editor", + organization_member=membership, + ) + rule = ErrorTrackingSeverityRule.objects.unscoped().create( + team=self.team, + filters=VALID_FILTERS, + bytecode=[], + severity="low", + order_key=0, + ) + self.client.force_login(restricted_user) + + response = self.client.patch(self._url(str(rule.id)), data={"severity": "high"}, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + rule.refresh_from_db() + assert rule.severity == "low" diff --git a/products/error_tracking/frontend/generated/api.schemas.ts b/products/error_tracking/frontend/generated/api.schemas.ts index 04243e5d7f92..2ef2b385cec3 100644 --- a/products/error_tracking/frontend/generated/api.schemas.ts +++ b/products/error_tracking/frontend/generated/api.schemas.ts @@ -1594,6 +1594,110 @@ export interface PatchedErrorTrackingSettingsApi { per_issue_rate_limit_bucket_size_minutes?: number | null } +/** + * * `low` - low + * * `medium` - medium + * * `high` - high + * * `critical` - critical + */ +export type ErrorTrackingIssueSeverityRuleEnumApi = + (typeof ErrorTrackingIssueSeverityRuleEnumApi)[keyof typeof ErrorTrackingIssueSeverityRuleEnumApi] + +export const ErrorTrackingIssueSeverityRuleEnumApi = { + Low: 'low', + Medium: 'medium', + High: 'high', + Critical: 'critical', +} as const + +/** + * Diagnostic details when ingestion automatically disables the rule, otherwise null. + * @nullable + */ +export type ErrorTrackingSeverityRuleApiDisabledData = { + message?: string + issue?: { [key: string]: unknown } + properties?: { [key: string]: unknown } +} | null + +export interface ErrorTrackingSeverityRuleApi { + /** Unique identifier of the severity rule. */ + readonly id: string + /** Property-group filters evaluated against the event that creates an issue. */ + filters: PropertyGroupFilterValueApi + /** Severity assigned to a newly created issue when this rule is the first match. + * + * * `low` - low + * * `medium` - medium + * * `high` - high + * * `critical` - critical */ + severity: ErrorTrackingIssueSeverityRuleEnumApi + /** Evaluation priority. Lower values run first, and the first matching rule wins. */ + order_key: number + /** + * Diagnostic details when ingestion automatically disables the rule, otherwise null. + * @nullable + */ + disabled_data: ErrorTrackingSeverityRuleApiDisabledData + /** When the rule was created. */ + readonly created_at: string + /** When the rule was last updated. */ + readonly updated_at: string +} + +export interface ErrorTrackingSeverityRuleListResponseApi { + /** Severity rules in ascending evaluation order. */ + results: ErrorTrackingSeverityRuleApi[] +} + +export interface ErrorTrackingSeverityRuleCreateRequestApi { + /** Property-group filters evaluated against the event that creates an issue. */ + filters: PropertyGroupFilterValueApi + /** Severity assigned when this rule is the first match. + * + * * `low` - low + * * `medium` - medium + * * `high` - high + * * `critical` - critical */ + severity: ErrorTrackingIssueSeverityRuleEnumApi + /** Evaluation priority. Lower values run first. Defaults to 0. */ + order_key?: number +} + +export interface ErrorTrackingSeverityRuleUpdateRequestApi { + /** Replacement property-group filters. Omit to preserve the existing filters. */ + filters?: PropertyGroupFilterValueApi + /** Replacement severity. Omit to preserve the existing severity. + * + * * `low` - low + * * `medium` - medium + * * `high` - high + * * `critical` - critical */ + severity?: ErrorTrackingIssueSeverityRuleEnumApi +} + +export interface PatchedErrorTrackingSeverityRuleUpdateRequestApi { + /** Replacement property-group filters. Omit to preserve the existing filters. */ + filters?: PropertyGroupFilterValueApi + /** Replacement severity. Omit to preserve the existing severity. + * + * * `low` - low + * * `medium` - medium + * * `high` - high + * * `critical` - critical */ + severity?: ErrorTrackingIssueSeverityRuleEnumApi +} + +/** + * Mapping from severity rule UUID to its new evaluation order. + */ +export type PatchedErrorTrackingSeverityRuleReorderRequestApiOrders = { [key: string]: number } + +export interface PatchedErrorTrackingSeverityRuleReorderRequestApi { + /** Mapping from severity rule UUID to its new evaluation order. */ + orders?: PatchedErrorTrackingSeverityRuleReorderRequestApiOrders +} + export interface ErrorTrackingSpikeDetectionConfigApi { /** * Time to wait before alerting again for the same issue after a spike is detected. diff --git a/products/error_tracking/frontend/generated/api.ts b/products/error_tracking/frontend/generated/api.ts index f6a5624c599e..a25f8eacf057 100644 --- a/products/error_tracking/frontend/generated/api.ts +++ b/products/error_tracking/frontend/generated/api.ts @@ -52,6 +52,10 @@ import type { ErrorTrackingReleaseUpdateRequestApi, ErrorTrackingReleasesListParams, ErrorTrackingSettingsApi, + ErrorTrackingSeverityRuleApi, + ErrorTrackingSeverityRuleCreateRequestApi, + ErrorTrackingSeverityRuleListResponseApi, + ErrorTrackingSeverityRuleUpdateRequestApi, ErrorTrackingSpikeDetectionConfigApi, ErrorTrackingSpikeEventsListParams, ErrorTrackingStackFrameApi, @@ -90,6 +94,8 @@ import type { PatchedErrorTrackingIssueWriteApi, PatchedErrorTrackingReleaseUpdateRequestApi, PatchedErrorTrackingSettingsApi, + PatchedErrorTrackingSeverityRuleReorderRequestApi, + PatchedErrorTrackingSeverityRuleUpdateRequestApi, PatchedErrorTrackingSpikeDetectionConfigApi, PatchedErrorTrackingSuppressionRuleApi, PatchedErrorTrackingSuppressionRuleUpdateRequestApi, @@ -1327,6 +1333,120 @@ export const errorTrackingSettingsUpdateSettingsPartialUpdate = async ( }) } +export const getErrorTrackingSeverityRulesListUrl = (projectId: string) => { + return `/api/projects/${projectId}/error_tracking/severity_rules/` +} + +export const errorTrackingSeverityRulesList = async ( + projectId: string, + options?: RequestInit +): Promise => { + return apiMutator(getErrorTrackingSeverityRulesListUrl(projectId), { + ...options, + method: 'GET', + }) +} + +export const getErrorTrackingSeverityRulesCreateUrl = (projectId: string) => { + return `/api/projects/${projectId}/error_tracking/severity_rules/` +} + +export const errorTrackingSeverityRulesCreate = async ( + projectId: string, + errorTrackingSeverityRuleCreateRequestApi: ErrorTrackingSeverityRuleCreateRequestApi, + options?: RequestInit +): Promise => { + return apiMutator(getErrorTrackingSeverityRulesCreateUrl(projectId), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(errorTrackingSeverityRuleCreateRequestApi), + }) +} + +export const getErrorTrackingSeverityRulesRetrieveUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/error_tracking/severity_rules/${id}/` +} + +export const errorTrackingSeverityRulesRetrieve = async ( + projectId: string, + id: string, + options?: RequestInit +): Promise => { + return apiMutator(getErrorTrackingSeverityRulesRetrieveUrl(projectId, id), { + ...options, + method: 'GET', + }) +} + +export const getErrorTrackingSeverityRulesUpdateUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/error_tracking/severity_rules/${id}/` +} + +export const errorTrackingSeverityRulesUpdate = async ( + projectId: string, + id: string, + errorTrackingSeverityRuleUpdateRequestApi?: ErrorTrackingSeverityRuleUpdateRequestApi, + options?: RequestInit +): Promise => { + return apiMutator(getErrorTrackingSeverityRulesUpdateUrl(projectId, id), { + ...options, + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(errorTrackingSeverityRuleUpdateRequestApi), + }) +} + +export const getErrorTrackingSeverityRulesPartialUpdateUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/error_tracking/severity_rules/${id}/` +} + +export const errorTrackingSeverityRulesPartialUpdate = async ( + projectId: string, + id: string, + patchedErrorTrackingSeverityRuleUpdateRequestApi?: PatchedErrorTrackingSeverityRuleUpdateRequestApi, + options?: RequestInit +): Promise => { + return apiMutator(getErrorTrackingSeverityRulesPartialUpdateUrl(projectId, id), { + ...options, + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(patchedErrorTrackingSeverityRuleUpdateRequestApi), + }) +} + +export const getErrorTrackingSeverityRulesDestroyUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/error_tracking/severity_rules/${id}/` +} + +export const errorTrackingSeverityRulesDestroy = async ( + projectId: string, + id: string, + options?: RequestInit +): Promise => { + return apiMutator(getErrorTrackingSeverityRulesDestroyUrl(projectId, id), { + ...options, + method: 'DELETE', + }) +} + +export const getErrorTrackingSeverityRulesReorderPartialUpdateUrl = (projectId: string) => { + return `/api/projects/${projectId}/error_tracking/severity_rules/reorder/` +} + +export const errorTrackingSeverityRulesReorderPartialUpdate = async ( + projectId: string, + patchedErrorTrackingSeverityRuleReorderRequestApi?: PatchedErrorTrackingSeverityRuleReorderRequestApi, + options?: RequestInit +): Promise => { + return apiMutator(getErrorTrackingSeverityRulesReorderPartialUpdateUrl(projectId), { + ...options, + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(patchedErrorTrackingSeverityRuleReorderRequestApi), + }) +} + export const getErrorTrackingSpikeDetectionConfigListUrl = (projectId: string) => { return `/api/projects/${projectId}/error_tracking/spike_detection_config/` } diff --git a/products/error_tracking/frontend/generated/api.zod.ts b/products/error_tracking/frontend/generated/api.zod.ts index d30e8dddcc73..c4c68e219c26 100644 --- a/products/error_tracking/frontend/generated/api.zod.ts +++ b/products/error_tracking/frontend/generated/api.zod.ts @@ -970,6 +970,62 @@ export const ErrorTrackingSettingsUpdateSettingsPartialUpdateBody = /* @__PURE__ .describe('Bucket window over which the per-issue rate limit applies, in minutes.'), }) +export const errorTrackingSeverityRulesCreateBodyOrderKeyDefault = 0 + +export const ErrorTrackingSeverityRulesCreateBody = /* @__PURE__ */ zod.object({ + filters: zod + .record(zod.string(), zod.unknown()) + .describe('Deep\/recursive schema (opaque in Zod — use TypeScript types for full shape)') + .describe('Property-group filters evaluated against the event that creates an issue.'), + severity: zod + .enum(['low', 'medium', 'high', 'critical']) + .describe('\* `low` - low\n\* `medium` - medium\n\* `high` - high\n\* `critical` - critical') + .describe( + 'Severity assigned when this rule is the first match.\n\n\* `low` - low\n\* `medium` - medium\n\* `high` - high\n\* `critical` - critical' + ), + order_key: zod + .number() + .default(errorTrackingSeverityRulesCreateBodyOrderKeyDefault) + .describe('Evaluation priority. Lower values run first. Defaults to 0.'), +}) + +export const ErrorTrackingSeverityRulesUpdateBody = /* @__PURE__ */ zod.object({ + filters: zod + .record(zod.string(), zod.unknown()) + .describe('Deep\/recursive schema (opaque in Zod — use TypeScript types for full shape)') + .optional() + .describe('Replacement property-group filters. Omit to preserve the existing filters.'), + severity: zod + .enum(['low', 'medium', 'high', 'critical']) + .describe('\* `low` - low\n\* `medium` - medium\n\* `high` - high\n\* `critical` - critical') + .optional() + .describe( + 'Replacement severity. Omit to preserve the existing severity.\n\n\* `low` - low\n\* `medium` - medium\n\* `high` - high\n\* `critical` - critical' + ), +}) + +export const ErrorTrackingSeverityRulesPartialUpdateBody = /* @__PURE__ */ zod.object({ + filters: zod + .record(zod.string(), zod.unknown()) + .describe('Deep\/recursive schema (opaque in Zod — use TypeScript types for full shape)') + .optional() + .describe('Replacement property-group filters. Omit to preserve the existing filters.'), + severity: zod + .enum(['low', 'medium', 'high', 'critical']) + .describe('\* `low` - low\n\* `medium` - medium\n\* `high` - high\n\* `critical` - critical') + .optional() + .describe( + 'Replacement severity. Omit to preserve the existing severity.\n\n\* `low` - low\n\* `medium` - medium\n\* `high` - high\n\* `critical` - critical' + ), +}) + +export const ErrorTrackingSeverityRulesReorderPartialUpdateBody = /* @__PURE__ */ zod.object({ + orders: zod + .record(zod.string(), zod.number()) + .optional() + .describe('Mapping from severity rule UUID to its new evaluation order.'), +}) + export const ErrorTrackingSpikeDetectionConfigUpdateConfigPartialUpdateBody = /* @__PURE__ */ zod.object({ snooze_duration_minutes: zod .number() diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 672a25e695d6..27c4083daebc 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -30277,6 +30277,22 @@ export namespace Schemas { cohort: ErrorTrackingIssueCohortRead | null; } + /** + * * `low` - low + * * `medium` - medium + * * `high` - high + * * `critical` - critical + */ + export type ErrorTrackingIssueSeverityRuleEnum = typeof ErrorTrackingIssueSeverityRuleEnum[keyof typeof ErrorTrackingIssueSeverityRuleEnum]; + + + export const ErrorTrackingIssueSeverityRuleEnum = { + Low: 'low', + Medium: 'medium', + High: 'high', + Critical: 'critical', + } as const; + export interface ErrorTrackingIssueSplitFingerprint { /** Fingerprint to split into a new issue. */ fingerprint: string; @@ -30625,6 +30641,72 @@ export namespace Schemas { per_issue_rate_limit_bucket_size_minutes?: number | null; } + /** + * Diagnostic details when ingestion automatically disables the rule, otherwise null. + * @nullable + */ + export type ErrorTrackingSeverityRuleDisabledData = { + message?: string; + issue?: { [key: string]: unknown }; + properties?: { [key: string]: unknown }; + } | null; + + export interface ErrorTrackingSeverityRule { + /** Unique identifier of the severity rule. */ + readonly id: string; + /** Property-group filters evaluated against the event that creates an issue. */ + filters: PropertyGroupFilterValue; + /** Severity assigned to a newly created issue when this rule is the first match. + * + * * `low` - low + * * `medium` - medium + * * `high` - high + * * `critical` - critical */ + severity: ErrorTrackingIssueSeverityRuleEnum; + /** Evaluation priority. Lower values run first, and the first matching rule wins. */ + order_key: number; + /** + * Diagnostic details when ingestion automatically disables the rule, otherwise null. + * @nullable + */ + disabled_data: ErrorTrackingSeverityRuleDisabledData; + /** When the rule was created. */ + readonly created_at: string; + /** When the rule was last updated. */ + readonly updated_at: string; + } + + export interface ErrorTrackingSeverityRuleCreateRequest { + /** Property-group filters evaluated against the event that creates an issue. */ + filters: PropertyGroupFilterValue; + /** Severity assigned when this rule is the first match. + * + * * `low` - low + * * `medium` - medium + * * `high` - high + * * `critical` - critical */ + severity: ErrorTrackingIssueSeverityRuleEnum; + /** Evaluation priority. Lower values run first. Defaults to 0. */ + order_key?: number; + } + + export interface ErrorTrackingSeverityRuleListResponse { + /** Severity rules in ascending evaluation order. */ + results: ErrorTrackingSeverityRule[]; + } + + export interface ErrorTrackingSeverityRuleUpdateRequest { + /** Replacement property-group filters. Omit to preserve the existing filters. */ + filters?: PropertyGroupFilterValue; + /** Replacement severity. Omit to preserve the existing severity. + * + * * `low` - low + * * `medium` - medium + * * `high` - high + * * `critical` - critical */ + severity?: ErrorTrackingIssueSeverityRuleEnum; + } + export interface ErrorTrackingSignalExtra { fingerprint: string; } @@ -58071,6 +58153,28 @@ export namespace Schemas { per_issue_rate_limit_bucket_size_minutes?: number | null; } + /** + * Mapping from severity rule UUID to its new evaluation order. + */ + export type PatchedErrorTrackingSeverityRuleReorderRequestOrders = {[key: string]: number}; + + export interface PatchedErrorTrackingSeverityRuleReorderRequest { + /** Mapping from severity rule UUID to its new evaluation order. */ + orders?: PatchedErrorTrackingSeverityRuleReorderRequestOrders; + } + + export interface PatchedErrorTrackingSeverityRuleUpdateRequest { + /** Replacement property-group filters. Omit to preserve the existing filters. */ + filters?: PropertyGroupFilterValue; + /** Replacement severity. Omit to preserve the existing severity. + * + * * `low` - low + * * `medium` - medium + * * `high` - high + * * `critical` - critical */ + severity?: ErrorTrackingIssueSeverityRuleEnum; + } + export interface PatchedErrorTrackingSpikeDetectionConfig { /** * Time to wait before alerting again for the same issue after a spike is detected. From 8b099dd71a5f60b063d21e4e4750888858b38781 Mon Sep 17 00:00:00 2001 From: hpouillot Date: Thu, 20 Aug 2026 11:53:19 +0200 Subject: [PATCH 2/2] feat(cymbal): evaluate severity rules --- rust/cymbal/src/core/metric_consts.rs | 5 + rust/cymbal/src/modes/processing/config.rs | 6 + rust/cymbal/src/modes/processing/rules/mod.rs | 1 + .../src/modes/processing/rules/severity.rs | 235 ++++++++++++++++++ rust/cymbal/src/modes/processing/teams.rs | 33 +++ rust/cymbal/tests/severity_rules.rs | 222 +++++++++++++++++ .../20260813000000_add_severity_rules.sql | 12 + 7 files changed, 514 insertions(+) create mode 100644 rust/cymbal/src/modes/processing/rules/severity.rs create mode 100644 rust/cymbal/tests/severity_rules.rs create mode 100644 rust/cymbal/tests/test_migrations/20260813000000_add_severity_rules.sql diff --git a/rust/cymbal/src/core/metric_consts.rs b/rust/cymbal/src/core/metric_consts.rs index d238fdf1c956..d70a7a2c7142 100644 --- a/rust/cymbal/src/core/metric_consts.rs +++ b/rust/cymbal/src/core/metric_consts.rs @@ -90,6 +90,11 @@ pub const ASSIGNMENT_RULES_FOUND: &str = "cymbal_assignment_rules_found"; pub const ASSIGNMENT_RULES_TRIED: &str = "cymbal_assignment_rules_tried"; pub const AUTO_ASSIGNMENTS: &str = "cymbal_auto_assignments"; pub const ASSIGNMENT_RULES_DISABLED: &str = "cymbal_assignment_rules_disabled"; +pub const SEVERITY_RULES_PROCESSING_TIME: &str = "cymbal_severity_rules_processing_time"; +pub const SEVERITY_RULES_FOUND: &str = "cymbal_severity_rules_found"; +pub const SEVERITY_RULES_TRIED: &str = "cymbal_severity_rules_tried"; +pub const SEVERITY_RULES_MATCHED: &str = "cymbal_severity_rules_matched"; +pub const SEVERITY_RULES_DISABLED: &str = "cymbal_severity_rules_disabled"; pub const GROUPING_RULES_DISABLED: &str = "cymbal_grouping_rules_disabled"; pub const GROUPING_RULES_PROCESSING_TIME: &str = "cymbal_grouping_rules_processing_time"; pub const GROUPING_RULES_FOUND: &str = "cymbal_grouping_rules_found"; diff --git a/rust/cymbal/src/modes/processing/config.rs b/rust/cymbal/src/modes/processing/config.rs index 2332cc4c21fe..c7f329d2cf97 100644 --- a/rust/cymbal/src/modes/processing/config.rs +++ b/rust/cymbal/src/modes/processing/config.rs @@ -118,6 +118,12 @@ pub struct ProcessingConfig { // The maximum number of bytecode operations we'll store in the cache, across all rules, across all teams pub max_assignment_rule_cache_size: u64, + #[envconfig(default = "300")] + pub severity_rule_cache_ttl_secs: u64, + + #[envconfig(default = "100000")] + pub max_severity_rule_cache_size: u64, + #[envconfig(default = "300")] pub grouping_rule_cache_ttl_secs: u64, diff --git a/rust/cymbal/src/modes/processing/rules/mod.rs b/rust/cymbal/src/modes/processing/rules/mod.rs index 39eac14bb080..c1eec5db3fbf 100644 --- a/rust/cymbal/src/modes/processing/rules/mod.rs +++ b/rust/cymbal/src/modes/processing/rules/mod.rs @@ -6,5 +6,6 @@ pub mod assignment; pub mod bypass; pub mod grouping; pub mod rate_limit; +pub mod severity; pub mod spike; pub mod suppression; diff --git a/rust/cymbal/src/modes/processing/rules/severity.rs b/rust/cymbal/src/modes/processing/rules/severity.rs new file mode 100644 index 000000000000..5db076835669 --- /dev/null +++ b/rust/cymbal/src/modes/processing/rules/severity.rs @@ -0,0 +1,235 @@ +use std::fmt::Display; + +use chrono::{DateTime, Utc}; +use common_types::TeamId; +use hogvm::{ExecutionContext, Program, StepOutcome, VmError}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::{FromRow, PgConnection}; +use uuid::Uuid; + +use crate::{ + error::UnhandledError, + issue_resolution::Issue, + metric_consts::{ + SEVERITY_RULES_DISABLED, SEVERITY_RULES_FOUND, SEVERITY_RULES_MATCHED, + SEVERITY_RULES_PROCESSING_TIME, SEVERITY_RULES_TRIED, + }, + teams::TeamManager, + types::ProcessedExceptionProperties, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuleSeverity { + Low, + Medium, + High, + Critical, +} + +impl Display for RuleSeverity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RuleSeverity::Low => write!(f, "low"), + RuleSeverity::Medium => write!(f, "medium"), + RuleSeverity::High => write!(f, "high"), + RuleSeverity::Critical => write!(f, "critical"), + } + } +} + +#[derive(Debug, Clone, FromRow)] +pub struct SeverityRule { + pub id: Uuid, + pub team_id: TeamId, + pub severity: String, + pub order_key: i32, + pub bytecode: Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl SeverityRule { + pub async fn load_for_team<'c, E>( + executor: E, + team_id: TeamId, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'c, Database = sqlx::Postgres>, + { + sqlx::query_as::<_, SeverityRule>( + r#" + SELECT id, team_id, severity, order_key, bytecode, created_at, updated_at + FROM posthog_errortrackingseverityrule + WHERE team_id = $1 AND disabled_data IS NULL + ORDER BY order_key, created_at, id + "#, + ) + .bind(team_id) + .fetch_all(executor) + .await + } + + pub async fn disable<'c, E>( + &self, + executor: E, + message: String, + issue: Value, + properties: Value, + ) -> Result<(), sqlx::Error> + where + E: sqlx::Executor<'c, Database = sqlx::Postgres>, + { + #[derive(Serialize)] + struct DisabledData { + message: String, + issue: Value, + properties: Value, + } + + let disabled_data = DisabledData { + message, + issue, + properties, + }; + sqlx::query( + r#" + UPDATE posthog_errortrackingseverityrule + SET disabled_data = $1, updated_at = NOW() + WHERE id = $2 + "#, + ) + .bind(serde_json::to_value(disabled_data).expect("Can serialize")) + .bind(self.id) + .execute(executor) + .await?; + + metrics::counter!(SEVERITY_RULES_DISABLED).increment(1); + Ok(()) + } + + fn parsed_severity(&self) -> Result { + match self.severity.as_str() { + "low" => Ok(RuleSeverity::Low), + "medium" => Ok(RuleSeverity::Medium), + "high" => Ok(RuleSeverity::High), + "critical" => Ok(RuleSeverity::Critical), + severity => Err(VmError::Other(format!( + "Invalid severity rule value {severity:?}" + ))), + } + } + + pub fn try_match( + &self, + issue: &Value, + properties: &Value, + ) -> Result, VmError> { + let rule_bytecode = match &self.bytecode { + Value::Array(ops) => ops, + _ => { + return Err(VmError::Other(format!( + "Invalid rule bytecode - expected array, got {:?}", + self.bytecode + ))) + } + }; + + let globals = Value::Object(serde_json::Map::from_iter([ + ("issue".to_string(), issue.clone()), + ("properties".to_string(), properties.clone()), + ])); + let program = Program::new(rule_bytecode.clone())?; + let context = ExecutionContext::with_defaults(program).with_globals(globals); + let mut vm = context.to_vm()?; + + metrics::counter!(SEVERITY_RULES_TRIED).increment(1); + + let mut steps = 0; + while steps < context.max_steps { + match vm.step()? { + StepOutcome::Finished(Value::Bool(true)) => { + return self.parsed_severity().map(Some) + } + StepOutcome::Finished(Value::Bool(false)) => return Ok(None), + StepOutcome::Finished(result) => { + return Err(VmError::Other(format!( + "Severity rule returned {result:?}, expected a boolean value" + ))) + } + StepOutcome::NativeCall(name, args) => { + context.execute_native_function_call(&mut vm, &name, args)? + } + StepOutcome::Continue => {} + } + steps += 1; + } + + Err(VmError::OutOfResource("steps".to_string())) + } +} + +pub async fn try_severity_rules( + connection: &mut PgConnection, + team_manager: &TeamManager, + issue: &Issue, + exception_properties: &ProcessedExceptionProperties, +) -> Result, UnhandledError> { + let timing = common_metrics::timing_guard(SEVERITY_RULES_PROCESSING_TIME, &[]); + let mut rules = team_manager + .get_severity_rules(&mut *connection, issue.team_id) + .await?; + + metrics::counter!(SEVERITY_RULES_FOUND).increment(rules.len() as u64); + if rules.is_empty() { + timing.label("outcome", "no_match").fin(); + return Ok(None); + } + + rules.sort_unstable_by_key(|rule| (rule.order_key, rule.created_at, rule.id)); + + #[derive(Serialize, Deserialize)] + struct IssueJson { + status: String, + name: Option, + description: Option, + } + + let issue_json = serde_json::to_value(IssueJson { + status: issue.status.to_string(), + name: issue.name.clone(), + description: issue.description.clone(), + })?; + let properties_json = serde_json::to_value(exception_properties)?; + + for rule in rules { + match rule.try_match(&issue_json, &properties_json) { + Ok(None) => continue, + Ok(Some(severity)) => { + metrics::counter!(SEVERITY_RULES_MATCHED).increment(1); + timing.label("outcome", "match").fin(); + return Ok(Some(severity)); + } + Err(VmError::OutOfResource(resource)) if resource == "steps" => { + tracing::warn!( + rule_id = %rule.id, + team_id = %rule.team_id, + "severity rule exceeded HogVM step budget for this event, skipping" + ); + } + Err(error) => { + rule.disable( + &mut *connection, + error.to_string(), + issue_json.clone(), + properties_json.clone(), + ) + .await?; + team_manager.severity_rules.invalidate(&issue.team_id); + } + } + } + + timing.label("outcome", "no_match").fin(); + Ok(None) +} diff --git a/rust/cymbal/src/modes/processing/teams.rs b/rust/cymbal/src/modes/processing/teams.rs index ca04986f0271..33d91a0c5e3b 100644 --- a/rust/cymbal/src/modes/processing/teams.rs +++ b/rust/cymbal/src/modes/processing/teams.rs @@ -10,6 +10,7 @@ use crate::{ modes::processing::rules::assignment::AssignmentRule, modes::processing::rules::bypass::BypassRule, modes::processing::rules::grouping::GroupingRule, modes::processing::rules::rate_limit::RateLimitSettings, + modes::processing::rules::severity::SeverityRule, modes::processing::rules::spike::SpikeDetectionConfig, modes::processing::rules::suppression::SuppressionRule, }; @@ -18,6 +19,7 @@ use crate::{ pub struct TeamManager { pub token_cache: Cache>, pub assignment_rules: Cache>, + pub severity_rules: Cache>, pub grouping_rules: Cache>, pub suppression_rules: Cache>, pub bypass_rules: Cache>, @@ -45,6 +47,16 @@ impl TeamManager { }) .build(); + let severity_rules = CacheBuilder::new(config.max_severity_rule_cache_size) + .time_to_live(Duration::from_secs(config.severity_rule_cache_ttl_secs)) + .weigher(|_, rules: &Vec| { + rules + .iter() + .map(|rule| rule.bytecode.as_array().map_or(0, Vec::len) as u32) + .sum() + }) + .build(); + let grouping_rules = CacheBuilder::new(config.max_grouping_rule_cache_size) .time_to_live(Duration::from_secs(config.grouping_rule_cache_ttl_secs)) .weigher(|_, v: &Vec| { @@ -83,6 +95,7 @@ impl TeamManager { Self { token_cache: cache, assignment_rules, + severity_rules, grouping_rules, suppression_rules, bypass_rules, @@ -159,6 +172,26 @@ impl TeamManager { Ok(rules) } + pub async fn get_severity_rules<'c, E>( + &self, + executor: E, + team_id: TeamId, + ) -> Result, UnhandledError> + where + E: sqlx::Executor<'c, Database = sqlx::Postgres>, + { + if let Some(rules) = self.severity_rules.get(&team_id) { + metrics::counter!(ANCILLARY_CACHE, "type" => "severity_rules", "outcome" => "hit") + .increment(1); + return Ok(rules.clone()); + } + metrics::counter!(ANCILLARY_CACHE, "type" => "severity_rules", "outcome" => "miss") + .increment(1); + let rules = SeverityRule::load_for_team(executor, team_id).await?; + self.severity_rules.insert(team_id, rules.clone()); + Ok(rules) + } + pub async fn get_suppression_rules<'c, E>( &self, e: E, diff --git a/rust/cymbal/tests/severity_rules.rs b/rust/cymbal/tests/severity_rules.rs new file mode 100644 index 000000000000..8725e88a3c01 --- /dev/null +++ b/rust/cymbal/tests/severity_rules.rs @@ -0,0 +1,222 @@ +use chrono::Utc; +use cymbal::{ + issue_resolution::{Issue, IssueStatus}, + modes::processing::{ + rules::severity::{try_severity_rules, SeverityRule}, + ProcessingConfig, + }, + teams::TeamManager, + types::ProcessedExceptionProperties, +}; +use serde_json::{json, Value}; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +fn bytecode(result: bool) -> Value { + json!(["_H", 1, if result { 29 } else { 30 }, 38]) +} + +fn rule(team_id: i32, severity: &str, order_key: i32, bytecode: Value) -> SeverityRule { + SeverityRule { + id: Uuid::now_v7(), + team_id, + severity: severity.to_string(), + order_key, + bytecode, + created_at: Utc::now(), + updated_at: Utc::now(), + } +} + +fn issue(team_id: i32) -> Issue { + Issue { + id: Uuid::now_v7(), + team_id, + status: IssueStatus::Active, + severity: Some("medium".to_string()), + name: Some("TypeError".to_string()), + description: Some("Example".to_string()), + created_at: Utc::now(), + } +} + +fn properties() -> ProcessedExceptionProperties { + serde_json::from_value(json!({ + "$exception_list": [{"type": "TypeError", "value": "Example"}], + "$exception_fingerprint": "fingerprint", + "$exception_fingerprint_record": [{"type": "manual"}], + "$exception_issue_id": Uuid::nil(), + "$exception_handled": false, + "$exception_types": ["TypeError"], + "$exception_values": ["Example"], + "$exception_sources": [], + "$exception_functions": [] + })) + .unwrap() +} + +async fn insert_rule(db: &PgPool, rule: &SeverityRule, disabled: bool) { + sqlx::query( + r#" + INSERT INTO posthog_errortrackingseverityrule + (id, team_id, filters, bytecode, severity, order_key, disabled_data, created_at, updated_at) + VALUES ($1, $2, '{}'::jsonb, $3, $4, $5, $6, $7, $8) + "#, + ) + .bind(rule.id) + .bind(rule.team_id) + .bind(&rule.bytecode) + .bind(&rule.severity) + .bind(rule.order_key) + .bind(disabled.then(|| json!({"message": "disabled"}))) + .bind(rule.created_at) + .bind(rule.updated_at) + .execute(db) + .await + .unwrap(); +} + +#[test] +fn supports_every_issue_severity() { + let issue = json!({"status": "active", "name": "TypeError", "description": "Example"}); + let properties = serde_json::to_value(properties()).unwrap(); + + for severity in ["low", "medium", "high", "critical"] { + let matched = rule(1, severity, 0, bytecode(true)) + .try_match(&issue, &properties) + .unwrap() + .unwrap(); + assert_eq!(matched.to_string(), severity); + } +} + +#[sqlx::test(migrations = "./tests/test_migrations")] +async fn loads_only_enabled_rules_for_the_requested_team(db: PgPool) { + let enabled = rule(1, "high", 0, bytecode(true)); + let disabled = rule(1, "critical", 1, bytecode(true)); + let other_team = rule(2, "low", 0, bytecode(true)); + insert_rule(&db, &enabled, false).await; + insert_rule(&db, &disabled, true).await; + insert_rule(&db, &other_team, false).await; + + let loaded = SeverityRule::load_for_team(&db, 1).await.unwrap(); + + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].id, enabled.id); +} + +#[sqlx::test(migrations = "./tests/test_migrations")] +async fn first_matching_rule_wins_and_no_match_preserves_fallback(db: PgPool) { + let config = ProcessingConfig::init_with_defaults().unwrap(); + let manager = TeamManager::new(&config); + let team_id = 1; + manager.severity_rules.insert( + team_id, + vec![ + rule(team_id, "critical", 0, bytecode(false)), + rule(team_id, "medium", 2, bytecode(true)), + rule(team_id, "high", 1, bytecode(true)), + ], + ); + let mut conn = db.acquire().await.unwrap(); + + let matched = try_severity_rules(&mut conn, &manager, &issue(team_id), &properties()) + .await + .unwrap(); + assert_eq!(matched.unwrap().to_string(), "high"); + + manager + .severity_rules + .insert(team_id, vec![rule(team_id, "critical", 0, bytecode(false))]); + let unmatched = try_severity_rules(&mut conn, &manager, &issue(team_id), &properties()) + .await + .unwrap(); + assert!(unmatched.is_none()); +} + +#[sqlx::test(migrations = "./tests/test_migrations")] +async fn exact_order_ties_use_rule_id(db: PgPool) { + let config = ProcessingConfig::init_with_defaults().unwrap(); + let manager = TeamManager::new(&config); + let team_id = 1; + let created_at = Utc::now(); + let mut high_id = rule(team_id, "critical", 0, bytecode(true)); + high_id.id = Uuid::from_u128(u128::MAX); + high_id.created_at = created_at; + let mut low_id = rule(team_id, "low", 0, bytecode(true)); + low_id.id = Uuid::from_u128(1); + low_id.created_at = created_at; + manager + .severity_rules + .insert(team_id, vec![high_id, low_id]); + let mut conn = db.acquire().await.unwrap(); + + let matched = try_severity_rules(&mut conn, &manager, &issue(team_id), &properties()) + .await + .unwrap(); + + assert_eq!(matched.unwrap().to_string(), "low"); +} + +#[sqlx::test(migrations = "./tests/test_migrations")] +async fn invalid_rule_is_disabled_and_later_rule_still_matches(db: PgPool) { + let config = ProcessingConfig::init_with_defaults().unwrap(); + let manager = TeamManager::new(&config); + let team_id = 1; + let invalid = rule(team_id, "low", 0, json!("invalid")); + insert_rule(&db, &invalid, false).await; + manager.severity_rules.insert( + team_id, + vec![ + invalid.clone(), + rule(team_id, "critical", 1, bytecode(true)), + ], + ); + let mut conn = db.acquire().await.unwrap(); + + let matched = try_severity_rules(&mut conn, &manager, &issue(team_id), &properties()) + .await + .unwrap(); + + assert_eq!(matched.unwrap().to_string(), "critical"); + let disabled_data: Option = + sqlx::query("SELECT disabled_data FROM posthog_errortrackingseverityrule WHERE id = $1") + .bind(invalid.id) + .fetch_one(&db) + .await + .unwrap() + .get("disabled_data"); + assert!(disabled_data.is_some()); + assert!(manager.severity_rules.get(&team_id).is_none()); +} + +#[sqlx::test(migrations = "./tests/test_migrations")] +async fn step_exhaustion_skips_without_disabling_rule(db: PgPool) { + let config = ProcessingConfig::init_with_defaults().unwrap(); + let manager = TeamManager::new(&config); + let team_id = 1; + let looping = rule(team_id, "low", 0, json!(["_H", 1, 39, -2])); + insert_rule(&db, &looping, false).await; + manager.severity_rules.insert( + team_id, + vec![ + looping.clone(), + rule(team_id, "critical", 1, bytecode(true)), + ], + ); + let mut conn = db.acquire().await.unwrap(); + + let matched = try_severity_rules(&mut conn, &manager, &issue(team_id), &properties()) + .await + .unwrap(); + + assert_eq!(matched.unwrap().to_string(), "critical"); + let disabled_data: Option = + sqlx::query("SELECT disabled_data FROM posthog_errortrackingseverityrule WHERE id = $1") + .bind(looping.id) + .fetch_one(&db) + .await + .unwrap() + .get("disabled_data"); + assert!(disabled_data.is_none()); +} diff --git a/rust/cymbal/tests/test_migrations/20260813000000_add_severity_rules.sql b/rust/cymbal/tests/test_migrations/20260813000000_add_severity_rules.sql new file mode 100644 index 000000000000..7955963cfd39 --- /dev/null +++ b/rust/cymbal/tests/test_migrations/20260813000000_add_severity_rules.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS posthog_errortrackingseverityrule +( + id uuid NOT NULL PRIMARY KEY, + team_id integer NOT NULL, + filters jsonb NOT NULL, + bytecode jsonb NOT NULL, + severity text NOT NULL, + order_key integer NOT NULL, + disabled_data jsonb, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +);