diff --git a/api/.gitignore b/api/.gitignore index 8e68b95c7df4..dc26bd138924 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -19,5 +19,6 @@ features/workflows/logic/ /tests/ldap_integration_tests/ /tests/saml_unit_tests/ -# Unit test coverage +# Code maintenance .coverage +.mypy_cache diff --git a/api/api/urls/v1.py b/api/api/urls/v1.py index 4ed9696cefa3..7ce38d30492a 100644 --- a/api/api/urls/v1.py +++ b/api/api/urls/v1.py @@ -74,6 +74,7 @@ name="environment-document", ), re_path("", include("features.versioning.urls", namespace="versioning")), + path("", include("features.feature_lifecycle.urls", namespace="feature-lifecycle")), # API documentation path( "swagger.json", diff --git a/api/app_analytics/influxdb_wrapper.py b/api/app_analytics/influxdb_wrapper.py index a6704b8be07c..0bdc7cc77b35 100644 --- a/api/app_analytics/influxdb_wrapper.py +++ b/api/app_analytics/influxdb_wrapper.py @@ -1,3 +1,4 @@ +import functools import json import logging import typing @@ -21,21 +22,10 @@ map_flux_tables_to_usage_data, map_labels_to_influx_record_values, ) -from app_analytics.types import Labels +from app_analytics.types import DownsampleSize, Labels logger = logging.getLogger(__name__) -url = settings.INFLUXDB_URL -token = settings.INFLUXDB_TOKEN -influx_org = settings.INFLUXDB_ORG -read_bucket = settings.INFLUXDB_BUCKET + "_downsampled_15m" - -retries = Retry(connect=3, read=3, redirect=3) -# Set a timeout to prevent threads being potentially stuck open due to network weirdness -influxdb_client = InfluxDBClient( - url=url, token=token, org=influx_org, retries=retries, timeout=30000 -) - DEFAULT_DROP_COLUMNS = ( "organisation", "organisation_id", @@ -52,18 +42,37 @@ ) -def get_range_bucket_mappings(date_start: datetime) -> str: - now = timezone.now() - if (now - date_start).days > 10: - return settings.INFLUXDB_BUCKET + "_downsampled_1h" - return settings.INFLUXDB_BUCKET + "_downsampled_15m" - - class InfluxDBWrapper: + client = None + def __init__(self, name): # type: ignore[no-untyped-def] self.name = name self.records = [] - self.write_api = influxdb_client.write_api(write_options=SYNCHRONOUS) + + @classmethod + @functools.cache + def get_client(cls) -> InfluxDBClient: + """A singleton InfluxDB client instance""" + retries = Retry(connect=3, read=3, redirect=3) + return InfluxDBClient( + url=settings.INFLUXDB_URL, + token=settings.INFLUXDB_TOKEN, + org=settings.INFLUXDB_ORG, + retries=retries, + timeout=30000, # Hard stop to prevent hanging requests + ) + + @classmethod + def get_bucket(cls, size: DownsampleSize | None) -> str: + if size is None: + return settings.INFLUXDB_BUCKET + return f"{settings.INFLUXDB_BUCKET}_downsampled_{size}" + + @classmethod + def get_downsampled_bucket(cls, date_start: datetime) -> str: + if (timezone.now() - date_start).days > 10: + return cls.get_bucket(DownsampleSize.LONG_TERM) + return cls.get_bucket(DownsampleSize.SHORT_TERM) def add_data_point( self, @@ -85,8 +94,12 @@ def add_data_point( self.records.append(point) def write(self) -> None: + """Persist collected data points to InfluxDB""" try: - self.write_api.write(bucket=settings.INFLUXDB_BUCKET, record=self.records) + self.get_client().write_api(write_options=SYNCHRONOUS).write( + bucket=settings.INFLUXDB_BUCKET, + record=self.records, + ) except (HTTPError, InfluxDBError) as e: logger.warning( "Failed to write records to Influx: %s", @@ -99,15 +112,19 @@ def write(self) -> None: settings.INFLUXDB_BUCKET, ) - @staticmethod + @classmethod def influx_query_manager( + cls, date_start: datetime | None = None, date_stop: datetime | None = None, drop_columns: tuple[str, ...] = DEFAULT_DROP_COLUMNS, filters: str = "|> filter(fn:(r) => r._measurement == 'api_call')", extra: str = "", - bucket: str = read_bucket, + bucket: str | None = None, ) -> list[FluxTable]: + if bucket is None: + bucket = cls.get_bucket(DownsampleSize.SHORT_TERM) # NOTE: Legacy default + now = timezone.now() if date_start is None: date_start = now - timedelta(days=30) @@ -119,7 +136,7 @@ def influx_query_manager( if date_start == date_stop: return [] - query_api = influxdb_client.query_api() + query_api = cls.get_client().query_api() drop_columns_input = str(list(drop_columns)).replace("'", '"') query = ( @@ -132,7 +149,7 @@ def influx_query_manager( logger.debug("Running query in influx: \n\n %s", query) try: - return query_api.query(org=influx_org, query=query) + return query_api.query(org=settings.INFLUXDB_ORG, query=query) except HTTPError as e: capture_exception(e) return [] @@ -390,7 +407,7 @@ def get_top_organisations( if limit: limit = f"|> limit(n:{limit})" - bucket = get_range_bucket_mappings(date_start) + bucket = InfluxDBWrapper.get_downsampled_bucket(date_start) results = InfluxDBWrapper.influx_query_manager( date_start=date_start, bucket=bucket, @@ -432,7 +449,7 @@ def get_current_api_usage( :return: number of current api calls """ - bucket = read_bucket + bucket = InfluxDBWrapper.get_bucket(DownsampleSize.SHORT_TERM) results = InfluxDBWrapper.influx_query_manager( date_start=date_start, bucket=bucket, @@ -474,7 +491,7 @@ def get_platform_usage_trends( org_id_set = ", ".join(f'"{oid}"' for oid in organisation_ids) - bucket = get_range_bucket_mappings(date_start) + bucket = InfluxDBWrapper.get_downsampled_bucket(date_start) results = InfluxDBWrapper.influx_query_manager( date_start=date_start, date_stop=date_stop, diff --git a/api/app_analytics/migrate_to_pg.py b/api/app_analytics/migrate_to_pg.py index 0e61a7b0696c..f6e7482562b8 100644 --- a/api/app_analytics/migrate_to_pg.py +++ b/api/app_analytics/migrate_to_pg.py @@ -1,10 +1,12 @@ from app_analytics.constants import ANALYTICS_READ_BUCKET_SIZE -from app_analytics.influxdb_wrapper import influxdb_client, read_bucket +from app_analytics.influxdb_wrapper import InfluxDBWrapper from app_analytics.models import FeatureEvaluationBucket +from app_analytics.types import DownsampleSize def migrate_feature_evaluations(migrate_till: int = 30) -> None: - query_api = influxdb_client.query_api() + query_api = InfluxDBWrapper.get_client().query_api() + read_bucket = InfluxDBWrapper.get_bucket(DownsampleSize.SHORT_TERM) for i in range(migrate_till): range_start = f"-{i + 1}d" diff --git a/api/app_analytics/services.py b/api/app_analytics/services.py index 21f8bf75c904..e34968026ec0 100644 --- a/api/app_analytics/services.py +++ b/api/app_analytics/services.py @@ -1,9 +1,16 @@ +from datetime import datetime + from django.conf import settings +from django.db.models import QuerySet +from app_analytics import constants from app_analytics.cache import APIUsageCache -from app_analytics.models import Resource +from app_analytics.influxdb_wrapper import InfluxDBWrapper, build_filter_string +from app_analytics.models import FeatureEvaluationBucket, Resource from app_analytics.tasks import track_request from app_analytics.types import Labels +from environments.models import Environment +from features.models import Feature api_usage_cache = APIUsageCache() @@ -31,3 +38,63 @@ def track_usage_by_resource_host_and_environment( "labels": labels, } ) + + +def get_features_in_use( + environment: Environment, + since: datetime | None = None, +) -> QuerySet[Feature] | None: + """Obtain features found in recent analytics data, i.e. in use""" + if settings.USE_POSTGRES_FOR_ANALYTICS: + feature_names = _get_feature_names_in_use_from_analytics_db(environment, since) + elif settings.INFLUXDB_TOKEN: + feature_names = _get_feature_names_in_use_from_influxdb(environment, since) + else: + return None + features_in_use: QuerySet[Feature] = Feature.objects.filter( + name__in=feature_names, + project__environments=environment, + ) + return features_in_use + + +def _get_feature_names_in_use_from_analytics_db( + environment: Environment, + since: datetime | None = None, +) -> list[str]: + # NOTE: Neighbour buckets may bleed depending on `since` + buckets = FeatureEvaluationBucket.objects.filter( + environment_id=environment.pk, + bucket_size=constants.ANALYTICS_READ_BUCKET_SIZE, + created_at__gte=since, + total_count__gt=0, + ) + feature_names = buckets.values_list("feature_name", flat=True).distinct() + return list(feature_names) + + +def _get_feature_names_in_use_from_influxdb( + environment: Environment, + since: datetime | None = None, +) -> list[str]: + results = InfluxDBWrapper.influx_query_manager( + date_start=since, + filters=build_filter_string( + [ + 'r._measurement == "feature_evaluation"', + 'r["_field"] == "request_count"', + f'r["environment_id"] == "{environment.pk}"', + ] + ), + extra=( + '|> keep(columns: ["feature_id"]) ' + '|> distinct(column: "feature_id") ' + '|> yield(name: "distinct")' + ), + ) + return [ + feature_name + for table in results + for record in table.records + if (feature_name := record.get_value()) is not None + ] diff --git a/api/app_analytics/types.py b/api/app_analytics/types.py index 85a49882c2b7..37dc68705059 100644 --- a/api/app_analytics/types.py +++ b/api/app_analytics/types.py @@ -1,4 +1,5 @@ from datetime import date +from enum import StrEnum from typing import TYPE_CHECKING, Literal, NamedTuple, TypeAlias, TypedDict if TYPE_CHECKING: @@ -27,6 +28,11 @@ ] +class DownsampleSize(StrEnum): + SHORT_TERM = "15m" + LONG_TERM = "1h" + + class APIUsageCacheKey(NamedTuple): resource: "Resource" host: str diff --git a/api/features/feature_lifecycle/serializers.py b/api/features/feature_lifecycle/serializers.py new file mode 100644 index 000000000000..0ba5c55bbbe7 --- /dev/null +++ b/api/features/feature_lifecycle/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers + + +class FeatureLifecycleCountsSerializer(serializers.Serializer[dict[str, int]]): + """Number of features in each lifecycle stage for an environment""" + + new = serializers.IntegerField() + live = serializers.IntegerField() + stale = serializers.IntegerField() + permanent = serializers.IntegerField() + needs_monitoring = serializers.IntegerField() + to_remove = serializers.IntegerField() diff --git a/api/features/feature_lifecycle/services.py b/api/features/feature_lifecycle/services.py new file mode 100644 index 000000000000..fcd2f8488a7d --- /dev/null +++ b/api/features/feature_lifecycle/services.py @@ -0,0 +1,91 @@ +from datetime import timedelta + +from django.db.models.expressions import Case, Exists, OuterRef, Value, When +from django.db.models.fields import BooleanField +from django.db.models.functions import Cast +from django.db.models.query import QuerySet +from django.utils import timezone + +from app_analytics.services import get_features_in_use +from environments.models import Environment +from features.feature_lifecycle.types import LifecycleStage +from features.models import Feature +from integrations.flagsmith.client import get_openfeature_client +from organisations.models import Organisation +from projects.code_references.services import get_feature_flags_in_latest_scan +from projects.tags.models import Tag, TagType + + +def is_feature_lifecycle_enabled(organisation: Organisation) -> bool: + return get_openfeature_client().get_boolean_value( + "feature_lifecycle", + default_value=False, + evaluation_context=organisation.openfeature_evaluation_context, + ) + + +def annotate_feature_queryset_with_lifecycle_stage( + queryset: QuerySet[Feature], + environment: Environment, +) -> QuerySet[Feature]: + """Annotate `queryset` with `lifecycle_stage: LifecycleStage`.""" + days_until_stale = environment.project.stale_flags_limit_days + usage_window = timezone.now() - timedelta(days=days_until_stale) + + features_in_code = get_feature_flags_in_latest_scan(environment.project) + features_in_use = get_features_in_use(environment, since=usage_window) + + return queryset.annotate( + has_code_references=Exists( + features_in_code.filter(pk=OuterRef("pk")), + ), + has_recent_usage=( + Exists(features_in_use.filter(pk=OuterRef("pk"))) + if features_in_use is not None + else Cast(Value(None), output_field=BooleanField()) + ), + has_permanent_tag=Exists( + Tag.objects.filter(feature=OuterRef("pk"), is_permanent=True), + ), + has_stale_tag=Exists( + Tag.objects.filter(feature=OuterRef("pk"), type=TagType.STALE), + ), + lifecycle_stage=Case( + When( + has_code_references=False, + has_permanent_tag=False, + has_stale_tag=False, + then=Value(LifecycleStage.NEW), + ), + When( + has_code_references=True, + has_permanent_tag=False, + has_stale_tag=False, + then=Value(LifecycleStage.LIVE), + ), + When( + has_code_references=True, + has_permanent_tag=False, + has_stale_tag=True, + then=Value(LifecycleStage.STALE), + ), + When( + has_permanent_tag=True, + then=Value(LifecycleStage.PERMANENT), + ), + When( + has_code_references=False, + has_permanent_tag=False, + has_stale_tag=True, + has_recent_usage=True, + then=Value(LifecycleStage.NEEDS_MONITORING), + ), + When( + has_code_references=False, + has_permanent_tag=False, + has_stale_tag=True, + has_recent_usage=False, + then=Value(LifecycleStage.TO_REMOVE), + ), + ), + ) diff --git a/api/features/feature_lifecycle/types.py b/api/features/feature_lifecycle/types.py new file mode 100644 index 000000000000..f46b1a20495a --- /dev/null +++ b/api/features/feature_lifecycle/types.py @@ -0,0 +1,12 @@ +from enum import StrEnum + + +class LifecycleStage(StrEnum): + """The lifecycle stage of a feature""" + + NEW = "new" + LIVE = "live" + STALE = "stale" + PERMANENT = "permanent" + NEEDS_MONITORING = "needs_monitoring" + TO_REMOVE = "to_remove" diff --git a/api/features/feature_lifecycle/urls.py b/api/features/feature_lifecycle/urls.py new file mode 100644 index 000000000000..eb0d10cb7fe8 --- /dev/null +++ b/api/features/feature_lifecycle/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from features.feature_lifecycle import views + +app_name = "feature-lifecycle" + +urlpatterns = [ + path( + "environments//feature-lifecycle-counts/", + views.FeatureLifecycleCountsAPIView.as_view(), + name="feature-lifecycle-counts", + ), +] diff --git a/api/features/feature_lifecycle/views.py b/api/features/feature_lifecycle/views.py new file mode 100644 index 000000000000..0a115f17e421 --- /dev/null +++ b/api/features/feature_lifecycle/views.py @@ -0,0 +1,59 @@ +import structlog +from common.environments.permissions import VIEW_ENVIRONMENT +from django.db.models import Count +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from environments.models import Environment +from features.feature_lifecycle.serializers import ( + FeatureLifecycleCountsSerializer, +) +from features.feature_lifecycle.services import ( + annotate_feature_queryset_with_lifecycle_stage, +) +from features.feature_lifecycle.types import LifecycleStage + +logger = structlog.get_logger("feature_lifecycle") + + +class FeatureLifecycleCountsAPIView(APIView): + """Count of features in each lifecycle stage for a project""" + + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["mcp"], + operation_id="get_feature_lifecycle_counts", + description=( + "Retrieves the count of features in each lifecycle stage " + "(new, live, stale, permanent, needs_monitoring, to_remove) " + "for the specified environment." + ), + responses={200: FeatureLifecycleCountsSerializer}, + ) + def get(self, request: Request, environment_pk: int) -> Response: + environment = get_object_or_404(Environment, pk=environment_pk) + if not request.user.has_environment_permission(VIEW_ENVIRONMENT, environment): # type: ignore[union-attr] + return Response(status=403) + + features = annotate_feature_queryset_with_lifecycle_stage( + environment.project.features.all(), + environment, + ) + + counts = features.values("lifecycle_stage").annotate(count=Count("pk")) # type: ignore[misc] + summary: dict[LifecycleStage, int] = {stage: 0 for stage in LifecycleStage} + for stage_count in counts: + summary[stage_count["lifecycle_stage"]] = stage_count["count"] + + logger.info( + "summarised", + organisation__id=environment.project.organisation_id, + environment__id=environment.pk, + ) + + return Response(summary) diff --git a/api/features/serializers.py b/api/features/serializers.py index 87d120109a43..08dad4020b1c 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -46,6 +46,7 @@ ) from .constants import CONTROL_VARIANT_KEY, INTERSECTION, UNION +from .feature_lifecycle.types import LifecycleStage from .feature_segments.limits import ( SEGMENT_OVERRIDE_LIMIT_EXCEEDED_MESSAGE, exceeds_segment_override_limit, @@ -115,6 +116,12 @@ class FeatureQuerySerializer(serializers.Serializer): # type: ignore[type-arg] help_text="ID of the identity to sort features with identity overrides first.", ) + lifecycle_stage = serializers.ChoiceField( + choices=list(LifecycleStage), + required=False, + help_text="Lifecycle stage to filter on. Requires `environment`.", + ) + is_enabled = serializers.BooleanField( allow_null=True, required=False, @@ -456,10 +463,17 @@ class FeatureSerializerWithMetadata(MetadataSerializerMixin, CreateFeatureSerial read_only=True, ) + # NOTE: This field is populated by `features.feature_lifecycle.services.annotate_feature_queryset_with_lifecycle_stage`. + lifecycle_stage = serializers.ChoiceField( + choices=list(LifecycleStage), + read_only=True, + ) + class Meta(CreateFeatureSerializer.Meta): fields = CreateFeatureSerializer.Meta.fields + ( # type: ignore[assignment] "metadata", "code_references_counts", + "lifecycle_stage", ) def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: diff --git a/api/features/views.py b/api/features/views.py index e3b4f958ad6b..e4d25e761a59 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -59,6 +59,10 @@ EnvironmentKeyPermissions, NestedEnvironmentPermissions, ) +from features.feature_lifecycle.services import ( + annotate_feature_queryset_with_lifecycle_stage, + is_feature_lifecycle_enabled, +) from features.value_types import BOOLEAN, INTEGER, STRING from projects.code_references.services import ( annotate_feature_queryset_with_code_references_summary, @@ -172,7 +176,7 @@ def get_serializer_class(self): # type: ignore[no-untyped-def] "partial_update": UpdateFeatureSerializer, }.get(self.action, ProjectFeatureSerializer) - def get_queryset(self): # type: ignore[no-untyped-def] + def get_queryset(self): # type: ignore[no-untyped-def] # noqa: C901 if getattr(self, "swagger_fake_view", False): return Feature.objects.none() @@ -261,8 +265,14 @@ def get_queryset(self): # type: ignore[no-untyped-def] queryset = queryset.order_by(*override_ordering, sort) if environment_id: - page = self.paginate_queryset(queryset) self.environment = Environment.objects.get(id=environment_id) + if is_feature_lifecycle_enabled(project.organisation): + queryset = annotate_feature_queryset_with_lifecycle_stage( + queryset, self.environment + ) + if lifecycle_stage := query_data.get("lifecycle_stage"): + queryset = queryset.filter(lifecycle_stage=lifecycle_stage) + page = self.paginate_queryset(queryset) self.feature_ids = [feature.id for feature in page] feature_states_query = Q( feature_id__in=self.feature_ids, diff --git a/api/integrations/flagsmith/data/environment.json b/api/integrations/flagsmith/data/environment.json index e463eaad3e18..80dd1247b32b 100644 --- a/api/integrations/flagsmith/data/environment.json +++ b/api/integrations/flagsmith/data/environment.json @@ -117,6 +117,19 @@ "feature_state_value": null, "featurestate_uuid": "fe8067b9-efef-4f02-a6d9-0d1ed901f7fa", "multivariate_feature_state_values": [] + }, + { + "django_id": 1468106, + "enabled": true, + "feature": { + "id": 216082, + "name": "feature_lifecycle", + "type": "STANDARD" + }, + "feature_segment": null, + "feature_state_value": null, + "featurestate_uuid": "93aeeaf9-492a-4fbf-badd-7dafb26cb553", + "multivariate_feature_state_values": [] } ], "id": 0, diff --git a/api/projects/code_references/services.py b/api/projects/code_references/services.py index 4e8705b0b85e..436d532e8116 100644 --- a/api/projects/code_references/services.py +++ b/api/projects/code_references/services.py @@ -102,6 +102,18 @@ def get_code_references_for_feature_flag( ] +def get_feature_flags_in_latest_scan(project: Project) -> QuerySet[Feature]: + """Obtain features found in the latest code references scan""" + fresh_scans = ScannedCodeReferences.objects.filter( + feature__project=project, + created_at=F("repository__last_scanned_at"), + ) + features_in_scan: QuerySet[Feature] = Feature.objects.filter( + scanned_code_references__in=fresh_scans, + ) + return features_in_scan + + def record_scan( project: Project, repository_url: str, diff --git a/api/sse/tasks.py b/api/sse/tasks.py index 6bed2591ceee..5b8438d88f3a 100644 --- a/api/sse/tasks.py +++ b/api/sse/tasks.py @@ -9,7 +9,7 @@ register_task_handler, ) -from app_analytics.influxdb_wrapper import influxdb_client +from app_analytics.influxdb_wrapper import InfluxDBWrapper from environments.models import Environment from projects.models import Project from sse import sse_service @@ -52,7 +52,7 @@ def update_sse_usage(): # type: ignore[no-untyped-def] agg_request_count[log.api_key] = agg_request_count.get(log.api_key, 0) + 1 agg_last_event_generated_at[log.api_key] = log.generated_at - with influxdb_client.write_api( + with InfluxDBWrapper.get_client().write_api( write_options=WriteOptions(batch_size=100, flush_interval=1000) ) as write_api: environments = Environment.objects.filter( diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 0ef751a26568..7b2117c98a77 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -63,6 +63,7 @@ from api_keys.models import MasterAPIKey from api_keys.user import APIKeyUser +from app_analytics.influxdb_wrapper import InfluxDBWrapper from environments.dynamodb import ( DynamoEnvironmentV2Wrapper, DynamoEnvironmentWrapper, @@ -229,6 +230,12 @@ def django_db_setup(request: pytest.FixtureRequest) -> None: db_settings["NAME"] = test_db_name +@pytest.fixture() +def mock_influxdb_client(mocker: MockerFixture) -> MagicMock: + client: MagicMock = mocker.patch.object(InfluxDBWrapper, "get_client").return_value + return client + + @pytest.fixture(autouse=True) def restrict_http_requests(monkeypatch: pytest.MonkeyPatch) -> None: """ diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 3928f15be48b..fade7704233a 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -6,12 +6,14 @@ from django.core.cache.backends.locmem import LocMemCache from django.test import Client as DjangoClient from django.urls import reverse +from influxdb_client import InfluxDBClient from pytest_django.fixtures import SettingsWrapper from pytest_mock import MockerFixture from rest_framework import status from rest_framework.test import APIClient from app.utils import create_hash +from app_analytics.influxdb_wrapper import InfluxDBWrapper from environments.enums import EnvironmentDocumentCacheMode from organisations.models import Organisation from tests.integration.helpers import create_mv_option_with_api @@ -38,6 +40,30 @@ def admin_client(api_client, admin_user): # type: ignore[no-untyped-def] return api_client +@pytest.fixture() +def influxdb(settings: SettingsWrapper) -> InfluxDBClient: + settings.INFLUXDB_BUCKET = "api_usage" + settings.INFLUXDB_URL = "http://localhost:8086" + settings.INFLUXDB_ORG = "flagsmith" + settings.INFLUXDB_TOKEN = "admin-token" + + # Matches api.app_analytics.influxdb_wrapper bucket definitions + client = InfluxDBWrapper.get_client() + bucket_api = client.buckets_api() + bucket_names = [ + "api_usage_downsampled_15m", + "api_usage_downsampled_1h", + ] + for bucket_name in bucket_names: + if not bucket_api.find_bucket_by_name(bucket_name): # type: ignore[no-untyped-call] + bucket_api.create_bucket( + org="flagsmith", + bucket_name=bucket_name, + ) + + return client + + @pytest.fixture() def organisation(admin_client): # type: ignore[no-untyped-def] organisation_data = {"name": "Test org"} diff --git a/api/tests/integration/features/feature_lifecycle/conftest.py b/api/tests/integration/features/feature_lifecycle/conftest.py new file mode 100644 index 000000000000..7cf27974288f --- /dev/null +++ b/api/tests/integration/features/feature_lifecycle/conftest.py @@ -0,0 +1,97 @@ +from collections.abc import Callable +from datetime import timedelta +from typing import Any +from uuid import uuid4 + +import pytest +from django.utils import timezone +from influxdb_client import InfluxDBClient, Point +from influxdb_client.client.write_api import SYNCHRONOUS + +from app_analytics.constants import ANALYTICS_READ_BUCKET_SIZE +from app_analytics.models import FeatureEvaluationBucket +from features.models import Feature +from projects.code_references.models import ScannedCodeReferences, VCSRepository +from projects.code_references.types import StoredCodeReference, VCSProvider +from projects.tags.models import Tag, TagType + +MakeCodeReferencesFixture = Callable[ + [Feature, list[StoredCodeReference]], ScannedCodeReferences +] +MakeFeatureUsageFixture = Callable[[Feature, int], Any] + + +@pytest.fixture() +def stale_tag(project: int) -> Tag: + return Tag.objects.create( # type: ignore[no-any-return] + project_id=project, + is_system_tag=True, + type=TagType.STALE, + ) + + +@pytest.fixture() +def permanent_tag(project: int) -> Tag: + return Tag.objects.create( # type: ignore[no-any-return] + label="permanent", + project_id=project, + is_permanent=True, + is_system_tag=True, + ) + + +@pytest.fixture() +def code_references_repository(project: int) -> VCSRepository: + return VCSRepository.objects.create( + project_id=project, + url="https://github.flagsmith.com/core/", + vcs_provider=VCSProvider.GITHUB, + last_scanned_at=(timezone.now() - timedelta(days=7)), + ) + + +@pytest.fixture +def make_code_references( + code_references_repository: VCSRepository, +) -> MakeCodeReferencesFixture: + now = timezone.now() + code_references_repository.last_scanned_at = now + code_references_repository.save() + return lambda feature, code_references: ScannedCodeReferences.objects.create( + created_at=now, + feature=feature, + repository=code_references_repository, + revision=str(uuid4()), + code_references=code_references, + code_references_hash="potato", + ) + + +@pytest.fixture +def make_analytics_db_usage( + environment: int, +) -> MakeFeatureUsageFixture: + return lambda feature, evaluation_count: FeatureEvaluationBucket.objects.create( + feature_name=feature.name, + bucket_size=15, + created_at=timezone.now(), + total_count=evaluation_count, + environment_id=environment, + ) + + +@pytest.fixture +def make_influxdb_usage( + environment: int, + influxdb: InfluxDBClient, +) -> MakeFeatureUsageFixture: + write_api = influxdb.write_api(write_options=SYNCHRONOUS) + return lambda feature, evaluation_count: write_api.write( + bucket="api_usage_downsampled_15m", + org="flagsmith", + record=Point("feature_evaluation") + .tag("environment_id", str(environment)) + .tag("feature_id", feature.name) + .field("request_count", evaluation_count) + .time(timezone.now() - timedelta(minutes=ANALYTICS_READ_BUCKET_SIZE)), + ) diff --git a/api/tests/integration/features/feature_lifecycle/test_endpoints.py b/api/tests/integration/features/feature_lifecycle/test_endpoints.py new file mode 100644 index 000000000000..677e1936ee24 --- /dev/null +++ b/api/tests/integration/features/feature_lifecycle/test_endpoints.py @@ -0,0 +1,171 @@ +import freezegun +import pytest +from pytest_django.fixtures import SettingsWrapper +from pytest_structlog import StructuredLogCapture +from rest_framework.test import APIClient + +from features.feature_lifecycle.types import LifecycleStage +from features.models import Feature +from projects.tags.models import Tag +from tests.integration.features.feature_lifecycle.conftest import ( + MakeCodeReferencesFixture, + MakeFeatureUsageFixture, +) +from users.models import FFAdminUser + + +@pytest.mark.use_analytics_db +@freezegun.freeze_time("2099-01-01T12:00:00Z") +def test_feature_lifecycle_counts__varied_stages_analytics_db__responds_200_with_json_summary( + admin_client: APIClient, + environment: int, + make_analytics_db_usage: MakeFeatureUsageFixture, + make_code_references: MakeCodeReferencesFixture, + permanent_tag: Tag, + project: int, + settings: SettingsWrapper, + stale_tag: Tag, +) -> None: + # Given + settings.USE_POSTGRES_FOR_ANALYTICS = True + + Feature.objects.create(project_id=project, name="new") + + live_feature = Feature.objects.create(project_id=project, name="live") + make_code_references(live_feature, [{"file_path": "file.py", "line_number": 1}]) + + stale_feature = Feature.objects.create(project_id=project, name="stale") + make_code_references(stale_feature, []) + stale_feature.tags.add(stale_tag) + + permanent_feature = Feature.objects.create(project_id=project, name="permanent") + permanent_feature.tags.add(permanent_tag) + + needs_monitoring_feature = Feature.objects.create( + project_id=project, name="needs_monitoring" + ) + needs_monitoring_feature.tags.add(stale_tag) + make_analytics_db_usage(needs_monitoring_feature, 1) + + to_remove_feature = Feature.objects.create(project_id=project, name="to_remove") + to_remove_feature.tags.add(stale_tag) + + # When + response = admin_client.get( + f"/api/v1/environments/{environment}/feature-lifecycle-counts/" + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + LifecycleStage.NEW: 1, + LifecycleStage.LIVE: 1, + LifecycleStage.STALE: 1, + LifecycleStage.PERMANENT: 1, + LifecycleStage.NEEDS_MONITORING: 1, + LifecycleStage.TO_REMOVE: 1, + } + + +@pytest.mark.usefixtures("influxdb") +@freezegun.freeze_time("2099-01-01T12:00:00Z") +def test_feature_lifecycle_counts__varied_stages_influxdb__responds_200_with_json_summary( + admin_client: APIClient, + environment: int, + make_code_references: MakeCodeReferencesFixture, + make_influxdb_usage: MakeFeatureUsageFixture, + permanent_tag: Tag, + project: int, + stale_tag: Tag, +) -> None: + # Given + Feature.objects.create(project_id=project, name="new") + + live_feature = Feature.objects.create(project_id=project, name="live") + make_code_references(live_feature, [{"file_path": "file.py", "line_number": 1}]) + + stale_feature = Feature.objects.create(project_id=project, name="stale") + make_code_references(stale_feature, []) + stale_feature.tags.add(stale_tag) + + permanent_feature = Feature.objects.create(project_id=project, name="permanent") + permanent_feature.tags.add(permanent_tag) + + needs_monitoring_feature = Feature.objects.create( + project_id=project, name="needs_monitoring" + ) + needs_monitoring_feature.tags.add(stale_tag) + make_influxdb_usage(needs_monitoring_feature, 1) + + to_remove_feature = Feature.objects.create(project_id=project, name="to_remove") + to_remove_feature.tags.add(stale_tag) + + # When + response = admin_client.get( + f"/api/v1/environments/{environment}/feature-lifecycle-counts/" + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + LifecycleStage.NEW: 1, + LifecycleStage.LIVE: 1, + LifecycleStage.STALE: 1, + LifecycleStage.PERMANENT: 1, + LifecycleStage.NEEDS_MONITORING: 1, + LifecycleStage.TO_REMOVE: 1, + } + + +def test_feature_lifecycle_counts__no_features__responds_200_with_empty_json_summary( + admin_client: APIClient, + environment: int, + log: StructuredLogCapture, + organisation: int, +) -> None: + # Given / When + response = admin_client.get( + f"/api/v1/environments/{environment}/feature-lifecycle-counts/" + ) + + # Then + assert response.status_code == 200 + assert response.json() == {lifecycle_stage: 0 for lifecycle_stage in LifecycleStage} + assert log.has( + "summarised", + level="info", + organisation__id=organisation, + environment__id=environment, + ) + + +def test_feature_lifecycle_counts__anonymous_user__responds_401( + environment: int, +) -> None: + # Given + client = APIClient() + + # When + response = client.get( + f"/api/v1/environments/{environment}/feature-lifecycle-counts/" + ) + + # Then + assert response.status_code == 401 + + +def test_feature_lifecycle_counts__non_member_user__responds_403( + environment: int, +) -> None: + # Given + non_member = FFAdminUser.objects.create(username="who") + client = APIClient() + client.force_authenticate(user=non_member) + + # When + response = client.get( + f"/api/v1/environments/{environment}/feature-lifecycle-counts/" + ) + + # Then + assert response.status_code == 403 diff --git a/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py b/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py new file mode 100644 index 000000000000..5af146a3d0bf --- /dev/null +++ b/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py @@ -0,0 +1,422 @@ +import freezegun +import pytest +from pytest_django.fixtures import SettingsWrapper +from rest_framework.test import APIClient + +from features.feature_lifecycle.types import LifecycleStage +from features.models import Feature +from projects.tags.models import Tag +from tests.integration.features.feature_lifecycle.conftest import ( + MakeCodeReferencesFixture, + MakeFeatureUsageFixture, +) +from tests.types import EnableFeaturesFixture + + +@pytest.mark.use_analytics_db +@freezegun.freeze_time("2099-01-01T12:00:00Z") +def test_feature_list_endpoint__varied_stages_analytics_db__responds_200_with_lifecycle_stage_in_each_feature( + admin_client: APIClient, + enable_features: EnableFeaturesFixture, + environment: int, + make_analytics_db_usage: MakeFeatureUsageFixture, + make_code_references: MakeCodeReferencesFixture, + permanent_tag: Tag, + project: int, + settings: SettingsWrapper, + stale_tag: Tag, +) -> None: + # Given + enable_features("feature_lifecycle") + settings.USE_POSTGRES_FOR_ANALYTICS = True + + Feature.objects.create(project_id=project, name="new") + + live_feature = Feature.objects.create(project_id=project, name="live") + make_code_references(live_feature, [{"file_path": "file.py", "line_number": 1}]) + + stale_feature = Feature.objects.create(project_id=project, name="stale") + make_code_references(stale_feature, []) + stale_feature.tags.add(stale_tag) + + permanent_feature = Feature.objects.create(project_id=project, name="permanent") + permanent_feature.tags.add(permanent_tag) + + needs_monitoring_feature = Feature.objects.create( + project_id=project, name="needs_monitoring" + ) + needs_monitoring_feature.tags.add(stale_tag) + make_analytics_db_usage(needs_monitoring_feature, 1) + + to_remove_feature = Feature.objects.create(project_id=project, name="to_remove") + to_remove_feature.tags.add(stale_tag) + + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/?environment={environment}" + ) + + # Then + assert response.status_code == 200 + json_features = {feature["name"]: feature for feature in response.json()["results"]} + assert json_features["new"]["lifecycle_stage"] == LifecycleStage.NEW + assert json_features["live"]["lifecycle_stage"] == LifecycleStage.LIVE + assert json_features["stale"]["lifecycle_stage"] == LifecycleStage.STALE + assert json_features["permanent"]["lifecycle_stage"] == LifecycleStage.PERMANENT + assert ( + json_features["needs_monitoring"]["lifecycle_stage"] + == LifecycleStage.NEEDS_MONITORING + ) + assert json_features["to_remove"]["lifecycle_stage"] == LifecycleStage.TO_REMOVE + + +@pytest.mark.usefixtures("influxdb") +@freezegun.freeze_time("2099-01-01T12:00:00Z") +def test_feature_list_endpoint__varied_stages_influxdb__responds_200_with_lifecycle_stage_in_each_feature( + admin_client: APIClient, + enable_features: EnableFeaturesFixture, + environment: int, + make_code_references: MakeCodeReferencesFixture, + make_influxdb_usage: MakeFeatureUsageFixture, + permanent_tag: Tag, + project: int, + stale_tag: Tag, +) -> None: + # Given + enable_features("feature_lifecycle") + + Feature.objects.create(project_id=project, name="new") + + live_feature = Feature.objects.create(project_id=project, name="live") + make_code_references(live_feature, [{"file_path": "file.py", "line_number": 1}]) + + stale_feature = Feature.objects.create(project_id=project, name="stale") + make_code_references(stale_feature, []) + stale_feature.tags.add(stale_tag) + + permanent_feature = Feature.objects.create(project_id=project, name="permanent") + permanent_feature.tags.add(permanent_tag) + + needs_monitoring_feature = Feature.objects.create( + project_id=project, name="needs_monitoring" + ) + needs_monitoring_feature.tags.add(stale_tag) + make_influxdb_usage(needs_monitoring_feature, 1) + + to_remove_feature = Feature.objects.create(project_id=project, name="to_remove") + to_remove_feature.tags.add(stale_tag) + + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/?environment={environment}" + ) + + # Then + assert response.status_code == 200 + json_features = {feature["name"]: feature for feature in response.json()["results"]} + assert json_features["new"]["lifecycle_stage"] == LifecycleStage.NEW + assert json_features["live"]["lifecycle_stage"] == LifecycleStage.LIVE + assert json_features["stale"]["lifecycle_stage"] == LifecycleStage.STALE + assert json_features["permanent"]["lifecycle_stage"] == LifecycleStage.PERMANENT + assert ( + json_features["needs_monitoring"]["lifecycle_stage"] + == LifecycleStage.NEEDS_MONITORING + ) + assert json_features["to_remove"]["lifecycle_stage"] == LifecycleStage.TO_REMOVE + + +@freezegun.freeze_time("2099-01-01T12:00:00Z") +def test_feature_list_endpoint__lifecycle_stage_filter__responds_200_with_only_matching_features( + admin_client: APIClient, + enable_features: EnableFeaturesFixture, + environment: int, + make_code_references: MakeCodeReferencesFixture, + permanent_tag: Tag, + project: int, + stale_tag: Tag, +) -> None: + # Given + enable_features("feature_lifecycle") + + Feature.objects.create(project_id=project, name="new") + + live_feature = Feature.objects.create(project_id=project, name="live") + make_code_references(live_feature, [{"file_path": "file.py", "line_number": 1}]) + + stale_feature = Feature.objects.create(project_id=project, name="stale") + make_code_references(stale_feature, []) + stale_feature.tags.add(stale_tag) + + permanent_feature = Feature.objects.create(project_id=project, name="permanent") + permanent_feature.tags.add(permanent_tag) + + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/" + f"?environment={environment}&lifecycle_stage={LifecycleStage.LIVE}" + ) + + # Then + assert response.status_code == 200 + json_features = response.json()["results"] + assert [feature["name"] for feature in json_features] == ["live"] + + +def test_feature_list_endpoint__invalid_lifecycle_stage_filter__responds_400( + admin_client: APIClient, + enable_features: EnableFeaturesFixture, + environment: int, + project: int, +) -> None: + # Given + enable_features("feature_lifecycle") + + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/" + f"?environment={environment}&lifecycle_stage=not_a_stage" + ) + + # Then + assert response.status_code == 400 + + +@freezegun.freeze_time("2099-01-01T12:00:00Z") +def test_feature_list_endpoint__lifecycle_stage_filter_flag_off__ignores_filter( + admin_client: APIClient, + environment: int, + project: int, +) -> None: + # Given + Feature.objects.create(project_id=project, name="feature") + + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/" + f"?environment={environment}&lifecycle_stage={LifecycleStage.LIVE}" + ) + + # Then + assert response.status_code == 200 + json_features = response.json()["results"] + assert [feature["name"] for feature in json_features] == ["feature"] + + +def test_feature_list_endpoint__flag_off__responds_200_without_lifecycle_stage( + admin_client: APIClient, + environment: int, + project: int, +) -> None: + # Given + Feature.objects.create(project_id=project, name="feature") + + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/?environment={environment}" + ) + + # Then + assert response.status_code == 200 + json_features = response.json()["results"] + assert not any(("lifecycle_stage" in feature) for feature in json_features) + + +def test_feature_list_endpoint__no_environment__responds_200_without_lifecycle_stage( + admin_client: APIClient, + enable_features: EnableFeaturesFixture, + project: int, +) -> None: + # Given + enable_features("feature_lifecycle") + Feature.objects.create(project_id=project, name="feature") + + # When + response = admin_client.get(f"/api/v1/projects/{project}/features/") + + # Then + assert response.status_code == 200 + json_features = response.json()["results"] + assert not any(("lifecycle_stage" in feature) for feature in json_features) + + +@freezegun.freeze_time("2099-01-01T12:00:00Z") +def test_feature_detail_endpoint__new_feature__responds_200_with_lifecycle_stage_new( + admin_client: APIClient, + enable_features: EnableFeaturesFixture, + environment: int, + project: int, +) -> None: + # Given + enable_features("feature_lifecycle") + feature = Feature.objects.create(project_id=project, name="new") + + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/{feature.id}/?environment={environment}" + ) + + # Then + assert response.status_code == 200 + assert response.json()["lifecycle_stage"] == LifecycleStage.NEW + + +@freezegun.freeze_time("2099-01-01T12:00:00Z") +def test_feature_detail_endpoint__live_feature__responds_200_with_lifecycle_stage_live( + admin_client: APIClient, + enable_features: EnableFeaturesFixture, + environment: int, + make_code_references: MakeCodeReferencesFixture, + project: int, +) -> None: + # Given + enable_features("feature_lifecycle") + feature = Feature.objects.create(project_id=project, name="live") + make_code_references(feature, [{"file_path": "file.py", "line_number": 1}]) + + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/{feature.id}/?environment={environment}" + ) + + # Then + assert response.status_code == 200 + assert response.json()["lifecycle_stage"] == LifecycleStage.LIVE + + +@freezegun.freeze_time("2099-01-01T12:00:00Z") +def test_feature_detail_endpoint__stale_feature__responds_200_with_lifecycle_stage_stale( + admin_client: APIClient, + enable_features: EnableFeaturesFixture, + environment: int, + make_code_references: MakeCodeReferencesFixture, + project: int, + stale_tag: Tag, +) -> None: + # Given + enable_features("feature_lifecycle") + feature = Feature.objects.create(project_id=project, name="stale") + make_code_references(feature, []) + feature.tags.add(stale_tag) + + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/{feature.id}/?environment={environment}" + ) + + # Then + assert response.status_code == 200 + assert response.json()["lifecycle_stage"] == LifecycleStage.STALE + + +@freezegun.freeze_time("2099-01-01T12:00:00Z") +def test_feature_detail_endpoint__permanent_feature__responds_200_with_lifecycle_stage_permanent( + admin_client: APIClient, + enable_features: EnableFeaturesFixture, + environment: int, + permanent_tag: Tag, + project: int, +) -> None: + # Given + enable_features("feature_lifecycle") + feature = Feature.objects.create(project_id=project, name="permanent") + feature.tags.add(permanent_tag) + + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/{feature.id}/?environment={environment}" + ) + + # Then + assert response.status_code == 200 + assert response.json()["lifecycle_stage"] == LifecycleStage.PERMANENT + + +@pytest.mark.use_analytics_db +@freezegun.freeze_time("2099-01-01T12:00:00Z") +def test_feature_detail_endpoint__needs_monitoring_feature__responds_200_with_lifecycle_stage_needs_monitoring( + admin_client: APIClient, + enable_features: EnableFeaturesFixture, + environment: int, + make_analytics_db_usage: MakeFeatureUsageFixture, + project: int, + settings: SettingsWrapper, + stale_tag: Tag, +) -> None: + # Given + enable_features("feature_lifecycle") + settings.USE_POSTGRES_FOR_ANALYTICS = True + feature = Feature.objects.create(project_id=project, name="needs_monitoring") + feature.tags.add(stale_tag) + make_analytics_db_usage(feature, 1) + + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/{feature.id}/?environment={environment}" + ) + + # Then + assert response.status_code == 200 + assert response.json()["lifecycle_stage"] == LifecycleStage.NEEDS_MONITORING + + +@pytest.mark.use_analytics_db +@freezegun.freeze_time("2099-01-01T12:00:00Z") +def test_feature_detail_endpoint__to_remove_feature__responds_200_with_lifecycle_stage_to_remove( + admin_client: APIClient, + enable_features: EnableFeaturesFixture, + environment: int, + project: int, + settings: SettingsWrapper, + stale_tag: Tag, +) -> None: + # Given + enable_features("feature_lifecycle") + settings.USE_POSTGRES_FOR_ANALYTICS = True + feature = Feature.objects.create(project_id=project, name="to_remove") + feature.tags.add(stale_tag) + + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/{feature.id}/?environment={environment}" + ) + + # Then + assert response.status_code == 200 + assert response.json()["lifecycle_stage"] == LifecycleStage.TO_REMOVE + + +@freezegun.freeze_time("2099-01-01T12:00:00Z") +def test_feature_detail_endpoint__flag_off__responds_200_without_lifecycle_stage( + admin_client: APIClient, + environment: int, + project: int, +) -> None: + # Given + feature = Feature.objects.create(project_id=project, name="feature") + + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/{feature.id}/?environment={environment}" + ) + + # Then + assert response.status_code == 200 + assert "lifecycle_stage" not in response.json() + + +@freezegun.freeze_time("2099-01-01T12:00:00Z") +def test_feature_detail_endpoint__no_environment__responds_200_without_lifecycle_stage( + admin_client: APIClient, + enable_features: EnableFeaturesFixture, + project: int, +) -> None: + # Given + enable_features("feature_lifecycle") + feature = Feature.objects.create(project_id=project, name="feature") + + # When + response = admin_client.get(f"/api/v1/projects/{project}/features/{feature.id}/") + + # Then + assert response.status_code == 200 + assert "lifecycle_stage" not in response.json() diff --git a/api/tests/unit/app_analytics/test_migrate_to_pg.py b/api/tests/unit/app_analytics/test_migrate_to_pg.py index 13703cd85fa2..b714f8c3a868 100644 --- a/api/tests/unit/app_analytics/test_migrate_to_pg.py +++ b/api/tests/unit/app_analytics/test_migrate_to_pg.py @@ -1,25 +1,23 @@ +from unittest.mock import Mock + import pytest from django.utils import timezone from pytest_mock import MockerFixture +from app_analytics.influxdb_wrapper import InfluxDBWrapper from app_analytics.migrate_to_pg import migrate_feature_evaluations from app_analytics.models import FeatureEvaluationBucket @pytest.mark.use_analytics_db def test_migrate_feature_evaluations__influx_records_exist__creates_pg_buckets( + mock_influxdb_client: Mock, mocker: MockerFixture, ) -> None: # Given feature_name = "test_feature_one" environment_id = "1" - - # mock the read bucket name - read_bucket = "test_bucket" - mocker.patch("app_analytics.migrate_to_pg.read_bucket", read_bucket) - - # Next, mock the influx client and create some records - mock_influxdb_client = mocker.patch("app_analytics.migrate_to_pg.influxdb_client") + mocker.patch.object(InfluxDBWrapper, "get_bucket", return_value="test_bucket") mock_query_api = mock_influxdb_client.query_api.return_value mock_tables = [] for i in range(3): @@ -54,23 +52,23 @@ def test_migrate_feature_evaluations__influx_records_exist__creates_pg_buckets( [ mocker.call.query( ( - f'from (bucket: "{read_bucket}") ' - f"|> range(start: -1d, stop: -0d) " - f'|> filter(fn: (r) => r._measurement == "feature_evaluation")' + 'from (bucket: "test_bucket") ' + "|> range(start: -1d, stop: -0d) " + '|> filter(fn: (r) => r._measurement == "feature_evaluation")' ) ), mocker.call.query( ( - f'from (bucket: "{read_bucket}") ' - f"|> range(start: -2d, stop: -1d) " - f'|> filter(fn: (r) => r._measurement == "feature_evaluation")' + 'from (bucket: "test_bucket") ' + "|> range(start: -2d, stop: -1d) " + '|> filter(fn: (r) => r._measurement == "feature_evaluation")' ) ), mocker.call.query( ( - f'from (bucket: "{read_bucket}") ' - f"|> range(start: -3d, stop: -2d) " - f'|> filter(fn: (r) => r._measurement == "feature_evaluation")' + 'from (bucket: "test_bucket") ' + "|> range(start: -3d, stop: -2d) " + '|> filter(fn: (r) => r._measurement == "feature_evaluation")' ) ), ] diff --git a/api/tests/unit/app_analytics/test_unit_app_analytics_influxdb_wrapper.py b/api/tests/unit/app_analytics/test_unit_app_analytics_influxdb_wrapper.py index 0a5da15042be..5d4568b81091 100644 --- a/api/tests/unit/app_analytics/test_unit_app_analytics_influxdb_wrapper.py +++ b/api/tests/unit/app_analytics/test_unit_app_analytics_influxdb_wrapper.py @@ -1,10 +1,9 @@ from datetime import date, datetime, timedelta -from typing import Generator, Type +from typing import Type from unittest import mock from unittest.mock import MagicMock import pytest -from _pytest.monkeypatch import MonkeyPatch from django.conf import settings from django.utils import timezone from influxdb_client.client.exceptions import InfluxDBError @@ -13,7 +12,6 @@ from pytest_mock import MockerFixture from urllib3.exceptions import HTTPError -import app_analytics from app_analytics.dataclasses import UsageData from app_analytics.influxdb_wrapper import ( InfluxDBWrapper, @@ -24,7 +22,6 @@ get_feature_evaluation_data, get_multiple_event_list_for_feature, get_multiple_event_list_for_organisation, - get_range_bucket_mappings, get_top_organisations, get_usage_data, ) @@ -39,26 +36,11 @@ read_bucket = settings.INFLUXDB_BUCKET + "_downsampled_15m" -@pytest.fixture() -def mock_influxdb_client(monkeypatch: Generator[MonkeyPatch, None, None]) -> MagicMock: - mock_influxdb_client = mock.MagicMock() - monkeypatch.setattr( # type: ignore[attr-defined] - app_analytics.influxdb_wrapper, "influxdb_client", mock_influxdb_client - ) - return mock_influxdb_client - - -@pytest.fixture() -def mock_write_api(mock_influxdb_client: MagicMock) -> MagicMock: - mock_write_api = mock.MagicMock() - mock_influxdb_client.write_api.return_value = mock_write_api - return mock_write_api - - def test_influxdb_wrapper_write__data_point_added__calls_write_api( - mock_write_api: MagicMock, + mock_influxdb_client: MagicMock, ) -> None: # Given + mock_write_api = mock_influxdb_client.write_api.return_value influxdb = InfluxDBWrapper("name") # type: ignore[no-untyped-call] influxdb.add_data_point("field_name", "field_value") @@ -71,11 +53,12 @@ def test_influxdb_wrapper_write__data_point_added__calls_write_api( @pytest.mark.parametrize("exception_class", [HTTPError, InfluxDBError, ApiException]) def test_influxdb_wrapper_write__write_raises_error__handles_gracefully( - mock_write_api: MagicMock, + mock_influxdb_client: MagicMock, exception_class: Type[Exception], caplog: pytest.LogCaptureFixture, ) -> None: # Given + mock_write_api = mock_influxdb_client.write_api.return_value mock_write_api.write.side_effect = exception_class influxdb = InfluxDBWrapper("name") # type: ignore[no-untyped-call] @@ -90,15 +73,13 @@ def test_influxdb_wrapper_write__write_raises_error__handles_gracefully( def test_influx_db_wrapper_query__http_error__logs_expected( + mock_influxdb_client: MagicMock, mocker: MockerFixture, ) -> None: # Given expected_exception = HTTPError("HTTP error occurred") - mock_query_api = mocker.patch( - "app_analytics.influxdb_wrapper.influxdb_client.query_api", - autospec=True, - ) - mock_query_api.return_value.query.side_effect = expected_exception + mock_query_api = mock_influxdb_client.query_api.return_value + mock_query_api.query.side_effect = expected_exception capture_exception_mock = mocker.patch( "app_analytics.influxdb_wrapper.capture_exception", autospec=True, @@ -115,9 +96,9 @@ def test_influx_db_wrapper_query__http_error__logs_expected( @pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") -def test_get_events_for_organisation__default_params__calls_query_api_with_expected_query( # type: ignore[no-untyped-def] - monkeypatch, -): +def test_get_events_for_organisation__default_params__calls_query_api_with_expected_query( + mock_influxdb_client: MagicMock, +) -> None: # Given expected_query = ( ( @@ -133,13 +114,7 @@ def test_get_events_for_organisation__default_params__calls_query_api_with_expec .replace(" ", "") .replace("\n", "") ) - mock_influxdb_client = mock.MagicMock() - monkeypatch.setattr( - app_analytics.influxdb_wrapper, "influxdb_client", mock_influxdb_client - ) - - mock_query_api = mock.MagicMock() - mock_influxdb_client.query_api.return_value = mock_query_api + mock_query_api = mock_influxdb_client.query_api.return_value # When get_events_for_organisation(org_id) @@ -154,7 +129,7 @@ def test_get_events_for_organisation__default_params__calls_query_api_with_expec @pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") def test_get_event_list_for_organisation__default_params__calls_query_api_with_expected_query( - mocker: MockerFixture, + mock_influxdb_client: MagicMock, ) -> None: # Given query = ( @@ -166,13 +141,7 @@ def test_get_event_list_for_organisation__default_params__calls_query_api_with_e f'"project_id", "environment", "environment_id", "host"]) ' f'|> aggregateWindow(every: 24h, fn: sum, timeSrc: "_start")' ) - mock_influxdb_client = mocker.patch( - "app_analytics.influxdb_wrapper.influxdb_client", - autospec=True, - ) - - mock_query_api = mock.MagicMock() - mock_influxdb_client.query_api.return_value = mock_query_api + mock_query_api = mock_influxdb_client.query_api.return_value # When get_event_list_for_organisation(org_id) @@ -221,6 +190,7 @@ def test_get_event_list_for_organisation__default_params__calls_query_api_with_e ) @pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") def test_get_multiple_event_list_for_organisation__various_filters__calls_expected_query( + mock_influxdb_client: MagicMock, mocker: MockerFixture, project_id: int | None, environment_id: int | None, @@ -237,11 +207,6 @@ def test_get_multiple_event_list_for_organisation__various_filters__calls_expect '|> aggregateWindow(every: 24h, fn: sum, timeSrc: "_start")' ) - mock_influxdb_client = mocker.patch( - "app_analytics.influxdb_wrapper.influxdb_client", - autospec=True, - ) - mock_query_api = mock_influxdb_client.query_api.return_value # When @@ -261,14 +226,10 @@ def test_get_multiple_event_list_for_organisation__various_filters__calls_expect def test_get_multiple_event_list_for_organisation__with_data__returns_expected_usage_data( + mock_influxdb_client: MagicMock, mocker: MockerFixture, ) -> None: # Given - mock_influxdb_client = mocker.patch( - "app_analytics.influxdb_wrapper.influxdb_client", - autospec=True, - ) - mock_query_api = mock_influxdb_client.query_api.return_value mock_query_api.query.return_value = [ mocker.MagicMock( @@ -340,7 +301,7 @@ def test_get_multiple_event_list_for_organisation__with_data__returns_expected_u @pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") def test_get_multiple_event_list_for_organisation__labels_filter__calls_expected( - mocker: MockerFixture, + mock_influxdb_client: MagicMock, ) -> None: # Given expected_query = ( @@ -355,11 +316,6 @@ def test_get_multiple_event_list_for_organisation__labels_filter__calls_expected '|> aggregateWindow(every: 24h, fn: sum, timeSrc: "_start")' ) - mock_influxdb_client = mocker.patch( - "app_analytics.influxdb_wrapper.influxdb_client", - autospec=True, - ) - mock_query_api = mock_influxdb_client.query_api.return_value # When @@ -373,7 +329,7 @@ def test_get_multiple_event_list_for_organisation__labels_filter__calls_expected @pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") def test_get_multiple_event_list_for_feature__default_params__calls_expected_query( - mocker: MockerFixture, + mock_influxdb_client: MagicMock, ) -> None: # Given query = ( @@ -390,10 +346,6 @@ def test_get_multiple_event_list_for_feature__default_params__calls_expected_que '|> yield(name: "sum")' ) - mock_influxdb_client = mocker.patch( - "app_analytics.influxdb_wrapper.influxdb_client", - autospec=True, - ) mock_query_api = mock_influxdb_client.query_api.return_value # When @@ -405,7 +357,7 @@ def test_get_multiple_event_list_for_feature__default_params__calls_expected_que @pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") def test_get_multiple_event_list_for_feature__labels_filter__calls_expected( - mocker: MockerFixture, + mock_influxdb_client: MagicMock, ) -> None: # Given query = ( @@ -423,9 +375,6 @@ def test_get_multiple_event_list_for_feature__labels_filter__calls_expected( '|> yield(name: "sum")' ) - mock_influxdb_client = mocker.patch( - "app_analytics.influxdb_wrapper.influxdb_client", autospec=True - ) mock_query_api = mock_influxdb_client.query_api.return_value # When @@ -622,43 +571,40 @@ def test_influx_query_manager__empty_date_range__returns_empty_list() -> None: assert results == [] -def test_get_range_bucket_mappings__less_than_10_days__returns_15m_bucket( +def test_get_downsampled_bucket__less_than_10_days__returns_15m_bucket( settings: SettingsWrapper, ) -> None: # Given two_days = timezone.now() - timedelta(days=2) # When - result = get_range_bucket_mappings(two_days) + result = InfluxDBWrapper.get_downsampled_bucket(two_days) # Then assert result == settings.INFLUXDB_BUCKET + "_downsampled_15m" -def test_get_range_bucket_mappings__more_than_10_days__returns_1h_bucket( +def test_get_downsampled_bucket__more_than_10_days__returns_1h_bucket( settings: SettingsWrapper, ) -> None: # Given twelve_days = timezone.now() - timedelta(days=12) # When - result = get_range_bucket_mappings(twelve_days) + result = InfluxDBWrapper.get_downsampled_bucket(twelve_days) # Then assert result == settings.INFLUXDB_BUCKET + "_downsampled_1h" def test_influx_query_manager__date_start_none__calls_query_api( - mocker: MockerFixture, + mock_influxdb_client: MagicMock, ) -> None: - # Given - mock_client = mocker.patch("app_analytics.influxdb_wrapper.influxdb_client") - - # When + # Given / When InfluxDBWrapper.influx_query_manager() # Then - mock_client.query_api.assert_called_once() + mock_influxdb_client.query_api.assert_called_once() @pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index 7cc60b0bd522..26b85fdbdc5d 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -3555,7 +3555,7 @@ def test_list_features__without_rbac__no_n_plus_1( with_project_permissions, django_assert_num_queries, environment, - num_queries=17, + num_queries=18, ) @@ -3580,7 +3580,7 @@ def test_list_features__with_rbac__no_n_plus_1( with_project_permissions, django_assert_num_queries, environment, - num_queries=18, + num_queries=19, ) @@ -4214,7 +4214,7 @@ def test_list_features__last_modified_without_rbac__returns_expected( feature, with_project_permissions, django_assert_num_queries, - num_queries=19, + num_queries=20, ) @@ -4242,7 +4242,7 @@ def test_list_features__last_modified_with_rbac__returns_expected( feature, with_project_permissions, django_assert_num_queries, - num_queries=20, + num_queries=21, ) diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index f751bf971fb8..9590b9c31766 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -525,7 +525,6 @@ def test_list_projects__valid_organisation__returns_projects( @pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") -@mock.patch("app_analytics.influxdb_wrapper.influxdb_client") def test_get_usage__valid_organisation__queries_influx( mock_influxdb_client: MagicMock, organisation: Organisation, diff --git a/api/tests/unit/sse/test_tasks.py b/api/tests/unit/sse/test_tasks.py index 34c9e58413fe..76805f200c22 100644 --- a/api/tests/unit/sse/test_tasks.py +++ b/api/tests/unit/sse/test_tasks.py @@ -1,5 +1,5 @@ from datetime import datetime -from unittest.mock import call +from unittest.mock import Mock, call import pytest from pytest_django import DjangoAssertNumQueries @@ -94,9 +94,10 @@ def test_get_auth_header__token_not_set__raises_sse_auth_token_not_set(settings) def test_update_sse_usage__valid_and_invalid_logs__writes_only_valid_to_influxdb( # type: ignore[no-untyped-def] - mocker: MockerFixture, environment: Environment, django_assert_num_queries: DjangoAssertNumQueries, + mock_influxdb_client: Mock, + mocker: MockerFixture, settings: SettingsWrapper, ): # Given - two valid logs @@ -113,7 +114,6 @@ def test_update_sse_usage__valid_and_invalid_logs__writes_only_valid_to_influxdb influxdb_bucket = "test_bucket" settings.INFLUXDB_BUCKET = influxdb_bucket - mocked_influx_db_client = mocker.patch("sse.tasks.influxdb_client") mocked_influx_point = mocker.patch("sse.tasks.Point") # When @@ -155,7 +155,7 @@ def test_update_sse_usage__valid_and_invalid_logs__writes_only_valid_to_influxdb # Only valid logs were written to InfluxDB write_method = ( - mocked_influx_db_client.write_api.return_value.__enter__.return_value.write + mock_influxdb_client.write_api.return_value.__enter__.return_value.write ) assert write_method.call_count == 1 diff --git a/docker/api/docker-compose.local.yml b/docker/api/docker-compose.local.yml index 050716c1a3b4..fcd08fbdfe75 100644 --- a/docker/api/docker-compose.local.yml +++ b/docker/api/docker-compose.local.yml @@ -3,6 +3,7 @@ name: flagsmith volumes: + influxdb_data: pg_11_data: pg_11_data_analytics: @@ -32,3 +33,17 @@ services: environment: POSTGRES_DB: analytics POSTGRES_PASSWORD: password + + influxdb: + image: influxdb:2-alpine + volumes: + - influxdb_data:/var/lib/influxdb2 + ports: + - 8086:8086 + environment: + DOCKER_INFLUXDB_INIT_MODE: setup + DOCKER_INFLUXDB_INIT_USERNAME: user + DOCKER_INFLUXDB_INIT_PASSWORD: password + DOCKER_INFLUXDB_INIT_ORG: flagsmith + DOCKER_INFLUXDB_INIT_BUCKET: unused + DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: admin-token diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 645eb0decb76..09186377a41c 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -130,6 +130,15 @@ Logged at `warning` from: Attributes: - `path` +### `feature_lifecycle.summarised` + +Logged at `info` from: + - `api/features/feature_lifecycle/views.py:54` + +Attributes: + - `environment.id` + - `organisation.id` + ### `gitlab.api_call.failed` Logged at `error` from: diff --git a/docs/docs/integrating-with-flagsmith/_mcp-tool-catalogue.md b/docs/docs/integrating-with-flagsmith/_mcp-tool-catalogue.md index 5c764c080e13..61e8efaa0a1c 100644 --- a/docs/docs/integrating-with-flagsmith/_mcp-tool-catalogue.md +++ b/docs/docs/integrating-with-flagsmith/_mcp-tool-catalogue.md @@ -16,6 +16,7 @@ | `get_feature_external_resources` | Retrieves external resources linked to the feature flag. | | `get_feature_flag` | Retrieves detailed information about a specific feature flag. | | `get_feature_health_events` | Retrieves feature health monitoring events and metrics for the project. | +| `get_feature_lifecycle_counts` | Retrieves the count of features in each lifecycle stage (new, live, stale, permanent, needs_monitoring, to_remove) for the specified environment. | | `get_project` | Retrieves comprehensive information about a specific project including configuration and statistics. | | `get_project_segment` | Retrieves detailed information about a specific user segment. | | `list_environments` | Lists all environments the user has access to |