-
-
Couldn't load subscription status.
- Fork 4.5k
feat(workflow_engine): Add Detector Life Cycle Hooks to DetectorSettings #101964
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
base: master
Are you sure you want to change the base?
Changes from all commits
ee191dd
fc1b635
e64078d
edcfd3f
559e23c
586fe08
b31450c
20af557
a8c267a
ed7b77e
8fa0021
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -141,6 +141,12 @@ def update(self, instance: Detector, validated_data: dict[str, Any]): | |
| event=audit_log.get_event_id("DETECTOR_EDIT"), | ||
| data=instance.get_audit_log_data(), | ||
| ) | ||
|
|
||
| # This hook is used for _after_ a detector has been updated | ||
| hooks = instance.settings.hooks | ||
| if hooks and hooks.on_update: | ||
| hooks.on_update(instance) | ||
|
|
||
| return instance | ||
|
|
||
| def _create_data_source(self, validated_data_source, detector: Detector): | ||
|
|
@@ -217,4 +223,10 @@ def create(self, validated_data): | |
| event=audit_log.get_event_id("DETECTOR_ADD"), | ||
| data=detector.get_audit_log_data(), | ||
| ) | ||
|
|
||
| hooks = detector.settings.hooks | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It might be nice, interface-wise, to have static methods for this dispatch.. |
||
|
|
||
| if hooks and hooks.on_create: | ||
| hooks.on_create(detector) | ||
|
|
||
| return detector | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from abc import ABC, abstractmethod | ||
| from collections.abc import Callable | ||
| from dataclasses import dataclass, field | ||
| from enum import IntEnum, StrEnum | ||
| from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypedDict, TypeVar | ||
|
|
@@ -182,8 +183,16 @@ class SnubaQueryDataSourceType(TypedDict): | |
| event_types: list[SnubaQueryEventType] | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class DetectorLifeCycleHooks: | ||
| on_create: Callable[[Detector], None] | None # invoked on validator.save() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've learned to ignore these sort of comments, as I can't assume it hasn't changed since they were added. I think the static dispatch method approach is maybe preferable, as it makes it answerable with trivial grep or any respectable jump-to-definition. |
||
| on_delete: Callable[[int], None] | None # invoked when the `deletion` code is invoked | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. mention it is called with the Detector ID |
||
| on_update: Callable[[Detector], None] | None # invoked on the validator.update() | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class DetectorSettings: | ||
| hooks: DetectorLifeCycleHooks | None = None | ||
| handler: type[DetectorHandler] | None = None | ||
| validator: type[BaseDetectorTypeValidator] | None = None | ||
| config_schema: dict[str, Any] = field(default_factory=dict) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| from unittest.mock import Mock, PropertyMock, patch | ||
|
|
||
| from django.test.client import RequestFactory | ||
|
|
||
| from sentry.deletions.tasks.scheduled import run_scheduled_deletions | ||
| from sentry.issues.grouptype import PerformanceSlowDBQueryGroupType | ||
| from sentry.testutils.cases import TestCase | ||
| from sentry.testutils.hybrid_cloud import HybridCloudTestMixin | ||
| from sentry.workflow_engine.endpoints.validators.base.detector import BaseDetectorTypeValidator | ||
| from sentry.workflow_engine.models import Detector | ||
| from sentry.workflow_engine.types import DetectorLifeCycleHooks, DetectorSettings | ||
|
|
||
|
|
||
| class TestDetectorLifeCycleValidatorHooks(TestCase): | ||
| def setUp(self) -> None: | ||
| super().setUp() | ||
|
|
||
| self.login_as(user=self.user) | ||
| self.url = f"/api/0/organizations/{self.organization.slug}/monitors" | ||
| self.request = RequestFactory().post(self.url) | ||
| self.request.user = self.user | ||
|
|
||
| self.context = { | ||
| "organization": self.organization, | ||
| "project": self.project, | ||
| "request": self.request, | ||
| } | ||
|
|
||
| self.valid_data = { | ||
| "name": "LifeCycle Test Detector", | ||
| # Just using a random type, this will have mocked info | ||
| "type": PerformanceSlowDBQueryGroupType, | ||
| } | ||
|
|
||
| self.detector_settings = DetectorSettings( | ||
| hooks=DetectorLifeCycleHooks( | ||
| on_delete=Mock(), | ||
| on_create=Mock(), | ||
| on_update=Mock(), | ||
| ) | ||
| ) | ||
|
|
||
| def test_create(self) -> None: | ||
| validator = BaseDetectorTypeValidator(self.valid_data, context=self.context) | ||
|
|
||
| with patch.object(Detector, "settings", new_callable=PropertyMock) as mock_settings: | ||
| mock_settings.return_value = self.detector_settings | ||
| detector = validator.create(self.valid_data) | ||
| detector.settings.hooks.on_create.assert_called_with(detector) | ||
|
|
||
| def test_create__no_hooks(self) -> None: | ||
| validator = BaseDetectorTypeValidator(self.valid_data, context=self.context) | ||
| self.detector_settings = DetectorSettings() | ||
|
|
||
| with patch.object(Detector, "settings", new_callable=PropertyMock) as mock_settings: | ||
| mock_settings.return_value = self.detector_settings | ||
| detector = validator.create(self.valid_data) | ||
| assert detector | ||
|
|
||
| def test_update(self) -> None: | ||
| validator = BaseDetectorTypeValidator(self.valid_data, context=self.context) | ||
| detector = self.create_detector(name="Example") | ||
|
|
||
| with patch.object(Detector, "settings", new_callable=PropertyMock) as mock_settings: | ||
| mock_settings.return_value = self.detector_settings | ||
| detector = validator.update(detector, self.valid_data) | ||
|
|
||
| # Ensure update happened, and hook was invoked | ||
| assert detector.name == self.valid_data["name"] | ||
| detector.settings.hooks.on_update.assert_called_with(detector) | ||
|
|
||
| def test_update__no_hooks(self) -> None: | ||
| validator = BaseDetectorTypeValidator(self.valid_data, context=self.context) | ||
| self.detector = self.create_detector(name="Example") | ||
|
|
||
| with patch.object(Detector, "settings", new_callable=PropertyMock) as mock_settings: | ||
| mock_settings.return_value = self.detector_settings | ||
| detector = validator.update(self.detector, self.valid_data) | ||
|
|
||
| assert detector.name == self.valid_data["name"] | ||
| detector.settings.hooks.on_update.assert_called_with(detector) | ||
|
|
||
|
|
||
| class TestDetectorLifeCycleDeletionHooks(TestCase, HybridCloudTestMixin): | ||
| def setUp(self) -> None: | ||
| super().setUp() | ||
|
|
||
| self.detector = self.create_detector(name="Test Detector") | ||
| self.detector_settings = DetectorSettings( | ||
| hooks=DetectorLifeCycleHooks( | ||
| on_delete=Mock(), | ||
| on_create=Mock(), | ||
| on_update=Mock(), | ||
| ) | ||
| ) | ||
|
|
||
| def test_delete(self) -> None: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd expect this to live in tests/sentry/deletions/test_detector.py; if I changed the behavior there, I'm not sure I'd know to find and run this task. |
||
| detector_id = self.detector.id | ||
| with patch.object(Detector, "settings", new_callable=PropertyMock) as mock_settings: | ||
| mock_settings.return_value = self.detector_settings | ||
| self.ScheduledDeletion.schedule(instance=self.detector, days=0) | ||
|
|
||
| with self.tasks(): | ||
| run_scheduled_deletions() | ||
|
|
||
| # The deletion worked | ||
| assert not Detector.objects.filter(id=detector_id).exists() | ||
| self.detector_settings.hooks.on_delete.assert_called_with(detector_id) # type: ignore[union-attr] | ||
|
|
||
| def test_delete__no_hooks(self) -> None: | ||
| with patch.object(Detector, "settings", new_callable=PropertyMock) as mock_settings: | ||
| mock_settings.return_value = DetectorSettings() | ||
| self.ScheduledDeletion.schedule(instance=self.detector, days=0) | ||
|
|
||
| with self.tasks(): | ||
| run_scheduled_deletions() | ||
|
|
||
| # The deletion still works | ||
| assert not Detector.objects.filter(id=self.detector.id).exists() | ||
Uh oh!
There was an error while loading. Please reload this page.
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.
a little nervous about having this hook (
on_update) only in the API -- is there a good place to invoke this outside of the validator? (🤔 i'm trying to avoid using a signal here, since others could just use a signal to hook into as well.)