diff --git a/api/audit/models.py b/api/audit/models.py index a97b3355bd2a..95cdb476e269 100644 --- a/api/audit/models.py +++ b/api/audit/models.py @@ -86,6 +86,14 @@ def history_record(self): klass = self.get_history_record_model_class(self.history_record_class_path) return klass.objects.get(id=self.history_record_id) + @property + def environment_name(self) -> str: + return getattr(self.environment, "name", "unknown") + + @property + def author_identifier(self) -> str: + return getattr(self.author, "email", "system") + @staticmethod def get_history_record_model_class( history_record_class_path: str, diff --git a/api/audit/signals.py b/api/audit/signals.py index 2e1bb0f27426..6ab7bddd6eb0 100644 --- a/api/audit/signals.py +++ b/api/audit/signals.py @@ -54,13 +54,7 @@ def signal_wrapper(sender, instance, **kwargs): def _track_event_async(instance, integration_client): - event_data = integration_client.generate_event_data( - log=instance.log, - email=instance.author.email if instance.author else "", - environment_name=instance.environment.name.lower() - if instance.environment - else "", - ) + event_data = integration_client.generate_event_data(audit_log_record=instance) integration_client.track_event_async(event=event_data) diff --git a/api/integrations/common/wrapper.py b/api/integrations/common/wrapper.py index 95dadcf6dfe9..65b791fea8d3 100644 --- a/api/integrations/common/wrapper.py +++ b/api/integrations/common/wrapper.py @@ -1,5 +1,5 @@ import typing -from abc import ABC, abstractmethod, abstractstaticmethod +from abc import ABC, abstractmethod from util.util import postpone @@ -18,8 +18,9 @@ def _track_event(self, event: dict) -> None: def track_event_async(self, event: dict) -> None: self._track_event(event) - @abstractstaticmethod - def generate_event_data(*args, **kwargs) -> None: + @staticmethod + @abstractmethod + def generate_event_data(*args, **kwargs) -> ...: raise NotImplementedError() diff --git a/api/integrations/datadog/datadog.py b/api/integrations/datadog/datadog.py index d912cd81239d..67d26b2a8ca7 100644 --- a/api/integrations/datadog/datadog.py +++ b/api/integrations/datadog/datadog.py @@ -3,6 +3,7 @@ import requests +from audit.models import AuditLog from integrations.common.wrapper import AbstractBaseEventIntegrationWrapper logger = logging.getLogger(__name__) @@ -21,7 +22,11 @@ def __init__(self, base_url: str, api_key: str, session: requests.Session = None self.session = session or requests.Session() @staticmethod - def generate_event_data(log: str, email: str, environment_name: str) -> dict: + def generate_event_data(audit_log_record: AuditLog) -> dict: + log = audit_log_record.log + environment_name = audit_log_record.environment_name + email = audit_log_record.author_identifier + return { "text": f"{log} by user {email}", "title": "Flagsmith Feature Flag Event", diff --git a/api/integrations/datadog/tests/test_datadog.py b/api/integrations/datadog/tests/test_datadog.py index e19d342ea070..8853ff3e899d 100644 --- a/api/integrations/datadog/tests/test_datadog.py +++ b/api/integrations/datadog/tests/test_datadog.py @@ -2,6 +2,8 @@ import pytest +from audit.models import AuditLog +from environments.models import Environment from integrations.datadog.datadog import EVENTS_API_URI, DataDogWrapper @@ -42,59 +44,69 @@ def test_datadog_track_event(mocker): ) -def test_datadog_when_generate_event_data_with_correct_values_then_success(): +def test_datadog_when_generate_event_data_with_correct_values_then_success( + django_user_model, + feature, +): # Given log = "some log data" - email = "tes@email.com" - env = "test" + + author = django_user_model(email="test@email.com") + environment = Environment(name="test") + + audit_log_record = AuditLog(log=log, author=author, environment=environment) + data_dog = DataDogWrapper(base_url="http://test.com", api_key="123key") # When - event_data = data_dog.generate_event_data( - log=log, email=email, environment_name=env - ) + event_data = data_dog.generate_event_data(audit_log_record=audit_log_record) # Then - expected_event_text = f"{log} by user {email}" + expected_event_text = f"{log} by user {author.email}" assert event_data["text"] == expected_event_text assert len(event_data["tags"]) == 1 - assert event_data["tags"][0] == "env:" + env + assert event_data["tags"][0] == f"env:{environment.name}" + + +def test_datadog_when_generate_event_data_with_missing_author_then_success(feature): + # Given + log = "some log data" + + environment = Environment(name="test") + audit_log_record = AuditLog(log=log, environment=environment) -def test_datadog_when_generate_event_data_with_with_missing_values_then_success(): - # Given no log or email data - log = None - email = None - env = "test" data_dog = DataDogWrapper(base_url="http://test.com", api_key="123key") # When - event_data = data_dog.generate_event_data( - log=log, email=email, environment_name=env - ) + event_data = data_dog.generate_event_data(audit_log_record=audit_log_record) # Then - expected_event_text = f"{log} by user {email}" + expected_event_text = f"{log} by user system" assert event_data["text"] == expected_event_text assert len(event_data["tags"]) == 1 - assert event_data["tags"][0] == f"env:{env}" + assert event_data["tags"][0] == f"env:{environment.name}" -def test_datadog_when_generate_event_data_with_with_missing_env_then_success(): +def test_datadog_when_generate_event_data_with_missing_env_then_success( + django_user_model, + feature, +): # Given environment log = "some log data" - email = "tes@email.com" - env = None + + author = django_user_model(email="test@email.com") + + audit_log_record = AuditLog(log=log, author=author) + data_dog = DataDogWrapper(base_url="http://test.com", api_key="123key") # When - event_data = data_dog.generate_event_data( - log=log, email=email, environment_name=env - ) + event_data = data_dog.generate_event_data(audit_log_record=audit_log_record) # Then - expected_event_text = f"{log} by user {email}" + expected_event_text = f"{log} by user {author.email}" assert event_data["text"] == expected_event_text assert len(event_data["tags"]) == 1 - assert event_data["tags"][0] == f"env:{env}" + assert event_data["tags"][0] == "env:unknown" diff --git a/api/integrations/dynatrace/dynatrace.py b/api/integrations/dynatrace/dynatrace.py index 15a7b696a9b6..0a448d24a27b 100644 --- a/api/integrations/dynatrace/dynatrace.py +++ b/api/integrations/dynatrace/dynatrace.py @@ -3,12 +3,20 @@ import requests +from audit.models import AuditLog +from audit.related_object_type import RelatedObjectType +from features.models import Feature from integrations.common.wrapper import AbstractBaseEventIntegrationWrapper +from segments.models import Segment logger = logging.getLogger(__name__) EVENTS_API_URI = "api/v2/events/ingest" +# use 'Deployment' as a fallback to maintain current behaviour in the +# event that we cannot determine the correct name to return. +DEFAULT_DEPLOYMENT_NAME = "Deployment" + class DynatraceWrapper(AbstractBaseEventIntegrationWrapper): def __init__(self, base_url: str, api_key: str, entity_selector: str): @@ -30,10 +38,15 @@ def _headers(self) -> dict: return {"Content-Type": "application/json"} @staticmethod - def generate_event_data(log: str, email: str, environment_name: str) -> dict: + def generate_event_data(audit_log_record: AuditLog) -> dict: + log = audit_log_record.log + environment_name = audit_log_record.environment_name + email = audit_log_record.author_identifier + flag_properties = { "event": f"{log} by user {email}", "environment": environment_name, + "dt.event.deployment.name": _get_deployment_name(audit_log_record), } return { @@ -41,3 +54,48 @@ def generate_event_data(log: str, email: str, environment_name: str) -> dict: "eventType": "CUSTOM_DEPLOYMENT", "properties": flag_properties, } + + +def _get_deployment_name(audit_log_record: AuditLog) -> str: + try: + related_object_type = RelatedObjectType[audit_log_record.related_object_type] + + if related_object_type in ( + RelatedObjectType.FEATURE, + RelatedObjectType.FEATURE_STATE, + ): + return _get_deployment_name_for_feature( + audit_log_record.related_object_id, related_object_type + ) + elif related_object_type == RelatedObjectType.SEGMENT: + return _get_deployment_name_for_segment(audit_log_record.related_object_id) + except KeyError: + pass + + # use 'Deployment' as a fallback to maintain current behaviour in the + # event that we cannot determine the correct name to return. + return DEFAULT_DEPLOYMENT_NAME + + +def _get_deployment_name_for_feature( + object_id: int, object_type: RelatedObjectType +) -> str: + qs = Feature.objects.all_with_deleted() + if object_type == RelatedObjectType.FEATURE: + qs = qs.filter(id=object_id) + elif object_type == RelatedObjectType.FEATURE_STATE: + qs = qs.filter(feature_states__id=object_id).distinct() + + if feature := qs.first(): + return f"Flagsmith Deployment - Flag Changed: {feature.name}" + + # use 'Deployment' as a fallback to maintain current behaviour in the + # event that we cannot determine the correct name to return. + return DEFAULT_DEPLOYMENT_NAME + + +def _get_deployment_name_for_segment(object_id: int) -> str: + if segment := Segment.objects.all_with_deleted().filter(id=object_id).first(): + return f"Flagsmith Deployment - Segment Changed: {segment.name}" + + return DEFAULT_DEPLOYMENT_NAME diff --git a/api/integrations/dynatrace/tests/test_dynatrace.py b/api/integrations/dynatrace/tests/test_dynatrace.py index 60b72b594108..5306158570fa 100644 --- a/api/integrations/dynatrace/tests/test_dynatrace.py +++ b/api/integrations/dynatrace/tests/test_dynatrace.py @@ -1,3 +1,9 @@ +import pytest +from pytest_lazyfixture import lazy_fixture + +from audit.models import AuditLog +from audit.related_object_type import RelatedObjectType +from environments.models import Environment from integrations.dynatrace.dynatrace import EVENTS_API_URI, DynatraceWrapper @@ -17,11 +23,43 @@ def test_dynatrace_initialized_correctly(): assert dynatrace.url == expected_url -def test_dynatrace_when_generate_event_data_with_correct_values_then_success(): +@pytest.mark.parametrize( + "related_object_type, related_object, expected_deployment_name", + ( + ( + RelatedObjectType.FEATURE.name, + lazy_fixture("feature"), + "Flagsmith Deployment - Flag Changed: Test Feature1", + ), + ( + RelatedObjectType.FEATURE_STATE.name, + lazy_fixture("feature_state"), + "Flagsmith Deployment - Flag Changed: Test Feature1", + ), + ( + RelatedObjectType.SEGMENT.name, + lazy_fixture("segment"), + "Flagsmith Deployment - Segment Changed: segment", + ), + ), +) +def test_dynatrace_when_generate_event_data_with_correct_values_then_success( + django_user_model, related_object_type, related_object, expected_deployment_name +): # Given log = "some log data" - email = "tes@email.com" - env = "test" + + author = django_user_model(email="test@email.com") + environment = Environment(name="test") + + audit_log_record = AuditLog( + log=log, + author=author, + environment=environment, + related_object_type=related_object_type, + related_object_id=related_object.id, + ) + dynatrace = DynatraceWrapper( base_url="http://test.com", api_key="123key", @@ -29,22 +67,26 @@ def test_dynatrace_when_generate_event_data_with_correct_values_then_success(): ) # When - event_data = dynatrace.generate_event_data( - log=log, email=email, environment_name=env - ) + event_data = dynatrace.generate_event_data(audit_log_record=audit_log_record) # Then - expected_event_text = f"{log} by user {email}" + expected_event_text = f"{log} by user {author.email}" assert event_data["properties"]["event"] == expected_event_text - assert event_data["properties"]["environment"] == env + assert event_data["properties"]["environment"] == environment.name + assert ( + event_data["properties"]["dt.event.deployment.name"] == expected_deployment_name + ) -def test_dynatrace_when_generate_event_data_with_with_missing_values_then_success(): - # Given no log or email data - log = None - email = None - env = "test" +def test_dynatrace_when_generate_event_data_with_missing_author_then_success(): + # Given + log = "some log data" + + environment = Environment(name="test") + + audit_log_record = AuditLog(log=log, environment=environment) + dynatrace = DynatraceWrapper( base_url="http://test.com", api_key="123key", @@ -52,11 +94,39 @@ def test_dynatrace_when_generate_event_data_with_with_missing_values_then_succes ) # When - event_data = dynatrace.generate_event_data( - log=log, email=email, environment_name=env + event_data = dynatrace.generate_event_data(audit_log_record=audit_log_record) + + # Then + expected_event_text = f"{log} by user system" + assert event_data["properties"]["event"] == expected_event_text + assert event_data["properties"]["environment"] == environment.name + + +def test_dynatrace_when_generate_event_data_with_missing_environment_then_success( + django_user_model, feature +): + # Given + log = "some log data" + + author = django_user_model(email="test@example.com") + + audit_log_record = AuditLog( + log=log, + author=author, + related_object_type=RelatedObjectType.FEATURE.name, + related_object_id=feature.id, + ) + + dynatrace = DynatraceWrapper( + base_url="http://test.com", + api_key="123key", + entity_selector="type(APPLICATION),entityName(docs)", ) + # When + event_data = dynatrace.generate_event_data(audit_log_record=audit_log_record) + # Then - expected_event_text = f"{log} by user {email}" + expected_event_text = f"{log} by user {author.email}" assert event_data["properties"]["event"] == expected_event_text - assert event_data["properties"]["environment"] == env + assert event_data["properties"]["environment"] == "unknown" diff --git a/api/integrations/new_relic/new_relic.py b/api/integrations/new_relic/new_relic.py index e62fb1987e35..6d172b0ee1bc 100644 --- a/api/integrations/new_relic/new_relic.py +++ b/api/integrations/new_relic/new_relic.py @@ -3,6 +3,7 @@ import requests +from audit.models import AuditLog from integrations.common.wrapper import AbstractBaseEventIntegrationWrapper logger = logging.getLogger(__name__) @@ -29,7 +30,11 @@ def _headers(self) -> dict: return {"Content-Type": "application/json", "X-Api-Key": self.api_key} @staticmethod - def generate_event_data(log: str, email: str, environment_name: str): + def generate_event_data(audit_log_record: AuditLog) -> dict: + log = audit_log_record.log + environment_name = audit_log_record.environment_name + email = audit_log_record.author_identifier + return { "deployment": { "revision": f"env:{environment_name}", diff --git a/api/integrations/new_relic/tests/test_new_relic.py b/api/integrations/new_relic/tests/test_new_relic.py index bc013dc9e268..1bfd77b2fecd 100644 --- a/api/integrations/new_relic/tests/test_new_relic.py +++ b/api/integrations/new_relic/tests/test_new_relic.py @@ -1,3 +1,5 @@ +from audit.models import AuditLog +from environments.models import Environment from integrations.new_relic.new_relic import EVENTS_API_URI, NewRelicWrapper @@ -15,72 +17,80 @@ def test_new_relic_initialized_correctly(): assert new_relic.url == expected_url -def test_new_relic_when_generate_event_data_with_correct_values_then_success(): +def test_new_relic_when_generate_event_data_with_correct_values_then_success( + django_user_model, +): # Given log = "some log data" - email = "tes@email.com" - env = "test" + + author = django_user_model(email="test@email.com") + environment = Environment(name="test") + + audit_log_record = AuditLog(log=log, author=author, environment=environment) + new_relic = NewRelicWrapper( base_url="http://test.com", api_key="123key", app_id="123id" ) # When - event_data = new_relic.generate_event_data( - log=log, email=email, environment_name=env - ) + event_data = new_relic.generate_event_data(audit_log_record=audit_log_record) # Then - expected_event_text = f"{log} by user {email}" + expected_event_text = f"{log} by user {author.email}" assert event_data.get("deployment") is not None event_deployment_data = event_data.get("deployment") - assert event_deployment_data["revision"] == f"env:{env}" + assert event_deployment_data["revision"] == f"env:{environment.name}" assert event_deployment_data["changelog"] == expected_event_text -def test_new_relic_when_generate_event_data_with_with_missing_values_then_success(): +def test_new_relic_when_generate_event_data_with_missing_author_then_success(): # Given - log = None - email = None - env = "test" + log = "some log data" + + environment = Environment(name="test") + + audit_log_record = AuditLog(log=log, environment=environment) + new_relic = NewRelicWrapper( base_url="http://test.com", api_key="123key", app_id="123id" ) # When - event_data = new_relic.generate_event_data( - log=log, email=email, environment_name=env - ) + event_data = new_relic.generate_event_data(audit_log_record=audit_log_record) # Then - expected_event_text = f"{log} by user {email}" + expected_event_text = f"{log} by user system" assert event_data.get("deployment") is not None event_deployment_data = event_data.get("deployment") - assert event_deployment_data["revision"] == f"env:{env}" + assert event_deployment_data["revision"] == f"env:{environment.name}" assert event_deployment_data["changelog"] == expected_event_text -def test_new_dog_when_generate_event_data_with_with_missing_env_then_success(): - # Given environment +def test_new_relic_when_generate_event_data_with_missing_env_then_success( + django_user_model, +): + # Given log = "some log data" - email = "tes@email.com" - env = None + + author = django_user_model(email="test@email.com") + + audit_log_record = AuditLog(log=log, author=author) + new_relic = NewRelicWrapper( base_url="http://test.com", api_key="123key", app_id="123id" ) # When - event_data = new_relic.generate_event_data( - log=log, email=email, environment_name=env - ) + event_data = new_relic.generate_event_data(audit_log_record=audit_log_record) # Then - expected_event_text = f"{log} by user {email}" + expected_event_text = f"{log} by user {author.email}" assert event_data.get("deployment") is not None event_deployment_data = event_data.get("deployment") - assert event_deployment_data["revision"] == f"env:{env}" + assert event_deployment_data["revision"] == "env:unknown" assert event_deployment_data["changelog"] == expected_event_text diff --git a/api/integrations/slack/slack.py b/api/integrations/slack/slack.py index 197c76c94aa6..0ac19e0cfa4d 100644 --- a/api/integrations/slack/slack.py +++ b/api/integrations/slack/slack.py @@ -6,6 +6,7 @@ from slack_sdk.errors import SlackApiError from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler +from audit.models import AuditLog from integrations.common.wrapper import AbstractBaseEventIntegrationWrapper from .exceptions import SlackChannelJoinError @@ -63,7 +64,11 @@ def _client(self) -> WebClient: return client @staticmethod - def generate_event_data(log: str, email: str, environment_name: str) -> dict: + def generate_event_data(audit_log_record: AuditLog) -> dict: + log = audit_log_record.log + environment_name = audit_log_record.environment_name + email = audit_log_record.author_identifier + return { "blocks": [ {"type": "section", "text": {"type": "plain_text", "text": log}}, diff --git a/api/tests/unit/integrations/slack/test_unit_slack.py b/api/tests/unit/integrations/slack/test_unit_slack.py index c0f59ba7ea00..d96b4e3d94d5 100644 --- a/api/tests/unit/integrations/slack/test_unit_slack.py +++ b/api/tests/unit/integrations/slack/test_unit_slack.py @@ -1,6 +1,8 @@ import pytest from slack_sdk.errors import SlackApiError +from audit.models import AuditLog +from environments.models import Environment from integrations.slack.exceptions import SlackChannelJoinError from integrations.slack.slack import SlackChannel, SlackWrapper @@ -142,14 +144,17 @@ def test_track_event_makes_correct_call(mocked_slack_internal_client): ) -def test_slack_generate_event_data_with_correct_values(): +def test_slack_generate_event_data_with_correct_values(django_user_model): # Given log = "some log data" - email = "tes@email.com" - environment_name = "test" + + author = django_user_model(email="test@email.com") + environment = Environment(name="test") + + audit_log_record = AuditLog(log=log, author=author, environment=environment) # When - event_data = SlackWrapper.generate_event_data(log, email, environment_name) + event_data = SlackWrapper.generate_event_data(audit_log_record=audit_log_record) # Then assert event_data["blocks"] == [ @@ -157,8 +162,8 @@ def test_slack_generate_event_data_with_correct_values(): { "type": "section", "fields": [ - {"type": "mrkdwn", "text": f"*Environment:*\n{environment_name}"}, - {"type": "mrkdwn", "text": f"*User:*\n{email}"}, + {"type": "mrkdwn", "text": f"*Environment:*\n{environment.name}"}, + {"type": "mrkdwn", "text": f"*User:*\n{author.email}"}, ], }, ]