From c5e633038a0a41674325bf4623aec812c97c5afc Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Sat, 4 Jul 2026 17:40:38 +0700 Subject: [PATCH 01/63] feat(integrations): add model to track integration health status --- api/app/settings/common.py | 1 + api/integrations/common/apps.py | 6 +++ .../common/migrations/0001_initial.py | 43 +++++++++++++++++++ .../common/migrations/__init__.py | 0 api/integrations/common/models.py | 10 ++++- 5 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 api/integrations/common/apps.py create mode 100644 api/integrations/common/migrations/0001_initial.py create mode 100644 api/integrations/common/migrations/__init__.py diff --git a/api/app/settings/common.py b/api/app/settings/common.py index f2768dfc454b..b26ce324691d 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -159,6 +159,7 @@ "integrations.gitlab", "integrations.grafana", "integrations.vcs", + "integrations.common", # Rate limiting admin endpoints "axes", "telemetry", diff --git a/api/integrations/common/apps.py b/api/integrations/common/apps.py new file mode 100644 index 000000000000..a714be5d806d --- /dev/null +++ b/api/integrations/common/apps.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from django.apps import AppConfig + + +class IntegrationCommonConfig(AppConfig): + name = "integrations.common" diff --git a/api/integrations/common/migrations/0001_initial.py b/api/integrations/common/migrations/0001_initial.py new file mode 100644 index 000000000000..afb5c11e2723 --- /dev/null +++ b/api/integrations/common/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.15 on 2026-07-04 10:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ] + + operations = [ + migrations.CreateModel( + name="IntegrationHealthRecord", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ("object_id", models.PositiveIntegerField()), + ("status_code", models.PositiveIntegerField()), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ], + ), + ] diff --git a/api/integrations/common/migrations/__init__.py b/api/integrations/common/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/common/models.py b/api/integrations/common/models.py index 87c323ea59b4..5f71791d4726 100644 --- a/api/integrations/common/models.py +++ b/api/integrations/common/models.py @@ -1,5 +1,4 @@ import logging - from django.db import models from django_lifecycle import ( # type: ignore[import-untyped] AFTER_SAVE, @@ -9,6 +8,8 @@ ) from core.models import SoftDeleteExportableModel +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericForeignKey from environments.models import Environment logger = logging.getLogger(__name__) @@ -40,3 +41,10 @@ def write_environment_to_dynamodb(self): # type: ignore[no-untyped-def] @hook(AFTER_UPDATE) def clear_environment_cache(self): # type: ignore[no-untyped-def] self.environment.clear_environment_cache() + +class IntegrationHealthRecord(models.Model): + created_at = models.DateTimeField("Created At", auto_now_add=True) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + status_code = models.PositiveIntegerField() \ No newline at end of file From 219ac2e4c190b019e3f8e97006bab773a9c0d597 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Sun, 5 Jul 2026 16:37:26 +0700 Subject: [PATCH 02/63] feat(integrations): add services to provide method for third-party wrappers --- api/integrations/common/services.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 api/integrations/common/services.py diff --git a/api/integrations/common/services.py b/api/integrations/common/services.py new file mode 100644 index 000000000000..e2de834cdbb9 --- /dev/null +++ b/api/integrations/common/services.py @@ -0,0 +1,13 @@ +from django.db import models + +from integrations.common.models import IntegrationHealthRecord + + +def record_integration_health( + integration_config: models.Model, + status_code: int, +) -> None: + IntegrationHealthRecord.objects.create( + content_object=integration_config, + status_code=status_code, + ) From 1b3914088b74ef57a150631d0a88ab14ba7e7906 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Mon, 6 Jul 2026 08:43:44 +0700 Subject: [PATCH 03/63] feat(integrations): add unit test for record_integration_health --- .../test_unit_integrations_common_services.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 api/tests/unit/integrations/common/test_unit_integrations_common_services.py diff --git a/api/tests/unit/integrations/common/test_unit_integrations_common_services.py b/api/tests/unit/integrations/common/test_unit_integrations_common_services.py new file mode 100644 index 000000000000..d8a4e26b92a0 --- /dev/null +++ b/api/tests/unit/integrations/common/test_unit_integrations_common_services.py @@ -0,0 +1,22 @@ +from integrations.amplitude.models import AmplitudeConfiguration +from integrations.common.models import IntegrationHealthRecord +from integrations.common.services import record_integration_health + + +def test_record_integration_health__valid_integration_config__creates_health_record( + environment, +): # type: ignore[no-untyped-def] + # Given + amplitude_config = AmplitudeConfiguration.objects.create( + api_key="test_amplitude", + environment=environment, + ) + status_code = 200 + + # When + record_integration_health(amplitude_config, status_code) + + # Then + health_record = IntegrationHealthRecord.objects.get() + assert health_record.content_object == amplitude_config + assert health_record.status_code == status_code \ No newline at end of file From a1099d5f4366ac8bec104475612bc9b85eb82c20 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Mon, 6 Jul 2026 10:25:07 +0700 Subject: [PATCH 04/63] feat(integrations): add method to retrieve latest integration health --- api/integrations/common/services.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/api/integrations/common/services.py b/api/integrations/common/services.py index e2de834cdbb9..7cdc8a4cd395 100644 --- a/api/integrations/common/services.py +++ b/api/integrations/common/services.py @@ -1,4 +1,5 @@ from django.db import models +from django.contrib.contenttypes.models import ContentType from integrations.common.models import IntegrationHealthRecord @@ -11,3 +12,20 @@ def record_integration_health( content_object=integration_config, status_code=status_code, ) + +def get_latest_integration_health( + integration_config: models.Model +) -> dict | None: + latest_record = IntegrationHealthRecord.objects.filter( + content_type=ContentType.objects.get_for_model(integration_config), + object_id=integration_config.id, + ).order_by("-created_at").first() + + if not latest_record: + return None + + return { + "status_code": latest_record.status_code, + "is_healthy": 200 <= latest_record.status_code < 300, + "created_at": latest_record.created_at, + } \ No newline at end of file From beb221cddd87a857d1620bf0377bfb314c1ed618 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Mon, 6 Jul 2026 11:01:04 +0700 Subject: [PATCH 05/63] feat(integrations): add latest_health_field to base environment integration serializer --- api/integrations/common/serializers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/integrations/common/serializers.py b/api/integrations/common/serializers.py index ee6a1b0fb135..6accf566fc11 100644 --- a/api/integrations/common/serializers.py +++ b/api/integrations/common/serializers.py @@ -2,6 +2,8 @@ from django.db.models import Model from rest_framework.serializers import ModelSerializer +from rest_framework.fields import SerializerMethodField +from .services import get_latest_integration_health class _BaseIntegrationModelSerializer(ModelSerializer): # type: ignore[type-arg] @@ -31,7 +33,10 @@ def _get_existing_integration_model_obj( class BaseEnvironmentIntegrationModelSerializer(_BaseIntegrationModelSerializer): one_to_one_field_name = "environment" # type: ignore[assignment] - + latest_health = SerializerMethodField() + + def get_latest_health(self, obj): + return get_latest_integration_health(obj) class BaseProjectIntegrationModelSerializer(_BaseIntegrationModelSerializer): one_to_one_field_name = "project_id" # type: ignore[assignment] From 3bbfe1a032528859d75e214a8017bca6dc2fceee Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Mon, 6 Jul 2026 11:03:56 +0700 Subject: [PATCH 06/63] feat(integrations): add latest_health field for meta class --- api/integrations/amplitude/serializers.py | 2 +- api/integrations/dynatrace/serializers.py | 2 +- api/integrations/heap/serializers.py | 2 +- api/integrations/mixpanel/serializers.py | 2 +- api/integrations/rudderstack/serializers.py | 2 +- api/integrations/segment/serializers.py | 2 +- api/integrations/sentry/serializers.py | 2 +- api/integrations/webhook/serializers.py | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api/integrations/amplitude/serializers.py b/api/integrations/amplitude/serializers.py index 4562de29aeeb..ad3bcc18a52e 100644 --- a/api/integrations/amplitude/serializers.py +++ b/api/integrations/amplitude/serializers.py @@ -7,4 +7,4 @@ class AmplitudeConfigurationSerializer(BaseEnvironmentIntegrationModelSerializer): class Meta: model = AmplitudeConfiguration - fields = ("id", "api_key", "base_url") + fields = ("id", "api_key", "base_url", "latest_health") diff --git a/api/integrations/dynatrace/serializers.py b/api/integrations/dynatrace/serializers.py index 87b12374e74f..26985739cab2 100644 --- a/api/integrations/dynatrace/serializers.py +++ b/api/integrations/dynatrace/serializers.py @@ -8,4 +8,4 @@ class DynatraceConfigurationSerializer(BaseEnvironmentIntegrationModelSerializer): class Meta: model = DynatraceConfiguration - fields = ("id", "base_url", "api_key", "entity_selector") + fields = ("id", "base_url", "api_key", "entity_selector", "latest_health") diff --git a/api/integrations/heap/serializers.py b/api/integrations/heap/serializers.py index d9af468b284b..c250161c67af 100644 --- a/api/integrations/heap/serializers.py +++ b/api/integrations/heap/serializers.py @@ -7,4 +7,4 @@ class HeapConfigurationSerializer(BaseEnvironmentIntegrationModelSerializer): class Meta: model = HeapConfiguration - fields = ("id", "api_key", "base_url") + fields = ("id", "api_key", "base_url", "latest_health") diff --git a/api/integrations/mixpanel/serializers.py b/api/integrations/mixpanel/serializers.py index 85fc1a0bbece..a3ccc20086e3 100644 --- a/api/integrations/mixpanel/serializers.py +++ b/api/integrations/mixpanel/serializers.py @@ -7,4 +7,4 @@ class MixpanelConfigurationSerializer(BaseEnvironmentIntegrationModelSerializer): class Meta: model = MixpanelConfiguration - fields = ("id", "api_key", "base_url") + fields = ("id", "api_key", "base_url", "latest_health") diff --git a/api/integrations/rudderstack/serializers.py b/api/integrations/rudderstack/serializers.py index 86df772f3109..78349c022e25 100644 --- a/api/integrations/rudderstack/serializers.py +++ b/api/integrations/rudderstack/serializers.py @@ -7,4 +7,4 @@ class RudderstackConfigurationSerializer(BaseEnvironmentIntegrationModelSerializer): class Meta: model = RudderstackConfiguration - fields = ("id", "base_url", "api_key") + fields = ("id", "base_url", "api_key", "latest_health") diff --git a/api/integrations/segment/serializers.py b/api/integrations/segment/serializers.py index 2508240a893e..eaf45946adb0 100644 --- a/api/integrations/segment/serializers.py +++ b/api/integrations/segment/serializers.py @@ -19,4 +19,4 @@ class SegmentConfigurationSerializer(BaseEnvironmentIntegrationModelSerializer): class Meta: model = SegmentConfiguration - fields = ("id", "api_key", "base_url") + fields = ("id", "api_key", "base_url", "latest_health") diff --git a/api/integrations/sentry/serializers.py b/api/integrations/sentry/serializers.py index 52f23e6ffec5..2caa3a105968 100644 --- a/api/integrations/sentry/serializers.py +++ b/api/integrations/sentry/serializers.py @@ -8,5 +8,5 @@ class SentryChangeTrackingConfigurationSerializer( ): class Meta: model = SentryChangeTrackingConfiguration - fields = ["id", "environment", "webhook_url", "secret"] + fields = ["id", "environment", "webhook_url", "secret", "latest_health"] read_only_fields = ["id", "environment"] diff --git a/api/integrations/webhook/serializers.py b/api/integrations/webhook/serializers.py index b844ff6b7308..50255ad83652 100644 --- a/api/integrations/webhook/serializers.py +++ b/api/integrations/webhook/serializers.py @@ -17,7 +17,7 @@ class WebhookConfigurationSerializer(BaseEnvironmentIntegrationModelSerializer): class Meta: model = WebhookConfiguration - fields = ("id", "url", "secret") + fields = ("id", "url", "secret", "latest_health") class SegmentSerializer(serializers.ModelSerializer): # type: ignore[type-arg] From 21c54d92308072d90674a612c2e55402e42dc7fb Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Mon, 6 Jul 2026 11:40:25 +0700 Subject: [PATCH 07/63] feat(integrations): add unit test for integration common services and serializers --- ...st_unit_integrations_common_serializers.py | 38 ++++++++++ .../test_unit_integrations_common_services.py | 75 +++++++++++++++---- 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/api/tests/unit/integrations/common/test_unit_integrations_common_serializers.py b/api/tests/unit/integrations/common/test_unit_integrations_common_serializers.py index 66ace0b7d504..952cde3400ae 100644 --- a/api/tests/unit/integrations/common/test_unit_integrations_common_serializers.py +++ b/api/tests/unit/integrations/common/test_unit_integrations_common_serializers.py @@ -1,5 +1,6 @@ from django.utils import timezone +from integrations.common.services import record_integration_health from integrations.datadog.models import DataDogConfiguration from integrations.datadog.serializers import DataDogConfigurationSerializer from integrations.webhook.models import WebhookConfiguration @@ -39,6 +40,43 @@ def test_base_environment_integration_model_serializer__soft_deleted_exists__upd assert updated_webhook_config.deleted_at is None +def test_base_environment_integration_model_serializer__latest_health__no_record( + environment, +): + # Given + webhook_config = WebhookConfiguration.objects.create( + environment=environment, url="https://webhook.url" + ) + + # When + serializer = WebhookConfigurationSerializer(webhook_config) + data = serializer.data + + # Then + assert "latest_health" in data + assert data["latest_health"] is None + + +def test_base_environment_integration_model_serializer__latest_health__has_record( + environment, +): + # Given + webhook_config = WebhookConfiguration.objects.create( + environment=environment, url="https://webhook.url" + ) + record_integration_health(webhook_config, 200) + + # When + serializer = WebhookConfigurationSerializer(webhook_config) + data = serializer.data + + # Then + assert data["latest_health"] is not None + assert data["latest_health"]["status_code"] == 200 + assert data["latest_health"]["is_healthy"] is True + assert data["latest_health"]["created_at"] is not None + + def test_base_project_integration_model_serializer__soft_deleted_exists__updates_existing( # type: ignore[no-untyped-def] # noqa: E501 project, ): diff --git a/api/tests/unit/integrations/common/test_unit_integrations_common_services.py b/api/tests/unit/integrations/common/test_unit_integrations_common_services.py index d8a4e26b92a0..c1b5d2ea2661 100644 --- a/api/tests/unit/integrations/common/test_unit_integrations_common_services.py +++ b/api/tests/unit/integrations/common/test_unit_integrations_common_services.py @@ -1,22 +1,71 @@ -from integrations.amplitude.models import AmplitudeConfiguration -from integrations.common.models import IntegrationHealthRecord -from integrations.common.services import record_integration_health +from django.utils import timezone +from integrations.common.services import ( + get_latest_integration_health, + record_integration_health, +) +from integrations.webhook.models import WebhookConfiguration -def test_record_integration_health__valid_integration_config__creates_health_record( + +def test_get_latest_integration_health__no_record__returns_none(environment): + # Given + webhook_config = WebhookConfiguration.objects.create( + environment=environment, url="https://webhook.url" + ) + + # When + result = get_latest_integration_health(webhook_config) + + # Then + assert result is None + + +def test_get_latest_integration_health__has_record__returns_health_dict(environment): + # Given + webhook_config = WebhookConfiguration.objects.create( + environment=environment, url="https://webhook.url" + ) + record_integration_health(webhook_config, 200) + + # When + result = get_latest_integration_health(webhook_config) + + # Then + assert result is not None + assert result["status_code"] == 200 + assert result["is_healthy"] is True + assert "created_at" in result + assert result["created_at"] is not None + + +def test_get_latest_integration_health__multiple_records__returns_latest( environment, -): # type: ignore[no-untyped-def] +): + # Given + webhook_config = WebhookConfiguration.objects.create( + environment=environment, url="https://webhook.url" + ) + record_integration_health(webhook_config, 500) + record_integration_health(webhook_config, 200) + + # When + result = get_latest_integration_health(webhook_config) + + # Then + assert result["status_code"] == 200 + assert result["is_healthy"] is True + + +def test_get_latest_integration_health__non_healthy_status(environment): # Given - amplitude_config = AmplitudeConfiguration.objects.create( - api_key="test_amplitude", - environment=environment, + webhook_config = WebhookConfiguration.objects.create( + environment=environment, url="https://webhook.url" ) - status_code = 200 + record_integration_health(webhook_config, 500) # When - record_integration_health(amplitude_config, status_code) + result = get_latest_integration_health(webhook_config) # Then - health_record = IntegrationHealthRecord.objects.get() - assert health_record.content_object == amplitude_config - assert health_record.status_code == status_code \ No newline at end of file + assert result["status_code"] == 500 + assert result["is_healthy"] is False From 9e28b0f1a209fc273e24ea2848fb662abaab2743 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Mon, 6 Jul 2026 14:22:22 +0700 Subject: [PATCH 08/63] feat(integrations): add types for ActiveIntegration interface --- frontend/common/types/responses.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index fa51e33faee2..2bc49c9fbfb2 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -377,6 +377,11 @@ export type IntegrationData = { export type ActiveIntegration = { id: string flagsmithEnvironment?: string + latest_health?: { + status_code: number + is_healthy: boolean + created_at: string + } | null } export type GithubRepository = { From 9fb45e05e697b690b7787807cfc7aa6deb2e6d87 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Mon, 6 Jul 2026 14:58:51 +0700 Subject: [PATCH 09/63] feat(integrations): add indicator for integration health status --- frontend/web/components/IntegrationList.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/web/components/IntegrationList.tsx b/frontend/web/components/IntegrationList.tsx index 15fd81640d7e..c141ce7bc6cd 100644 --- a/frontend/web/components/IntegrationList.tsx +++ b/frontend/web/components/IntegrationList.tsx @@ -24,6 +24,7 @@ import each from 'lodash/each' import { useGetProjectQuery } from 'common/services/useProject' import API from 'project/api' import Constants from 'common/constants' +import BooleanDotIndicator from 'components/BooleanDotIndicator' type IntegrationAction = { label: string @@ -341,6 +342,18 @@ const Integration: FC = (props) => { integration={props.integration} /> +
+ + + {integration.latest_health + ? integration.latest_health.is_healthy + ? 'Healthy' + : 'Unhealthy' + : 'No health data'} + +
e.stopPropagation()}> Date: Mon, 6 Jul 2026 16:55:36 +0700 Subject: [PATCH 10/63] feat(integrations): add record_integration_health method for HTTP-based integrations --- api/audit/signals.py | 28 ++++------------------ api/integrations/amplitude/amplitude.py | 3 +++ api/integrations/datadog/datadog.py | 14 ++++++----- api/integrations/dynatrace/dynatrace.py | 12 ++++++---- api/integrations/grafana/grafana.py | 16 ++++++++++--- api/integrations/heap/heap.py | 3 +++ api/integrations/mixpanel/mixpanel.py | 3 +++ api/integrations/new_relic/new_relic.py | 12 ++++++---- api/integrations/sentry/change_tracking.py | 10 +++++--- api/integrations/webhook/webhook.py | 2 ++ 10 files changed, 60 insertions(+), 43 deletions(-) diff --git a/api/audit/signals.py b/api/audit/signals.py index d77fd68448fa..eba27f0f37bf 100644 --- a/api/audit/signals.py +++ b/api/audit/signals.py @@ -141,11 +141,7 @@ def send_audit_log_event_to_datadog(sender, instance, **kwargs): # type: ignore if not data_dog_config: return - data_dog = DataDogWrapper( - base_url=data_dog_config.base_url, # type: ignore[arg-type] - api_key=data_dog_config.api_key, - use_custom_source=data_dog_config.use_custom_source, - ) + data_dog = DataDogWrapper(data_dog_config) _track_event_async(instance, data_dog) # type: ignore[no-untyped-call] @@ -156,11 +152,7 @@ def send_audit_log_event_to_new_relic(sender, instance, **kwargs): # type: igno if not new_relic_config: return - new_relic = NewRelicWrapper( - base_url=new_relic_config.base_url, # type: ignore[arg-type] - api_key=new_relic_config.api_key, - app_id=new_relic_config.app_id, - ) + new_relic = NewRelicWrapper(new_relic_config) _track_event_async(instance, new_relic) # type: ignore[no-untyped-call] @@ -171,11 +163,7 @@ def send_audit_log_event_to_dynatrace(sender, instance, **kwargs): # type: igno if not dynatrace_config: return - dynatrace = DynatraceWrapper( - base_url=dynatrace_config.base_url, # type: ignore[arg-type] - api_key=dynatrace_config.api_key, - entity_selector=dynatrace_config.entity_selector, - ) + dynatrace = DynatraceWrapper(dynatrace_config) _track_event_async(instance, dynatrace) # type: ignore[no-untyped-call] @@ -186,10 +174,7 @@ def send_audit_log_event_to_grafana(sender, instance, **kwargs): # type: ignore if not grafana_config: return - grafana = GrafanaWrapper( - base_url=grafana_config.base_url, # type: ignore[arg-type] - api_key=grafana_config.api_key, - ) + grafana = GrafanaWrapper(grafana_config) _track_event_async(instance, grafana) # type: ignore[no-untyped-call] @@ -238,10 +223,7 @@ def send_audit_log_event_to_sentry(sender: FeatureState, **kwargs: Any) -> None: ).first() if not config: return - sentry = SentryChangeTracking( - webhook_url=config.webhook_url, - secret=config.secret, - ) + sentry = SentryChangeTracking(config) if event_data := sentry.generate_event_data(sender): sentry.track_event_async(event=event_data) diff --git a/api/integrations/amplitude/amplitude.py b/api/integrations/amplitude/amplitude.py index 7bf758e8c45e..fb45076d86b9 100644 --- a/api/integrations/amplitude/amplitude.py +++ b/api/integrations/amplitude/amplitude.py @@ -7,6 +7,7 @@ from environments.identities.models import Identity from environments.identities.traits.models import Trait from features.models import FeatureState +from integrations.common.services import record_integration_health from integrations.common.wrapper import AbstractBaseIdentityIntegrationWrapper from .models import AmplitudeConfiguration @@ -18,6 +19,7 @@ class AmplitudeWrapper(AbstractBaseIdentityIntegrationWrapper[AmplitudeUserData]): def __init__(self, config: AmplitudeConfiguration): + self.config = config self.api_key = config.api_key self.url = f"{config.base_url}/identify" @@ -25,6 +27,7 @@ def _identify_user(self, user_data: AmplitudeUserData) -> None: payload = {"api_key": self.api_key, "identification": json.dumps([user_data])} response = requests.post(self.url, data=payload) + record_integration_health(self.config, response.status_code) logger.debug( "Sent event to Amplitude. Response code was: %s" % response.status_code ) diff --git a/api/integrations/datadog/datadog.py b/api/integrations/datadog/datadog.py index 7fbd759b0dd9..8472cec937d1 100644 --- a/api/integrations/datadog/datadog.py +++ b/api/integrations/datadog/datadog.py @@ -4,7 +4,9 @@ import requests from audit.models import AuditLog +from integrations.common.services import record_integration_health from integrations.common.wrapper import AbstractBaseEventIntegrationWrapper +from integrations.datadog.models import DataDogConfiguration logger = logging.getLogger(__name__) @@ -15,18 +17,17 @@ class DataDogWrapper(AbstractBaseEventIntegrationWrapper): def __init__( self, - base_url: str, - api_key: str, + config: DataDogConfiguration, session: requests.Session = None, # type: ignore[assignment] - use_custom_source: bool = False, ) -> None: - self.base_url = base_url + self.config = config + self.base_url = config.base_url if self.base_url[-1] != "/": self.base_url += "/" self.events_url = f"{self.base_url}{EVENTS_API_URI}" - self.use_custom_source = use_custom_source + self.use_custom_source = config.use_custom_source - self.api_key = api_key + self.api_key = config.api_key self.session = session or requests.Session() @staticmethod @@ -48,6 +49,7 @@ def _track_event(self, event: dict) -> None: # type: ignore[type-arg] response = self.session.post( f"{self.events_url}?api_key={self.api_key}", data=json.dumps(event) ) + record_integration_health(self.config, response.status_code) logger.debug( "Sent event to DataDog. Response code was %s" % response.status_code ) diff --git a/api/integrations/dynatrace/dynatrace.py b/api/integrations/dynatrace/dynatrace.py index 75bc41df566e..e700af84ea9c 100644 --- a/api/integrations/dynatrace/dynatrace.py +++ b/api/integrations/dynatrace/dynatrace.py @@ -7,7 +7,9 @@ from audit.services import get_audited_instance_from_audit_log_record from features.models import Feature, FeatureState from features.versioning.models import EnvironmentFeatureVersion +from integrations.common.services import record_integration_health from integrations.common.wrapper import AbstractBaseEventIntegrationWrapper +from integrations.dynatrace.models import DynatraceConfiguration from segments.models import Segment logger = logging.getLogger(__name__) @@ -20,10 +22,11 @@ class DynatraceWrapper(AbstractBaseEventIntegrationWrapper): - def __init__(self, base_url: str, api_key: str, entity_selector: str): - self.base_url = base_url - self.api_key = api_key - self.entity_selector = entity_selector + def __init__(self, config: DynatraceConfiguration): + self.config = config + self.base_url = config.base_url + self.api_key = config.api_key + self.entity_selector = config.entity_selector self.url = f"{self.base_url}{EVENTS_API_URI}?api-token={self.api_key}" def _track_event(self, event: dict) -> None: # type: ignore[type-arg] @@ -31,6 +34,7 @@ def _track_event(self, event: dict) -> None: # type: ignore[type-arg] response = requests.post( self.url, headers=self._headers(), data=json.dumps(event) ) + record_integration_health(self.config, response.status_code) logger.debug( "Sent event to Dynatrace. Response code was %s" % response.status_code ) diff --git a/api/integrations/grafana/grafana.py b/api/integrations/grafana/grafana.py index 21289d13aa51..81464b5fd14d 100644 --- a/api/integrations/grafana/grafana.py +++ b/api/integrations/grafana/grafana.py @@ -5,10 +5,15 @@ import requests from audit.models import AuditLog +from integrations.common.services import record_integration_health from integrations.common.wrapper import AbstractBaseEventIntegrationWrapper from integrations.grafana.mappers import ( map_audit_log_record_to_grafana_annotation, ) +from integrations.grafana.models import ( + GrafanaOrganisationConfiguration, + GrafanaProjectConfiguration, +) logger = logging.getLogger(__name__) @@ -16,10 +21,14 @@ class GrafanaWrapper(AbstractBaseEventIntegrationWrapper): - def __init__(self, base_url: str, api_key: str) -> None: - base_url = base_url[:-1] if base_url.endswith("/") else base_url + def __init__( + self, + config: GrafanaProjectConfiguration | GrafanaOrganisationConfiguration, + ) -> None: + self.config = config + base_url = config.base_url[:-1] if config.base_url.endswith("/") else config.base_url self.url = f"{base_url}{ROUTE_API_ANNOTATIONS}" - self.api_key = api_key + self.api_key = config.api_key @staticmethod def generate_event_data(audit_log_record: AuditLog) -> dict[str, Any]: @@ -37,6 +46,7 @@ def _track_event(self, event: dict[str, Any]) -> None: headers=self._headers(), data=json.dumps(event), ) + record_integration_health(self.config, response.status_code) logger.debug( "Sent event to Grafana. Response code was %s" % response.status_code diff --git a/api/integrations/heap/heap.py b/api/integrations/heap/heap.py index 5b15d4ca1c17..d6845c2ba765 100644 --- a/api/integrations/heap/heap.py +++ b/api/integrations/heap/heap.py @@ -6,6 +6,7 @@ from environments.identities.models import Identity from environments.identities.traits.models import Trait from features.models import FeatureState +from integrations.common.services import record_integration_health from integrations.common.wrapper import AbstractBaseIdentityIntegrationWrapper from .constants import DEFAULT_HEAP_API_URL @@ -16,12 +17,14 @@ class HeapWrapper(AbstractBaseIdentityIntegrationWrapper): # type: ignore[type-arg] def __init__(self, config: HeapConfiguration): + self.config = config self.api_key = config.api_key base_url = (config.base_url or DEFAULT_HEAP_API_URL).rstrip("/") self.url = f"{base_url}/api/track" def _identify_user(self, user_data: dict) -> None: # type: ignore[type-arg] response = requests.post(self.url, json=user_data) + record_integration_health(self.config, response.status_code) logger.debug("Sent event to Heap. Response code was: %s" % response.status_code) def generate_user_data( diff --git a/api/integrations/mixpanel/mixpanel.py b/api/integrations/mixpanel/mixpanel.py index cc2820aaa53c..77d4852fae3c 100644 --- a/api/integrations/mixpanel/mixpanel.py +++ b/api/integrations/mixpanel/mixpanel.py @@ -6,6 +6,7 @@ from environments.identities.models import Identity from environments.identities.traits.models import Trait from features.models import FeatureState +from integrations.common.services import record_integration_health from integrations.common.wrapper import AbstractBaseIdentityIntegrationWrapper from .constants import DEFAULT_MIXPANEL_API_URL @@ -19,6 +20,7 @@ class MixpanelWrapper(AbstractBaseIdentityIntegrationWrapper[MixpanelUserData]): def __init__(self, config: MixpanelConfiguration): + self.config = config self.api_key = config.api_key base_url = (config.base_url or DEFAULT_MIXPANEL_API_URL).rstrip("/") self.url = f"{base_url}/engage#profile-set" @@ -31,6 +33,7 @@ def __init__(self, config: MixpanelConfiguration): def _identify_user(self, user_data: MixpanelUserData) -> None: response = requests.post(self.url, headers=self.headers, json=user_data) + record_integration_health(self.config, response.status_code) logger.debug( "Sent event to Mixpanel. Response code was: %s" % response.status_code ) diff --git a/api/integrations/new_relic/new_relic.py b/api/integrations/new_relic/new_relic.py index 622756f45d90..1eedffb25052 100644 --- a/api/integrations/new_relic/new_relic.py +++ b/api/integrations/new_relic/new_relic.py @@ -4,7 +4,9 @@ import requests from audit.models import AuditLog +from integrations.common.services import record_integration_health from integrations.common.wrapper import AbstractBaseEventIntegrationWrapper +from integrations.new_relic.models import NewRelicConfiguration logger = logging.getLogger(__name__) @@ -12,16 +14,18 @@ class NewRelicWrapper(AbstractBaseEventIntegrationWrapper): - def __init__(self, base_url: str, api_key: str, app_id: str): - self.base_url = base_url - self.api_key = api_key - self.app_id = app_id + def __init__(self, config: NewRelicConfiguration): + self.config = config + self.base_url = config.base_url + self.api_key = config.api_key + self.app_id = config.app_id self.url = f"{self.base_url}{EVENTS_API_URI}{self.app_id}/deployments.json" def _track_event(self, event: dict) -> None: # type: ignore[type-arg] response = requests.post( self.url, headers=self._headers(), data=json.dumps(event) ) + record_integration_health(self.config, response.status_code) logger.debug( "Sent event to NewRelic. Response code was %s" % response.status_code ) diff --git a/api/integrations/sentry/change_tracking.py b/api/integrations/sentry/change_tracking.py index db8e4b6d516a..434bcffee6c4 100644 --- a/api/integrations/sentry/change_tracking.py +++ b/api/integrations/sentry/change_tracking.py @@ -7,7 +7,9 @@ from core.signing import sign_payload from features.models import FeatureState +from integrations.common.services import record_integration_health from integrations.common.wrapper import AbstractBaseEventIntegrationWrapper +from integrations.sentry.models import SentryChangeTrackingConfiguration logger = structlog.get_logger("sentry_change_tracking") @@ -26,9 +28,10 @@ class SentryChangeTracking(AbstractBaseEventIntegrationWrapper): - ...deleting the flag because deleting means saving the model instance with a deleted_at timestamp. """ - def __init__(self, webhook_url: str, secret: str) -> None: - self.webhook_url = webhook_url - self.secret = secret + def __init__(self, config: SentryChangeTrackingConfiguration) -> None: + self.config = config + self.webhook_url = config.webhook_url + self.secret = config.secret @staticmethod def generate_event_data(feature_state: FeatureState) -> dict[str, Any]: @@ -94,6 +97,7 @@ def _track_event(self, event: dict[str, Any]) -> None: headers=headers, data=json_payload, ) + record_integration_health(self.config, response.status_code) response.raise_for_status() # NOTE: This is for future-proofing, as Sentry won't respond 4xx. except requests.exceptions.RequestException as error: log.warning( diff --git a/api/integrations/webhook/webhook.py b/api/integrations/webhook/webhook.py index 541b33800444..5f3645a12b74 100644 --- a/api/integrations/webhook/webhook.py +++ b/api/integrations/webhook/webhook.py @@ -2,6 +2,7 @@ import typing from environments.identities.traits.serializers import TraitSerializerBasic +from integrations.common.services import record_integration_health from integrations.common.wrapper import AbstractBaseIdentityIntegrationWrapper from webhooks.webhooks import call_integration_webhook @@ -23,6 +24,7 @@ def __init__(self, config: WebhookConfiguration): def _identify_user(self, data: typing.Mapping) -> None: # type: ignore[type-arg] response = call_integration_webhook(self.config, data) if response: + record_integration_health(self.config, response.status_code) logger.debug( "Sent event to Webhook. Response code was: %s" % response.status_code ) From 55838f6bafb962764271623a4904b60af7e42ab4 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Tue, 7 Jul 2026 08:42:31 +0700 Subject: [PATCH 11/63] feat(integrations): add unit tests for wrappers --- ...est_change_tracking_webhook_integration.py | 5 ++ .../amplitude/test_unit_amplitude.py | 40 ++++++++++++ .../integrations/datadog/test_unit_datadog.py | 58 +++++++++++++++-- .../dynatrace/test_unit_dynatrace.py | 64 ++++++++++++++----- .../unit/integrations/grafana/test_grafana.py | 40 +++++++++++- .../unit/integrations/heap/test_unit_heap.py | 26 ++++++++ .../mixpanel/test_unit_mixpanel.py | 41 +++++++++++- .../new_relic/test_unit_new_relic.py | 57 ++++++++++++++--- .../integrations/webhook/test_unit_webhook.py | 23 +++++++ 9 files changed, 319 insertions(+), 35 deletions(-) diff --git a/api/tests/integration/sentry/test_change_tracking_webhook_integration.py b/api/tests/integration/sentry/test_change_tracking_webhook_integration.py index 3a8221a43a54..ed33be29c368 100644 --- a/api/tests/integration/sentry/test_change_tracking_webhook_integration.py +++ b/api/tests/integration/sentry/test_change_tracking_webhook_integration.py @@ -15,6 +15,7 @@ from features.models import FeatureState from features.versioning.tasks import enable_v2_versioning from features.workflows.core.models import ChangeRequest +from integrations.common.models import IntegrationHealthRecord from integrations.sentry.models import SentryChangeTrackingConfiguration from users.models import FFAdminUser @@ -97,6 +98,10 @@ def test_sentry_change_tracking__flag_created__sends_update_to_sentry( "sentry_action": "created", }, ] + health_record = IntegrationHealthRecord.objects.get( + object_id=sentry_configuration.id + ) + assert health_record.status_code == 200 def test_sentry_change_tracking__flag_state_change__sends_update_to_sentry( diff --git a/api/tests/unit/integrations/amplitude/test_unit_amplitude.py b/api/tests/unit/integrations/amplitude/test_unit_amplitude.py index edd5402bbe47..d73fb54e4c31 100644 --- a/api/tests/unit/integrations/amplitude/test_unit_amplitude.py +++ b/api/tests/unit/integrations/amplitude/test_unit_amplitude.py @@ -1,4 +1,5 @@ import typing +from unittest import mock import pytest @@ -8,6 +9,7 @@ from integrations.amplitude.amplitude import AmplitudeWrapper from integrations.amplitude.constants import DEFAULT_AMPLITUDE_API_URL from integrations.amplitude.models import AmplitudeConfiguration +from integrations.common.models import IntegrationHealthRecord def test_amplitude_wrapper__default_config__sets_correct_url() -> None: @@ -72,3 +74,41 @@ def test_amplitude_generate_user_data__feature_states_with_values__returns_expec } assert expected_user_data == user_data + + +@pytest.mark.django_db +@mock.patch("integrations.amplitude.amplitude.requests.post") +def test_amplitude_wrapper__identify_user__records_healthy_status( + post_mock: mock.Mock, + environment: Environment, +) -> None: + # Given + post_mock.return_value.status_code = 200 + config = AmplitudeConfiguration.objects.create(environment=environment, api_key="123key") + amplitude_wrapper = AmplitudeWrapper(config) + + # When + amplitude_wrapper._identify_user({"user_id": "identity-1", "user_properties": {}}) + + # Then + health_record = IntegrationHealthRecord.objects.get(object_id=config.id) + assert health_record.status_code == 200 + + +@pytest.mark.django_db +@mock.patch("integrations.amplitude.amplitude.requests.post") +def test_amplitude_wrapper__identify_user__records_unhealthy_status( + post_mock: mock.Mock, + environment: Environment, +) -> None: + # Given + post_mock.return_value.status_code = 500 + config = AmplitudeConfiguration.objects.create(environment=environment, api_key="123key") + amplitude_wrapper = AmplitudeWrapper(config) + + # When + amplitude_wrapper._identify_user({"user_id": "identity-1", "user_properties": {}}) + + # Then + health_record = IntegrationHealthRecord.objects.get(object_id=config.id) + assert health_record.status_code == 500 diff --git a/api/tests/unit/integrations/datadog/test_unit_datadog.py b/api/tests/unit/integrations/datadog/test_unit_datadog.py index 8171af2414a6..836546b2521f 100644 --- a/api/tests/unit/integrations/datadog/test_unit_datadog.py +++ b/api/tests/unit/integrations/datadog/test_unit_datadog.py @@ -5,11 +5,13 @@ from audit.models import AuditLog from environments.models import Environment +from integrations.common.models import IntegrationHealthRecord from integrations.datadog.datadog import ( EVENTS_API_URI, FLAGSMITH_SOURCE_TYPE_NAME, DataDogWrapper, ) +from integrations.datadog.models import DataDogConfiguration @pytest.mark.parametrize( @@ -27,7 +29,11 @@ def test_datadog_init__valid_base_url__sets_correct_events_url( # type: ignore[ # When initialized data_dog = DataDogWrapper( - base_url=base_url, api_key=api_key, use_custom_source=True + DataDogConfiguration( + api_key=api_key, + base_url=base_url, + use_custom_source=True, + ) ) # Then @@ -46,6 +52,7 @@ def test_datadog_init__valid_base_url__sets_correct_events_url( # type: ignore[ ), ), ) +@pytest.mark.django_db def test_datadog_track_event__given_event_data__posts_expected_data( mocker: MockerFixture, event_data: dict, # type: ignore[type-arg] @@ -56,12 +63,19 @@ def test_datadog_track_event__given_event_data__posts_expected_data( base_url = "https://test.com" api_key = "key" mock_session = mocker.MagicMock() + mock_session.post.return_value.status_code = 200 + record_integration_health_mock = mocker.patch( + "integrations.datadog.datadog.record_integration_health", + autospec=True, + ) datadog = DataDogWrapper( - base_url=base_url, - api_key=api_key, + DataDogConfiguration( + api_key=api_key, + base_url=base_url, + use_custom_source=use_custom_source, + ), session=mock_session, - use_custom_source=use_custom_source, ) # When @@ -71,6 +85,7 @@ def test_datadog_track_event__given_event_data__posts_expected_data( mock_session.post.assert_called_once_with( f"{datadog.events_url}?api_key={api_key}", data=json.dumps(expected_data) ) + record_integration_health_mock.assert_called_once_with(datadog.config, 200) def test_generate_event_data__valid_audit_log__returns_correct_event( # type: ignore[no-untyped-def] @@ -85,7 +100,9 @@ def test_generate_event_data__valid_audit_log__returns_correct_event( # type: i audit_log_record = AuditLog(log=log, author=author, environment=environment) - data_dog = DataDogWrapper(base_url="http://test.com", api_key="123key") + data_dog = DataDogWrapper( + DataDogConfiguration(api_key="123key", base_url="http://test.com") + ) # When event_data = data_dog.generate_event_data(audit_log_record=audit_log_record) @@ -106,7 +123,9 @@ def test_generate_event_data__missing_author__returns_system_user(feature): # t audit_log_record = AuditLog(log=log, environment=environment) - data_dog = DataDogWrapper(base_url="http://test.com", api_key="123key") + data_dog = DataDogWrapper( + DataDogConfiguration(api_key="123key", base_url="http://test.com") + ) # When event_data = data_dog.generate_event_data(audit_log_record=audit_log_record) @@ -129,7 +148,9 @@ def test_generate_event_data__missing_environment__returns_unknown_env( # type: audit_log_record = AuditLog(log=log, author=author) - data_dog = DataDogWrapper(base_url="http://test.com", api_key="123key") + data_dog = DataDogWrapper( + DataDogConfiguration(api_key="123key", base_url="http://test.com") + ) # When event_data = data_dog.generate_event_data(audit_log_record=audit_log_record) @@ -139,3 +160,26 @@ def test_generate_event_data__missing_environment__returns_unknown_env( # type: assert event_data["text"] == expected_event_text assert len(event_data["tags"]) == 1 assert event_data["tags"][0] == "env:unknown" + + +@pytest.mark.django_db +def test_datadog_track_event__records_health_status( + mocker: MockerFixture, + project, +) -> None: + # Given + config = DataDogConfiguration.objects.create( + project=project, + api_key="123key", + base_url="https://test.com", + ) + mock_session = mocker.MagicMock() + mock_session.post.return_value.status_code = 200 + datadog = DataDogWrapper(config, session=mock_session) + + # When + datadog._track_event({"foo": "bar"}) + + # Then + health_record = IntegrationHealthRecord.objects.get(object_id=config.id) + assert health_record.status_code == 200 diff --git a/api/tests/unit/integrations/dynatrace/test_unit_dynatrace.py b/api/tests/unit/integrations/dynatrace/test_unit_dynatrace.py index 64afb3ca470c..6a846a628c3e 100644 --- a/api/tests/unit/integrations/dynatrace/test_unit_dynatrace.py +++ b/api/tests/unit/integrations/dynatrace/test_unit_dynatrace.py @@ -9,24 +9,26 @@ from audit.related_object_type import RelatedObjectType from environments.models import Environment from features.models import Feature, FeatureState +from integrations.common.models import IntegrationHealthRecord from integrations.dynatrace.dynatrace import EVENTS_API_URI, DynatraceWrapper +from integrations.dynatrace.models import DynatraceConfiguration from projects.models import Project from segments.models import Segment def test_dynatrace_wrapper__valid_config__initializes_correctly(): # type: ignore[no-untyped-def] # Given - api_key = "123key" - base_url = "http://test.com" - entity_selector = "type(APPLICATION),entityName(docs)" + config = DynatraceConfiguration( + api_key="123key", + base_url="http://test.com", + entity_selector="type(APPLICATION),entityName(docs)", + ) # When initialized - dynatrace = DynatraceWrapper( - base_url=base_url, api_key=api_key, entity_selector=entity_selector - ) + dynatrace = DynatraceWrapper(config) # Then - expected_url = f"{base_url}{EVENTS_API_URI}?api-token={api_key}" + expected_url = f"{config.base_url}{EVENTS_API_URI}?api-token={config.api_key}" assert dynatrace.url == expected_url @@ -79,9 +81,11 @@ def test_dynatrace_generate_event_data__correct_values__returns_expected( ) dynatrace = DynatraceWrapper( - base_url="http://test.com", - api_key="123key", - entity_selector="type(APPLICATION),entityName(docs)", + DynatraceConfiguration( + api_key="123key", + base_url="http://test.com", + entity_selector="type(APPLICATION),entityName(docs)", + ) ) # When @@ -108,9 +112,11 @@ def test_dynatrace_generate_event_data__missing_author__returns_system_user(): audit_log_record = AuditLog(log=log, environment=environment) dynatrace = DynatraceWrapper( - base_url="http://test.com", - api_key="123key", - entity_selector="type(APPLICATION),entityName(docs)", + DynatraceConfiguration( + api_key="123key", + base_url="http://test.com", + entity_selector="type(APPLICATION),entityName(docs)", + ) ) # When @@ -138,9 +144,11 @@ def test_dynatrace_generate_event_data__missing_environment__returns_unknown( # ) dynatrace = DynatraceWrapper( - base_url="http://test.com", - api_key="123key", - entity_selector="type(APPLICATION),entityName(docs)", + DynatraceConfiguration( + api_key="123key", + base_url="http://test.com", + entity_selector="type(APPLICATION),entityName(docs)", + ) ) # When @@ -150,3 +158,27 @@ def test_dynatrace_generate_event_data__missing_environment__returns_unknown( # expected_event_text = f"{log} by user {author.email}" assert event_data["properties"]["event"] == expected_event_text assert event_data["properties"]["environment"] == "unknown" + + +@pytest.mark.django_db +def test_dynatrace_track_event__records_health_status( + mocker: MockerFixture, + environment: Environment, +) -> None: + # Given + config = DynatraceConfiguration.objects.create( + environment=environment, + api_key="123key", + base_url="http://test.com/", + entity_selector="type(APPLICATION),entityName(docs)", + ) + dynatrace = DynatraceWrapper(config) + mocked_post = mocker.patch("integrations.dynatrace.dynatrace.requests.post") + mocked_post.return_value.status_code = 200 + + # When + dynatrace._track_event({"eventType": "CUSTOM_DEPLOYMENT"}) + + # Then + health_record = IntegrationHealthRecord.objects.get(object_id=config.id) + assert health_record.status_code == 200 diff --git a/api/tests/unit/integrations/grafana/test_grafana.py b/api/tests/unit/integrations/grafana/test_grafana.py index 78281bfbe005..f2e3bbe212c5 100644 --- a/api/tests/unit/integrations/grafana/test_grafana.py +++ b/api/tests/unit/integrations/grafana/test_grafana.py @@ -5,23 +5,36 @@ from pytest_mock import MockerFixture from audit.models import AuditLog +from integrations.common.models import IntegrationHealthRecord from integrations.grafana.grafana import GrafanaWrapper +from integrations.grafana.models import GrafanaProjectConfiguration @pytest.mark.parametrize("base_url", ["test.com", "test.com/"]) def test_grafana_wrapper__base_url__expected_url(base_url: str) -> None: # Given / When - wrapper = GrafanaWrapper(base_url=base_url, api_key="any") + wrapper = GrafanaWrapper( + GrafanaProjectConfiguration(base_url=base_url, api_key="any") + ) # Then assert wrapper.url == "test.com/api/annotations" +@pytest.mark.django_db @responses.activate() -def test_grafana_wrapper__track_event__expected_api_call() -> None: +def test_grafana_wrapper__track_event__expected_api_call( + mocker: MockerFixture, +) -> None: # Given - wrapper = GrafanaWrapper(base_url="https://test.com", api_key="any") + wrapper = GrafanaWrapper( + GrafanaProjectConfiguration(base_url="https://test.com", api_key="any") + ) event = {"sample": "event"} + record_integration_health_mock = mocker.patch( + "integrations.grafana.grafana.record_integration_health", + autospec=True, + ) responses.add(url="https://test.com/api/annotations", method="POST", status=200) @@ -33,6 +46,27 @@ def test_grafana_wrapper__track_event__expected_api_call() -> None: assert responses.calls[0].request.headers["Authorization"] == "Bearer any" # type: ignore[union-attr] assert responses.calls[0].request.headers["Content-Type"] == "application/json" # type: ignore[union-attr] assert json.loads(responses.calls[0].request.body) == event # type: ignore[union-attr] + record_integration_health_mock.assert_called_once_with(wrapper.config, 200) + + +@pytest.mark.django_db +@responses.activate() +def test_grafana_wrapper__track_event__records_health_status(project) -> None: + # Given + config = GrafanaProjectConfiguration.objects.create( + project=project, + base_url="https://test.com", + api_key="any", + ) + wrapper = GrafanaWrapper(config) + responses.add(url="https://test.com/api/annotations", method="POST", status=200) + + # When + wrapper._track_event({"sample": "event"}) + + # Then + health_record = IntegrationHealthRecord.objects.get(object_id=config.id) + assert health_record.status_code == 200 def test_grafana_wrapper__generate_event_data__return_expected( diff --git a/api/tests/unit/integrations/heap/test_unit_heap.py b/api/tests/unit/integrations/heap/test_unit_heap.py index ae450f47ca5f..5beca1771f40 100644 --- a/api/tests/unit/integrations/heap/test_unit_heap.py +++ b/api/tests/unit/integrations/heap/test_unit_heap.py @@ -5,6 +5,7 @@ from environments.identities.models import Identity from environments.models import Environment from features.models import FeatureState +from integrations.common.models import IntegrationHealthRecord from integrations.heap.heap import HeapWrapper from integrations.heap.models import HeapConfiguration from integrations.integration import identify_integrations @@ -89,6 +90,11 @@ def test_identify_integrations__heap_configured__posts_to_expected_url( base_url=base_url, ) mocked_post = mocker.patch("integrations.heap.heap.requests.post") + mocked_post.return_value.status_code = 200 + record_integration_health_mock = mocker.patch( + "integrations.heap.heap.record_integration_health", + autospec=True, + ) # When identify_integrations(identity, identity.get_all_feature_states()) # type: ignore[no-untyped-call] @@ -96,3 +102,23 @@ def test_identify_integrations__heap_configured__posts_to_expected_url( # Then assert mocked_post.call_args.args[0] == expected_url assert mocked_post.call_args.kwargs["json"]["app_id"] == api_key + record_integration_health_mock.assert_called_once() + + +@pytest.mark.django_db +def test_heap_wrapper__identify_user__records_health_status( + mocker: "MockerFixture", + environment: Environment, +) -> None: + # Given + config = HeapConfiguration.objects.create(environment=environment, api_key="123key") + heap_wrapper = HeapWrapper(config) + mocked_post = mocker.patch("integrations.heap.heap.requests.post") + mocked_post.return_value.status_code = 200 + + # When + heap_wrapper._identify_user({"identity": "identity-1"}) + + # Then + health_record = IntegrationHealthRecord.objects.get(object_id=config.id) + assert health_record.status_code == 200 diff --git a/api/tests/unit/integrations/mixpanel/test_unit_mixpanel.py b/api/tests/unit/integrations/mixpanel/test_unit_mixpanel.py index aca925dc0c85..d9de77dcc397 100644 --- a/api/tests/unit/integrations/mixpanel/test_unit_mixpanel.py +++ b/api/tests/unit/integrations/mixpanel/test_unit_mixpanel.py @@ -3,6 +3,7 @@ import pytest +from integrations.common.models import IntegrationHealthRecord from integrations.mixpanel.constants import DEFAULT_MIXPANEL_API_URL from integrations.mixpanel.mixpanel import MixpanelWrapper from integrations.mixpanel.models import MixpanelConfiguration @@ -77,10 +78,14 @@ def test_mixpanel_identify_user__valid_identity__posts_to_api( caplog: "LogCaptureFixture", feature: "Feature", identity: "Identity", + environment: "Environment", ) -> None: # Given caplog.set_level(DEBUG) - config = MixpanelConfiguration(api_key="123key") + config = MixpanelConfiguration.objects.create( + environment=environment, + api_key="123key", + ) feature_states = [*feature.feature_states.all()] mixpanel = MixpanelWrapper(config) @@ -92,6 +97,10 @@ def test_mixpanel_identify_user__valid_identity__posts_to_api( post_mock = mocker.patch("integrations.mixpanel.mixpanel.requests.post") post_mock.return_value.status_code = 200 post_mock.return_value.text = expected_response_text = "test content" + record_integration_health_mock = mocker.patch( + "integrations.mixpanel.mixpanel.record_integration_health", + autospec=True, + ) # When mixpanel._identify_user(expected_user_data) @@ -114,6 +123,30 @@ def test_mixpanel_identify_user__valid_identity__posts_to_api( f"Sent event to Mixpanel. Response content was: {expected_response_text}", ), ] + record_integration_health_mock.assert_called_once_with(config, 200) + + +@pytest.mark.django_db +def test_mixpanel_identify_user__records_health_status( + mocker: "MockerFixture", + environment: "Environment", +) -> None: + # Given + config = MixpanelConfiguration.objects.create( + environment=environment, + api_key="123key", + ) + mixpanel = MixpanelWrapper(config) + post_mock = mocker.patch("integrations.mixpanel.mixpanel.requests.post") + post_mock.return_value.status_code = 200 + post_mock.return_value.text = "ok" + + # When + mixpanel._identify_user([{"$distinct_id": "identity-1"}]) + + # Then + health_record = IntegrationHealthRecord.objects.get(object_id=config.id) + assert health_record.status_code == 200 def test_mixpanel_generate_user_data__identity_with_features__returns_expected_format( @@ -192,6 +225,11 @@ def test_identify_integrations__mixpanel_configured__posts_to_expected_url( base_url=base_url, ) mocked_post = mocker.patch("integrations.mixpanel.mixpanel.requests.post") + mocked_post.return_value.status_code = 200 + record_integration_health_mock = mocker.patch( + "integrations.mixpanel.mixpanel.record_integration_health", + autospec=True, + ) # When identify_integrations(identity, identity.get_all_feature_states()) # type: ignore[no-untyped-call] @@ -199,3 +237,4 @@ def test_identify_integrations__mixpanel_configured__posts_to_expected_url( # Then assert mocked_post.call_args.args[0] == expected_url assert mocked_post.call_args.kwargs["json"][0]["$token"] == api_key + record_integration_health_mock.assert_called_once() diff --git a/api/tests/unit/integrations/new_relic/test_unit_new_relic.py b/api/tests/unit/integrations/new_relic/test_unit_new_relic.py index 19d8aceadbba..2fd015a97862 100644 --- a/api/tests/unit/integrations/new_relic/test_unit_new_relic.py +++ b/api/tests/unit/integrations/new_relic/test_unit_new_relic.py @@ -1,19 +1,24 @@ from audit.models import AuditLog from environments.models import Environment +import pytest +from integrations.common.models import IntegrationHealthRecord from integrations.new_relic.new_relic import EVENTS_API_URI, NewRelicWrapper +from integrations.new_relic.models import NewRelicConfiguration def test_new_relic_wrapper__valid_config__initializes_correctly(): # type: ignore[no-untyped-def] # Given - api_key = "123key" - app_id = "123id" - base_url = "http://test.com" + config = NewRelicConfiguration( + api_key="123key", + app_id="123id", + base_url="http://test.com", + ) # When initialized - new_relic = NewRelicWrapper(base_url=base_url, api_key=api_key, app_id=app_id) + new_relic = NewRelicWrapper(config) # Then - expected_url = f"{base_url}{EVENTS_API_URI}{app_id}/deployments.json" + expected_url = f"{config.base_url}{EVENTS_API_URI}{config.app_id}/deployments.json" assert new_relic.url == expected_url @@ -29,7 +34,11 @@ def test_new_relic_generate_event_data__correct_values__returns_expected( # typ audit_log_record = AuditLog(log=log, author=author, environment=environment) new_relic = NewRelicWrapper( - base_url="http://test.com", api_key="123key", app_id="123id" + NewRelicConfiguration( + api_key="123key", + app_id="123id", + base_url="http://test.com", + ) ) # When @@ -53,7 +62,11 @@ def test_new_relic_generate_event_data__missing_author__returns_system_user(): audit_log_record = AuditLog(log=log, environment=environment) new_relic = NewRelicWrapper( - base_url="http://test.com", api_key="123key", app_id="123id" + NewRelicConfiguration( + api_key="123key", + app_id="123id", + base_url="http://test.com", + ) ) # When @@ -80,7 +93,11 @@ def test_new_relic_generate_event_data__missing_environment__returns_unknown_env audit_log_record = AuditLog(log=log, author=author) new_relic = NewRelicWrapper( - base_url="http://test.com", api_key="123key", app_id="123id" + NewRelicConfiguration( + api_key="123key", + app_id="123id", + base_url="http://test.com", + ) ) # When @@ -94,3 +111,27 @@ def test_new_relic_generate_event_data__missing_environment__returns_unknown_env assert event_deployment_data["revision"] == "env:unknown" # type: ignore[index] assert event_deployment_data["changelog"] == expected_event_text # type: ignore[index] + + +@pytest.mark.django_db +def test_new_relic_track_event__records_health_status( + mocker, + project, +): + # Given + config = NewRelicConfiguration.objects.create( + project=project, + api_key="123key", + app_id="123id", + base_url="http://test.com", + ) + new_relic = NewRelicWrapper(config) + mocked_post = mocker.patch("integrations.new_relic.new_relic.requests.post") + mocked_post.return_value.status_code = 200 + + # When + new_relic._track_event({"deployment": {}}) + + # Then + health_record = IntegrationHealthRecord.objects.get(object_id=config.id) + assert health_record.status_code == 200 diff --git a/api/tests/unit/integrations/webhook/test_unit_webhook.py b/api/tests/unit/integrations/webhook/test_unit_webhook.py index 9234503b38a2..af91f8a3b970 100644 --- a/api/tests/unit/integrations/webhook/test_unit_webhook.py +++ b/api/tests/unit/integrations/webhook/test_unit_webhook.py @@ -2,6 +2,7 @@ from environments.identities.traits.models import Trait from environments.identities.traits.serializers import TraitSerializerBasic from features.models import Feature, FeatureState +from integrations.common.models import IntegrationHealthRecord from integrations.webhook.serializers import ( IntegrationFeatureStateSerializer, SegmentSerializer, @@ -76,3 +77,25 @@ def test_webhook_generate_user_data__trait_models_provided__uses_trait_models_ar # Then assert expected_data == user_data + + +def test_webhook_identify_user__records_health_status( # type: ignore[no-untyped-def] + mocker, + integration_webhook_config, +): + # Given + webhook_wrapper = WebhookWrapper(integration_webhook_config) + response = mocker.MagicMock(status_code=200) + mocker.patch( + "integrations.webhook.webhook.call_integration_webhook", + return_value=response, + ) + + # When + webhook_wrapper._identify_user({"identity": "identity-1"}) + + # Then + health_record = IntegrationHealthRecord.objects.get( + object_id=integration_webhook_config.id + ) + assert health_record.status_code == 200 From 6add1a7c3844d729edab63b638abce9455c0541d Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Tue, 7 Jul 2026 11:31:13 +0700 Subject: [PATCH 12/63] feat(integrations): add payload field to accomodate new serializer format --- .../unit/integrations/amplitude/test_unit_amplitude_views.py | 1 + .../unit/integrations/dynatrace/test_unit_dynatrace_views.py | 1 + api/tests/unit/integrations/heap/test_unit_heap_views.py | 1 + .../unit/integrations/mixpanel/test_unit_mixpanel_views.py | 1 + .../integrations/rudderstack/test_unit_rudderstack_views.py | 1 + api/tests/unit/integrations/segment/test_unit_segment_views.py | 2 +- 6 files changed, 6 insertions(+), 1 deletion(-) diff --git a/api/tests/unit/integrations/amplitude/test_unit_amplitude_views.py b/api/tests/unit/integrations/amplitude/test_unit_amplitude_views.py index c3cd40ae7359..e9b694c783b4 100644 --- a/api/tests/unit/integrations/amplitude/test_unit_amplitude_views.py +++ b/api/tests/unit/integrations/amplitude/test_unit_amplitude_views.py @@ -111,6 +111,7 @@ def test_list_amplitude_config__config_exists__returns_config_list( "id": config.id, "api_key": config.api_key, "base_url": config.base_url, + "latest_health": None, } assert response.data == [expected_response] diff --git a/api/tests/unit/integrations/dynatrace/test_unit_dynatrace_views.py b/api/tests/unit/integrations/dynatrace/test_unit_dynatrace_views.py index e752bfeb3e01..b16b70a9ce72 100644 --- a/api/tests/unit/integrations/dynatrace/test_unit_dynatrace_views.py +++ b/api/tests/unit/integrations/dynatrace/test_unit_dynatrace_views.py @@ -122,6 +122,7 @@ def test_list_dynatrace_config__config_exists__returns_config_list( "base_url": config.base_url, "entity_selector": "", "id": config.id, + "latest_health": None, } ] diff --git a/api/tests/unit/integrations/heap/test_unit_heap_views.py b/api/tests/unit/integrations/heap/test_unit_heap_views.py index 04daaf24e23a..55a46aacf4ca 100644 --- a/api/tests/unit/integrations/heap/test_unit_heap_views.py +++ b/api/tests/unit/integrations/heap/test_unit_heap_views.py @@ -109,6 +109,7 @@ def test_list_heap_config__config_exists__returns_config_list( "api_key": config.api_key, "id": config.id, "base_url": None, + "latest_health": None, } assert response.data == [expected_response] diff --git a/api/tests/unit/integrations/mixpanel/test_unit_mixpanel_views.py b/api/tests/unit/integrations/mixpanel/test_unit_mixpanel_views.py index cac77fe780aa..8e2b9bef82dd 100644 --- a/api/tests/unit/integrations/mixpanel/test_unit_mixpanel_views.py +++ b/api/tests/unit/integrations/mixpanel/test_unit_mixpanel_views.py @@ -110,6 +110,7 @@ def test_list_mixpanel_config__config_exists__returns_config_list( "api_key": config.api_key, "id": config.id, "base_url": None, + "latest_health": None, } assert response.data == [expected_response] diff --git a/api/tests/unit/integrations/rudderstack/test_unit_rudderstack_views.py b/api/tests/unit/integrations/rudderstack/test_unit_rudderstack_views.py index 15c48e2cf356..e654febe379c 100644 --- a/api/tests/unit/integrations/rudderstack/test_unit_rudderstack_views.py +++ b/api/tests/unit/integrations/rudderstack/test_unit_rudderstack_views.py @@ -112,6 +112,7 @@ def test_list_rudderstack_config__existing_config__returns_200( "api_key": config.api_key, "base_url": config.base_url, "id": config.id, + "latest_health": None, } ] diff --git a/api/tests/unit/integrations/segment/test_unit_segment_views.py b/api/tests/unit/integrations/segment/test_unit_segment_views.py index 116a2ea6b71f..28e4dad98295 100644 --- a/api/tests/unit/integrations/segment/test_unit_segment_views.py +++ b/api/tests/unit/integrations/segment/test_unit_segment_views.py @@ -109,7 +109,7 @@ def test_list_segment_config__existing_config__returns_200( response = admin_client.get(url) # Then - expected_response = {"api_key": config.api_key, "id": config.id, "base_url": None} + expected_response = {"api_key": config.api_key, "id": config.id, "base_url": None, "latest_health": None} assert response.status_code == status.HTTP_200_OK assert response.data == [expected_response] From 3cd4ce3af811f49a26faab2b82883407bb06e905 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:44:52 +0000 Subject: [PATCH 13/63] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/integrations/common/models.py | 8 ++++--- api/integrations/common/serializers.py | 6 ++++-- api/integrations/common/services.py | 21 +++++++++++-------- api/integrations/grafana/grafana.py | 4 +++- .../amplitude/test_unit_amplitude.py | 8 +++++-- .../test_unit_integrations_common_services.py | 2 -- .../new_relic/test_unit_new_relic.py | 5 +++-- .../segment/test_unit_segment_views.py | 7 ++++++- 8 files changed, 39 insertions(+), 22 deletions(-) diff --git a/api/integrations/common/models.py b/api/integrations/common/models.py index 5f71791d4726..a21a117a7437 100644 --- a/api/integrations/common/models.py +++ b/api/integrations/common/models.py @@ -1,4 +1,7 @@ import logging + +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.db import models from django_lifecycle import ( # type: ignore[import-untyped] AFTER_SAVE, @@ -8,8 +11,6 @@ ) from core.models import SoftDeleteExportableModel -from django.contrib.contenttypes.models import ContentType -from django.contrib.contenttypes.fields import GenericForeignKey from environments.models import Environment logger = logging.getLogger(__name__) @@ -42,9 +43,10 @@ def write_environment_to_dynamodb(self): # type: ignore[no-untyped-def] def clear_environment_cache(self): # type: ignore[no-untyped-def] self.environment.clear_environment_cache() + class IntegrationHealthRecord(models.Model): created_at = models.DateTimeField("Created At", auto_now_add=True) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() content_object = GenericForeignKey("content_type", "object_id") - status_code = models.PositiveIntegerField() \ No newline at end of file + status_code = models.PositiveIntegerField() diff --git a/api/integrations/common/serializers.py b/api/integrations/common/serializers.py index 6accf566fc11..3acefeec6808 100644 --- a/api/integrations/common/serializers.py +++ b/api/integrations/common/serializers.py @@ -1,8 +1,9 @@ import typing from django.db.models import Model -from rest_framework.serializers import ModelSerializer from rest_framework.fields import SerializerMethodField +from rest_framework.serializers import ModelSerializer + from .services import get_latest_integration_health @@ -34,10 +35,11 @@ def _get_existing_integration_model_obj( class BaseEnvironmentIntegrationModelSerializer(_BaseIntegrationModelSerializer): one_to_one_field_name = "environment" # type: ignore[assignment] latest_health = SerializerMethodField() - + def get_latest_health(self, obj): return get_latest_integration_health(obj) + class BaseProjectIntegrationModelSerializer(_BaseIntegrationModelSerializer): one_to_one_field_name = "project_id" # type: ignore[assignment] diff --git a/api/integrations/common/services.py b/api/integrations/common/services.py index 7cdc8a4cd395..4749d6b05a2e 100644 --- a/api/integrations/common/services.py +++ b/api/integrations/common/services.py @@ -1,5 +1,5 @@ -from django.db import models from django.contrib.contenttypes.models import ContentType +from django.db import models from integrations.common.models import IntegrationHealthRecord @@ -13,13 +13,16 @@ def record_integration_health( status_code=status_code, ) -def get_latest_integration_health( - integration_config: models.Model -) -> dict | None: - latest_record = IntegrationHealthRecord.objects.filter( - content_type=ContentType.objects.get_for_model(integration_config), - object_id=integration_config.id, - ).order_by("-created_at").first() + +def get_latest_integration_health(integration_config: models.Model) -> dict | None: + latest_record = ( + IntegrationHealthRecord.objects.filter( + content_type=ContentType.objects.get_for_model(integration_config), + object_id=integration_config.id, + ) + .order_by("-created_at") + .first() + ) if not latest_record: return None @@ -28,4 +31,4 @@ def get_latest_integration_health( "status_code": latest_record.status_code, "is_healthy": 200 <= latest_record.status_code < 300, "created_at": latest_record.created_at, - } \ No newline at end of file + } diff --git a/api/integrations/grafana/grafana.py b/api/integrations/grafana/grafana.py index 81464b5fd14d..aee7557a6660 100644 --- a/api/integrations/grafana/grafana.py +++ b/api/integrations/grafana/grafana.py @@ -26,7 +26,9 @@ def __init__( config: GrafanaProjectConfiguration | GrafanaOrganisationConfiguration, ) -> None: self.config = config - base_url = config.base_url[:-1] if config.base_url.endswith("/") else config.base_url + base_url = ( + config.base_url[:-1] if config.base_url.endswith("/") else config.base_url + ) self.url = f"{base_url}{ROUTE_API_ANNOTATIONS}" self.api_key = config.api_key diff --git a/api/tests/unit/integrations/amplitude/test_unit_amplitude.py b/api/tests/unit/integrations/amplitude/test_unit_amplitude.py index d73fb54e4c31..f25f700910c8 100644 --- a/api/tests/unit/integrations/amplitude/test_unit_amplitude.py +++ b/api/tests/unit/integrations/amplitude/test_unit_amplitude.py @@ -84,7 +84,9 @@ def test_amplitude_wrapper__identify_user__records_healthy_status( ) -> None: # Given post_mock.return_value.status_code = 200 - config = AmplitudeConfiguration.objects.create(environment=environment, api_key="123key") + config = AmplitudeConfiguration.objects.create( + environment=environment, api_key="123key" + ) amplitude_wrapper = AmplitudeWrapper(config) # When @@ -103,7 +105,9 @@ def test_amplitude_wrapper__identify_user__records_unhealthy_status( ) -> None: # Given post_mock.return_value.status_code = 500 - config = AmplitudeConfiguration.objects.create(environment=environment, api_key="123key") + config = AmplitudeConfiguration.objects.create( + environment=environment, api_key="123key" + ) amplitude_wrapper = AmplitudeWrapper(config) # When diff --git a/api/tests/unit/integrations/common/test_unit_integrations_common_services.py b/api/tests/unit/integrations/common/test_unit_integrations_common_services.py index c1b5d2ea2661..294643a23322 100644 --- a/api/tests/unit/integrations/common/test_unit_integrations_common_services.py +++ b/api/tests/unit/integrations/common/test_unit_integrations_common_services.py @@ -1,5 +1,3 @@ -from django.utils import timezone - from integrations.common.services import ( get_latest_integration_health, record_integration_health, diff --git a/api/tests/unit/integrations/new_relic/test_unit_new_relic.py b/api/tests/unit/integrations/new_relic/test_unit_new_relic.py index 2fd015a97862..2d044400ba82 100644 --- a/api/tests/unit/integrations/new_relic/test_unit_new_relic.py +++ b/api/tests/unit/integrations/new_relic/test_unit_new_relic.py @@ -1,9 +1,10 @@ +import pytest + from audit.models import AuditLog from environments.models import Environment -import pytest from integrations.common.models import IntegrationHealthRecord -from integrations.new_relic.new_relic import EVENTS_API_URI, NewRelicWrapper from integrations.new_relic.models import NewRelicConfiguration +from integrations.new_relic.new_relic import EVENTS_API_URI, NewRelicWrapper def test_new_relic_wrapper__valid_config__initializes_correctly(): # type: ignore[no-untyped-def] diff --git a/api/tests/unit/integrations/segment/test_unit_segment_views.py b/api/tests/unit/integrations/segment/test_unit_segment_views.py index 28e4dad98295..2bd09b8af0ed 100644 --- a/api/tests/unit/integrations/segment/test_unit_segment_views.py +++ b/api/tests/unit/integrations/segment/test_unit_segment_views.py @@ -109,7 +109,12 @@ def test_list_segment_config__existing_config__returns_200( response = admin_client.get(url) # Then - expected_response = {"api_key": config.api_key, "id": config.id, "base_url": None, "latest_health": None} + expected_response = { + "api_key": config.api_key, + "id": config.id, + "base_url": None, + "latest_health": None, + } assert response.status_code == status.HTTP_200_OK assert response.data == [expected_response] From 7a3e7863e29cd2fa1b9994228f9cd18a1899e202 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 06:56:46 +0700 Subject: [PATCH 14/63] fix(integrations): fix component by lint --- frontend/web/components/IntegrationList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/web/components/IntegrationList.tsx b/frontend/web/components/IntegrationList.tsx index c141ce7bc6cd..58813d766a21 100644 --- a/frontend/web/components/IntegrationList.tsx +++ b/frontend/web/components/IntegrationList.tsx @@ -24,7 +24,7 @@ import each from 'lodash/each' import { useGetProjectQuery } from 'common/services/useProject' import API from 'project/api' import Constants from 'common/constants' -import BooleanDotIndicator from 'components/BooleanDotIndicator' +import BooleanDotIndicator from './BooleanDotIndicator' type IntegrationAction = { label: string From 62670ad166e2f6d5f3a9f30e64e0d3217ad579b5 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:00:34 +0700 Subject: [PATCH 15/63] fix(serializer): add type notation --- api/integrations/common/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/integrations/common/serializers.py b/api/integrations/common/serializers.py index 3acefeec6808..5ccf142c2f06 100644 --- a/api/integrations/common/serializers.py +++ b/api/integrations/common/serializers.py @@ -36,7 +36,7 @@ class BaseEnvironmentIntegrationModelSerializer(_BaseIntegrationModelSerializer) one_to_one_field_name = "environment" # type: ignore[assignment] latest_health = SerializerMethodField() - def get_latest_health(self, obj): + def get_latest_health(self, obj: Model) -> dict | None: return get_latest_integration_health(obj) From 3cd6231034aa1ced448b8e9042d9b045becbf342 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:06:42 +0700 Subject: [PATCH 16/63] fix(serializer): parameterize return type and use .pk instead of .id --- api/integrations/common/services.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/integrations/common/services.py b/api/integrations/common/services.py index 4749d6b05a2e..d196118e9e19 100644 --- a/api/integrations/common/services.py +++ b/api/integrations/common/services.py @@ -1,3 +1,5 @@ +from typing import Any + from django.contrib.contenttypes.models import ContentType from django.db import models @@ -14,11 +16,11 @@ def record_integration_health( ) -def get_latest_integration_health(integration_config: models.Model) -> dict | None: +def get_latest_integration_health(integration_config: models.Model) -> dict[str, Any] | None: latest_record = ( IntegrationHealthRecord.objects.filter( content_type=ContentType.objects.get_for_model(integration_config), - object_id=integration_config.id, + object_id=integration_config.pk, ) .order_by("-created_at") .first() From 515240969c604d2432f96a3d462c8d40a390aa86 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:07:43 +0000 Subject: [PATCH 17/63] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/integrations/common/serializers.py | 2 +- api/integrations/common/services.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/api/integrations/common/serializers.py b/api/integrations/common/serializers.py index 5ccf142c2f06..d33fab0d9f67 100644 --- a/api/integrations/common/serializers.py +++ b/api/integrations/common/serializers.py @@ -36,7 +36,7 @@ class BaseEnvironmentIntegrationModelSerializer(_BaseIntegrationModelSerializer) one_to_one_field_name = "environment" # type: ignore[assignment] latest_health = SerializerMethodField() - def get_latest_health(self, obj: Model) -> dict | None: + def get_latest_health(self, obj: Model) -> dict | None: return get_latest_integration_health(obj) diff --git a/api/integrations/common/services.py b/api/integrations/common/services.py index d196118e9e19..56d36b7618f8 100644 --- a/api/integrations/common/services.py +++ b/api/integrations/common/services.py @@ -16,7 +16,9 @@ def record_integration_health( ) -def get_latest_integration_health(integration_config: models.Model) -> dict[str, Any] | None: +def get_latest_integration_health( + integration_config: models.Model, +) -> dict[str, Any] | None: latest_record = ( IntegrationHealthRecord.objects.filter( content_type=ContentType.objects.get_for_model(integration_config), From a21a5d032bae532ade3cc70a35cf3a94f8d3c868 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:09:46 +0700 Subject: [PATCH 18/63] fix(integrations-newrelic): normalize base_url --- api/integrations/new_relic/new_relic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/integrations/new_relic/new_relic.py b/api/integrations/new_relic/new_relic.py index 1eedffb25052..5838b9df18d1 100644 --- a/api/integrations/new_relic/new_relic.py +++ b/api/integrations/new_relic/new_relic.py @@ -16,7 +16,7 @@ class NewRelicWrapper(AbstractBaseEventIntegrationWrapper): def __init__(self, config: NewRelicConfiguration): self.config = config - self.base_url = config.base_url + self.base_url = config.base_url.rstrip("/") + "/" self.api_key = config.api_key self.app_id = config.app_id self.url = f"{self.base_url}{EVENTS_API_URI}{self.app_id}/deployments.json" From 5371467a9132184a3169b24732b4e7ba528a11a0 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:16:11 +0700 Subject: [PATCH 19/63] fix(integrations-newrelic): use try and except --- api/integrations/new_relic/new_relic.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/api/integrations/new_relic/new_relic.py b/api/integrations/new_relic/new_relic.py index 5838b9df18d1..3965ba8876a5 100644 --- a/api/integrations/new_relic/new_relic.py +++ b/api/integrations/new_relic/new_relic.py @@ -21,13 +21,17 @@ def __init__(self, config: NewRelicConfiguration): self.app_id = config.app_id self.url = f"{self.base_url}{EVENTS_API_URI}{self.app_id}/deployments.json" - def _track_event(self, event: dict) -> None: # type: ignore[type-arg] - response = requests.post( - self.url, headers=self._headers(), data=json.dumps(event) - ) - record_integration_health(self.config, response.status_code) - logger.debug( - "Sent event to NewRelic. Response code was %s" % response.status_code + def _track_event(self, event: dict) -> None: # type: ignore[type-arg] + try: + response = requests.post( + self.url, headers=self._headers(), data=json.dumps(event), timeout=10 + ) + except requests.exceptions.RequestException: + logger.warning("Failed to send event to NewRelic", exc_info=True) + return + record_integration_health(self.config, response.status_code) + logger.debug( + "Sent event to NewRelic. Response code was %s" % response.status_code ) def _headers(self) -> dict: # type: ignore[type-arg] From 9d6f4769ca1f36ceb423077b85b53953975f9d36 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:22:56 +0700 Subject: [PATCH 20/63] fix(integrations): use try and except to prevent crash --- api/integrations/common/services.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/api/integrations/common/services.py b/api/integrations/common/services.py index 56d36b7618f8..1384c6c728e9 100644 --- a/api/integrations/common/services.py +++ b/api/integrations/common/services.py @@ -5,15 +5,22 @@ from integrations.common.models import IntegrationHealthRecord - -def record_integration_health( - integration_config: models.Model, - status_code: int, -) -> None: - IntegrationHealthRecord.objects.create( - content_object=integration_config, - status_code=status_code, - ) +import logging + +logger = logging.getLogger(__name__) + + +def record_integration_health( + integration_config: models.Model, + status_code: int, +) -> None: + try: + IntegrationHealthRecord.objects.create( + content_object=integration_config, + status_code=status_code, + ) + except Exception: + logger.exception("Failed to record integration health.") def get_latest_integration_health( From 43f80d8adb514bcc43eb09d334ef921b6c837a93 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:28:00 +0000 Subject: [PATCH 21/63] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/integrations/common/services.py | 23 +++++++++++------------ api/integrations/new_relic/new_relic.py | 22 +++++++++++----------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/api/integrations/common/services.py b/api/integrations/common/services.py index 1384c6c728e9..1ceb029c4f78 100644 --- a/api/integrations/common/services.py +++ b/api/integrations/common/services.py @@ -1,3 +1,4 @@ +import logging from typing import Any from django.contrib.contenttypes.models import ContentType @@ -5,21 +6,19 @@ from integrations.common.models import IntegrationHealthRecord -import logging - logger = logging.getLogger(__name__) -def record_integration_health( - integration_config: models.Model, - status_code: int, -) -> None: - try: - IntegrationHealthRecord.objects.create( - content_object=integration_config, - status_code=status_code, - ) - except Exception: +def record_integration_health( + integration_config: models.Model, + status_code: int, +) -> None: + try: + IntegrationHealthRecord.objects.create( + content_object=integration_config, + status_code=status_code, + ) + except Exception: logger.exception("Failed to record integration health.") diff --git a/api/integrations/new_relic/new_relic.py b/api/integrations/new_relic/new_relic.py index 3965ba8876a5..ac9872bc5caf 100644 --- a/api/integrations/new_relic/new_relic.py +++ b/api/integrations/new_relic/new_relic.py @@ -21,17 +21,17 @@ def __init__(self, config: NewRelicConfiguration): self.app_id = config.app_id self.url = f"{self.base_url}{EVENTS_API_URI}{self.app_id}/deployments.json" - def _track_event(self, event: dict) -> None: # type: ignore[type-arg] - try: - response = requests.post( - self.url, headers=self._headers(), data=json.dumps(event), timeout=10 - ) - except requests.exceptions.RequestException: - logger.warning("Failed to send event to NewRelic", exc_info=True) - return - record_integration_health(self.config, response.status_code) - logger.debug( - "Sent event to NewRelic. Response code was %s" % response.status_code + def _track_event(self, event: dict) -> None: # type: ignore[type-arg] + try: + response = requests.post( + self.url, headers=self._headers(), data=json.dumps(event), timeout=10 + ) + except requests.exceptions.RequestException: + logger.warning("Failed to send event to NewRelic", exc_info=True) + return + record_integration_health(self.config, response.status_code) + logger.debug( + "Sent event to NewRelic. Response code was %s" % response.status_code ) def _headers(self) -> dict: # type: ignore[type-arg] From d5a982ffcbb1d10f082f2cf8e748dc81bf98a6ab Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:37:39 +0700 Subject: [PATCH 22/63] fix(integrations-amplitude): add timeout for requests.post() --- api/integrations/amplitude/amplitude.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/integrations/amplitude/amplitude.py b/api/integrations/amplitude/amplitude.py index fb45076d86b9..f2200920cb69 100644 --- a/api/integrations/amplitude/amplitude.py +++ b/api/integrations/amplitude/amplitude.py @@ -26,7 +26,7 @@ def __init__(self, config: AmplitudeConfiguration): def _identify_user(self, user_data: AmplitudeUserData) -> None: payload = {"api_key": self.api_key, "identification": json.dumps([user_data])} - response = requests.post(self.url, data=payload) + response = requests.post(self.url, data=payload, timeout=10) record_integration_health(self.config, response.status_code) logger.debug( "Sent event to Amplitude. Response code was: %s" % response.status_code From 13022b197accf49b64586df40738dbd1a1214333 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:44:10 +0700 Subject: [PATCH 23/63] fix(integrations-amlitude): use try and except --- api/integrations/amplitude/amplitude.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/integrations/amplitude/amplitude.py b/api/integrations/amplitude/amplitude.py index f2200920cb69..87e4ec5ce409 100644 --- a/api/integrations/amplitude/amplitude.py +++ b/api/integrations/amplitude/amplitude.py @@ -27,7 +27,10 @@ def _identify_user(self, user_data: AmplitudeUserData) -> None: payload = {"api_key": self.api_key, "identification": json.dumps([user_data])} response = requests.post(self.url, data=payload, timeout=10) - record_integration_health(self.config, response.status_code) + try: + record_integration_health(self.config, response.status_code) + except Exception: + logger.warning("Failed to record Amplitude integration health") logger.debug( "Sent event to Amplitude. Response code was: %s" % response.status_code ) From 1ab3906ff080f415424decc60ecf7cd4e1c5e2d4 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:44:17 +0700 Subject: [PATCH 24/63] fix(integrations-datadog): use try and except --- api/integrations/datadog/datadog.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/integrations/datadog/datadog.py b/api/integrations/datadog/datadog.py index 8472cec937d1..71c10216f00f 100644 --- a/api/integrations/datadog/datadog.py +++ b/api/integrations/datadog/datadog.py @@ -49,7 +49,11 @@ def _track_event(self, event: dict) -> None: # type: ignore[type-arg] response = self.session.post( f"{self.events_url}?api_key={self.api_key}", data=json.dumps(event) ) - record_integration_health(self.config, response.status_code) + + try: + record_integration_health(self.config, response.status_code) + except Exception: + logger.warning("Failed to record DataDog integration health") logger.debug( "Sent event to DataDog. Response code was %s" % response.status_code ) From 28bf293682a5d9ee6ccfe9aaeb143b1c667dd176 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:44:37 +0700 Subject: [PATCH 25/63] fix(integrations-dynatrace): use try and except --- api/integrations/dynatrace/dynatrace.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/integrations/dynatrace/dynatrace.py b/api/integrations/dynatrace/dynatrace.py index e700af84ea9c..807e2e668f8e 100644 --- a/api/integrations/dynatrace/dynatrace.py +++ b/api/integrations/dynatrace/dynatrace.py @@ -34,7 +34,11 @@ def _track_event(self, event: dict) -> None: # type: ignore[type-arg] response = requests.post( self.url, headers=self._headers(), data=json.dumps(event) ) - record_integration_health(self.config, response.status_code) + + try: + record_integration_health(self.config, response.status_code) + except Exception: + logger.warning("Failed to record Dynatrace integration health") logger.debug( "Sent event to Dynatrace. Response code was %s" % response.status_code ) From cf3bb8c6bd934baae1226681c694de616a85db47 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:44:56 +0700 Subject: [PATCH 26/63] fix(integrations-grafana): use try and except --- api/integrations/grafana/grafana.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/integrations/grafana/grafana.py b/api/integrations/grafana/grafana.py index aee7557a6660..a3f522228f21 100644 --- a/api/integrations/grafana/grafana.py +++ b/api/integrations/grafana/grafana.py @@ -48,8 +48,11 @@ def _track_event(self, event: dict[str, Any]) -> None: headers=self._headers(), data=json.dumps(event), ) - record_integration_health(self.config, response.status_code) + try: + record_integration_health(self.config, response.status_code) + except Exception: + logger.warning("Failed to record Grafana integration health") logger.debug( "Sent event to Grafana. Response code was %s" % response.status_code ) From 8e658e91196dc4a3d7c21a72fae65c98fae4911a Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:45:15 +0700 Subject: [PATCH 27/63] fix(integrations-heap): use try and except --- api/integrations/heap/heap.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/integrations/heap/heap.py b/api/integrations/heap/heap.py index d6845c2ba765..70990f3d8a4b 100644 --- a/api/integrations/heap/heap.py +++ b/api/integrations/heap/heap.py @@ -24,7 +24,11 @@ def __init__(self, config: HeapConfiguration): def _identify_user(self, user_data: dict) -> None: # type: ignore[type-arg] response = requests.post(self.url, json=user_data) - record_integration_health(self.config, response.status_code) + + try: + record_integration_health(self.config, response.status_code) + except Exception: + logger.warning("Failed to record Heap integration health") logger.debug("Sent event to Heap. Response code was: %s" % response.status_code) def generate_user_data( From 15020a9b20f179880ac55c085a0a6a60f17c6904 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:45:26 +0700 Subject: [PATCH 28/63] fix(integrations-mixpanel): use try and except --- api/integrations/mixpanel/mixpanel.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/integrations/mixpanel/mixpanel.py b/api/integrations/mixpanel/mixpanel.py index 77d4852fae3c..8a6f0bbd94a1 100644 --- a/api/integrations/mixpanel/mixpanel.py +++ b/api/integrations/mixpanel/mixpanel.py @@ -33,7 +33,11 @@ def __init__(self, config: MixpanelConfiguration): def _identify_user(self, user_data: MixpanelUserData) -> None: response = requests.post(self.url, headers=self.headers, json=user_data) - record_integration_health(self.config, response.status_code) + + try: + record_integration_health(self.config, response.status_code) + except Exception: + logger.warning("Failed to record Mixpanel integration health") logger.debug( "Sent event to Mixpanel. Response code was: %s" % response.status_code ) From 2fef4b0482c251eeda2364ab400971826f142125 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:45:39 +0700 Subject: [PATCH 29/63] fix(integrations-newrelic): use try and except --- api/integrations/new_relic/new_relic.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/integrations/new_relic/new_relic.py b/api/integrations/new_relic/new_relic.py index ac9872bc5caf..b21a4b049aa2 100644 --- a/api/integrations/new_relic/new_relic.py +++ b/api/integrations/new_relic/new_relic.py @@ -29,7 +29,11 @@ def _track_event(self, event: dict) -> None: # type: ignore[type-arg] except requests.exceptions.RequestException: logger.warning("Failed to send event to NewRelic", exc_info=True) return - record_integration_health(self.config, response.status_code) + + try: + record_integration_health(self.config, response.status_code) + except Exception: + logger.warning("Failed to record New Relic integration health") logger.debug( "Sent event to NewRelic. Response code was %s" % response.status_code ) From 179151af224de84d177bc266a0ddc85597d48d32 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:45:52 +0700 Subject: [PATCH 30/63] fix(integrations-webhook): use try and except --- api/integrations/webhook/webhook.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/integrations/webhook/webhook.py b/api/integrations/webhook/webhook.py index 5f3645a12b74..e477c8d37128 100644 --- a/api/integrations/webhook/webhook.py +++ b/api/integrations/webhook/webhook.py @@ -24,7 +24,10 @@ def __init__(self, config: WebhookConfiguration): def _identify_user(self, data: typing.Mapping) -> None: # type: ignore[type-arg] response = call_integration_webhook(self.config, data) if response: - record_integration_health(self.config, response.status_code) + try: + record_integration_health(self.config, response.status_code) + except Exception: + logger.warning("Failed to record Webhook integration health") logger.debug( "Sent event to Webhook. Response code was: %s" % response.status_code ) From 2719fad28506d8b95e38206c5b9d8096d64481cc Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:12:08 +0700 Subject: [PATCH 31/63] feat(docs): add guide on how to cleanup integration health record --- api/integrations/README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 api/integrations/README.md diff --git a/api/integrations/README.md b/api/integrations/README.md new file mode 100644 index 000000000000..7486f2219937 --- /dev/null +++ b/api/integrations/README.md @@ -0,0 +1,29 @@ +# Integrations + +## Integration Health Records Cleanup + +Integration health records are stored to track the status of external integration calls. These records can grow over time. A management command is provided to clean up old records. + +### Running the cleanup command + +To delete integration health records older than 30 days (default): + +```bash +python manage.py cleanup_integration_health_records +``` + +To specify a custom retention period: + +```bash +python manage.py cleanup_integration_health_records --days=60 +``` + +### Scheduling automatic cleanup + +To prevent the `IntegrationHealthRecord` table from growing unbounded, schedule the cleanup command to run periodically using cron or your preferred task scheduler. + +Example cron entry (runs daily at 3 AM): + +```cron +0 3 * * * cd /path/to/flagsmith && python manage.py cleanup_integration_health_records +``` \ No newline at end of file From 1656c15c6e424e871a5e0f2369f3332ebb8b528e Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:12:51 +0700 Subject: [PATCH 32/63] feat(integrations): add scheduled action to cleanup health records that are older than 30 days --- .../common/management/__init__.py | 0 .../common/management/commands/__init__.py | 0 .../cleanup_integration_health_records.py | 24 +++++++++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 api/integrations/common/management/__init__.py create mode 100644 api/integrations/common/management/commands/__init__.py create mode 100644 api/integrations/common/management/commands/cleanup_integration_health_records.py diff --git a/api/integrations/common/management/__init__.py b/api/integrations/common/management/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/common/management/commands/__init__.py b/api/integrations/common/management/commands/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/common/management/commands/cleanup_integration_health_records.py b/api/integrations/common/management/commands/cleanup_integration_health_records.py new file mode 100644 index 000000000000..1c1ba9342e6e --- /dev/null +++ b/api/integrations/common/management/commands/cleanup_integration_health_records.py @@ -0,0 +1,24 @@ +from django.core.management import BaseCommand +from django.utils import timezone + +from integrations.common.models import IntegrationHealthRecord + + +class Command(BaseCommand): + help = "Delete integration health records older than the specified number of days." + + def add_arguments(self, parser): + parser.add_argument( + "--days", + type=int, + default=30, + help="Delete records older than this many days (default: 30).", + ) + + def handle(self, *args, **options): + days = options["days"] + cutoff = timezone.now() - timezone.timedelta(days=days) + deleted, _ = IntegrationHealthRecord.objects.filter( + created_at__lt=cutoff + ).delete() + self.stdout.write(f"Deleted {deleted} integration health record(s) older than {days} days.") \ No newline at end of file From f41d82f52654944f8410a6a5c7671ffb02bf8abc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:13:15 +0000 Subject: [PATCH 33/63] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../management/commands/cleanup_integration_health_records.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/integrations/common/management/commands/cleanup_integration_health_records.py b/api/integrations/common/management/commands/cleanup_integration_health_records.py index 1c1ba9342e6e..8eb3b6a92cab 100644 --- a/api/integrations/common/management/commands/cleanup_integration_health_records.py +++ b/api/integrations/common/management/commands/cleanup_integration_health_records.py @@ -21,4 +21,6 @@ def handle(self, *args, **options): deleted, _ = IntegrationHealthRecord.objects.filter( created_at__lt=cutoff ).delete() - self.stdout.write(f"Deleted {deleted} integration health record(s) older than {days} days.") \ No newline at end of file + self.stdout.write( + f"Deleted {deleted} integration health record(s) older than {days} days." + ) From 4daa9a81ede54f75639cb76ba1165768d0c893d8 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:14:13 +0700 Subject: [PATCH 34/63] feat(integrations): add Meta class for IntegrationHealthRecord class --- api/integrations/common/models.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/integrations/common/models.py b/api/integrations/common/models.py index a21a117a7437..bac94071237f 100644 --- a/api/integrations/common/models.py +++ b/api/integrations/common/models.py @@ -50,3 +50,8 @@ class IntegrationHealthRecord(models.Model): object_id = models.PositiveIntegerField() content_object = GenericForeignKey("content_type", "object_id") status_code = models.PositiveIntegerField() + + class Meta: + indexes = [ + models.Index(fields=["content_type", "object_id", "-created_at"]), + ] From c18a57c579a7d05f720c3c50ec2e5abaec8d25fd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:15:13 +0000 Subject: [PATCH 35/63] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/integrations/common/models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/integrations/common/models.py b/api/integrations/common/models.py index bac94071237f..8759733a3f64 100644 --- a/api/integrations/common/models.py +++ b/api/integrations/common/models.py @@ -50,8 +50,8 @@ class IntegrationHealthRecord(models.Model): object_id = models.PositiveIntegerField() content_object = GenericForeignKey("content_type", "object_id") status_code = models.PositiveIntegerField() - - class Meta: - indexes = [ - models.Index(fields=["content_type", "object_id", "-created_at"]), + + class Meta: + indexes = [ + models.Index(fields=["content_type", "object_id", "-created_at"]), ] From 0105e6b662f2a9d9ce3e183315916cbb94b61ec1 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:27:27 +0700 Subject: [PATCH 36/63] feat(integrations): add migration for health record indexing --- .../0002_add_health_record_index.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 api/integrations/common/migrations/0002_add_health_record_index.py diff --git a/api/integrations/common/migrations/0002_add_health_record_index.py b/api/integrations/common/migrations/0002_add_health_record_index.py new file mode 100644 index 000000000000..49c6bc910d74 --- /dev/null +++ b/api/integrations/common/migrations/0002_add_health_record_index.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.15 on 2026-07-09 01:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("common", "0001_initial"), + ("contenttypes", "0002_remove_content_type_name"), + ] + + operations = [ + migrations.AddIndex( + model_name="integrationhealthrecord", + index=models.Index( + fields=["content_type", "object_id", "-created_at"], + name="common_inte_content_bd807d_idx", + ), + ), + ] From 2cf644a44054730729a05e6d2f4c6e37a3e46e8e Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:29:07 +0700 Subject: [PATCH 37/63] fix(integrations-dynatrace): normalize base_url --- api/integrations/dynatrace/dynatrace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/integrations/dynatrace/dynatrace.py b/api/integrations/dynatrace/dynatrace.py index 807e2e668f8e..1540dcc05b5a 100644 --- a/api/integrations/dynatrace/dynatrace.py +++ b/api/integrations/dynatrace/dynatrace.py @@ -24,7 +24,7 @@ class DynatraceWrapper(AbstractBaseEventIntegrationWrapper): def __init__(self, config: DynatraceConfiguration): self.config = config - self.base_url = config.base_url + self.base_url = config.base_url.rstrip("/") + "/" self.api_key = config.api_key self.entity_selector = config.entity_selector self.url = f"{self.base_url}{EVENTS_API_URI}?api-token={self.api_key}" From bd91942540c2f0d4e692009333520f9cecf35726 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:30:08 +0700 Subject: [PATCH 38/63] fix(integrations-dynatrace): add timeout for requests.post() --- api/integrations/dynatrace/dynatrace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/integrations/dynatrace/dynatrace.py b/api/integrations/dynatrace/dynatrace.py index 1540dcc05b5a..d89c454d58fd 100644 --- a/api/integrations/dynatrace/dynatrace.py +++ b/api/integrations/dynatrace/dynatrace.py @@ -32,7 +32,7 @@ def __init__(self, config: DynatraceConfiguration): def _track_event(self, event: dict) -> None: # type: ignore[type-arg] event["entitySelector"] = self.entity_selector response = requests.post( - self.url, headers=self._headers(), data=json.dumps(event) + self.url, headers=self._headers(), data=json.dumps(event), timeout=10 ) try: From 7ed565936048fadccf9ba506f037253e7b5b8769 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:30:59 +0700 Subject: [PATCH 39/63] fix(integrations-heap): add timeout for requests.post() --- api/integrations/heap/heap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/integrations/heap/heap.py b/api/integrations/heap/heap.py index 70990f3d8a4b..40498a701319 100644 --- a/api/integrations/heap/heap.py +++ b/api/integrations/heap/heap.py @@ -23,7 +23,7 @@ def __init__(self, config: HeapConfiguration): self.url = f"{base_url}/api/track" def _identify_user(self, user_data: dict) -> None: # type: ignore[type-arg] - response = requests.post(self.url, json=user_data) + response = requests.post(self.url, json=user_data, timeout=10) try: record_integration_health(self.config, response.status_code) From 275956f4d4021283196a93a95c3446faec594908 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:37:28 +0700 Subject: [PATCH 40/63] fix(tests): add content_type when searching for health records --- .../sentry/test_change_tracking_webhook_integration.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/tests/integration/sentry/test_change_tracking_webhook_integration.py b/api/tests/integration/sentry/test_change_tracking_webhook_integration.py index ed33be29c368..29282d431d7b 100644 --- a/api/tests/integration/sentry/test_change_tracking_webhook_integration.py +++ b/api/tests/integration/sentry/test_change_tracking_webhook_integration.py @@ -18,7 +18,7 @@ from integrations.common.models import IntegrationHealthRecord from integrations.sentry.models import SentryChangeTrackingConfiguration from users.models import FFAdminUser - +from django.contrib.contenttypes.models import ContentType @pytest.fixture(autouse=True) def sentry_configuration(environment: int) -> SentryChangeTrackingConfiguration: @@ -99,6 +99,7 @@ def test_sentry_change_tracking__flag_created__sends_update_to_sentry( }, ] health_record = IntegrationHealthRecord.objects.get( + content_type=ContentType.objects.get_for_model(sentry_configuration), object_id=sentry_configuration.id ) assert health_record.status_code == 200 From 25b4fb1cba9ca8b68361ae98b979fe2bb28de3fd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:38:02 +0000 Subject: [PATCH 41/63] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../sentry/test_change_tracking_webhook_integration.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/tests/integration/sentry/test_change_tracking_webhook_integration.py b/api/tests/integration/sentry/test_change_tracking_webhook_integration.py index 29282d431d7b..8e4e4b30277c 100644 --- a/api/tests/integration/sentry/test_change_tracking_webhook_integration.py +++ b/api/tests/integration/sentry/test_change_tracking_webhook_integration.py @@ -6,6 +6,7 @@ import freezegun import pytest +from django.contrib.contenttypes.models import ContentType from django.urls import reverse from pytest_structlog import StructuredLogCapture from responses import RequestsMock @@ -18,7 +19,7 @@ from integrations.common.models import IntegrationHealthRecord from integrations.sentry.models import SentryChangeTrackingConfiguration from users.models import FFAdminUser -from django.contrib.contenttypes.models import ContentType + @pytest.fixture(autouse=True) def sentry_configuration(environment: int) -> SentryChangeTrackingConfiguration: @@ -100,7 +101,7 @@ def test_sentry_change_tracking__flag_created__sends_update_to_sentry( ] health_record = IntegrationHealthRecord.objects.get( content_type=ContentType.objects.get_for_model(sentry_configuration), - object_id=sentry_configuration.id + object_id=sentry_configuration.id, ) assert health_record.status_code == 200 From 92e3c818402ef5aa5f70f4673d1ec7707ea22bec Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:40:54 +0700 Subject: [PATCH 42/63] fix(tests): add coverage for non-2xx status code --- ...st_unit_integrations_common_serializers.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/api/tests/unit/integrations/common/test_unit_integrations_common_serializers.py b/api/tests/unit/integrations/common/test_unit_integrations_common_serializers.py index 952cde3400ae..7964e7caabd6 100644 --- a/api/tests/unit/integrations/common/test_unit_integrations_common_serializers.py +++ b/api/tests/unit/integrations/common/test_unit_integrations_common_serializers.py @@ -77,6 +77,26 @@ def test_base_environment_integration_model_serializer__latest_health__has_recor assert data["latest_health"]["created_at"] is not None +def test_base_environment_integration_model_serializer__latest_health__unhealthy_status( + environment, +): + # Given + webhook_config = WebhookConfiguration.objects.create( + environment=environment, url="https://webhook.url" + ) + record_integration_health(webhook_config, 500) + + # When + serializer = WebhookConfigurationSerializer(webhook_config) + data = serializer.data + + # Then + assert data["latest_health"] is not None + assert data["latest_health"]["status_code"] == 500 + assert data["latest_health"]["is_healthy"] is False + assert data["latest_health"]["created_at"] is not None + + def test_base_project_integration_model_serializer__soft_deleted_exists__updates_existing( # type: ignore[no-untyped-def] # noqa: E501 project, ): From b15cadd9a9989b05dd17df37fa8e2b259577504d Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:42:11 +0700 Subject: [PATCH 43/63] fix(tests): add None type for function --- .../common/test_unit_integrations_common_services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/unit/integrations/common/test_unit_integrations_common_services.py b/api/tests/unit/integrations/common/test_unit_integrations_common_services.py index 294643a23322..641675e00c79 100644 --- a/api/tests/unit/integrations/common/test_unit_integrations_common_services.py +++ b/api/tests/unit/integrations/common/test_unit_integrations_common_services.py @@ -5,7 +5,7 @@ from integrations.webhook.models import WebhookConfiguration -def test_get_latest_integration_health__no_record__returns_none(environment): +def test_get_latest_integration_health__no_record__returns_none(environment) -> None: # Given webhook_config = WebhookConfiguration.objects.create( environment=environment, url="https://webhook.url" From 47c29784f6666c0249f9d5b178a85e3ebfdb63f9 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:43:39 +0700 Subject: [PATCH 44/63] fix(tests): assert result is not None --- .../common/test_unit_integrations_common_services.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/tests/unit/integrations/common/test_unit_integrations_common_services.py b/api/tests/unit/integrations/common/test_unit_integrations_common_services.py index 641675e00c79..543cc9956b61 100644 --- a/api/tests/unit/integrations/common/test_unit_integrations_common_services.py +++ b/api/tests/unit/integrations/common/test_unit_integrations_common_services.py @@ -50,6 +50,7 @@ def test_get_latest_integration_health__multiple_records__returns_latest( result = get_latest_integration_health(webhook_config) # Then + assert result is not None assert result["status_code"] == 200 assert result["is_healthy"] is True From de63aedd0d2b969cd5b5c0ae90671e75070aade2 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:51:09 +0700 Subject: [PATCH 45/63] fix(tests): add testing for anti-pattern --- .../integrations/webhook/test_unit_webhook.py | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/api/tests/unit/integrations/webhook/test_unit_webhook.py b/api/tests/unit/integrations/webhook/test_unit_webhook.py index af91f8a3b970..0acde620406e 100644 --- a/api/tests/unit/integrations/webhook/test_unit_webhook.py +++ b/api/tests/unit/integrations/webhook/test_unit_webhook.py @@ -1,4 +1,7 @@ +import requests + from core.constants import STRING +from django.contrib.contenttypes.models import ContentType from environments.identities.traits.models import Trait from environments.identities.traits.serializers import TraitSerializerBasic from features.models import Feature, FeatureState @@ -85,7 +88,14 @@ def test_webhook_identify_user__records_health_status( # type: ignore[no-untype ): # Given webhook_wrapper = WebhookWrapper(integration_webhook_config) - response = mocker.MagicMock(status_code=200) + + response = mocker.MagicMock(spec=requests.Response, status_code=200) + + def bool_true(self): + return True + + response.__bool__ = bool_true.__get__(response) + mocker.patch( "integrations.webhook.webhook.call_integration_webhook", return_value=response, @@ -96,6 +106,37 @@ def test_webhook_identify_user__records_health_status( # type: ignore[no-untype # Then health_record = IntegrationHealthRecord.objects.get( - object_id=integration_webhook_config.id + object_id=integration_webhook_config.id, + content_type=ContentType.objects.get_for_model(integration_webhook_config), ) assert health_record.status_code == 200 + + +def test_webhook_identify_user__unhealthy_status__records_health( + mocker, + integration_webhook_config, +): + # Given + webhook_wrapper = WebhookWrapper(integration_webhook_config) + + response = mocker.MagicMock(spec=requests.Response, status_code=500) + + def bool_true(self): + return True + + response.__bool__ = bool_true.__get__(response) + + mocker.patch( + "integrations.webhook.webhook.call_integration_webhook", + return_value=response, + ) + + # When + webhook_wrapper._identify_user({"identity": "identity-1"}) + + # Then + health_record = IntegrationHealthRecord.objects.get( + object_id=integration_webhook_config.id, + content_type=ContentType.objects.get_for_model(integration_webhook_config), + ) + assert health_record.status_code == 500 From fd743547f949c75997d19e8a38c7dd5d2271a17e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:52:12 +0000 Subject: [PATCH 46/63] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/tests/unit/integrations/webhook/test_unit_webhook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/unit/integrations/webhook/test_unit_webhook.py b/api/tests/unit/integrations/webhook/test_unit_webhook.py index 0acde620406e..5410c890a9dc 100644 --- a/api/tests/unit/integrations/webhook/test_unit_webhook.py +++ b/api/tests/unit/integrations/webhook/test_unit_webhook.py @@ -1,7 +1,7 @@ import requests +from django.contrib.contenttypes.models import ContentType from core.constants import STRING -from django.contrib.contenttypes.models import ContentType from environments.identities.traits.models import Trait from environments.identities.traits.serializers import TraitSerializerBasic from features.models import Feature, FeatureState From 61c715ee7cbb3478939a720814582f59ac221be9 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:54:16 +0700 Subject: [PATCH 47/63] fix(integrations): fix import path --- frontend/web/components/IntegrationList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/web/components/IntegrationList.tsx b/frontend/web/components/IntegrationList.tsx index 58813d766a21..c141ce7bc6cd 100644 --- a/frontend/web/components/IntegrationList.tsx +++ b/frontend/web/components/IntegrationList.tsx @@ -24,7 +24,7 @@ import each from 'lodash/each' import { useGetProjectQuery } from 'common/services/useProject' import API from 'project/api' import Constants from 'common/constants' -import BooleanDotIndicator from './BooleanDotIndicator' +import BooleanDotIndicator from 'components/BooleanDotIndicator' type IntegrationAction = { label: string From d82d0e61e54d997a15a2c70e485bfcc4d5ac3fa1 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:57:55 +0700 Subject: [PATCH 48/63] fix(integrations): extract ternary expressions into a variable --- frontend/web/components/IntegrationList.tsx | 60 +++++++++++---------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/frontend/web/components/IntegrationList.tsx b/frontend/web/components/IntegrationList.tsx index c141ce7bc6cd..0550a897cb3d 100644 --- a/frontend/web/components/IntegrationList.tsx +++ b/frontend/web/components/IntegrationList.tsx @@ -326,34 +326,35 @@ const Integration: FC = (props) => { )} {activeIntegrations && - activeIntegrations.map((integration) => ( -
edit(integration)} - > - - - - -
- - - {integration.latest_health - ? integration.latest_health.is_healthy - ? 'Healthy' - : 'Unhealthy' - : 'No health data'} - -
+ activeIntegrations.map((integration) => { + const healthStatusText = !integration.latest_health + ? 'No health data' + : integration.latest_health.is_healthy + ? 'Healthy' + : 'Unhealthy' + + return ( +
edit(integration)} + > + + + + +
+ + {healthStatusText} +
e.stopPropagation()}> = (props) => {
- ))} + ) + })}
) } From 0fd89f073fc52decf0c875468e415df861903c53 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 09:08:44 +0700 Subject: [PATCH 49/63] fix(integrations): guard base_url when it's None --- api/integrations/datadog/datadog.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/integrations/datadog/datadog.py b/api/integrations/datadog/datadog.py index 71c10216f00f..e0761fb271fd 100644 --- a/api/integrations/datadog/datadog.py +++ b/api/integrations/datadog/datadog.py @@ -21,9 +21,7 @@ def __init__( session: requests.Session = None, # type: ignore[assignment] ) -> None: self.config = config - self.base_url = config.base_url - if self.base_url[-1] != "/": - self.base_url += "/" + self.base_url = (config.base_url or "").rstrip("/") + "/" self.events_url = f"{self.base_url}{EVENTS_API_URI}" self.use_custom_source = config.use_custom_source From a98cb12c1eb39e33da65ccb9a43bbe4e1f637927 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 09:19:17 +0700 Subject: [PATCH 50/63] fix(integrations): run lint --- frontend/web/components/IntegrationList.tsx | 42 +++++++++++---------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/frontend/web/components/IntegrationList.tsx b/frontend/web/components/IntegrationList.tsx index 0550a897cb3d..cad2c410dfae 100644 --- a/frontend/web/components/IntegrationList.tsx +++ b/frontend/web/components/IntegrationList.tsx @@ -24,7 +24,7 @@ import each from 'lodash/each' import { useGetProjectQuery } from 'common/services/useProject' import API from 'project/api' import Constants from 'common/constants' -import BooleanDotIndicator from 'components/BooleanDotIndicator' +import BooleanDotIndicator from './BooleanDotIndicator' type IntegrationAction = { label: string @@ -353,26 +353,28 @@ const Integration: FC = (props) => { - {healthStatusText} + + {healthStatusText} +
-
e.stopPropagation()}> - edit(integration), - }, - { - dataTest: 'delete-integration', - label: 'Delete', - onClick: () => remove(integration), - }, - ]} - /> -
- - +
e.stopPropagation()}> + edit(integration), + }, + { + dataTest: 'delete-integration', + label: 'Delete', + onClick: () => remove(integration), + }, + ]} + /> +
+ + ) })} From 1ef07951af4705ca824a6b67fbad24fe383ce9b7 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 16 Jul 2026 08:23:35 +0700 Subject: [PATCH 51/63] fix(integrations): address typing errors in unit tests from CI --- .../test_unit_integrations_common_services.py | 18 +++++++++++++----- .../integrations/webhook/test_unit_webhook.py | 6 +++--- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/api/tests/unit/integrations/common/test_unit_integrations_common_services.py b/api/tests/unit/integrations/common/test_unit_integrations_common_services.py index 543cc9956b61..cf6993f6ad82 100644 --- a/api/tests/unit/integrations/common/test_unit_integrations_common_services.py +++ b/api/tests/unit/integrations/common/test_unit_integrations_common_services.py @@ -1,3 +1,4 @@ +from environments.models import Environment from integrations.common.services import ( get_latest_integration_health, record_integration_health, @@ -5,7 +6,9 @@ from integrations.webhook.models import WebhookConfiguration -def test_get_latest_integration_health__no_record__returns_none(environment) -> None: +def test_get_latest_integration_health__no_record__returns_none( + environment: Environment, +) -> None: # Given webhook_config = WebhookConfiguration.objects.create( environment=environment, url="https://webhook.url" @@ -18,7 +21,9 @@ def test_get_latest_integration_health__no_record__returns_none(environment) -> assert result is None -def test_get_latest_integration_health__has_record__returns_health_dict(environment): +def test_get_latest_integration_health__has_record__returns_health_dict( + environment: Environment, +) -> None: # Given webhook_config = WebhookConfiguration.objects.create( environment=environment, url="https://webhook.url" @@ -37,8 +42,8 @@ def test_get_latest_integration_health__has_record__returns_health_dict(environm def test_get_latest_integration_health__multiple_records__returns_latest( - environment, -): + environment: Environment, +) -> None: # Given webhook_config = WebhookConfiguration.objects.create( environment=environment, url="https://webhook.url" @@ -55,7 +60,9 @@ def test_get_latest_integration_health__multiple_records__returns_latest( assert result["is_healthy"] is True -def test_get_latest_integration_health__non_healthy_status(environment): +def test_get_latest_integration_health__non_healthy_status( + environment: Environment, +) -> None: # Given webhook_config = WebhookConfiguration.objects.create( environment=environment, url="https://webhook.url" @@ -66,5 +73,6 @@ def test_get_latest_integration_health__non_healthy_status(environment): result = get_latest_integration_health(webhook_config) # Then + assert result is not None assert result["status_code"] == 500 assert result["is_healthy"] is False diff --git a/api/tests/unit/integrations/webhook/test_unit_webhook.py b/api/tests/unit/integrations/webhook/test_unit_webhook.py index 5410c890a9dc..fd8b2cdc799a 100644 --- a/api/tests/unit/integrations/webhook/test_unit_webhook.py +++ b/api/tests/unit/integrations/webhook/test_unit_webhook.py @@ -91,7 +91,7 @@ def test_webhook_identify_user__records_health_status( # type: ignore[no-untype response = mocker.MagicMock(spec=requests.Response, status_code=200) - def bool_true(self): + def bool_true(self): # type: ignore[no-untyped-def] return True response.__bool__ = bool_true.__get__(response) @@ -112,7 +112,7 @@ def bool_true(self): assert health_record.status_code == 200 -def test_webhook_identify_user__unhealthy_status__records_health( +def test_webhook_identify_user__unhealthy_status__records_health( # type: ignore[no-untyped-def] mocker, integration_webhook_config, ): @@ -121,7 +121,7 @@ def test_webhook_identify_user__unhealthy_status__records_health( response = mocker.MagicMock(spec=requests.Response, status_code=500) - def bool_true(self): + def bool_true(self): # type: ignore[no-untyped-def] return True response.__bool__ = bool_true.__get__(response) From 1dd55b44092b793915f09a4e524655fdb51e3362 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 16 Jul 2026 08:23:45 +0700 Subject: [PATCH 52/63] fix(integrations): address typing errors in serializers tests from CI --- api/integrations/common/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/integrations/common/serializers.py b/api/integrations/common/serializers.py index d33fab0d9f67..50501eb6ac6c 100644 --- a/api/integrations/common/serializers.py +++ b/api/integrations/common/serializers.py @@ -36,7 +36,7 @@ class BaseEnvironmentIntegrationModelSerializer(_BaseIntegrationModelSerializer) one_to_one_field_name = "environment" # type: ignore[assignment] latest_health = SerializerMethodField() - def get_latest_health(self, obj: Model) -> dict | None: + def get_latest_health(self, obj: Model) -> dict[str, typing.Any] | None: return get_latest_integration_health(obj) From 627de8ced1d25fdbec677fb956f4c0cb5e5bd7b4 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 16 Jul 2026 09:24:21 +0700 Subject: [PATCH 53/63] fix(integrations): add guard if base_url is null --- api/integrations/dynatrace/dynatrace.py | 2 +- api/integrations/grafana/grafana.py | 4 +--- api/integrations/new_relic/new_relic.py | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/api/integrations/dynatrace/dynatrace.py b/api/integrations/dynatrace/dynatrace.py index d89c454d58fd..95666b497aec 100644 --- a/api/integrations/dynatrace/dynatrace.py +++ b/api/integrations/dynatrace/dynatrace.py @@ -24,7 +24,7 @@ class DynatraceWrapper(AbstractBaseEventIntegrationWrapper): def __init__(self, config: DynatraceConfiguration): self.config = config - self.base_url = config.base_url.rstrip("/") + "/" + self.base_url = (config.base_url or "").rstrip("/") + "/" self.api_key = config.api_key self.entity_selector = config.entity_selector self.url = f"{self.base_url}{EVENTS_API_URI}?api-token={self.api_key}" diff --git a/api/integrations/grafana/grafana.py b/api/integrations/grafana/grafana.py index a3f522228f21..5c1bd5af3b6f 100644 --- a/api/integrations/grafana/grafana.py +++ b/api/integrations/grafana/grafana.py @@ -26,9 +26,7 @@ def __init__( config: GrafanaProjectConfiguration | GrafanaOrganisationConfiguration, ) -> None: self.config = config - base_url = ( - config.base_url[:-1] if config.base_url.endswith("/") else config.base_url - ) + base_url = (config.base_url or "").rstrip("/") self.url = f"{base_url}{ROUTE_API_ANNOTATIONS}" self.api_key = config.api_key diff --git a/api/integrations/new_relic/new_relic.py b/api/integrations/new_relic/new_relic.py index b21a4b049aa2..24ef5c020df3 100644 --- a/api/integrations/new_relic/new_relic.py +++ b/api/integrations/new_relic/new_relic.py @@ -16,7 +16,7 @@ class NewRelicWrapper(AbstractBaseEventIntegrationWrapper): def __init__(self, config: NewRelicConfiguration): self.config = config - self.base_url = config.base_url.rstrip("/") + "/" + self.base_url = (config.base_url or "").rstrip("/") + "/" self.api_key = config.api_key self.app_id = config.app_id self.url = f"{self.base_url}{EVENTS_API_URI}{self.app_id}/deployments.json" From 3c97ab1bc726d5cf41ff0e0f7cb4faecd43c72c3 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 16 Jul 2026 09:27:33 +0700 Subject: [PATCH 54/63] fix(integrations): add type annotations --- .../commands/cleanup_integration_health_records.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/api/integrations/common/management/commands/cleanup_integration_health_records.py b/api/integrations/common/management/commands/cleanup_integration_health_records.py index 8eb3b6a92cab..9334130245cf 100644 --- a/api/integrations/common/management/commands/cleanup_integration_health_records.py +++ b/api/integrations/common/management/commands/cleanup_integration_health_records.py @@ -1,4 +1,6 @@ -from django.core.management import BaseCommand +from datetime import timedelta + +from django.core.management import BaseCommand, CommandParser from django.utils import timezone from integrations.common.models import IntegrationHealthRecord @@ -7,7 +9,7 @@ class Command(BaseCommand): help = "Delete integration health records older than the specified number of days." - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: parser.add_argument( "--days", type=int, @@ -15,9 +17,9 @@ def add_arguments(self, parser): help="Delete records older than this many days (default: 30).", ) - def handle(self, *args, **options): + def handle(self, *args, **options): # type: ignore[no-untyped-def] days = options["days"] - cutoff = timezone.now() - timezone.timedelta(days=days) + cutoff = timezone.now() - timedelta(days=days) deleted, _ = IntegrationHealthRecord.objects.filter( created_at__lt=cutoff ).delete() From 048633dd81a44f62b21234bd560799e31b9af1b8 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 16 Jul 2026 09:29:09 +0700 Subject: [PATCH 55/63] fix(integrations): add missing type annotations --- .../common/test_unit_integrations_common_serializers.py | 6 +++--- api/tests/unit/integrations/datadog/test_unit_datadog.py | 3 ++- api/tests/unit/integrations/grafana/test_grafana.py | 5 ++++- .../unit/integrations/new_relic/test_unit_new_relic.py | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/api/tests/unit/integrations/common/test_unit_integrations_common_serializers.py b/api/tests/unit/integrations/common/test_unit_integrations_common_serializers.py index 7964e7caabd6..236c83e8378a 100644 --- a/api/tests/unit/integrations/common/test_unit_integrations_common_serializers.py +++ b/api/tests/unit/integrations/common/test_unit_integrations_common_serializers.py @@ -40,7 +40,7 @@ def test_base_environment_integration_model_serializer__soft_deleted_exists__upd assert updated_webhook_config.deleted_at is None -def test_base_environment_integration_model_serializer__latest_health__no_record( +def test_base_environment_integration_model_serializer__latest_health__no_record( # type: ignore[no-untyped-def] environment, ): # Given @@ -57,7 +57,7 @@ def test_base_environment_integration_model_serializer__latest_health__no_record assert data["latest_health"] is None -def test_base_environment_integration_model_serializer__latest_health__has_record( +def test_base_environment_integration_model_serializer__latest_health__has_record( # type: ignore[no-untyped-def] environment, ): # Given @@ -77,7 +77,7 @@ def test_base_environment_integration_model_serializer__latest_health__has_recor assert data["latest_health"]["created_at"] is not None -def test_base_environment_integration_model_serializer__latest_health__unhealthy_status( +def test_base_environment_integration_model_serializer__latest_health__unhealthy_status( # type: ignore[no-untyped-def] # noqa: E501 environment, ): # Given diff --git a/api/tests/unit/integrations/datadog/test_unit_datadog.py b/api/tests/unit/integrations/datadog/test_unit_datadog.py index 836546b2521f..4f5f07d27d3c 100644 --- a/api/tests/unit/integrations/datadog/test_unit_datadog.py +++ b/api/tests/unit/integrations/datadog/test_unit_datadog.py @@ -12,6 +12,7 @@ DataDogWrapper, ) from integrations.datadog.models import DataDogConfiguration +from projects.models import Project @pytest.mark.parametrize( @@ -165,7 +166,7 @@ def test_generate_event_data__missing_environment__returns_unknown_env( # type: @pytest.mark.django_db def test_datadog_track_event__records_health_status( mocker: MockerFixture, - project, + project: Project, ) -> None: # Given config = DataDogConfiguration.objects.create( diff --git a/api/tests/unit/integrations/grafana/test_grafana.py b/api/tests/unit/integrations/grafana/test_grafana.py index f2e3bbe212c5..ca7ae956fa54 100644 --- a/api/tests/unit/integrations/grafana/test_grafana.py +++ b/api/tests/unit/integrations/grafana/test_grafana.py @@ -8,6 +8,7 @@ from integrations.common.models import IntegrationHealthRecord from integrations.grafana.grafana import GrafanaWrapper from integrations.grafana.models import GrafanaProjectConfiguration +from projects.models import Project @pytest.mark.parametrize("base_url", ["test.com", "test.com/"]) @@ -51,7 +52,9 @@ def test_grafana_wrapper__track_event__expected_api_call( @pytest.mark.django_db @responses.activate() -def test_grafana_wrapper__track_event__records_health_status(project) -> None: +def test_grafana_wrapper__track_event__records_health_status( + project: Project, +) -> None: # Given config = GrafanaProjectConfiguration.objects.create( project=project, diff --git a/api/tests/unit/integrations/new_relic/test_unit_new_relic.py b/api/tests/unit/integrations/new_relic/test_unit_new_relic.py index 2d044400ba82..fb54c3fddc9a 100644 --- a/api/tests/unit/integrations/new_relic/test_unit_new_relic.py +++ b/api/tests/unit/integrations/new_relic/test_unit_new_relic.py @@ -115,7 +115,7 @@ def test_new_relic_generate_event_data__missing_environment__returns_unknown_env @pytest.mark.django_db -def test_new_relic_track_event__records_health_status( +def test_new_relic_track_event__records_health_status( # type: ignore[no-untyped-def] mocker, project, ): From 4c60801eb065e4bc35755b04cccd685d1f421fe3 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 16 Jul 2026 09:30:45 +0700 Subject: [PATCH 56/63] fix(tests): fix runtime bug --- .../unit/integrations/webhook/test_unit_webhook.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/api/tests/unit/integrations/webhook/test_unit_webhook.py b/api/tests/unit/integrations/webhook/test_unit_webhook.py index fd8b2cdc799a..d23913a74eaa 100644 --- a/api/tests/unit/integrations/webhook/test_unit_webhook.py +++ b/api/tests/unit/integrations/webhook/test_unit_webhook.py @@ -90,11 +90,7 @@ def test_webhook_identify_user__records_health_status( # type: ignore[no-untype webhook_wrapper = WebhookWrapper(integration_webhook_config) response = mocker.MagicMock(spec=requests.Response, status_code=200) - - def bool_true(self): # type: ignore[no-untyped-def] - return True - - response.__bool__ = bool_true.__get__(response) + response.__bool__.return_value = True mocker.patch( "integrations.webhook.webhook.call_integration_webhook", @@ -120,11 +116,7 @@ def test_webhook_identify_user__unhealthy_status__records_health( # type: ignor webhook_wrapper = WebhookWrapper(integration_webhook_config) response = mocker.MagicMock(spec=requests.Response, status_code=500) - - def bool_true(self): # type: ignore[no-untyped-def] - return True - - response.__bool__ = bool_true.__get__(response) + response.__bool__.return_value = True mocker.patch( "integrations.webhook.webhook.call_integration_webhook", From 84f679e78017b5a847263e395f14d16c85fc7a60 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 16 Jul 2026 11:17:23 +0700 Subject: [PATCH 57/63] docs(event-catalogue): update changes based on CI --- .../observability/_events-catalogue.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 06de7fe32398..85ecf89d21dd 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -458,7 +458,7 @@ Attributes: ### `sentry_change_tracking.integration_error` Logged at `warning` from: - - `api/integrations/sentry/change_tracking.py:109` + - `api/integrations/sentry/change_tracking.py:113` Attributes: - `feature_name` @@ -469,7 +469,7 @@ Attributes: ### `sentry_change_tracking.request_failure` Logged at `warning` from: - - `api/integrations/sentry/change_tracking.py:99` + - `api/integrations/sentry/change_tracking.py:103` Attributes: - `error` @@ -479,7 +479,7 @@ Attributes: ### `sentry_change_tracking.sending` Logged at `debug` from: - - `api/integrations/sentry/change_tracking.py:84` + - `api/integrations/sentry/change_tracking.py:87` Attributes: - `feature_name` @@ -491,7 +491,7 @@ Attributes: ### `sentry_change_tracking.success` Logged at `info` from: - - `api/integrations/sentry/change_tracking.py:106` + - `api/integrations/sentry/change_tracking.py:110` Attributes: - `feature_name` From 91b1c27fa4e1c22baca91901d3fa622d054eeecf Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 16 Jul 2026 11:24:32 +0700 Subject: [PATCH 58/63] fix(integrations): remove ternary expression on component --- frontend/web/components/IntegrationList.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/web/components/IntegrationList.tsx b/frontend/web/components/IntegrationList.tsx index cad2c410dfae..793211fcde7a 100644 --- a/frontend/web/components/IntegrationList.tsx +++ b/frontend/web/components/IntegrationList.tsx @@ -327,11 +327,12 @@ const Integration: FC = (props) => { {activeIntegrations && activeIntegrations.map((integration) => { - const healthStatusText = !integration.latest_health - ? 'No health data' - : integration.latest_health.is_healthy - ? 'Healthy' - : 'Unhealthy' + let healthStatusText = 'No health data' + if (integration.latest_health) { + healthStatusText = integration.latest_health.is_healthy + ? 'Healthy' + : 'Unhealthy' + } return (
Date: Thu, 16 Jul 2026 12:37:43 +0700 Subject: [PATCH 59/63] fix(integrations): add separator for expected url --- api/tests/unit/integrations/dynatrace/test_unit_dynatrace.py | 2 +- api/tests/unit/integrations/new_relic/test_unit_new_relic.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/tests/unit/integrations/dynatrace/test_unit_dynatrace.py b/api/tests/unit/integrations/dynatrace/test_unit_dynatrace.py index 6a846a628c3e..547e6702a9b1 100644 --- a/api/tests/unit/integrations/dynatrace/test_unit_dynatrace.py +++ b/api/tests/unit/integrations/dynatrace/test_unit_dynatrace.py @@ -28,7 +28,7 @@ def test_dynatrace_wrapper__valid_config__initializes_correctly(): # type: igno dynatrace = DynatraceWrapper(config) # Then - expected_url = f"{config.base_url}{EVENTS_API_URI}?api-token={config.api_key}" + expected_url = f"{config.base_url}/{EVENTS_API_URI}?api-token={config.api_key}" assert dynatrace.url == expected_url diff --git a/api/tests/unit/integrations/new_relic/test_unit_new_relic.py b/api/tests/unit/integrations/new_relic/test_unit_new_relic.py index fb54c3fddc9a..7d4cec906168 100644 --- a/api/tests/unit/integrations/new_relic/test_unit_new_relic.py +++ b/api/tests/unit/integrations/new_relic/test_unit_new_relic.py @@ -19,7 +19,7 @@ def test_new_relic_wrapper__valid_config__initializes_correctly(): # type: igno new_relic = NewRelicWrapper(config) # Then - expected_url = f"{config.base_url}{EVENTS_API_URI}{config.app_id}/deployments.json" + expected_url = f"{config.base_url}/{EVENTS_API_URI}{config.app_id}/deployments.json" assert new_relic.url == expected_url From b5763ffe93fc0e2317eff72d0fc3f7f46cbd8b7c Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 16 Jul 2026 12:38:20 +0700 Subject: [PATCH 60/63] fix(tests): add separator for expected url and match assertions --- .../unit/audit/test_unit_audit_signals.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/api/tests/unit/audit/test_unit_audit_signals.py b/api/tests/unit/audit/test_unit_audit_signals.py index 5ddf4e671396..c7576bf81db7 100644 --- a/api/tests/unit/audit/test_unit_audit_signals.py +++ b/api/tests/unit/audit/test_unit_audit_signals.py @@ -126,10 +126,7 @@ def test_send_audit_log_event_to_grafana__project_grafana_config__calls_expected send_audit_log_event_to_grafana(AuditLog, audit_log_record) # Then - grafana_wrapper_mock.assert_called_once_with( - base_url=grafana_config.base_url, - api_key=grafana_config.api_key, - ) + grafana_wrapper_mock.assert_called_once_with(grafana_config) grafana_wrapper_instance_mock.generate_event_data.assert_called_once_with( audit_log_record ) @@ -160,10 +157,7 @@ def test_send_audit_log_event_to_grafana__organisation_grafana_config__calls_exp send_audit_log_event_to_grafana(AuditLog, audit_log_record) # Then - grafana_wrapper_mock.assert_called_once_with( - base_url=grafana_config.base_url, - api_key=grafana_config.api_key, - ) + grafana_wrapper_mock.assert_called_once_with(grafana_config) grafana_wrapper_instance_mock.generate_event_data.assert_called_once_with( audit_log_record ) @@ -277,11 +271,7 @@ def test_send_audit_log_event_to_dynatrace__environment_dynatrace_config__calls_ send_audit_log_event_to_dynatrace(AuditLog, audit_log_record) # Then - dynatrace_wrapper_mock.assert_called_once_with( - base_url=dynatrace_config.base_url, - api_key=dynatrace_config.api_key, - entity_selector=dynatrace_config.entity_selector, - ) + dynatrace_wrapper_mock.assert_called_once_with(dynatrace_config) dynatrace_wrapper_instance_mock.generate_event_data.assert_called_once_with( audit_log_record ) @@ -311,7 +301,7 @@ def test_send_audit_log_event_to_dynatrace__environment_feature_version__sends_e responses.add( method=responses.POST, - url=f"{base_url}{EVENTS_API_URI}?api-token={api_key}", + url=f"{base_url}/{EVENTS_API_URI}?api-token={api_key}", status=201, json={ "reportCount": 1, From ce57e43ac140c823daa73d52a33206abb3b24f3f Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 16 Jul 2026 12:40:39 +0700 Subject: [PATCH 61/63] fix(integrations): add savepoint --- api/integrations/common/services.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/api/integrations/common/services.py b/api/integrations/common/services.py index 1ceb029c4f78..8b1d5e28eec2 100644 --- a/api/integrations/common/services.py +++ b/api/integrations/common/services.py @@ -2,7 +2,7 @@ from typing import Any from django.contrib.contenttypes.models import ContentType -from django.db import models +from django.db import models, transaction from integrations.common.models import IntegrationHealthRecord @@ -14,10 +14,11 @@ def record_integration_health( status_code: int, ) -> None: try: - IntegrationHealthRecord.objects.create( - content_object=integration_config, - status_code=status_code, - ) + with transaction.atomic(): + IntegrationHealthRecord.objects.create( + content_object=integration_config, + status_code=status_code, + ) except Exception: logger.exception("Failed to record integration health.") From fecbe35afa87e00b0d5d79001e9da0f637bb5e47 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 16 Jul 2026 13:53:10 +0700 Subject: [PATCH 62/63] fix(tests): add unit test for cleanup health records function --- ...integrations_common_management_commands.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 api/tests/unit/integrations/common/test_unit_integrations_common_management_commands.py diff --git a/api/tests/unit/integrations/common/test_unit_integrations_common_management_commands.py b/api/tests/unit/integrations/common/test_unit_integrations_common_management_commands.py new file mode 100644 index 000000000000..35eedafd62df --- /dev/null +++ b/api/tests/unit/integrations/common/test_unit_integrations_common_management_commands.py @@ -0,0 +1,82 @@ +from datetime import timedelta +from io import StringIO + +import freezegun +from django.core.management import call_command +from django.utils import timezone + +from environments.models import Environment +from integrations.common.models import IntegrationHealthRecord +from integrations.common.services import record_integration_health +from integrations.webhook.models import WebhookConfiguration + + +def test_cleanup_integration_health_records__default_days__deletes_old_records_only( + environment: Environment, +) -> None: + # Given + webhook_config = WebhookConfiguration.objects.create( + environment=environment, url="https://webhook.url" + ) + + with freezegun.freeze_time(timezone.now() - timedelta(days=31)): + record_integration_health(webhook_config, 200) + + record_integration_health(webhook_config, 200) + + stdout = StringIO() + + # When + call_command("cleanup_integration_health_records", stdout=stdout) + + # Then + assert IntegrationHealthRecord.objects.count() == 1 + assert ( + "Deleted 1 integration health record(s) older than 30 days." + in stdout.getvalue() + ) + + +def test_cleanup_integration_health_records__custom_days__deletes_expected( + environment: Environment, +) -> None: + # Given + webhook_config = WebhookConfiguration.objects.create( + environment=environment, url="https://webhook.url" + ) + + with freezegun.freeze_time(timezone.now() - timedelta(days=10)): + record_integration_health(webhook_config, 200) + + stdout = StringIO() + + # When + call_command("cleanup_integration_health_records", days=5, stdout=stdout) + + # Then + assert IntegrationHealthRecord.objects.count() == 0 + assert ( + "Deleted 1 integration health record(s) older than 5 days." in stdout.getvalue() + ) + + +def test_cleanup_integration_health_records__no_old_records__deletes_nothing( + environment: Environment, +) -> None: + # Given + webhook_config = WebhookConfiguration.objects.create( + environment=environment, url="https://webhook.url" + ) + record_integration_health(webhook_config, 200) + + stdout = StringIO() + + # When + call_command("cleanup_integration_health_records", stdout=stdout) + + # Then + assert IntegrationHealthRecord.objects.count() == 1 + assert ( + "Deleted 0 integration health record(s) older than 30 days." + in stdout.getvalue() + ) From aeb772a54f3cf9e7459779b738d0496bb8cadccd Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 16 Jul 2026 14:16:56 +0700 Subject: [PATCH 63/63] fix(tests): add exception-path tests to address codecov's review --- .../unit/audit/test_unit_audit_signals.py | 33 ++++++++++++ .../amplitude/test_unit_amplitude.py | 22 ++++++++ .../integrations/datadog/test_unit_datadog.py | 23 ++++++++ .../dynatrace/test_unit_dynatrace.py | 24 +++++++++ .../unit/integrations/grafana/test_grafana.py | 23 ++++++++ .../unit/integrations/heap/test_unit_heap.py | 19 +++++++ .../mixpanel/test_unit_mixpanel.py | 23 ++++++++ .../new_relic/test_unit_new_relic.py | 52 +++++++++++++++++++ .../integrations/webhook/test_unit_webhook.py | 23 ++++++++ 9 files changed, 242 insertions(+) diff --git a/api/tests/unit/audit/test_unit_audit_signals.py b/api/tests/unit/audit/test_unit_audit_signals.py index c7576bf81db7..18bc8a217baa 100644 --- a/api/tests/unit/audit/test_unit_audit_signals.py +++ b/api/tests/unit/audit/test_unit_audit_signals.py @@ -12,6 +12,7 @@ call_webhooks, send_audit_log_event_to_dynatrace, send_audit_log_event_to_grafana, + send_audit_log_event_to_new_relic, send_feature_flag_went_live_signal, trigger_feature_state_change_webhooks, ) @@ -25,6 +26,7 @@ GrafanaOrganisationConfiguration, GrafanaProjectConfiguration, ) +from integrations.new_relic.models import NewRelicConfiguration from organisations.models import Organisation, OrganisationWebhook from projects.models import Project from projects.tags.models import Tag @@ -280,6 +282,37 @@ def test_send_audit_log_event_to_dynatrace__environment_dynatrace_config__calls_ ) +def test_send_audit_log_event_to_new_relic__project_new_relic_config__calls_expected( + mocker: MockerFixture, + project: Project, +) -> None: + # Given + audit_log_record = AuditLog.objects.create( + project=project, + related_object_type=RelatedObjectType.FEATURE.name, + ) + new_relic_wrapper_mock = mocker.patch( + "audit.signals.NewRelicWrapper", autospec=True + ) + new_relic_wrapper_instance_mock = new_relic_wrapper_mock.return_value + + new_relic_config = NewRelicConfiguration.objects.create( + project=project, api_key="123key", app_id="123id", base_url="http://test.com" + ) + + # When + send_audit_log_event_to_new_relic(AuditLog, audit_log_record) + + # Then + new_relic_wrapper_mock.assert_called_once_with(new_relic_config) + new_relic_wrapper_instance_mock.generate_event_data.assert_called_once_with( + audit_log_record + ) + new_relic_wrapper_instance_mock.track_event_async.assert_called_once_with( + event=new_relic_wrapper_instance_mock.generate_event_data.return_value + ) + + @responses.activate def test_send_audit_log_event_to_dynatrace__environment_feature_version__sends_expected_payload( feature: Feature, diff --git a/api/tests/unit/integrations/amplitude/test_unit_amplitude.py b/api/tests/unit/integrations/amplitude/test_unit_amplitude.py index f25f700910c8..a9e5fbc877b5 100644 --- a/api/tests/unit/integrations/amplitude/test_unit_amplitude.py +++ b/api/tests/unit/integrations/amplitude/test_unit_amplitude.py @@ -116,3 +116,25 @@ def test_amplitude_wrapper__identify_user__records_unhealthy_status( # Then health_record = IntegrationHealthRecord.objects.get(object_id=config.id) assert health_record.status_code == 500 + + +@pytest.mark.django_db +@mock.patch("integrations.amplitude.amplitude.record_integration_health") +@mock.patch("integrations.amplitude.amplitude.requests.post") +def test_amplitude_wrapper__identify_user__record_health_raises__does_not_propagate( + post_mock: mock.Mock, + record_integration_health_mock: mock.Mock, + environment: Environment, +) -> None: + # Given + post_mock.return_value.status_code = 200 + record_integration_health_mock.side_effect = Exception("boom") + config = AmplitudeConfiguration.objects.create( + environment=environment, api_key="123key" + ) + amplitude_wrapper = AmplitudeWrapper(config) + + # When + amplitude_wrapper._identify_user( + {"user_id": "identity-1", "user_properties": {}} + ) # does not raise diff --git a/api/tests/unit/integrations/datadog/test_unit_datadog.py b/api/tests/unit/integrations/datadog/test_unit_datadog.py index 4f5f07d27d3c..b4ae3119e8db 100644 --- a/api/tests/unit/integrations/datadog/test_unit_datadog.py +++ b/api/tests/unit/integrations/datadog/test_unit_datadog.py @@ -184,3 +184,26 @@ def test_datadog_track_event__records_health_status( # Then health_record = IntegrationHealthRecord.objects.get(object_id=config.id) assert health_record.status_code == 200 + + +@pytest.mark.django_db +def test_datadog_track_event__record_health_raises__does_not_propagate( + mocker: MockerFixture, + project: Project, +) -> None: + # Given + config = DataDogConfiguration.objects.create( + project=project, + api_key="123key", + base_url="https://test.com", + ) + mock_session = mocker.MagicMock() + mock_session.post.return_value.status_code = 200 + datadog = DataDogWrapper(config, session=mock_session) + mocker.patch( + "integrations.datadog.datadog.record_integration_health", + side_effect=Exception("boom"), + ) + + # When + datadog._track_event({"foo": "bar"}) # does not raise diff --git a/api/tests/unit/integrations/dynatrace/test_unit_dynatrace.py b/api/tests/unit/integrations/dynatrace/test_unit_dynatrace.py index 547e6702a9b1..1c7226a009b6 100644 --- a/api/tests/unit/integrations/dynatrace/test_unit_dynatrace.py +++ b/api/tests/unit/integrations/dynatrace/test_unit_dynatrace.py @@ -182,3 +182,27 @@ def test_dynatrace_track_event__records_health_status( # Then health_record = IntegrationHealthRecord.objects.get(object_id=config.id) assert health_record.status_code == 200 + + +@pytest.mark.django_db +def test_dynatrace_track_event__record_health_raises__does_not_propagate( + mocker: MockerFixture, + environment: Environment, +) -> None: + # Given + config = DynatraceConfiguration.objects.create( + environment=environment, + api_key="123key", + base_url="http://test.com/", + entity_selector="type(APPLICATION),entityName(docs)", + ) + dynatrace = DynatraceWrapper(config) + mocked_post = mocker.patch("integrations.dynatrace.dynatrace.requests.post") + mocked_post.return_value.status_code = 200 + mocker.patch( + "integrations.dynatrace.dynatrace.record_integration_health", + side_effect=Exception("boom"), + ) + + # When + dynatrace._track_event({"eventType": "CUSTOM_DEPLOYMENT"}) # does not raise diff --git a/api/tests/unit/integrations/grafana/test_grafana.py b/api/tests/unit/integrations/grafana/test_grafana.py index ca7ae956fa54..470e4418b703 100644 --- a/api/tests/unit/integrations/grafana/test_grafana.py +++ b/api/tests/unit/integrations/grafana/test_grafana.py @@ -72,6 +72,29 @@ def test_grafana_wrapper__track_event__records_health_status( assert health_record.status_code == 200 +@pytest.mark.django_db +@responses.activate() +def test_grafana_wrapper__track_event__record_health_raises__does_not_propagate( + mocker: MockerFixture, + project: Project, +) -> None: + # Given + config = GrafanaProjectConfiguration.objects.create( + project=project, + base_url="https://test.com", + api_key="any", + ) + wrapper = GrafanaWrapper(config) + responses.add(url="https://test.com/api/annotations", method="POST", status=200) + mocker.patch( + "integrations.grafana.grafana.record_integration_health", + side_effect=Exception("boom"), + ) + + # When + wrapper._track_event({"sample": "event"}) # does not raise + + def test_grafana_wrapper__generate_event_data__return_expected( mocker: MockerFixture, ) -> None: diff --git a/api/tests/unit/integrations/heap/test_unit_heap.py b/api/tests/unit/integrations/heap/test_unit_heap.py index 5beca1771f40..3039986f428e 100644 --- a/api/tests/unit/integrations/heap/test_unit_heap.py +++ b/api/tests/unit/integrations/heap/test_unit_heap.py @@ -122,3 +122,22 @@ def test_heap_wrapper__identify_user__records_health_status( # Then health_record = IntegrationHealthRecord.objects.get(object_id=config.id) assert health_record.status_code == 200 + + +@pytest.mark.django_db +def test_heap_wrapper__identify_user__record_health_raises__does_not_propagate( + mocker: "MockerFixture", + environment: Environment, +) -> None: + # Given + config = HeapConfiguration.objects.create(environment=environment, api_key="123key") + heap_wrapper = HeapWrapper(config) + mocked_post = mocker.patch("integrations.heap.heap.requests.post") + mocked_post.return_value.status_code = 200 + mocker.patch( + "integrations.heap.heap.record_integration_health", + side_effect=Exception("boom"), + ) + + # When + heap_wrapper._identify_user({"identity": "identity-1"}) # does not raise diff --git a/api/tests/unit/integrations/mixpanel/test_unit_mixpanel.py b/api/tests/unit/integrations/mixpanel/test_unit_mixpanel.py index d9de77dcc397..780c3891fbdd 100644 --- a/api/tests/unit/integrations/mixpanel/test_unit_mixpanel.py +++ b/api/tests/unit/integrations/mixpanel/test_unit_mixpanel.py @@ -149,6 +149,29 @@ def test_mixpanel_identify_user__records_health_status( assert health_record.status_code == 200 +@pytest.mark.django_db +def test_mixpanel_identify_user__record_health_raises__does_not_propagate( + mocker: "MockerFixture", + environment: "Environment", +) -> None: + # Given + config = MixpanelConfiguration.objects.create( + environment=environment, + api_key="123key", + ) + mixpanel = MixpanelWrapper(config) + post_mock = mocker.patch("integrations.mixpanel.mixpanel.requests.post") + post_mock.return_value.status_code = 200 + post_mock.return_value.text = "ok" + mocker.patch( + "integrations.mixpanel.mixpanel.record_integration_health", + side_effect=Exception("boom"), + ) + + # When + mixpanel._identify_user([{"$distinct_id": "identity-1"}]) # does not raise + + def test_mixpanel_generate_user_data__identity_with_features__returns_expected_format( project: "Project", feature: "Feature", diff --git a/api/tests/unit/integrations/new_relic/test_unit_new_relic.py b/api/tests/unit/integrations/new_relic/test_unit_new_relic.py index 7d4cec906168..bb8849ae24c6 100644 --- a/api/tests/unit/integrations/new_relic/test_unit_new_relic.py +++ b/api/tests/unit/integrations/new_relic/test_unit_new_relic.py @@ -1,10 +1,13 @@ import pytest +import requests +from pytest_mock import MockerFixture from audit.models import AuditLog from environments.models import Environment from integrations.common.models import IntegrationHealthRecord from integrations.new_relic.models import NewRelicConfiguration from integrations.new_relic.new_relic import EVENTS_API_URI, NewRelicWrapper +from projects.models import Project def test_new_relic_wrapper__valid_config__initializes_correctly(): # type: ignore[no-untyped-def] @@ -136,3 +139,52 @@ def test_new_relic_track_event__records_health_status( # type: ignore[no-untype # Then health_record = IntegrationHealthRecord.objects.get(object_id=config.id) assert health_record.status_code == 200 + + +@pytest.mark.django_db +def test_new_relic_track_event__request_exception__does_not_record_health( + mocker: MockerFixture, + project: Project, +) -> None: + # Given + config = NewRelicConfiguration.objects.create( + project=project, + api_key="123key", + app_id="123id", + base_url="http://test.com", + ) + new_relic = NewRelicWrapper(config) + mocker.patch( + "integrations.new_relic.new_relic.requests.post", + side_effect=requests.exceptions.RequestException("connection refused"), + ) + + # When + new_relic._track_event({"deployment": {}}) + + # Then + assert not IntegrationHealthRecord.objects.filter(object_id=config.id).exists() + + +@pytest.mark.django_db +def test_new_relic_track_event__record_health_raises__does_not_propagate( + mocker: MockerFixture, + project: Project, +) -> None: + # Given + config = NewRelicConfiguration.objects.create( + project=project, + api_key="123key", + app_id="123id", + base_url="http://test.com", + ) + new_relic = NewRelicWrapper(config) + mocked_post = mocker.patch("integrations.new_relic.new_relic.requests.post") + mocked_post.return_value.status_code = 200 + mocker.patch( + "integrations.new_relic.new_relic.record_integration_health", + side_effect=Exception("boom"), + ) + + # When + new_relic._track_event({"deployment": {}}) # does not raise diff --git a/api/tests/unit/integrations/webhook/test_unit_webhook.py b/api/tests/unit/integrations/webhook/test_unit_webhook.py index d23913a74eaa..2b24d9c92fb8 100644 --- a/api/tests/unit/integrations/webhook/test_unit_webhook.py +++ b/api/tests/unit/integrations/webhook/test_unit_webhook.py @@ -132,3 +132,26 @@ def test_webhook_identify_user__unhealthy_status__records_health( # type: ignor content_type=ContentType.objects.get_for_model(integration_webhook_config), ) assert health_record.status_code == 500 + + +def test_webhook_identify_user__record_health_raises__does_not_propagate( # type: ignore[no-untyped-def] + mocker, + integration_webhook_config, +): + # Given + webhook_wrapper = WebhookWrapper(integration_webhook_config) + + response = mocker.MagicMock(spec=requests.Response, status_code=200) + response.__bool__.return_value = True + + mocker.patch( + "integrations.webhook.webhook.call_integration_webhook", + return_value=response, + ) + mocker.patch( + "integrations.webhook.webhook.record_integration_health", + side_effect=Exception("boom"), + ) + + # When + webhook_wrapper._identify_user({"identity": "identity-1"}) # does not raise