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/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/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 diff --git a/api/integrations/amplitude/amplitude.py b/api/integrations/amplitude/amplitude.py index 7bf758e8c45e..87e4ec5ce409 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,13 +19,18 @@ 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" 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) + 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 ) 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/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/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..8eb3b6a92cab --- /dev/null +++ b/api/integrations/common/management/commands/cleanup_integration_health_records.py @@ -0,0 +1,26 @@ +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." + ) 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/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", + ), + ), + ] 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..8759733a3f64 100644 --- a/api/integrations/common/models.py +++ b/api/integrations/common/models.py @@ -1,5 +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, @@ -40,3 +42,16 @@ 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() + + class Meta: + indexes = [ + models.Index(fields=["content_type", "object_id", "-created_at"]), + ] diff --git a/api/integrations/common/serializers.py b/api/integrations/common/serializers.py index ee6a1b0fb135..d33fab0d9f67 100644 --- a/api/integrations/common/serializers.py +++ b/api/integrations/common/serializers.py @@ -1,8 +1,11 @@ import typing from django.db.models import Model +from rest_framework.fields import SerializerMethodField from rest_framework.serializers import ModelSerializer +from .services import get_latest_integration_health + class _BaseIntegrationModelSerializer(ModelSerializer): # type: ignore[type-arg] one_to_one_field_name = None @@ -31,6 +34,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: Model) -> dict | None: + return get_latest_integration_health(obj) class BaseProjectIntegrationModelSerializer(_BaseIntegrationModelSerializer): diff --git a/api/integrations/common/services.py b/api/integrations/common/services.py new file mode 100644 index 000000000000..1ceb029c4f78 --- /dev/null +++ b/api/integrations/common/services.py @@ -0,0 +1,44 @@ +import logging +from typing import Any + +from django.contrib.contenttypes.models import ContentType +from django.db import models + +from integrations.common.models import IntegrationHealthRecord + +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( + 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.pk, + ) + .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, + } diff --git a/api/integrations/datadog/datadog.py b/api/integrations/datadog/datadog.py index 7fbd759b0dd9..e0761fb271fd 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,15 @@ 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 - if self.base_url[-1] != "/": - self.base_url += "/" + self.config = config + self.base_url = (config.base_url or "").rstrip("/") + "/" 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 +47,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) ) + + 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 ) diff --git a/api/integrations/dynatrace/dynatrace.py b/api/integrations/dynatrace/dynatrace.py index 75bc41df566e..d89c454d58fd 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,17 +22,23 @@ 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.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}" 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: + 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 ) 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/grafana/grafana.py b/api/integrations/grafana/grafana.py index 21289d13aa51..a3f522228f21 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,16 @@ 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]: @@ -38,6 +49,10 @@ def _track_event(self, event: dict[str, Any]) -> None: data=json.dumps(event), ) + 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 ) diff --git a/api/integrations/heap/heap.py b/api/integrations/heap/heap.py index 5b15d4ca1c17..40498a701319 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,18 @@ 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) + response = requests.post(self.url, json=user_data, timeout=10) + + 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( 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/mixpanel.py b/api/integrations/mixpanel/mixpanel.py index cc2820aaa53c..8a6f0bbd94a1 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,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) + + 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 ) 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/new_relic/new_relic.py b/api/integrations/new_relic/new_relic.py index 622756f45d90..b21a4b049aa2 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,26 @@ 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.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" def _track_event(self, event: dict) -> None: # type: ignore[type-arg] - response = requests.post( - self.url, headers=self._headers(), data=json.dumps(event) - ) + 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 + + 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 ) 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/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/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] diff --git a/api/integrations/webhook/webhook.py b/api/integrations/webhook/webhook.py index 541b33800444..e477c8d37128 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,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: + 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 ) 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..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 @@ -15,6 +16,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 +99,11 @@ def test_sentry_change_tracking__flag_created__sends_update_to_sentry( "sentry_action": "created", }, ] + 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 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..f25f700910c8 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,45 @@ 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/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/common/test_unit_integrations_common_serializers.py b/api/tests/unit/integrations/common/test_unit_integrations_common_serializers.py index 66ace0b7d504..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 @@ -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,63 @@ 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_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, ): 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..543cc9956b61 --- /dev/null +++ b/api/tests/unit/integrations/common/test_unit_integrations_common_services.py @@ -0,0 +1,70 @@ +from integrations.common.services import ( + get_latest_integration_health, + record_integration_health, +) +from integrations.webhook.models import WebhookConfiguration + + +def test_get_latest_integration_health__no_record__returns_none(environment) -> None: + # 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, +): + # 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 is not None + assert result["status_code"] == 200 + assert result["is_healthy"] is True + + +def test_get_latest_integration_health__non_healthy_status(environment): + # Given + webhook_config = WebhookConfiguration.objects.create( + environment=environment, url="https://webhook.url" + ) + record_integration_health(webhook_config, 500) + + # When + result = get_latest_integration_health(webhook_config) + + # Then + assert result["status_code"] == 500 + assert result["is_healthy"] is False 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/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/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/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.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/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/new_relic/test_unit_new_relic.py b/api/tests/unit/integrations/new_relic/test_unit_new_relic.py index 19d8aceadbba..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,19 +1,25 @@ +import pytest + from audit.models import AuditLog from environments.models import Environment +from integrations.common.models import IntegrationHealthRecord +from integrations.new_relic.models import NewRelicConfiguration from integrations.new_relic.new_relic import EVENTS_API_URI, NewRelicWrapper 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 +35,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 +63,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 +94,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 +112,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/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..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} + 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] diff --git a/api/tests/unit/integrations/webhook/test_unit_webhook.py b/api/tests/unit/integrations/webhook/test_unit_webhook.py index 9234503b38a2..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,11 @@ +import requests +from django.contrib.contenttypes.models import ContentType + from core.constants import STRING 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 +80,63 @@ 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(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, + ) + + # 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 == 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 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 = { diff --git a/frontend/web/components/IntegrationList.tsx b/frontend/web/components/IntegrationList.tsx index 63c9eaa3139c..11480486dd06 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 './BooleanDotIndicator' type IntegrationAction = { label: string @@ -325,41 +326,57 @@ const Integration: FC = (props) => { )} {activeIntegrations && - activeIntegrations.map((integration) => ( -
edit(integration)} - > - - - - -
e.stopPropagation()}> - edit(integration), - }, - { - dataTest: 'delete-integration', - label: 'Delete', - onClick: () => remove(integration), - }, - ]} - /> -
-
-
- ))} + activeIntegrations.map((integration) => { + const healthStatusText = !integration.latest_health + ? 'No health data' + : integration.latest_health.is_healthy + ? 'Healthy' + : 'Unhealthy' + + return ( +
edit(integration)} + > + + + + +
+ + + {healthStatusText} + +
+
e.stopPropagation()}> + edit(integration), + }, + { + dataTest: 'delete-integration', + label: 'Delete', + onClick: () => remove(integration), + }, + ]} + /> +
+
+
+ ) + })} ) }