-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat(webhooks): Add payload validation during dual-write migration #116040
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 05cb1af
ref: Extract ParityChecker to sentry/utils with format_value param
Christinarlong 89bdf8d
fix: Recurse into nested dicts/lists when known_diffs is empty
Christinarlong eea224e
feat(webhooks): Add payload validation during dual-write migration
Christinarlong 9b2c3ee
fix: Use renamed ParityChecker in validation module
Christinarlong 53588db
fix: Assert exact mismatch strings in validation tests
Christinarlong b8cb5c1
fix: Use _get_triggering_rule_name instead of create_rule_instance_fr…
Christinarlong 4d2e64f
ref: Use dict[str, Any] instead of Mapping, move imports to top of file
Christinarlong 581d107
fix: Use Mapping for old_payload, LegacyWebhookPayload for new_payload
Christinarlong 5f35168
add option to toggle payload parity comparison and fix typing
Christinarlong 911dbb7
Merge branch 'master' into crl/webhook-payload-validation-usage
Christinarlong 4ff7af3
ref(webhooks): Use rollout rate for payload validation, remove dry ru…
Christinarlong a90e775
Merge branch 'master' into crl/webhook-payload-validation-usage
Christinarlong c34300d
fix: Rename _get_triggering_rule_name to get_triggering_rule_name in …
Christinarlong b6c8f0c
fix(webhooks): Only run payload validation for legacy webhooks, not s…
Christinarlong b7a4ef5
ref(webhooks): Address PR feedback on validation logging and error ha…
Christinarlong File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
40 changes: 40 additions & 0 deletions
40
src/sentry/sentry_apps/services/legacy_webhook/validation.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
|
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}, | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
124 changes: 124 additions & 0 deletions
124
tests/sentry/sentry_apps/services/legacy_webhook/test_validation.py
|
Christinarlong marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }, | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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