From fa6c72b9041485e2062e90c072b9ad5144a4cde8 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Thu, 18 Jun 2026 19:48:59 -0300 Subject: [PATCH 01/14] Sanitise InfluxDB API --- api/app_analytics/influxdb_wrapper.py | 74 +++++++----- api/app_analytics/migrate_to_pg.py | 6 +- api/app_analytics/types.py | 6 + api/sse/tasks.py | 4 +- api/tests/conftest.py | 6 + .../unit/app_analytics/test_migrate_to_pg.py | 18 ++- ...est_unit_app_analytics_influxdb_wrapper.py | 106 +++++------------- .../test_unit_organisations_views.py | 1 - api/tests/unit/sse/test_tasks.py | 8 +- 9 files changed, 102 insertions(+), 127 deletions(-) diff --git a/api/app_analytics/influxdb_wrapper.py b/api/app_analytics/influxdb_wrapper.py index a6704b8be07c..30b0810eb10a 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", @@ -48,22 +38,42 @@ ) GET_MULTIPLE_EVENTS_LIST_GROUP_CLAUSE = ( + f"|> group(columns: {json.dumps(['resource', *LABELS])}) " ) -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 +95,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 +113,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 +137,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 +150,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 +408,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 +450,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 +492,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/types.py b/api/app_analytics/types.py index 85a49882c2b7..9e9770cb86c1 100644 --- a/api/app_analytics/types.py +++ b/api/app_analytics/types.py @@ -1,3 +1,4 @@ +from enum import StrEnum from datetime import date from typing import TYPE_CHECKING, Literal, NamedTuple, TypeAlias, TypedDict @@ -27,6 +28,11 @@ ] +class DownsampleSize(StrEnum): + SHORT_TERM = "15m" + LONG_TERM = "1h" + + class APIUsageCacheKey(NamedTuple): resource: "Resource" host: 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..71dcf6c317a9 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,11 @@ def django_db_setup(request: pytest.FixtureRequest) -> None: db_settings["NAME"] = test_db_name +@pytest.fixture() +def mock_influxdb_client(mocker: MockerFixture) -> MagicMock: + return mocker.patch.object(InfluxDBWrapper, "get_client").return_value + + @pytest.fixture(autouse=True) def restrict_http_requests(monkeypatch: pytest.MonkeyPatch) -> None: """ 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..a14806a11622 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,21 +52,21 @@ def test_migrate_feature_evaluations__influx_records_exist__creates_pg_buckets( [ mocker.call.query( ( - f'from (bucket: "{read_bucket}") ' + 'from (bucket: "test_bucket") ' f"|> range(start: -1d, stop: -0d) " f'|> filter(fn: (r) => r._measurement == "feature_evaluation")' ) ), mocker.call.query( ( - f'from (bucket: "{read_bucket}") ' + 'from (bucket: "test_bucket") ' f"|> range(start: -2d, stop: -1d) " f'|> filter(fn: (r) => r._measurement == "feature_evaluation")' ) ), mocker.call.query( ( - f'from (bucket: "{read_bucket}") ' + 'from (bucket: "test_bucket") ' f"|> range(start: -3d, stop: -2d) " f'|> 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/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 From 9326c3d0b9e4be5062ffa5c55d17dbe043f2d261 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 19 Jun 2026 13:58:58 -0300 Subject: [PATCH 02/14] Introduce foundation for feature lifecycle --- api/features/feature_lifecycle/types.py | 11 +++ api/features/feature_lifecycle/urls.py | 11 +++ api/features/feature_lifecycle/views.py | 27 +++++++ api/tests/integration/conftest.py | 21 +++++ .../features/feature_lifecycle/conftest.py | 76 ++++++++++++++++++ .../feature_lifecycle/test_endpoints.py | 79 +++++++++++++++++++ .../test_feature_endpoints.py | 3 + docker/api/docker-compose.local.yml | 15 ++++ 8 files changed, 243 insertions(+) create mode 100644 api/features/feature_lifecycle/types.py create mode 100644 api/features/feature_lifecycle/urls.py create mode 100644 api/features/feature_lifecycle/views.py create mode 100644 api/tests/integration/features/feature_lifecycle/conftest.py create mode 100644 api/tests/integration/features/feature_lifecycle/test_endpoints.py create mode 100644 api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py diff --git a/api/features/feature_lifecycle/types.py b/api/features/feature_lifecycle/types.py new file mode 100644 index 000000000000..574b3d7ec349 --- /dev/null +++ b/api/features/feature_lifecycle/types.py @@ -0,0 +1,11 @@ +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..d7cc00f4c30b --- /dev/null +++ b/api/features/feature_lifecycle/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from features.feature_lifecycle import views + +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..b89bbcd76119 --- /dev/null +++ b/api/features/feature_lifecycle/views.py @@ -0,0 +1,27 @@ +from typing import Any + +from common.projects.permissions import VIEW_PROJECT +from django.shortcuts import get_object_or_404 +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from features.feature_lifecycle.types import LifecycleStage + + +class FeatureLifecycleCountsAPIView(APIView): + """Count of features in each lifecycle stage for a project""" + + permission_classes = [IsAuthenticated] + + def get(self, request: Any, project_pk: int, **kwargs: Any) -> Response: + project = get_object_or_404( + request.user.get_permitted_projects(VIEW_PROJECT), + pk=project_pk, + ) + + summary: dict[LifecycleStage, int] = {} + + features = project.features.all() + + return Response(summary) diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 3928f15be48b..bbf31e11a4c3 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,25 @@ 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() + client.buckets_api().create_bucket( + org="flagsmith", bucket_name="api_usage_downsampled_15m" + ) + client.buckets_api().create_bucket( + org="flagsmith", bucket_name="api_usage_downsampled_1h" + ) + + 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..4277462109a6 --- /dev/null +++ b/api/tests/integration/features/feature_lifecycle/conftest.py @@ -0,0 +1,76 @@ +from collections.abc import Callable +from datetime import timedelta +from uuid import uuid4 + +import pytest +from django.utils import timezone +from influxdb_client import InfluxDBClient + +from app_analytics.models import FeatureEvaluationBucket +from environments.models import Environment +from features.models import Feature +from projects.code_references.models import ScannedCodeReferences, VCSRepository +from projects.code_references.types import StoredCodeReference, VCSProvider +from projects.models import Project +from projects.tags.models import Tag + + +@pytest.fixture() +def stale_tag(project: int) -> Tag: + return Tag.objects.create( # type: ignore[no-any-return] + label="stale", + project_id=project, + is_system_tag=True, + ) + + +@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( # type: ignore[no-any-return] + 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, +) -> Callable[[Feature, list[StoredCodeReference]], ScannedCodeReferences]: + return lambda feature, code_references: ScannedCodeReferences.objects.create( + created_at=timezone.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) -> Callable[[Feature, int], FeatureEvaluationBucket]: + 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( + influxdb: InfluxDBClient, +) -> Callable[[Feature, Environment, int], None]: + raise NotImplementedError 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..465aff90340d --- /dev/null +++ b/api/tests/integration/features/feature_lifecycle/test_endpoints.py @@ -0,0 +1,79 @@ +from collections.abc import Callable + +import freezegun +import pytest +from rest_framework.test import APIClient + +from app_analytics.models import FeatureEvaluationBucket +from features.feature_lifecycle.types import LifecycleStage +from features.models import Feature +from projects.code_references.models import ScannedCodeReferences +from projects.tags.models import Tag + + +@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: Callable[[Feature, int], FeatureEvaluationBucket], + make_code_references: Callable[[Feature, list], ScannedCodeReferences], + permanent_tag: Tag, + project: int, + stale_tag: Tag, +): + # 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_name": "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") + make_analytics_db_usage(needs_monitoring_feature, 1) + + # 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.parametrize("stage", [ + LifecycleStage.NEW, + LifecycleStage.LIVE, + LifecycleStage.STALE, + LifecycleStage.PERMANENT, + LifecycleStage.NEEDS_MONITORING, + LifecycleStage.TO_REMOVE, +]) +def test_feature_lifecycle_counts__one_feature__responds_200_with_json_summary( + stage: LifecycleStage, +): + raise NotImplementedError + + +def test_feature_lifecycle_counts__no_features__responds_200_with_empty_json_summary(): + raise NotImplementedError + + +def test_feature_lifecycle_counts__anonymous_user__responds_401(): + raise NotImplementedError + + +def test_feature_lifecycle_counts__non_member_user__responds_403(): + raise NotImplementedError 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..83a7b3026b58 --- /dev/null +++ b/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py @@ -0,0 +1,3 @@ +# TODO: +# - list features: check lifecycle stage filtering, and annotation to each feature +# - retrieve feature: check lifecycle stage info 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 From a7469748666e28d12569a455a7d8f3817399b915 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 19 Jun 2026 21:47:33 -0300 Subject: [PATCH 03/14] Fix lint --- api/app_analytics/influxdb_wrapper.py | 1 - .../feature_lifecycle/test_endpoints.py | 19 +++++++++++-------- .../unit/app_analytics/test_migrate_to_pg.py | 12 ++++++------ 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/api/app_analytics/influxdb_wrapper.py b/api/app_analytics/influxdb_wrapper.py index 30b0810eb10a..0bdc7cc77b35 100644 --- a/api/app_analytics/influxdb_wrapper.py +++ b/api/app_analytics/influxdb_wrapper.py @@ -38,7 +38,6 @@ ) GET_MULTIPLE_EVENTS_LIST_GROUP_CLAUSE = ( - f"|> group(columns: {json.dumps(['resource', *LABELS])}) " ) diff --git a/api/tests/integration/features/feature_lifecycle/test_endpoints.py b/api/tests/integration/features/feature_lifecycle/test_endpoints.py index 465aff90340d..79823d35a695 100644 --- a/api/tests/integration/features/feature_lifecycle/test_endpoints.py +++ b/api/tests/integration/features/feature_lifecycle/test_endpoints.py @@ -53,14 +53,17 @@ def test_feature_lifecycle_counts__varied_stages_analytics_db__responds_200_with } -@pytest.mark.parametrize("stage", [ - LifecycleStage.NEW, - LifecycleStage.LIVE, - LifecycleStage.STALE, - LifecycleStage.PERMANENT, - LifecycleStage.NEEDS_MONITORING, - LifecycleStage.TO_REMOVE, -]) +@pytest.mark.parametrize( + "stage", + [ + LifecycleStage.NEW, + LifecycleStage.LIVE, + LifecycleStage.STALE, + LifecycleStage.PERMANENT, + LifecycleStage.NEEDS_MONITORING, + LifecycleStage.TO_REMOVE, + ], +) def test_feature_lifecycle_counts__one_feature__responds_200_with_json_summary( stage: LifecycleStage, ): 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 a14806a11622..b714f8c3a868 100644 --- a/api/tests/unit/app_analytics/test_migrate_to_pg.py +++ b/api/tests/unit/app_analytics/test_migrate_to_pg.py @@ -53,22 +53,22 @@ def test_migrate_feature_evaluations__influx_records_exist__creates_pg_buckets( mocker.call.query( ( 'from (bucket: "test_bucket") ' - f"|> range(start: -1d, stop: -0d) " - f'|> filter(fn: (r) => r._measurement == "feature_evaluation")' + "|> range(start: -1d, stop: -0d) " + '|> filter(fn: (r) => r._measurement == "feature_evaluation")' ) ), mocker.call.query( ( 'from (bucket: "test_bucket") ' - f"|> range(start: -2d, stop: -1d) " - f'|> filter(fn: (r) => r._measurement == "feature_evaluation")' + "|> range(start: -2d, stop: -1d) " + '|> filter(fn: (r) => r._measurement == "feature_evaluation")' ) ), mocker.call.query( ( 'from (bucket: "test_bucket") ' - f"|> range(start: -3d, stop: -2d) " - f'|> filter(fn: (r) => r._measurement == "feature_evaluation")' + "|> range(start: -3d, stop: -2d) " + '|> filter(fn: (r) => r._measurement == "feature_evaluation")' ) ), ] From fac1a2ea9b7a74bd661e49caa74fa9acdff30626 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 19 Jun 2026 21:46:58 -0300 Subject: [PATCH 04/14] Feature lifecycle summary --- api/api/urls/v1.py | 1 + api/app_analytics/services.py | 68 ++++++++++++- api/app_analytics/types.py | 2 +- api/features/feature_lifecycle/types.py | 1 + api/features/feature_lifecycle/urls.py | 2 + api/features/feature_lifecycle/views.py | 97 +++++++++++++++++-- api/projects/code_references/services.py | 11 +++ .../features/feature_lifecycle/conftest.py | 14 ++- .../feature_lifecycle/test_endpoints.py | 16 ++- 9 files changed, 194 insertions(+), 18 deletions(-) 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/services.py b/api/app_analytics/services.py index 21f8bf75c904..39c89024ae5c 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,62 @@ 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 + return Feature.objects.filter( + name__in=feature_names, + project__environments=environment, + ) + + +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 9e9770cb86c1..37dc68705059 100644 --- a/api/app_analytics/types.py +++ b/api/app_analytics/types.py @@ -1,5 +1,5 @@ -from enum import StrEnum from datetime import date +from enum import StrEnum from typing import TYPE_CHECKING, Literal, NamedTuple, TypeAlias, TypedDict if TYPE_CHECKING: diff --git a/api/features/feature_lifecycle/types.py b/api/features/feature_lifecycle/types.py index 574b3d7ec349..f46b1a20495a 100644 --- a/api/features/feature_lifecycle/types.py +++ b/api/features/feature_lifecycle/types.py @@ -3,6 +3,7 @@ class LifecycleStage(StrEnum): """The lifecycle stage of a feature""" + NEW = "new" LIVE = "live" STALE = "stale" diff --git a/api/features/feature_lifecycle/urls.py b/api/features/feature_lifecycle/urls.py index d7cc00f4c30b..eb0d10cb7fe8 100644 --- a/api/features/feature_lifecycle/urls.py +++ b/api/features/feature_lifecycle/urls.py @@ -2,6 +2,8 @@ from features.feature_lifecycle import views +app_name = "feature-lifecycle" + urlpatterns = [ path( "environments//feature-lifecycle-counts/", diff --git a/api/features/feature_lifecycle/views.py b/api/features/feature_lifecycle/views.py index b89bbcd76119..ba4f864e2b17 100644 --- a/api/features/feature_lifecycle/views.py +++ b/api/features/feature_lifecycle/views.py @@ -1,12 +1,20 @@ -from typing import Any +from datetime import timedelta -from common.projects.permissions import VIEW_PROJECT +from common.environments.permissions import VIEW_ENVIRONMENT +from django.db.models import BooleanField, Count, Exists, OuterRef, Q, Value +from django.db.models.functions import Cast from django.shortcuts import get_object_or_404 +from django.utils import timezone 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 app_analytics.services import get_features_in_use +from environments.models import Environment from features.feature_lifecycle.types import LifecycleStage +from projects.code_references.services import get_feature_flags_in_latest_scan +from projects.tags.models import Tag, TagType class FeatureLifecycleCountsAPIView(APIView): @@ -14,14 +22,85 @@ class FeatureLifecycleCountsAPIView(APIView): permission_classes = [IsAuthenticated] - def get(self, request: Any, project_pk: int, **kwargs: Any) -> Response: - project = get_object_or_404( - request.user.get_permitted_projects(VIEW_PROJECT), - pk=project_pk, - ) + 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): + return Response(status=403) + + days_until_stale = environment.project.stale_flags_limit_days + usage_window = timezone.now() - timedelta(days=days_until_stale) - summary: dict[LifecycleStage, int] = {} + features_in_code = get_feature_flags_in_latest_scan(environment.project) + features_in_use = get_features_in_use(environment, since=usage_window) - features = project.features.all() + features = environment.project.features.only("name").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), + ), + ) + + summary: dict[LifecycleStage, int] = features.aggregate( + **{ + LifecycleStage.NEW: Count( + "pk", + filter=Q( + has_code_references=False, + has_permanent_tag=False, + has_stale_tag=False, + ), + ), + LifecycleStage.LIVE: Count( + "pk", + filter=Q( + has_code_references=True, + has_permanent_tag=False, + has_stale_tag=False, + ), + ), + LifecycleStage.STALE: Count( + "pk", + filter=Q( + has_code_references=True, + has_permanent_tag=False, + has_stale_tag=True, + ), + ), + LifecycleStage.PERMANENT: Count( + "pk", + filter=Q( + has_permanent_tag=True, + ), + ), + LifecycleStage.NEEDS_MONITORING: Count( + "pk", + filter=Q( + has_code_references=False, + has_permanent_tag=False, + has_stale_tag=True, + has_recent_usage=True, + ), + ), + LifecycleStage.TO_REMOVE: Count( + "pk", + filter=Q( + has_code_references=False, + has_permanent_tag=False, + has_stale_tag=True, + has_recent_usage=False, + ), + ), + } + ) return Response(summary) diff --git a/api/projects/code_references/services.py b/api/projects/code_references/services.py index 4e8705b0b85e..bab411df23ce 100644 --- a/api/projects/code_references/services.py +++ b/api/projects/code_references/services.py @@ -102,6 +102,17 @@ 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"), + ) + return Feature.objects.filter( + scanned_code_references__in=fresh_scans, + ) + + def record_scan( project: Project, repository_url: str, diff --git a/api/tests/integration/features/feature_lifecycle/conftest.py b/api/tests/integration/features/feature_lifecycle/conftest.py index 4277462109a6..265d802fd31c 100644 --- a/api/tests/integration/features/feature_lifecycle/conftest.py +++ b/api/tests/integration/features/feature_lifecycle/conftest.py @@ -11,16 +11,15 @@ from features.models import Feature from projects.code_references.models import ScannedCodeReferences, VCSRepository from projects.code_references.types import StoredCodeReference, VCSProvider -from projects.models import Project -from projects.tags.models import Tag +from projects.tags.models import Tag, TagType @pytest.fixture() def stale_tag(project: int) -> Tag: return Tag.objects.create( # type: ignore[no-any-return] - label="stale", project_id=project, is_system_tag=True, + type=TagType.STALE, ) @@ -48,8 +47,11 @@ def code_references_repository(project: int) -> VCSRepository: def make_code_references( code_references_repository: VCSRepository, ) -> Callable[[Feature, list[StoredCodeReference]], ScannedCodeReferences]: + now = timezone.now() + code_references_repository.last_scanned_at = now + code_references_repository.save() return lambda feature, code_references: ScannedCodeReferences.objects.create( - created_at=timezone.now(), + created_at=now, feature=feature, repository=code_references_repository, revision=str(uuid4()), @@ -59,7 +61,9 @@ def make_code_references( @pytest.fixture -def make_analytics_db_usage(environment: int) -> Callable[[Feature, int], FeatureEvaluationBucket]: +def make_analytics_db_usage( + environment: int, +) -> Callable[[Feature, int], FeatureEvaluationBucket]: return lambda feature, evaluation_count: FeatureEvaluationBucket.objects.create( feature_name=feature.name, bucket_size=15, diff --git a/api/tests/integration/features/feature_lifecycle/test_endpoints.py b/api/tests/integration/features/feature_lifecycle/test_endpoints.py index 79823d35a695..a3d1e91f03df 100644 --- a/api/tests/integration/features/feature_lifecycle/test_endpoints.py +++ b/api/tests/integration/features/feature_lifecycle/test_endpoints.py @@ -2,6 +2,7 @@ import freezegun import pytest +from pytest_django.fixtures import SettingsWrapper from rest_framework.test import APIClient from app_analytics.models import FeatureEvaluationBucket @@ -20,9 +21,12 @@ def test_feature_lifecycle_counts__varied_stages_analytics_db__responds_200_with make_code_references: Callable[[Feature, list], ScannedCodeReferences], permanent_tag: Tag, project: int, + settings: SettingsWrapper, stale_tag: Tag, ): # 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") @@ -35,11 +39,19 @@ def test_feature_lifecycle_counts__varied_stages_analytics_db__responds_200_with 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 = 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/") + response = admin_client.get( + f"/api/v1/environments/{environment}/feature-lifecycle-counts/" + ) # Then assert response.status_code == 200 From 8788442ede2f062ef22dcff0cf4341ce071a019f Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 19 Jun 2026 23:08:03 -0300 Subject: [PATCH 05/14] Proven Influxdb flavour! --- api/tests/integration/conftest.py | 17 +++--- .../features/feature_lifecycle/conftest.py | 15 +++++- .../feature_lifecycle/test_endpoints.py | 54 +++++++++++++++++++ 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index bbf31e11a4c3..0bd35b2f494f 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -49,12 +49,17 @@ def influxdb(settings: SettingsWrapper) -> InfluxDBClient: # Matches api.app_analytics.influxdb_wrapper bucket definitions client = InfluxDBWrapper.get_client() - client.buckets_api().create_bucket( - org="flagsmith", bucket_name="api_usage_downsampled_15m" - ) - client.buckets_api().create_bucket( - org="flagsmith", bucket_name="api_usage_downsampled_1h" - ) + 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): + bucket_api.create_bucket( + org="flagsmith", + bucket_name=bucket_name, + ) return client diff --git a/api/tests/integration/features/feature_lifecycle/conftest.py b/api/tests/integration/features/feature_lifecycle/conftest.py index 265d802fd31c..851d00e8900a 100644 --- a/api/tests/integration/features/feature_lifecycle/conftest.py +++ b/api/tests/integration/features/feature_lifecycle/conftest.py @@ -4,8 +4,10 @@ import pytest from django.utils import timezone -from influxdb_client import InfluxDBClient +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 environments.models import Environment from features.models import Feature @@ -77,4 +79,13 @@ def make_analytics_db_usage( def make_influxdb_usage( influxdb: InfluxDBClient, ) -> Callable[[Feature, Environment, int], None]: - raise NotImplementedError + write_api = influxdb.write_api(write_options=SYNCHRONOUS) + return lambda feature, environment, evaluation_count: write_api.write( + bucket="api_usage_downsampled_15m", + org="flagsmith", + record=Point("feature_evaluation") + .tag("environment_id", str(environment.pk)) + .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 index a3d1e91f03df..1e75226549ef 100644 --- a/api/tests/integration/features/feature_lifecycle/test_endpoints.py +++ b/api/tests/integration/features/feature_lifecycle/test_endpoints.py @@ -2,10 +2,12 @@ import freezegun import pytest +from influxdb_client import InfluxDBClient from pytest_django.fixtures import SettingsWrapper from rest_framework.test import APIClient from app_analytics.models import FeatureEvaluationBucket +from environments.models import Environment from features.feature_lifecycle.types import LifecycleStage from features.models import Feature from projects.code_references.models import ScannedCodeReferences @@ -65,6 +67,58 @@ def test_feature_lifecycle_counts__varied_stages_analytics_db__responds_200_with } +@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, + influxdb: InfluxDBClient, + make_code_references: Callable[[Feature, list], ScannedCodeReferences], + make_influxdb_usage: Callable[[Feature, Environment, int], None], + permanent_tag: Tag, + project: int, + stale_tag: Tag, +): + # Given + environment_object = Environment.objects.get(pk=environment) + + Feature.objects.create(project_id=project, name="new") + + live_feature = Feature.objects.create(project_id=project, name="live") + make_code_references(live_feature, [{"file_name": "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, environment_object, 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.parametrize( "stage", [ From 450a2c65f82de60aaa01e3cc4f35b044c3fe8342 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 19 Jun 2026 23:25:12 -0300 Subject: [PATCH 06/14] Unhappy paths --- .../feature_lifecycle/test_endpoints.py | 58 ++++++++++++------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/api/tests/integration/features/feature_lifecycle/test_endpoints.py b/api/tests/integration/features/feature_lifecycle/test_endpoints.py index 1e75226549ef..406132effc93 100644 --- a/api/tests/integration/features/feature_lifecycle/test_endpoints.py +++ b/api/tests/integration/features/feature_lifecycle/test_endpoints.py @@ -12,6 +12,7 @@ from features.models import Feature from projects.code_references.models import ScannedCodeReferences from projects.tags.models import Tag +from users.models import FFAdminUser @pytest.mark.use_analytics_db @@ -119,30 +120,47 @@ def test_feature_lifecycle_counts__varied_stages_influxdb__responds_200_with_jso } -@pytest.mark.parametrize( - "stage", - [ - LifecycleStage.NEW, - LifecycleStage.LIVE, - LifecycleStage.STALE, - LifecycleStage.PERMANENT, - LifecycleStage.NEEDS_MONITORING, - LifecycleStage.TO_REMOVE, - ], -) -def test_feature_lifecycle_counts__one_feature__responds_200_with_json_summary( - stage: LifecycleStage, +def test_feature_lifecycle_counts__no_features__responds_200_with_empty_json_summary( + admin_client: APIClient, + environment: int, +): + # 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} + + +def test_feature_lifecycle_counts__anonymous_user__responds_401( + environment: int, ): - raise NotImplementedError + # Given + client = APIClient() + # When + response = client.get( + f"/api/v1/environments/{environment}/feature-lifecycle-counts/" + ) -def test_feature_lifecycle_counts__no_features__responds_200_with_empty_json_summary(): - raise NotImplementedError + # Then + assert response.status_code == 401 -def test_feature_lifecycle_counts__anonymous_user__responds_401(): - raise NotImplementedError +def test_feature_lifecycle_counts__non_member_user__responds_403( + environment: int, +): + # 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/" + ) -def test_feature_lifecycle_counts__non_member_user__responds_403(): - raise NotImplementedError + # Then + assert response.status_code == 403 From a637c316c2e70c2f1f3650184da2674c3bde2e7f Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 19 Jun 2026 23:25:19 -0300 Subject: [PATCH 07/14] Ignore mypy cache --- api/.gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 5ed1952c0224f3b381b3dda12576d317c5ea7c8b Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 19 Jun 2026 23:45:18 -0300 Subject: [PATCH 08/14] Make it portable --- api/features/feature_lifecycle/services.py | 81 +++++++++++++++++++ api/features/feature_lifecycle/views.py | 92 +++------------------- 2 files changed, 92 insertions(+), 81 deletions(-) create mode 100644 api/features/feature_lifecycle/services.py diff --git a/api/features/feature_lifecycle/services.py b/api/features/feature_lifecycle/services.py new file mode 100644 index 000000000000..288a83b33f52 --- /dev/null +++ b/api/features/feature_lifecycle/services.py @@ -0,0 +1,81 @@ +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 projects.code_references.services import get_feature_flags_in_latest_scan +from projects.tags.models import Tag, TagType + + +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/views.py b/api/features/feature_lifecycle/views.py index ba4f864e2b17..1b305f409256 100644 --- a/api/features/feature_lifecycle/views.py +++ b/api/features/feature_lifecycle/views.py @@ -1,20 +1,16 @@ -from datetime import timedelta - from common.environments.permissions import VIEW_ENVIRONMENT -from django.db.models import BooleanField, Count, Exists, OuterRef, Q, Value -from django.db.models.functions import Cast +from django.db.models import Count from django.shortcuts import get_object_or_404 -from django.utils import timezone 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 app_analytics.services import get_features_in_use from environments.models import Environment +from features.feature_lifecycle.services import ( + annotate_feature_queryset_with_lifecycle_stage, +) from features.feature_lifecycle.types import LifecycleStage -from projects.code_references.services import get_feature_flags_in_latest_scan -from projects.tags.models import Tag, TagType class FeatureLifecycleCountsAPIView(APIView): @@ -27,80 +23,14 @@ def get(self, request: Request, environment_pk: int) -> Response: if not request.user.has_environment_permission(VIEW_ENVIRONMENT, environment): return Response(status=403) - 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) - - features = environment.project.features.only("name").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), - ), + features = annotate_feature_queryset_with_lifecycle_stage( + environment.project.features.all(), + environment, ) - summary: dict[LifecycleStage, int] = features.aggregate( - **{ - LifecycleStage.NEW: Count( - "pk", - filter=Q( - has_code_references=False, - has_permanent_tag=False, - has_stale_tag=False, - ), - ), - LifecycleStage.LIVE: Count( - "pk", - filter=Q( - has_code_references=True, - has_permanent_tag=False, - has_stale_tag=False, - ), - ), - LifecycleStage.STALE: Count( - "pk", - filter=Q( - has_code_references=True, - has_permanent_tag=False, - has_stale_tag=True, - ), - ), - LifecycleStage.PERMANENT: Count( - "pk", - filter=Q( - has_permanent_tag=True, - ), - ), - LifecycleStage.NEEDS_MONITORING: Count( - "pk", - filter=Q( - has_code_references=False, - has_permanent_tag=False, - has_stale_tag=True, - has_recent_usage=True, - ), - ), - LifecycleStage.TO_REMOVE: Count( - "pk", - filter=Q( - has_code_references=False, - has_permanent_tag=False, - has_stale_tag=True, - has_recent_usage=False, - ), - ), - } - ) + counts = features.values("lifecycle_stage").annotate(count=Count("pk")) + summary: dict[LifecycleStage, int] = {stage: 0 for stage in LifecycleStage} + for stage_count in counts: + summary[stage_count["lifecycle_stage"]] = stage_count["count"] return Response(summary) From 0941176bf82fdaa84f56bfeb0c310c10d41d74a1 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Sat, 20 Jun 2026 00:22:18 -0300 Subject: [PATCH 09/14] TDD for existing endpoints --- .../test_feature_endpoints.py | 109 +++++++++++++++++- 1 file changed, 106 insertions(+), 3 deletions(-) diff --git a/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py b/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py index 83a7b3026b58..d7e6c99e0091 100644 --- a/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py +++ b/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py @@ -1,3 +1,106 @@ -# TODO: -# - list features: check lifecycle stage filtering, and annotation to each feature -# - retrieve feature: check lifecycle stage info +from collections.abc import Callable + +import freezegun +import pytest +from pytest_django.fixtures import SettingsWrapper +from rest_framework.test import APIClient + +from app_analytics.models import FeatureEvaluationBucket +from features.feature_lifecycle.types import LifecycleStage +from features.models import Feature +from projects.code_references.models import ScannedCodeReferences +from projects.tags.models import Tag + + +@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, + environment: int, + make_analytics_db_usage: Callable[[Feature, int], FeatureEvaluationBucket], + make_code_references: Callable[[Feature, list], ScannedCodeReferences], + permanent_tag: Tag, + project: int, + settings: SettingsWrapper, + stale_tag: Tag, +): + # 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_name": "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.STALE + assert json_features["to_remove"]["lifecycle_stage"] == LifecycleStage.STALE + + +def test_feature_list_endpoint__varied_stages_influxdb__responds_200_with_lifecycle_stage_in_each_feature(): + raise NotImplementedError + + +def test_feature_list_endpoint__flag_off__responds_200_with_lifecycle_stage_none(): + raise NotImplementedError + + +def test_feature_list_endpoint__no_environment__responds_200_with_lifecycle_stage_none(): + raise NotImplementedError + + +def test_feature_detail_endpoint__new_feature__responds_200_with_lifecycle_stage_new(): + raise NotImplementedError + + +def test_feature_detail_endpoint__live_feature__responds_200_with_lifecycle_stage_live(): + raise NotImplementedError + + +def test_feature_detail_endpoint__stale_feature__responds_200_with_lifecycle_stage_stale(): + raise NotImplementedError + + +def test_feature_detail_endpoint__permanent_feature__responds_200_with_lifecycle_stage_permanent(): + raise NotImplementedError + + +def test_feature_detail_endpoint__needs_monitoring_feature__responds_200_with_lifecycle_stage_needs_monitoring(): + raise NotImplementedError + + +def test_feature_detail_endpoint__to_remove_feature__responds_200_with_lifecycle_stage_to_remove(): + raise NotImplementedError + + +def test_feature_detail_endpoint__flag_off__responds_200_with_lifecycle_stage_none(): + raise NotImplementedError + + +def test_feature_detail_endpoint__no_environment__responds_200_with_lifecycle_stage_none(): + raise NotImplementedError From bc4570dbc540017c0d19b6ffd55fc99e7b1f1822 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Sat, 20 Jun 2026 01:29:00 -0300 Subject: [PATCH 10/14] Add lifecycle stage to existing feature endpoints --- api/features/feature_lifecycle/services.py | 10 + api/features/serializers.py | 8 + api/features/views.py | 11 +- .../flagsmith/data/environment.json | 13 + .../feature_lifecycle/test_endpoints.py | 3 +- .../test_feature_endpoints.py | 291 ++++++++++++++++-- .../unit/features/test_unit_features_views.py | 8 +- 7 files changed, 312 insertions(+), 32 deletions(-) diff --git a/api/features/feature_lifecycle/services.py b/api/features/feature_lifecycle/services.py index 288a83b33f52..fcd2f8488a7d 100644 --- a/api/features/feature_lifecycle/services.py +++ b/api/features/feature_lifecycle/services.py @@ -10,10 +10,20 @@ 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, diff --git a/api/features/serializers.py b/api/features/serializers.py index 87d120109a43..45cd4155554c 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, @@ -456,10 +457,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..d2edc71f714d 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -18,6 +18,7 @@ Value, When, ) +from django.db.models.fields import CharField from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page @@ -59,6 +60,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, @@ -261,8 +266,12 @@ 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 + ) + 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/tests/integration/features/feature_lifecycle/test_endpoints.py b/api/tests/integration/features/feature_lifecycle/test_endpoints.py index 406132effc93..79f1a584a361 100644 --- a/api/tests/integration/features/feature_lifecycle/test_endpoints.py +++ b/api/tests/integration/features/feature_lifecycle/test_endpoints.py @@ -2,7 +2,6 @@ import freezegun import pytest -from influxdb_client import InfluxDBClient from pytest_django.fixtures import SettingsWrapper from rest_framework.test import APIClient @@ -68,11 +67,11 @@ def test_feature_lifecycle_counts__varied_stages_analytics_db__responds_200_with } +@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, - influxdb: InfluxDBClient, make_code_references: Callable[[Feature, list], ScannedCodeReferences], make_influxdb_usage: Callable[[Feature, Environment, int], None], permanent_tag: Tag, diff --git a/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py b/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py index d7e6c99e0091..241219aeb7aa 100644 --- a/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py +++ b/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py @@ -6,16 +6,19 @@ from rest_framework.test import APIClient from app_analytics.models import FeatureEvaluationBucket +from environments.models import Environment from features.feature_lifecycle.types import LifecycleStage from features.models import Feature from projects.code_references.models import ScannedCodeReferences from projects.tags.models import Tag +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: Callable[[Feature, int], FeatureEvaluationBucket], make_code_references: Callable[[Feature, list], ScannedCodeReferences], @@ -25,6 +28,7 @@ def test_feature_list_endpoint__varied_stages_analytics_db__responds_200_with_li stale_tag: Tag, ): # Given + enable_features("feature_lifecycle") settings.USE_POSTGRES_FOR_ANALYTICS = True Feature.objects.create(project_id=project, name="new") @@ -49,7 +53,9 @@ def test_feature_list_endpoint__varied_stages_analytics_db__responds_200_with_li to_remove_feature.tags.add(stale_tag) # When - response = admin_client.get(f"/api/v1/projects/{project}/features/?environment={environment}") + response = admin_client.get( + f"/api/v1/projects/{project}/features/?environment={environment}" + ) # Then assert response.status_code == 200 @@ -58,49 +64,284 @@ def test_feature_list_endpoint__varied_stages_analytics_db__responds_200_with_li 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.STALE - assert json_features["to_remove"]["lifecycle_stage"] == LifecycleStage.STALE + 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: Callable[[Feature, list], ScannedCodeReferences], + make_influxdb_usage: Callable[[Feature, Environment, int], None], + permanent_tag: Tag, + project: int, + stale_tag: Tag, +): + # Given + enable_features("feature_lifecycle") + environment_object = Environment.objects.get(pk=environment) + + Feature.objects.create(project_id=project, name="new") + + live_feature = Feature.objects.create(project_id=project, name="live") + make_code_references(live_feature, [{"file_name": "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, environment_object, 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 + + +def test_feature_list_endpoint__flag_off__responds_200_without_lifecycle_stage( + admin_client: APIClient, + environment: int, + project: int, +): + # 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, +): + # 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, +): + # 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: Callable[[Feature, list], ScannedCodeReferences], + project: int, +): + # Given + enable_features("feature_lifecycle") + feature = Feature.objects.create(project_id=project, name="live") + make_code_references(feature, [{"file_name": "file.py", "line_number": 1}]) + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/{feature.id}/?environment={environment}" + ) -def test_feature_list_endpoint__varied_stages_influxdb__responds_200_with_lifecycle_stage_in_each_feature(): - raise NotImplementedError + # Then + assert response.status_code == 200 + assert response.json()["lifecycle_stage"] == LifecycleStage.LIVE -def test_feature_list_endpoint__flag_off__responds_200_with_lifecycle_stage_none(): - raise NotImplementedError +@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: Callable[[Feature, list], ScannedCodeReferences], + project: int, + stale_tag: Tag, +): + # 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}" + ) -def test_feature_list_endpoint__no_environment__responds_200_with_lifecycle_stage_none(): - raise NotImplementedError + # Then + assert response.status_code == 200 + assert response.json()["lifecycle_stage"] == LifecycleStage.STALE -def test_feature_detail_endpoint__new_feature__responds_200_with_lifecycle_stage_new(): - raise NotImplementedError +@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, +): + # 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}" + ) -def test_feature_detail_endpoint__live_feature__responds_200_with_lifecycle_stage_live(): - raise NotImplementedError + # Then + assert response.status_code == 200 + assert response.json()["lifecycle_stage"] == LifecycleStage.PERMANENT -def test_feature_detail_endpoint__stale_feature__responds_200_with_lifecycle_stage_stale(): - raise NotImplementedError +@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: Callable[[Feature, int], FeatureEvaluationBucket], + project: int, + settings: SettingsWrapper, + stale_tag: Tag, +): + # 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, +): + # 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}" + ) -def test_feature_detail_endpoint__permanent_feature__responds_200_with_lifecycle_stage_permanent(): - raise NotImplementedError + # Then + assert response.status_code == 200 + assert response.json()["lifecycle_stage"] == LifecycleStage.TO_REMOVE -def test_feature_detail_endpoint__needs_monitoring_feature__responds_200_with_lifecycle_stage_needs_monitoring(): - raise NotImplementedError +@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, +): + # 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}" + ) -def test_feature_detail_endpoint__to_remove_feature__responds_200_with_lifecycle_stage_to_remove(): - raise NotImplementedError + # Then + assert response.status_code == 200 + assert "lifecycle_stage" not in response.json() -def test_feature_detail_endpoint__flag_off__responds_200_with_lifecycle_stage_none(): - raise NotImplementedError +@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, +): + # 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}/") -def test_feature_detail_endpoint__no_environment__responds_200_with_lifecycle_stage_none(): - raise NotImplementedError + # Then + assert response.status_code == 200 + assert "lifecycle_stage" not in response.json() 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, ) From 71361ae42be97c1da3414bbf2c3cc9fc89059f48 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Sat, 20 Jun 2026 01:39:43 -0300 Subject: [PATCH 11/14] Add lifecycle counts to the MCP stack --- api/features/feature_lifecycle/serializers.py | 12 ++++++++++++ api/features/feature_lifecycle/views.py | 14 ++++++++++++++ .../_mcp-tool-catalogue.md | 1 + 3 files changed, 27 insertions(+) create mode 100644 api/features/feature_lifecycle/serializers.py 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/views.py b/api/features/feature_lifecycle/views.py index 1b305f409256..5a9e8105160b 100644 --- a/api/features/feature_lifecycle/views.py +++ b/api/features/feature_lifecycle/views.py @@ -1,12 +1,16 @@ 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, ) @@ -18,6 +22,16 @@ class FeatureLifecycleCountsAPIView(APIView): 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): 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 | From 247e682020872157489fad20fe145ef81ca8d91a Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Sat, 20 Jun 2026 01:48:44 -0300 Subject: [PATCH 12/14] Log --- api/features/feature_lifecycle/views.py | 9 +++++++++ .../features/feature_lifecycle/test_endpoints.py | 9 +++++++++ .../observability/_events-catalogue.md | 9 +++++++++ 3 files changed, 27 insertions(+) diff --git a/api/features/feature_lifecycle/views.py b/api/features/feature_lifecycle/views.py index 5a9e8105160b..7304ff2a733b 100644 --- a/api/features/feature_lifecycle/views.py +++ b/api/features/feature_lifecycle/views.py @@ -1,3 +1,4 @@ +import structlog from common.environments.permissions import VIEW_ENVIRONMENT from django.db.models import Count from django.shortcuts import get_object_or_404 @@ -16,6 +17,8 @@ ) 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""" @@ -47,4 +50,10 @@ def get(self, request: Request, environment_pk: int) -> Response: 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/tests/integration/features/feature_lifecycle/test_endpoints.py b/api/tests/integration/features/feature_lifecycle/test_endpoints.py index 79f1a584a361..81c45ee023d7 100644 --- a/api/tests/integration/features/feature_lifecycle/test_endpoints.py +++ b/api/tests/integration/features/feature_lifecycle/test_endpoints.py @@ -3,6 +3,7 @@ import freezegun import pytest from pytest_django.fixtures import SettingsWrapper +from pytest_structlog import StructuredLogCapture from rest_framework.test import APIClient from app_analytics.models import FeatureEvaluationBucket @@ -122,6 +123,8 @@ def test_feature_lifecycle_counts__varied_stages_influxdb__responds_200_with_jso def test_feature_lifecycle_counts__no_features__responds_200_with_empty_json_summary( admin_client: APIClient, environment: int, + log: StructuredLogCapture, + organisation: int, ): # Given / When response = admin_client.get( @@ -131,6 +134,12 @@ def test_feature_lifecycle_counts__no_features__responds_200_with_empty_json_sum # 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( diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 645eb0decb76..5e189c9485ad 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:53` + +Attributes: + - `environment.id` + - `organisation.id` + ### `gitlab.api_call.failed` Logged at `error` from: From c54b9fdc1fa869d35da676d76091bc794b4415bb Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Sat, 20 Jun 2026 01:57:51 -0300 Subject: [PATCH 13/14] Filter by stage --- api/features/serializers.py | 6 ++ api/features/views.py | 2 + .../test_feature_endpoints.py | 77 +++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/api/features/serializers.py b/api/features/serializers.py index 45cd4155554c..08dad4020b1c 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -116,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, diff --git a/api/features/views.py b/api/features/views.py index d2edc71f714d..ce8e6ab306cc 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -271,6 +271,8 @@ def get_queryset(self): # type: ignore[no-untyped-def] 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( diff --git a/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py b/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py index 241219aeb7aa..d9c4e8729bc9 100644 --- a/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py +++ b/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py @@ -127,6 +127,83 @@ def test_feature_list_endpoint__varied_stages_influxdb__responds_200_with_lifecy 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: Callable[[Feature, list], ScannedCodeReferences], + permanent_tag: Tag, + project: int, + stale_tag: Tag, +): + # 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_name": "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, +): + # 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, +): + # 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, From 20ee2335f1f2c03201ccf2360c3ba72d79e8f5d8 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Sat, 20 Jun 2026 02:08:32 -0300 Subject: [PATCH 14/14] Fix lint --- api/app_analytics/services.py | 3 +- api/features/feature_lifecycle/views.py | 4 +- api/features/views.py | 3 +- api/projects/code_references/services.py | 3 +- api/tests/conftest.py | 3 +- api/tests/integration/conftest.py | 2 +- .../features/feature_lifecycle/conftest.py | 20 ++++-- .../feature_lifecycle/test_endpoints.py | 35 +++++----- .../test_feature_endpoints.py | 66 +++++++++---------- .../observability/_events-catalogue.md | 2 +- 10 files changed, 72 insertions(+), 69 deletions(-) diff --git a/api/app_analytics/services.py b/api/app_analytics/services.py index 39c89024ae5c..e34968026ec0 100644 --- a/api/app_analytics/services.py +++ b/api/app_analytics/services.py @@ -51,10 +51,11 @@ def get_features_in_use( feature_names = _get_feature_names_in_use_from_influxdb(environment, since) else: return None - return Feature.objects.filter( + 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( diff --git a/api/features/feature_lifecycle/views.py b/api/features/feature_lifecycle/views.py index 7304ff2a733b..0a115f17e421 100644 --- a/api/features/feature_lifecycle/views.py +++ b/api/features/feature_lifecycle/views.py @@ -37,7 +37,7 @@ class FeatureLifecycleCountsAPIView(APIView): ) 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): + 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( @@ -45,7 +45,7 @@ def get(self, request: Request, environment_pk: int) -> Response: environment, ) - counts = features.values("lifecycle_stage").annotate(count=Count("pk")) + 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"] diff --git a/api/features/views.py b/api/features/views.py index ce8e6ab306cc..e4d25e761a59 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -18,7 +18,6 @@ Value, When, ) -from django.db.models.fields import CharField from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page @@ -177,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() diff --git a/api/projects/code_references/services.py b/api/projects/code_references/services.py index bab411df23ce..436d532e8116 100644 --- a/api/projects/code_references/services.py +++ b/api/projects/code_references/services.py @@ -108,9 +108,10 @@ def get_feature_flags_in_latest_scan(project: Project) -> QuerySet[Feature]: feature__project=project, created_at=F("repository__last_scanned_at"), ) - return Feature.objects.filter( + features_in_scan: QuerySet[Feature] = Feature.objects.filter( scanned_code_references__in=fresh_scans, ) + return features_in_scan def record_scan( diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 71dcf6c317a9..7b2117c98a77 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -232,7 +232,8 @@ def django_db_setup(request: pytest.FixtureRequest) -> None: @pytest.fixture() def mock_influxdb_client(mocker: MockerFixture) -> MagicMock: - return mocker.patch.object(InfluxDBWrapper, "get_client").return_value + client: MagicMock = mocker.patch.object(InfluxDBWrapper, "get_client").return_value + return client @pytest.fixture(autouse=True) diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 0bd35b2f494f..fade7704233a 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -55,7 +55,7 @@ def influxdb(settings: SettingsWrapper) -> InfluxDBClient: "api_usage_downsampled_1h", ] for bucket_name in bucket_names: - if not bucket_api.find_bucket_by_name(bucket_name): + 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, diff --git a/api/tests/integration/features/feature_lifecycle/conftest.py b/api/tests/integration/features/feature_lifecycle/conftest.py index 851d00e8900a..7cf27974288f 100644 --- a/api/tests/integration/features/feature_lifecycle/conftest.py +++ b/api/tests/integration/features/feature_lifecycle/conftest.py @@ -1,5 +1,6 @@ from collections.abc import Callable from datetime import timedelta +from typing import Any from uuid import uuid4 import pytest @@ -9,12 +10,16 @@ from app_analytics.constants import ANALYTICS_READ_BUCKET_SIZE from app_analytics.models import FeatureEvaluationBucket -from environments.models import Environment 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: @@ -37,7 +42,7 @@ def permanent_tag(project: int) -> Tag: @pytest.fixture() def code_references_repository(project: int) -> VCSRepository: - return VCSRepository.objects.create( # type: ignore[no-any-return] + return VCSRepository.objects.create( project_id=project, url="https://github.flagsmith.com/core/", vcs_provider=VCSProvider.GITHUB, @@ -48,7 +53,7 @@ def code_references_repository(project: int) -> VCSRepository: @pytest.fixture def make_code_references( code_references_repository: VCSRepository, -) -> Callable[[Feature, list[StoredCodeReference]], ScannedCodeReferences]: +) -> MakeCodeReferencesFixture: now = timezone.now() code_references_repository.last_scanned_at = now code_references_repository.save() @@ -65,7 +70,7 @@ def make_code_references( @pytest.fixture def make_analytics_db_usage( environment: int, -) -> Callable[[Feature, int], FeatureEvaluationBucket]: +) -> MakeFeatureUsageFixture: return lambda feature, evaluation_count: FeatureEvaluationBucket.objects.create( feature_name=feature.name, bucket_size=15, @@ -77,14 +82,15 @@ def make_analytics_db_usage( @pytest.fixture def make_influxdb_usage( + environment: int, influxdb: InfluxDBClient, -) -> Callable[[Feature, Environment, int], None]: +) -> MakeFeatureUsageFixture: write_api = influxdb.write_api(write_options=SYNCHRONOUS) - return lambda feature, environment, evaluation_count: write_api.write( + return lambda feature, evaluation_count: write_api.write( bucket="api_usage_downsampled_15m", org="flagsmith", record=Point("feature_evaluation") - .tag("environment_id", str(environment.pk)) + .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 index 81c45ee023d7..677e1936ee24 100644 --- a/api/tests/integration/features/feature_lifecycle/test_endpoints.py +++ b/api/tests/integration/features/feature_lifecycle/test_endpoints.py @@ -1,17 +1,16 @@ -from collections.abc import Callable - import freezegun import pytest from pytest_django.fixtures import SettingsWrapper from pytest_structlog import StructuredLogCapture from rest_framework.test import APIClient -from app_analytics.models import FeatureEvaluationBucket -from environments.models import Environment from features.feature_lifecycle.types import LifecycleStage from features.models import Feature -from projects.code_references.models import ScannedCodeReferences from projects.tags.models import Tag +from tests.integration.features.feature_lifecycle.conftest import ( + MakeCodeReferencesFixture, + MakeFeatureUsageFixture, +) from users.models import FFAdminUser @@ -20,20 +19,20 @@ def test_feature_lifecycle_counts__varied_stages_analytics_db__responds_200_with_json_summary( admin_client: APIClient, environment: int, - make_analytics_db_usage: Callable[[Feature, int], FeatureEvaluationBucket], - make_code_references: Callable[[Feature, list], ScannedCodeReferences], + 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_name": "file.py", "line_number": 1}]) + 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, []) @@ -73,19 +72,17 @@ def test_feature_lifecycle_counts__varied_stages_analytics_db__responds_200_with def test_feature_lifecycle_counts__varied_stages_influxdb__responds_200_with_json_summary( admin_client: APIClient, environment: int, - make_code_references: Callable[[Feature, list], ScannedCodeReferences], - make_influxdb_usage: Callable[[Feature, Environment, int], None], + make_code_references: MakeCodeReferencesFixture, + make_influxdb_usage: MakeFeatureUsageFixture, permanent_tag: Tag, project: int, stale_tag: Tag, -): +) -> None: # Given - environment_object = Environment.objects.get(pk=environment) - Feature.objects.create(project_id=project, name="new") live_feature = Feature.objects.create(project_id=project, name="live") - make_code_references(live_feature, [{"file_name": "file.py", "line_number": 1}]) + 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, []) @@ -98,7 +95,7 @@ def test_feature_lifecycle_counts__varied_stages_influxdb__responds_200_with_jso project_id=project, name="needs_monitoring" ) needs_monitoring_feature.tags.add(stale_tag) - make_influxdb_usage(needs_monitoring_feature, environment_object, 1) + 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) @@ -125,7 +122,7 @@ def test_feature_lifecycle_counts__no_features__responds_200_with_empty_json_sum environment: int, log: StructuredLogCapture, organisation: int, -): +) -> None: # Given / When response = admin_client.get( f"/api/v1/environments/{environment}/feature-lifecycle-counts/" @@ -144,7 +141,7 @@ def test_feature_lifecycle_counts__no_features__responds_200_with_empty_json_sum def test_feature_lifecycle_counts__anonymous_user__responds_401( environment: int, -): +) -> None: # Given client = APIClient() @@ -159,7 +156,7 @@ def test_feature_lifecycle_counts__anonymous_user__responds_401( def test_feature_lifecycle_counts__non_member_user__responds_403( environment: int, -): +) -> None: # Given non_member = FFAdminUser.objects.create(username="who") client = APIClient() diff --git a/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py b/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py index d9c4e8729bc9..5af146a3d0bf 100644 --- a/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py +++ b/api/tests/integration/features/feature_lifecycle/test_feature_endpoints.py @@ -1,16 +1,15 @@ -from collections.abc import Callable - import freezegun import pytest from pytest_django.fixtures import SettingsWrapper from rest_framework.test import APIClient -from app_analytics.models import FeatureEvaluationBucket -from environments.models import Environment from features.feature_lifecycle.types import LifecycleStage from features.models import Feature -from projects.code_references.models import ScannedCodeReferences from projects.tags.models import Tag +from tests.integration.features.feature_lifecycle.conftest import ( + MakeCodeReferencesFixture, + MakeFeatureUsageFixture, +) from tests.types import EnableFeaturesFixture @@ -20,13 +19,13 @@ def test_feature_list_endpoint__varied_stages_analytics_db__responds_200_with_li admin_client: APIClient, enable_features: EnableFeaturesFixture, environment: int, - make_analytics_db_usage: Callable[[Feature, int], FeatureEvaluationBucket], - make_code_references: Callable[[Feature, list], ScannedCodeReferences], + 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 @@ -34,7 +33,7 @@ def test_feature_list_endpoint__varied_stages_analytics_db__responds_200_with_li Feature.objects.create(project_id=project, name="new") live_feature = Feature.objects.create(project_id=project, name="live") - make_code_references(live_feature, [{"file_name": "file.py", "line_number": 1}]) + 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, []) @@ -77,20 +76,19 @@ def test_feature_list_endpoint__varied_stages_influxdb__responds_200_with_lifecy admin_client: APIClient, enable_features: EnableFeaturesFixture, environment: int, - make_code_references: Callable[[Feature, list], ScannedCodeReferences], - make_influxdb_usage: Callable[[Feature, Environment, int], None], + make_code_references: MakeCodeReferencesFixture, + make_influxdb_usage: MakeFeatureUsageFixture, permanent_tag: Tag, project: int, stale_tag: Tag, -): +) -> None: # Given enable_features("feature_lifecycle") - environment_object = Environment.objects.get(pk=environment) Feature.objects.create(project_id=project, name="new") live_feature = Feature.objects.create(project_id=project, name="live") - make_code_references(live_feature, [{"file_name": "file.py", "line_number": 1}]) + 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, []) @@ -103,7 +101,7 @@ def test_feature_list_endpoint__varied_stages_influxdb__responds_200_with_lifecy project_id=project, name="needs_monitoring" ) needs_monitoring_feature.tags.add(stale_tag) - make_influxdb_usage(needs_monitoring_feature, environment_object, 1) + 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) @@ -132,18 +130,18 @@ def test_feature_list_endpoint__lifecycle_stage_filter__responds_200_with_only_m admin_client: APIClient, enable_features: EnableFeaturesFixture, environment: int, - make_code_references: Callable[[Feature, list], ScannedCodeReferences], + 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_name": "file.py", "line_number": 1}]) + 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, []) @@ -169,7 +167,7 @@ def test_feature_list_endpoint__invalid_lifecycle_stage_filter__responds_400( enable_features: EnableFeaturesFixture, environment: int, project: int, -): +) -> None: # Given enable_features("feature_lifecycle") @@ -188,7 +186,7 @@ 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") @@ -208,7 +206,7 @@ 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") @@ -227,7 +225,7 @@ def test_feature_list_endpoint__no_environment__responds_200_without_lifecycle_s admin_client: APIClient, enable_features: EnableFeaturesFixture, project: int, -): +) -> None: # Given enable_features("feature_lifecycle") Feature.objects.create(project_id=project, name="feature") @@ -247,7 +245,7 @@ def test_feature_detail_endpoint__new_feature__responds_200_with_lifecycle_stage enable_features: EnableFeaturesFixture, environment: int, project: int, -): +) -> None: # Given enable_features("feature_lifecycle") feature = Feature.objects.create(project_id=project, name="new") @@ -267,13 +265,13 @@ def test_feature_detail_endpoint__live_feature__responds_200_with_lifecycle_stag admin_client: APIClient, enable_features: EnableFeaturesFixture, environment: int, - make_code_references: Callable[[Feature, list], ScannedCodeReferences], + 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_name": "file.py", "line_number": 1}]) + make_code_references(feature, [{"file_path": "file.py", "line_number": 1}]) # When response = admin_client.get( @@ -290,10 +288,10 @@ def test_feature_detail_endpoint__stale_feature__responds_200_with_lifecycle_sta admin_client: APIClient, enable_features: EnableFeaturesFixture, environment: int, - make_code_references: Callable[[Feature, list], ScannedCodeReferences], + make_code_references: MakeCodeReferencesFixture, project: int, stale_tag: Tag, -): +) -> None: # Given enable_features("feature_lifecycle") feature = Feature.objects.create(project_id=project, name="stale") @@ -317,7 +315,7 @@ def test_feature_detail_endpoint__permanent_feature__responds_200_with_lifecycle environment: int, permanent_tag: Tag, project: int, -): +) -> None: # Given enable_features("feature_lifecycle") feature = Feature.objects.create(project_id=project, name="permanent") @@ -339,11 +337,11 @@ def test_feature_detail_endpoint__needs_monitoring_feature__responds_200_with_li admin_client: APIClient, enable_features: EnableFeaturesFixture, environment: int, - make_analytics_db_usage: Callable[[Feature, int], FeatureEvaluationBucket], + make_analytics_db_usage: MakeFeatureUsageFixture, project: int, settings: SettingsWrapper, stale_tag: Tag, -): +) -> None: # Given enable_features("feature_lifecycle") settings.USE_POSTGRES_FOR_ANALYTICS = True @@ -370,7 +368,7 @@ def test_feature_detail_endpoint__to_remove_feature__responds_200_with_lifecycle project: int, settings: SettingsWrapper, stale_tag: Tag, -): +) -> None: # Given enable_features("feature_lifecycle") settings.USE_POSTGRES_FOR_ANALYTICS = True @@ -392,7 +390,7 @@ 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") @@ -411,7 +409,7 @@ def test_feature_detail_endpoint__no_environment__responds_200_without_lifecycle admin_client: APIClient, enable_features: EnableFeaturesFixture, project: int, -): +) -> None: # Given enable_features("feature_lifecycle") feature = Feature.objects.create(project_id=project, name="feature") diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 5e189c9485ad..09186377a41c 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -133,7 +133,7 @@ Attributes: ### `feature_lifecycle.summarised` Logged at `info` from: - - `api/features/feature_lifecycle/views.py:53` + - `api/features/feature_lifecycle/views.py:54` Attributes: - `environment.id`