From d45664c9bc1ae99c34c8ccb63d2ec08506f5b194 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Sat, 4 Jul 2026 17:40:38 +0700 Subject: [PATCH 01/50] 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 53b829be4b71..5fe346b3f69d 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 1fededc489ca42a5c8d024776073c3505ee8469b Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Sun, 5 Jul 2026 16:37:26 +0700 Subject: [PATCH 02/50] 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 fe660206423cef7aa0ebcfeefeb13c3973983951 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Mon, 6 Jul 2026 08:43:44 +0700 Subject: [PATCH 03/50] 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 c6ae3f32bd2ae88ab2342aaf86eca274f387faba Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Mon, 6 Jul 2026 10:25:07 +0700 Subject: [PATCH 04/50] 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 2dbce0d68511f0ef02ea93d86b0324e1e78e86a5 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Mon, 6 Jul 2026 11:01:04 +0700 Subject: [PATCH 05/50] 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 d863f4645071703574d56526bd3c6ebf5f842da9 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Mon, 6 Jul 2026 11:03:56 +0700 Subject: [PATCH 06/50] 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 87e3120acd34fc9b6a8eaed4966ae9239f98b948 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Mon, 6 Jul 2026 11:40:25 +0700 Subject: [PATCH 07/50] 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 5ff350fa6b795b0f80009dd0fade0db8be5cf0c8 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Mon, 6 Jul 2026 14:22:22 +0700 Subject: [PATCH 08/50] 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 e20cdb828f45..5c4d854b511c 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 7d667b611c6db645d89bee10dd67b2c5a40c7ccb Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Mon, 6 Jul 2026 14:58:51 +0700 Subject: [PATCH 09/50] 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 63c9eaa3139c..6a0a0481202a 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/50] 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 09a12117806a7d5173a0b0ee4c9700df3f71d8c7 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Tue, 7 Jul 2026 08:42:31 +0700 Subject: [PATCH 11/50] 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 cb2721c8fe1c1ce1c813d3f5126623eaf77e6686 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Tue, 7 Jul 2026 11:31:13 +0700 Subject: [PATCH 12/50] 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 b6d4f87d5fdf976068f317bbda987fb12c8809ef 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/50] [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 9b5a2a98d2b5bcee2078fbcf668e194c787bcdb8 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 06:56:46 +0700 Subject: [PATCH 14/50] 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 6a0a0481202a..0553a3972bb2 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 baa0f2c2e047f89afc0f52efc9255d40e8df0862 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:00:34 +0700 Subject: [PATCH 15/50] 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 c60fca76c50f25229b628b879477633b84954e53 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:06:42 +0700 Subject: [PATCH 16/50] 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 ec11badf6f6a3dd0065c1e0fc16c61edabae3d73 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/50] [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 cef089512a70f15249e963460f46f4fdbcbd9e49 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:09:46 +0700 Subject: [PATCH 18/50] 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 0e7baa28fc3fa9bb14423aa40bf61745a9b54425 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:16:11 +0700 Subject: [PATCH 19/50] 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 29cc0ac306338dd4fa559d7830dc365b11456272 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:22:56 +0700 Subject: [PATCH 20/50] 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 cf38f3343119ee334e6effc9c3b2131d148a44e8 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/50] [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 ece3fbb9b06979b2bb6e9ce600798c02abe56f18 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:37:39 +0700 Subject: [PATCH 22/50] 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 efca717a17c9c19bbd7b80f5a10097ae3fa23100 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:44:10 +0700 Subject: [PATCH 23/50] 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 5452e7fae5b6d911aa3d1352313948ce787191f8 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:44:17 +0700 Subject: [PATCH 24/50] 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 63ef4eb640071047f0ceaf91735bc11738580a10 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:44:37 +0700 Subject: [PATCH 25/50] 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 f399143410cbc8af27e9071b733f001b4d0b0479 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:44:56 +0700 Subject: [PATCH 26/50] 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 26cdad83e26084777ec4014cd01cfbe515ee6444 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:45:15 +0700 Subject: [PATCH 27/50] 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 2de342eb1952a19ae6465df838f1bcf43a62f7c8 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:45:26 +0700 Subject: [PATCH 28/50] 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 86682b5c98295451faf02d749ed350e253b2a98f Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:45:39 +0700 Subject: [PATCH 29/50] 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 8e239da98394ee9d68c10ed1549e0662bf7e627f Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 07:45:52 +0700 Subject: [PATCH 30/50] 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 35d478ca485282f74143e540be975623dd641afd Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:12:08 +0700 Subject: [PATCH 31/50] 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 db9c4a6aa4fe6db4254c1beff76f2501f535445f Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:12:51 +0700 Subject: [PATCH 32/50] 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 3f1c741d28c7dfb8ad1b3531e2b6aeacef0441b6 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/50] [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 8b1f150f10d6217555aa4410005a88c17a01326a Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:14:13 +0700 Subject: [PATCH 34/50] 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 2820f3453e83cc99b8c8a1777061a270029c6fa2 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/50] [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 1fec0e9872a141c466856df606e8e72b01bc1b0d Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:27:27 +0700 Subject: [PATCH 36/50] 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 4a1cf50694e1cf1944df5cd4100ccc420e22ad09 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:29:07 +0700 Subject: [PATCH 37/50] 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 02066a8bd74818d6ff6484f2c4899532b6a58d0d Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:30:08 +0700 Subject: [PATCH 38/50] 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 3f050fa5e82f938e332e7329343b102b266ceab9 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:30:59 +0700 Subject: [PATCH 39/50] 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 f1f33cc9c8c09e3b633ea762fc8ba51ad2dc8d2c Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:37:28 +0700 Subject: [PATCH 40/50] 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 8739e76d79035c74b03ec0bd1ca433afdf678f81 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/50] [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 265d4078768f427936e7535bcbd953a598cf889c Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:40:54 +0700 Subject: [PATCH 42/50] 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 85060c28fcbe5c19eee7b5a304bacea5293a636b Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:42:11 +0700 Subject: [PATCH 43/50] 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 d44cd4ed84ec9b52cc624c1ce3408ffda19ef1bd Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:43:39 +0700 Subject: [PATCH 44/50] 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 82ed25deb7609ceb7c1d22679ad9eeec7428d5be Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:51:09 +0700 Subject: [PATCH 45/50] 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 30762d9e028ee7e3cbbf8cbc48b4477301679a97 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/50] [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 9e66261f4d73ffb1cf154018f42b43eb64d9dd78 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:54:16 +0700 Subject: [PATCH 47/50] 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 0553a3972bb2..6a0a0481202a 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 c53857f2d9e1006ee0c22cb315eda28900ba2311 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 08:57:55 +0700 Subject: [PATCH 48/50] 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 6a0a0481202a..2f79bd1adfa1 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 c06eb35abd2ad258941f1361d28b60d127eadfad Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 09:08:44 +0700 Subject: [PATCH 49/50] 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 85480bda0b9604541a359e9661ee689b015d9316 Mon Sep 17 00:00:00 2001 From: afsuyadi Date: Thu, 9 Jul 2026 09:19:17 +0700 Subject: [PATCH 50/50] 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 2f79bd1adfa1..11480486dd06 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), + }, + ]} + /> +
+ + ) })}