Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b511483
ref: Extract PayloadComparator from _ParityChecker with format_value …
Christinarlong May 21, 2026
05cb1af
ref: Extract ParityChecker to sentry/utils with format_value param
Christinarlong May 21, 2026
89bdf8d
fix: Recurse into nested dicts/lists when known_diffs is empty
Christinarlong May 21, 2026
eea224e
feat(webhooks): Add payload validation during dual-write migration
Christinarlong May 21, 2026
9b2c3ee
fix: Use renamed ParityChecker in validation module
Christinarlong May 21, 2026
53588db
fix: Assert exact mismatch strings in validation tests
Christinarlong May 21, 2026
b8cb5c1
fix: Use _get_triggering_rule_name instead of create_rule_instance_fr…
Christinarlong May 21, 2026
4d2e64f
ref: Use dict[str, Any] instead of Mapping, move imports to top of file
Christinarlong May 21, 2026
581d107
fix: Use Mapping for old_payload, LegacyWebhookPayload for new_payload
Christinarlong May 21, 2026
5f35168
add option to toggle payload parity comparison and fix typing
Christinarlong May 21, 2026
911dbb7
Merge branch 'master' into crl/webhook-payload-validation-usage
Christinarlong May 26, 2026
4ff7af3
ref(webhooks): Use rollout rate for payload validation, remove dry ru…
Christinarlong May 26, 2026
a90e775
Merge branch 'master' into crl/webhook-payload-validation-usage
Christinarlong May 26, 2026
c34300d
fix: Rename _get_triggering_rule_name to get_triggering_rule_name in …
Christinarlong May 26, 2026
b6c8f0c
fix(webhooks): Only run payload validation for legacy webhooks, not s…
Christinarlong May 26, 2026
b7a4ef5
ref(webhooks): Address PR feedback on validation logging and error ha…
Christinarlong May 26, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@

from sentry import features
from sentry.notifications.notification_action.utils import execute_via_group_type_registry
from sentry.options.rollout import in_random_rollout
from sentry.plugins.sentry_webhooks.plugin import WebHooksPlugin
from sentry.sentry_apps.services.legacy_webhook.service import (
build_legacy_webhook_payload,
get_triggering_rule_name,
send_legacy_webhooks_for_invocation,
send_sentry_app_webhook,
)
from sentry.sentry_apps.services.legacy_webhook.validation import validate_payload_equivalence
from sentry.services.eventstore.models import GroupEvent
from sentry.workflow_engine.models import Action
from sentry.workflow_engine.registry import action_handler_registry
Expand All @@ -16,6 +20,22 @@
logger = logging.getLogger(__name__)


def _validate_webhook_payloads(invocation: ActionInvocation) -> None:
group = invocation.event_data.group
event = invocation.event_data.event
rule_name = get_triggering_rule_name(invocation)

old_payload = WebHooksPlugin().get_group_data(group, event, [rule_name])
new_payload = build_legacy_webhook_payload(invocation)
Comment on lines +28 to +29
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How expensive are these? Are these doing any queries that could add additional latency into our delivery process?

This may be another place we want to have an option to enable/disable these checks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think these are expensive at all since we're just extracting fields for old and new payload. The more expensive thing would be the amount of recurring we're doing on the event payload. Yeah option makes sense.

Also this validation would happen after the old and new path webhooks have been sent out so it wouldn't affect webhook delivery

Comment thread
cursor[bot] marked this conversation as resolved.

validate_payload_equivalence(
old_payload,
new_payload,
organization_id=invocation.detector.project.organization_id,
project_id=invocation.detector.project.id,
)


@action_handler_registry.register(Action.Type.WEBHOOK)
class WebhookActionHandler(ActionHandler):
group = ActionHandler.Group.OTHER
Expand Down Expand Up @@ -63,6 +83,12 @@ def execute(invocation: ActionInvocation) -> None:
target_identifier = invocation.action.config.get("target_identifier")
if target_identifier == "webhooks":
send_legacy_webhooks_for_invocation(invocation)

if in_random_rollout("sentry-apps.legacy-webhook-payload-validation.rate"):
Comment thread
Christinarlong marked this conversation as resolved.
try:
_validate_webhook_payloads(invocation)
except Exception:
logger.exception("webhook_action_handler.validation_error")
else:
send_sentry_app_webhook(
group_event=invocation.event_data.event,
Expand Down
8 changes: 8 additions & 0 deletions src/sentry/options/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -4100,6 +4100,14 @@
flags=FLAG_AUTOMATOR_MODIFIABLE,
)

# Rollout rate for legacy webhook payload validation (0.0 to 1.0)
register(
"sentry-apps.legacy-webhook-payload-validation.rate",
type=Float,
default=0.0,
flags=FLAG_AUTOMATOR_MODIFIABLE,
)

# Killswitch for web vital issue detection
register(
"issue-detection.web-vitals-detection.enabled",
Expand Down
7 changes: 0 additions & 7 deletions src/sentry/sentry_apps/services/legacy_webhook/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,6 @@ def send_legacy_webhook_task(url: str, payload: LegacyWebhookPayload, **kwargs:
organization = group.project.organization

if features.has("organizations:legacy-webhook-dry-run", organization):
logger.info(
"legacy_webhook.dry_run",
extra={
"url": url,
"payload": payload,
},
)
metrics.incr(
"legacy_webhook.task.result",
tags={"outcome": LegacyWebhookOutcome.DRY_RUN},
Expand Down
40 changes: 40 additions & 0 deletions src/sentry/sentry_apps/services/legacy_webhook/validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from __future__ import annotations

import logging
from collections.abc import Mapping
from typing import Any

from sentry.utils.payload_comparison import ParityChecker, describe_value

logger = logging.getLogger("sentry.legacy_webhook")


def compare_payloads(old_payload: Mapping[str, Any], new_payload: Mapping[str, Any]) -> list[str]:
comparator = ParityChecker(format_value=describe_value)
comparator.compare(old_payload, new_payload, frozenset())
return comparator.mismatches


def validate_payload_equivalence(
old_payload: Mapping[str, Any],
new_payload: Mapping[str, Any],
organization_id: int,
project_id: int,
) -> None:
logging_context = {"organization_id": organization_id, "project_id": project_id}

if old_payload == new_payload:
logger.info("legacy_webhook.validation.match", extra=logging_context)
return

try:
mismatches = compare_payloads(old_payload, new_payload)
Comment thread
Christinarlong marked this conversation as resolved.
except Exception:
logger.exception("legacy_webhook.validation.comparison_error", extra=logging_context)
return

if mismatches:
logger.warning(
"legacy_webhook.validation.payload_mismatch",
extra={**logging_context, "mismatches": mismatches},
)
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,7 @@ def test_new_path_disable_old_fires_new_only(self) -> None:
assert body["id"] == str(self.group.id)

@responses.activate
@mock.patch("sentry.sentry_apps.services.legacy_webhook.tasks.logger")
def test_new_path_dry_run_logs_instead_of_sending(self, mock_logger: mock.MagicMock) -> None:
def test_new_path_dry_run_does_not_send(self) -> None:
responses.add(responses.POST, "http://example.com/hook")

with (
Expand All @@ -95,8 +94,6 @@ def test_new_path_dry_run_logs_instead_of_sending(self, mock_logger: mock.MagicM
WebhookActionHandler.execute(self.invocation)

assert len(responses.calls) == 0
mock_logger.info.assert_called_once()
assert mock_logger.info.call_args[0][0] == "legacy_webhook.dry_run"

@responses.activate
def test_disable_old_without_new_path_fires_nothing(self) -> None:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import uuid
from unittest import mock

import responses

Expand Down Expand Up @@ -46,17 +45,11 @@ def test_task_sends_webhook(self) -> None:
assert body["message"] == self.group_event.message

@responses.activate
@mock.patch("sentry.sentry_apps.services.legacy_webhook.tasks.logger")
@with_feature("organizations:legacy-webhook-dry-run")
def test_task_dry_run_logs_instead_of_sending(self, mock_logger: mock.MagicMock) -> None:
def test_task_dry_run_does_not_send(self) -> None:
responses.add(responses.POST, "http://example.com/hook")

payload = build_legacy_webhook_payload(self.invocation)
send_legacy_webhook_task(url="http://example.com/hook", payload=payload)

assert len(responses.calls) == 0
mock_logger.info.assert_called_once()
call_args = mock_logger.info.call_args
assert call_args[0][0] == "legacy_webhook.dry_run"
assert call_args[1]["extra"]["url"] == "http://example.com/hook"
assert call_args[1]["extra"]["payload"] == payload
124 changes: 124 additions & 0 deletions tests/sentry/sentry_apps/services/legacy_webhook/test_validation.py
Comment thread
Christinarlong marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from __future__ import annotations

import uuid
from typing import Any
from unittest import mock

from sentry.plugins.sentry_webhooks.plugin import WebHooksPlugin
from sentry.sentry_apps.services.legacy_webhook.service import (
build_legacy_webhook_payload,
get_triggering_rule_name,
)
from sentry.sentry_apps.services.legacy_webhook.validation import (
compare_payloads,
validate_payload_equivalence,
)
from sentry.workflow_engine.models import Action
from sentry.workflow_engine.types import ActionInvocation, WorkflowEventData
from tests.sentry.workflow_engine.test_base import BaseWorkflowTest


class TestWebhookPayloadValidation(BaseWorkflowTest):
def setUp(self) -> None:
super().setUp()
self.detector = self.create_detector(project=self.project)
self.workflow = self.create_workflow(environment=self.environment)
self.action = self.create_action(
type=Action.Type.WEBHOOK,
config={"target_identifier": "webhooks"},
)
self.group, self.event, self.group_event = self.create_group_event()
self.event_data = WorkflowEventData(
event=self.group_event, workflow_env=self.environment, group=self.group
)
self.invocation = ActionInvocation(
event_data=self.event_data,
action=self.action,
detector=self.detector,
notification_uuid=str(uuid.uuid4()),
workflow_id=self.workflow.id,
)

def _build_old_payload(self) -> dict[str, Any]:
rule_name = get_triggering_rule_name(self.invocation)
return WebHooksPlugin().get_group_data(self.group, self.group_event, [rule_name])

def _build_new_payload(self) -> dict[str, Any]:
return dict(build_legacy_webhook_payload(self.invocation))

def test_old_and_new_payloads_match(self) -> None:
old = self._build_old_payload()
new = self._build_new_payload()
mismatches = compare_payloads(old, new)
assert mismatches == []

def test_detects_extra_field_in_new_payload(self) -> None:
old = self._build_old_payload()
new = self._build_new_payload()
new["extra_field"] = "surprise"
mismatches = compare_payloads(old, new)
assert mismatches == ["Extra in new: extra_field"]

def test_detects_missing_field_in_new_payload(self) -> None:
old = self._build_old_payload()
new = self._build_new_payload()
del new["culprit"]
mismatches = compare_payloads(old, new)
assert mismatches == ["Missing from new: culprit"]

def test_detects_nested_event_field_difference(self) -> None:
old = self._build_old_payload()
new = self._build_new_payload()
new["event"]["event_id"] = "tampered"
mismatches = compare_payloads(old, new)
assert mismatches == ["event.event_id: old=str(len=32), new=str(len=8)"]

@mock.patch("sentry.sentry_apps.services.legacy_webhook.validation.logger")
def test_validate_logs_match_on_identical_payloads(self, mock_logger: mock.MagicMock) -> None:
old = self._build_old_payload()
new = self._build_new_payload()
validate_payload_equivalence(
old, new, organization_id=self.project.organization_id, project_id=self.project.id
)
mock_logger.info.assert_called_once_with(
"legacy_webhook.validation.match",
extra={
"organization_id": self.project.organization_id,
"project_id": self.project.id,
},
)

@mock.patch("sentry.sentry_apps.services.legacy_webhook.validation.logger")
def test_validate_logs_warnings_on_mismatch(self, mock_logger: mock.MagicMock) -> None:
old = self._build_old_payload()
new = self._build_new_payload()
new["extra_field"] = "surprise"
validate_payload_equivalence(
old, new, organization_id=self.project.organization_id, project_id=self.project.id
)
mock_logger.warning.assert_called_once()
assert mock_logger.warning.call_args[1]["extra"]["mismatches"] == [
"Extra in new: extra_field"
]

@mock.patch("sentry.sentry_apps.services.legacy_webhook.validation.logger")
@mock.patch(
"sentry.sentry_apps.services.legacy_webhook.validation.compare_payloads",
side_effect=RuntimeError("boom"),
)
def test_validate_logs_exception_on_comparison_error(
self, _mock_compare: mock.MagicMock, mock_logger: mock.MagicMock
) -> None:
validate_payload_equivalence(
{"a": 1},
{"b": 2},
organization_id=self.project.organization_id,
project_id=self.project.id,
)
mock_logger.exception.assert_called_once_with(
"legacy_webhook.validation.comparison_error",
extra={
"organization_id": self.project.organization_id,
"project_id": self.project.id,
},
)
Loading