diff --git a/api/app_analytics/constants.py b/api/app_analytics/constants.py index 01e7dd35e825..670f37578680 100644 --- a/api/app_analytics/constants.py +++ b/api/app_analytics/constants.py @@ -142,6 +142,12 @@ "Flagsmith-SDK-User-Agent": "sdk_user_agent", "User-Agent": "user_agent", } + +SDK_USER_AGENT_HEADERS: list[str] = [ + "Flagsmith-SDK-User-Agent", # Exists because browsers own `User-Agent` + "User-Agent", +] + LABELS: tuple[Label, ...] = get_args(Label) NO_ANALYTICS_DATABASE_CONFIGURED_WARNING = ( diff --git a/api/app_analytics/mappers.py b/api/app_analytics/mappers.py index 2a6bcd3fc11c..998bba797bfd 100644 --- a/api/app_analytics/mappers.py +++ b/api/app_analytics/mappers.py @@ -8,6 +8,7 @@ from app_analytics.constants import ( LABELS, SDK_INFLUX_IDS_BY_USER_AGENT, + SDK_USER_AGENT_HEADERS, SDK_USER_AGENT_KNOWN_VERSIONS, SDK_USER_AGENTS_BY_INFLUX_ID, TRACK_HEADERS, @@ -36,10 +37,19 @@ def map_user_agent_to_sdk_user_agent(value: str) -> str | None: return None +def map_request_to_sdk_label(request: HttpRequest) -> KnownSDK | None: + for header in SDK_USER_AGENT_HEADERS: + if (value := request.headers.get(header)) and ( + sdk_user_agent := map_user_agent_to_sdk_user_agent(value) + ): + return cast(KnownSDK, sdk_user_agent.partition("/")[0]) + return None + + _request_header_labels_model_fields: dict[str, Any] = { str(label): ( Annotated[str | None, BeforeValidator(map_user_agent_to_sdk_user_agent)] - if label in ("user_agent", "sdk_user_agent") + if header in SDK_USER_AGENT_HEADERS else str | None, Field(default=None, alias=header), ) diff --git a/api/environments/migrations/0038_add_first_evaluated_fields.py b/api/environments/migrations/0038_add_first_evaluated_fields.py new file mode 100644 index 000000000000..3b91d0a8b5fe --- /dev/null +++ b/api/environments/migrations/0038_add_first_evaluated_fields.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.16 on 2026-07-20 22:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("environments", "0037_add_uuid_field"), + ] + + operations = [ + migrations.AddField( + model_name="environment", + name="first_evaluated_at", + field=models.DateTimeField( + blank=True, + help_text="When the environment's flags were first evaluated by an SDK.", + null=True, + ), + ), + migrations.AddField( + model_name="environment", + name="first_evaluated_sdk_label", + field=models.CharField( + blank=True, + help_text="SDK that first evaluated the environment's flags.", + max_length=100, + null=True, + ), + ), + migrations.AddField( + model_name="historicalenvironment", + name="first_evaluated_at", + field=models.DateTimeField( + blank=True, + help_text="When the environment's flags were first evaluated by an SDK.", + null=True, + ), + ), + migrations.AddField( + model_name="historicalenvironment", + name="first_evaluated_sdk_label", + field=models.CharField( + blank=True, + help_text="SDK that first evaluated the environment's flags.", + max_length=100, + null=True, + ), + ), + ] diff --git a/api/environments/models.py b/api/environments/models.py index 18b90242f9f6..42c55e15fa28 100644 --- a/api/environments/models.py +++ b/api/environments/models.py @@ -2,6 +2,7 @@ import typing import uuid from copy import deepcopy +from datetime import datetime from typing import TYPE_CHECKING, Literal from common.core.utils import using_database_replica @@ -24,6 +25,7 @@ from softdelete.models import SoftDeleteObject # type: ignore[import-untyped] from app.utils import create_hash +from app_analytics.types import KnownSDK from audit.constants import ( ENVIRONMENT_CREATED_MESSAGE, ENVIRONMENT_UPDATED_MESSAGE, @@ -160,6 +162,19 @@ class Environment( help_text="Attribute used to indicate when an environment is still being created (via clone for example)", ) + first_evaluated_at = models.DateTimeField[datetime | None, datetime | None]( + null=True, + blank=True, + help_text="When the environment's flags were first evaluated by an SDK.", + ) + + first_evaluated_sdk_label = models.CharField[KnownSDK | None, KnownSDK | None]( + null=True, + blank=True, + max_length=100, + help_text="SDK that first evaluated the environment's flags.", + ) + objects = EnvironmentManager() class Meta: @@ -240,6 +255,8 @@ def clone( clone.name = name clone.api_key = api_key if api_key else create_hash() clone.is_creating = True + clone.first_evaluated_at = None + clone.first_evaluated_sdk_label = None clone.save() from environments.tasks import clone_environment_feature_states diff --git a/api/environments/onboarding/__init__.py b/api/environments/onboarding/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/environments/onboarding/serializers.py b/api/environments/onboarding/serializers.py new file mode 100644 index 000000000000..eb9a3ca129e7 --- /dev/null +++ b/api/environments/onboarding/serializers.py @@ -0,0 +1,16 @@ +from typing import get_args + +from rest_framework import serializers + +from app_analytics.types import KnownSDK +from environments.models import Environment + + +class EnvironmentOnboardingStatusSerializer(serializers.ModelSerializer[Environment]): + class Meta: + model = Environment + fields = ("first_evaluated_at", "first_evaluated_sdk_label") + + +class EnvironmentOnboardingStatusUpdateSerializer(serializers.Serializer[None]): + first_evaluated_sdk_label = serializers.ChoiceField(choices=get_args(KnownSDK)) diff --git a/api/environments/onboarding/services.py b/api/environments/onboarding/services.py new file mode 100644 index 000000000000..83b639b88aca --- /dev/null +++ b/api/environments/onboarding/services.py @@ -0,0 +1,32 @@ +import structlog +from django.utils import timezone + +from app_analytics.types import KnownSDK +from environments.models import Environment + +logger = structlog.get_logger("onboarding") + + +def record_environment_first_evaluation( + environment: Environment, + sdk_label: KnownSDK, +) -> None: + """Mark this environment as having been evaluated by a client SDK.""" + log = logger.bind( + environment__id=environment.id, + project__id=environment.project_id, + organisation__id=environment.project.organisation_id, + sdk__label=sdk_label, + ) + + if environment.first_evaluated_at is not None: + log.info("environment.already_evaluated") + return + + environment.first_evaluated_at = timezone.now() + environment.first_evaluated_sdk_label = sdk_label + environment.save(update_fields=["first_evaluated_at", "first_evaluated_sdk_label"]) + + Environment.write_environment_documents(environment_id=environment.id) + + log.info("environment.first_evaluated") diff --git a/api/environments/onboarding/urls.py b/api/environments/onboarding/urls.py new file mode 100644 index 000000000000..5ea62892eb75 --- /dev/null +++ b/api/environments/onboarding/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from environments.onboarding.views import EnvironmentOnboardingStatusAPIView + +app_name = "onboarding" + +urlpatterns = [ + path( + "", + EnvironmentOnboardingStatusAPIView.as_view(), + name="onboarding-status", + ), +] diff --git a/api/environments/onboarding/views.py b/api/environments/onboarding/views.py new file mode 100644 index 000000000000..01a804d41169 --- /dev/null +++ b/api/environments/onboarding/views.py @@ -0,0 +1,36 @@ +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.generics import RetrieveAPIView +from rest_framework.request import Request +from rest_framework.response import Response + +from environments.models import Environment +from environments.onboarding.serializers import ( + EnvironmentOnboardingStatusSerializer, + EnvironmentOnboardingStatusUpdateSerializer, +) +from environments.onboarding.services import record_environment_first_evaluation + + +class EnvironmentOnboardingStatusAPIView(RetrieveAPIView[Environment]): + """Obtain information on whether features for this environment have been evaluated yet.""" + + authentication_classes = () + permission_classes = () + throttle_classes = [] + + queryset = Environment.objects.select_related("project") + lookup_field = "api_key" + lookup_url_kwarg = "environment_api_key" + serializer_class = EnvironmentOnboardingStatusSerializer + + @extend_schema(exclude=True) + def put(self, request: Request, environment_api_key: str) -> Response: + """Mark this environment as having been evaluated by a client SDK.""" + serializer = EnvironmentOnboardingStatusUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + record_environment_first_evaluation( + environment=self.get_object(), + sdk_label=serializer.validated_data["first_evaluated_sdk_label"], + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/api/environments/urls.py b/api/environments/urls.py index be71c2179840..e0f66d90052b 100644 --- a/api/environments/urls.py +++ b/api/environments/urls.py @@ -185,4 +185,8 @@ "/experiment-metrics/", include("experimentation.metric_urls"), ), + path( + "/onboarding-status/", + include("environments.onboarding.urls"), + ), ] diff --git a/api/features/views.py b/api/features/views.py index 462053151125..42a28d858f37 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -42,6 +42,7 @@ from app.pagination import CustomPagination from app_analytics.analytics_db_service import get_feature_evaluation_data from app_analytics.influxdb_wrapper import get_multiple_event_list_for_feature +from app_analytics.mappers import map_request_to_sdk_label from app_analytics.throttles import InfluxQueryThrottle from core.constants import FLAGSMITH_UPDATED_AT_HEADER, SDK_ENVIRONMENT_KEY_HEADER from core.request_origin import RequestOrigin @@ -55,6 +56,7 @@ IdentitySourceIdentityRequestSerializer, ) from environments.models import Environment +from environments.onboarding.services import record_environment_first_evaluation from environments.permissions.permissions import ( EnvironmentKeyPermissions, NestedEnvironmentPermissions, @@ -1007,6 +1009,11 @@ def get(self, request, identifier=None, *args, **kwargs): # type: ignore[no-unt *Note*: using this endpoint with an identifier is deprecated. Please use `/api/v1/identities/?identifier=` instead. """ + if request.environment.first_evaluated_at is None and ( + sdk_label := map_request_to_sdk_label(request) + ): + record_environment_first_evaluation(request.environment, sdk_label) + if identifier: return self._get_flags_response_with_identifier(request, identifier) diff --git a/api/tests/unit/environments/onboarding/__init__.py b/api/tests/unit/environments/onboarding/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/environments/onboarding/test_unit_environments_views.py b/api/tests/unit/environments/onboarding/test_unit_environments_views.py new file mode 100644 index 000000000000..fc7b6932d6a5 --- /dev/null +++ b/api/tests/unit/environments/onboarding/test_unit_environments_views.py @@ -0,0 +1,247 @@ +from unittest.mock import Mock + +import freezegun +import pytest +from django.utils import timezone +from pytest_mock import MockerFixture +from pytest_structlog import StructuredLogCapture +from rest_framework.test import APIClient + +from environments.models import Environment + +pytestmark = pytest.mark.freeze_time("2077-07-07T07:07:07Z") + + +@pytest.fixture +def onboarded_environment(environment: Environment) -> Environment: + environment.first_evaluated_at = timezone.now() + environment.first_evaluated_sdk_label = "flagsmith-python-sdk" + environment.save(update_fields=["first_evaluated_at", "first_evaluated_sdk_label"]) + return environment + + +@pytest.fixture(autouse=True) +def write_environment_documents(mocker: MockerFixture) -> Mock: + return mocker.patch.object(Environment, "write_environment_documents") + + +def test_get_onboarding_status__never_evaluated__responds_200( + api_client: APIClient, + environment: Environment, +) -> None: + # Given / When + response = api_client.get( + f"/api/v1/environments/{environment.api_key}/onboarding-status/" + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "first_evaluated_at": None, + "first_evaluated_sdk_label": None, + } + + +def test_get_onboarding_status__evaluated__responds_200( + api_client: APIClient, + onboarded_environment: Environment, +) -> None: + # Given / When + response = api_client.get( + f"/api/v1/environments/{onboarded_environment.api_key}/onboarding-status/" + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "first_evaluated_at": "2077-07-07T07:07:07Z", + "first_evaluated_sdk_label": "flagsmith-python-sdk", + } + + +@pytest.mark.django_db +def test_get_onboarding_status__unknown_environment__responds_404( + api_client: APIClient, +) -> None: + # Given / When + response = api_client.get("/api/v1/environments/unknown-api-key/onboarding-status/") + + # Then + assert response.status_code == 404 + + +def test_put_onboarding_status__never_evaluated__updates_environment( + api_client: APIClient, + environment: Environment, +) -> None: + # Given + assert environment.first_evaluated_at is None + assert environment.first_evaluated_sdk_label is None + + # When + response = api_client.put( + f"/api/v1/environments/{environment.api_key}/onboarding-status/", + data={ + "first_evaluated_sdk_label": "flagsmith-python-sdk", + }, + format="json", + ) + + # Then + assert response.status_code == 204 + environment.refresh_from_db() + assert environment.first_evaluated_at == timezone.now() + assert environment.first_evaluated_sdk_label == "flagsmith-python-sdk" + + +def test_put_onboarding_status__evaluated__does_not_update_environment( + api_client: APIClient, + onboarded_environment: Environment, +) -> None: + # Given + assert onboarded_environment.first_evaluated_at is not None + assert onboarded_environment.first_evaluated_sdk_label is not None + + # When + with freezegun.freeze_time("2088-08-08T08:08:08Z"): + response = api_client.put( + f"/api/v1/environments/{onboarded_environment.api_key}/onboarding-status/", + data={ + "first_evaluated_sdk_label": "flagsmith-php-sdk", + }, + format="json", + ) + + # Then + assert response.status_code == 204 + onboarded_environment.refresh_from_db() + assert "2077" in onboarded_environment.first_evaluated_at.isoformat() + assert "python" in onboarded_environment.first_evaluated_sdk_label + + +def test_put_onboarding_status__never_evaluated__writes_environment_document( + api_client: APIClient, + environment: Environment, + write_environment_documents: Mock, +) -> None: + # Given / When + response = api_client.put( + f"/api/v1/environments/{environment.api_key}/onboarding-status/", + data={ + "first_evaluated_sdk_label": "flagsmith-python-sdk", + }, + format="json", + ) + + # Then + assert response.status_code == 204 + write_environment_documents.assert_called_once_with(environment_id=environment.id) + + +def test_put_onboarding_status__evaluated__skips_environment_document( + api_client: APIClient, + onboarded_environment: Environment, + write_environment_documents: Mock, +) -> None: + # Given / When + response = api_client.put( + f"/api/v1/environments/{onboarded_environment.api_key}/onboarding-status/", + data={ + "first_evaluated_sdk_label": "flagsmith-python-sdk", + }, + format="json", + ) + + # Then + assert response.status_code == 204 + write_environment_documents.assert_not_called() + + +def test_put_onboarding_status__never_evaluated__logs_first_evaluation( + api_client: APIClient, + environment: Environment, + log: StructuredLogCapture, +) -> None: + # Given / When + response = api_client.put( + f"/api/v1/environments/{environment.api_key}/onboarding-status/", + data={ + "first_evaluated_sdk_label": "flagsmith-python-sdk", + }, + format="json", + ) + + # Then + assert response.status_code == 204 + assert log.has( + "environment.first_evaluated", + level="info", + environment__id=environment.id, + project__id=environment.project_id, + organisation__id=environment.project.organisation_id, + sdk__label="flagsmith-python-sdk", + ) + + +def test_put_onboarding_status__evaluated__logs_already_evaluated( + api_client: APIClient, + onboarded_environment: Environment, + log: StructuredLogCapture, +) -> None: + # Given / When + response = api_client.put( + f"/api/v1/environments/{onboarded_environment.api_key}/onboarding-status/", + data={ + "first_evaluated_sdk_label": "flagsmith-php-sdk", + }, + format="json", + ) + + # Then + assert response.status_code == 204 + assert log.has( + "environment.already_evaluated", + level="info", + environment__id=onboarded_environment.id, + project__id=onboarded_environment.project_id, + organisation__id=onboarded_environment.project.organisation_id, + sdk__label="flagsmith-php-sdk", + ) + assert not log.has("environment.first_evaluated") + + +@pytest.mark.django_db +def test_put_onboarding_status__unknown_environment__responds_404( + api_client: APIClient, +) -> None: + # Given / When + response = api_client.put( + "/api/v1/environments/unknown-api-key/onboarding-status/", + data={ + "first_evaluated_sdk_label": "flagsmith-python-sdk", + }, + format="json", + ) + + # Then + assert response.status_code == 404 + + +def test_put_onboarding_status__invalid_sdk_label__responds_400( + api_client: APIClient, + environment: Environment, +) -> None: + # Given / When + response = api_client.put( + f"/api/v1/environments/{environment.api_key}/onboarding-status/", + data={ + "first_evaluated_sdk_label": "invalid-sdk-label", + }, + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.json() == { + "first_evaluated_sdk_label": ['"invalid-sdk-label" is not a valid choice.'], + } diff --git a/api/tests/unit/environments/test_unit_environments_models.py b/api/tests/unit/environments/test_unit_environments_models.py index 5a1256b278b0..cbba88ab635e 100644 --- a/api/tests/unit/environments/test_unit_environments_models.py +++ b/api/tests/unit/environments/test_unit_environments_models.py @@ -97,6 +97,22 @@ def test_environment_clone__default__does_not_modify_original_instance( assert clone.api_key != original_api_key +def test_environment_clone__evaluated_source__resets_first_evaluation_fields( + environment: Environment, +) -> None: + # Given + environment.first_evaluated_at = timezone.now() + environment.first_evaluated_sdk_label = "flagsmith-python-sdk" + environment.save() + + # When + clone = environment.clone(name="Cloned env") + + # Then + assert clone.first_evaluated_at is None + assert clone.first_evaluated_sdk_label is None + + def test_environment_clone__with_feature__creates_feature_states( # type: ignore[no-untyped-def] environment: Environment, feature: Feature ): diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index c6f42c9352b2..5f0af1d548fc 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -1415,6 +1415,103 @@ def test_get_flags__user_throttle_set__is_not_throttled( # type: ignore[no-unty assert response.status_code == status.HTTP_200_OK +@pytest.mark.parametrize( + "headers,expected_sdk_label", + [ + ( + {"Flagsmith-SDK-User-Agent": "flagsmith-js-sdk/9.3.1"}, + "flagsmith-js-sdk", + ), + ( + {"User-Agent": "flagsmith-python-sdk/6.0.0"}, + "flagsmith-python-sdk", + ), + ( + { + "Flagsmith-SDK-User-Agent": "flagsmith-js-sdk/9.3.1", + "User-Agent": "flagsmith-python-sdk/6.0.0", + }, + "flagsmith-js-sdk", + ), + ], +) +def test_get_flags__environment_never_evaluated__records_first_evaluation( + api_client: APIClient, + environment: Environment, + mocker: MockerFixture, + headers: dict[str, str], + expected_sdk_label: str, +) -> None: + # Given + record_environment_first_evaluation = mocker.patch.object( + views, "record_environment_first_evaluation" + ) + api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) + + # When + response = api_client.get("/api/v1/flags/", headers=headers) + + # Then + assert response.status_code == 200 + record_environment_first_evaluation.assert_called_once_with( + environment, expected_sdk_label + ) + + +@pytest.mark.parametrize( + "headers", + [ + {}, + { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15" + }, + ], +) +def test_get_flags__sdk_not_identified__does_not_record_first_evaluation( + api_client: APIClient, + environment: Environment, + mocker: MockerFixture, + headers: dict[str, str], +) -> None: + # Given + record_environment_first_evaluation = mocker.patch.object( + views, "record_environment_first_evaluation" + ) + api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) + + # When + response = api_client.get("/api/v1/flags/", headers=headers) + + # Then + assert response.status_code == 200 + record_environment_first_evaluation.assert_not_called() + + +def test_get_flags__environment_already_evaluated__does_not_record_first_evaluation( + api_client: APIClient, + environment: Environment, + mocker: MockerFixture, +) -> None: + # Given + record_environment_first_evaluation = mocker.patch.object( + views, "record_environment_first_evaluation" + ) + environment.first_evaluated_at = timezone.now() + environment.first_evaluated_sdk_label = "flagsmith-js-sdk" + environment.save(update_fields=["first_evaluated_at", "first_evaluated_sdk_label"]) + api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) + + # When + response = api_client.get( + "/api/v1/flags/", + headers={"Flagsmith-SDK-User-Agent": "flagsmith-js-sdk/9.3.1"}, + ) + + # Then + assert response.status_code == 200 + record_environment_first_evaluation.assert_not_called() + + def test_list_feature_states__simple_view_set__returns_expected_count( environment: Environment, feature: Feature, diff --git a/api/tests/unit/util/mappers/test_unit_mappers_dynamodb.py b/api/tests/unit/util/mappers/test_unit_mappers_dynamodb.py index 31e8b13ed978..32b3f0de2d75 100644 --- a/api/tests/unit/util/mappers/test_unit_mappers_dynamodb.py +++ b/api/tests/unit/util/mappers/test_unit_mappers_dynamodb.py @@ -4,6 +4,8 @@ from decimal import Decimal from typing import TYPE_CHECKING +from django.utils import timezone + from environments.dynamodb.constants import ( ENVIRONMENTS_V2_ENVIRONMENT_META_DOCUMENT_KEY, ) @@ -59,6 +61,7 @@ def test_map_environment_to_environment_document__valid_environment__returns_exp "id": Decimal(environment.pk), "mixpanel_config": None, "name": "Test Environment", + "onboarding_pending": True, "project": { "enable_realtime_updates": False, "hide_disabled_flags": False, @@ -83,6 +86,20 @@ def test_map_environment_to_environment_document__valid_environment__returns_exp } +def test_map_environment_to_environment_document__first_evaluated__onboarding_pending_false( + environment: "Environment", +) -> None: + # Given + environment.first_evaluated_at = timezone.now() + environment.save(update_fields=["first_evaluated_at"]) + + # When + result = dynamodb.map_environment_to_environment_document(environment) + + # Then + assert result["onboarding_pending"] is False + + def test_map_environment_api_key_to_environment_api_key_document__valid_key__returns_expected_document( environment_api_key: "EnvironmentAPIKey", ) -> None: @@ -179,6 +196,7 @@ def test_map_environment_to_environment_v2_document__valid_environment__returns_ "id": Decimal(environment.pk), "mixpanel_config": None, "name": "Test Environment", + "onboarding_pending": True, "project": { "enable_realtime_updates": False, "hide_disabled_flags": False, diff --git a/api/tests/unit/util/mappers/test_unit_mappers_engine.py b/api/tests/unit/util/mappers/test_unit_mappers_engine.py index 75f49a25d998..f941ad82abad 100644 --- a/api/tests/unit/util/mappers/test_unit_mappers_engine.py +++ b/api/tests/unit/util/mappers/test_unit_mappers_engine.py @@ -467,6 +467,7 @@ def test_map_environment_to_engine__multiple_segments_and_versions__returns_expe use_identity_overrides_in_local_eval=environment.use_identity_overrides_in_local_eval, hide_sensitive_data=environment.hide_sensitive_data, hide_disabled_flags=environment.hide_disabled_flags, + onboarding_pending=True, amplitude_config=None, dynatrace_config=None, heap_config=None, diff --git a/api/util/engine_models/environments/models.py b/api/util/engine_models/environments/models.py index 697e79077465..4198a1b3814c 100644 --- a/api/util/engine_models/environments/models.py +++ b/api/util/engine_models/environments/models.py @@ -39,6 +39,7 @@ class EnvironmentModel(BaseModel): hide_disabled_flags: typing.Optional[bool] = None use_identity_composite_key_for_hashing: bool = False use_identity_overrides_in_local_eval: bool = False + onboarding_pending: typing.Optional[bool] = None amplitude_config: typing.Optional[IntegrationModel] = None dynatrace_config: typing.Optional[IntegrationModel] = None diff --git a/api/util/mappers/engine.py b/api/util/mappers/engine.py index 75da44509d3d..45a3a0232ef0 100644 --- a/api/util/mappers/engine.py +++ b/api/util/mappers/engine.py @@ -358,6 +358,7 @@ def map_environment_to_engine( hide_sensitive_data=environment.hide_sensitive_data, hide_disabled_flags=environment.hide_disabled_flags, use_identity_overrides_in_local_eval=environment.use_identity_overrides_in_local_eval, + onboarding_pending=environment.first_evaluated_at is None, # # Relationships: project=project_model, diff --git a/api/util/mappers/sdk.py b/api/util/mappers/sdk.py index 24f32b356dfc..0d9e9865c603 100644 --- a/api/util/mappers/sdk.py +++ b/api/util/mappers/sdk.py @@ -13,40 +13,31 @@ SDKDocumentValue: TypeAlias = dict[str, "SDKDocumentValue"] | str | bool | None | float SDKDocument: TypeAlias = dict[str, SDKDocumentValue] -SDK_DOCUMENT_EXCLUDE = [ +SDK_DOCUMENT_EXCLUDE = { *IDENTITY_INTEGRATIONS_RELATION_NAMES, "dynatrace_config", -] + "onboarding_pending", +} def map_environment_to_sdk_document(environment: "Environment") -> SDKDocument: - """ - Map an `environments.models.Environment` instance to an SDK document - used by SDKs with local evaluation mode. + """Map an `Environment` to a document used by SDKs on local evaluation. - It's virtually the same data that gets indexed in DynamoDB, - except it presents identity overrides and omits integrations configurations. + It's virtually the same data that gets indexed in DynamoDB, except it + presents identity overrides and omits information irrelevant to SDKs. """ - # Read relationships. - identities_with_overrides = {} + engine_environment = map_environment_to_engine(environment, with_integrations=False) + if environment.use_identity_overrides_in_local_eval: + identities_with_overrides = {} for feature_state in environment.feature_states.all(): if (identity_id := feature_state.identity_id) and ( identity_id not in identities_with_overrides ): identities_with_overrides[identity_id] = feature_state.identity + engine_environment.identity_overrides = [ + map_identity_to_engine(identity, with_traits=False) + for identity in identities_with_overrides.values() + ] - # Get the engine data. - engine_environment = map_environment_to_engine(environment, with_integrations=False) - - # No reading from ORM past this point! - - # Prepare relationships. - engine_environment.identity_overrides = [ - map_identity_to_engine(identity, with_traits=False) - for identity in identities_with_overrides.values() - ] - - return engine_environment.model_dump( - exclude=SDK_DOCUMENT_EXCLUDE, # type: ignore[arg-type] - ) + return engine_environment.model_dump(exclude=SDK_DOCUMENT_EXCLUDE) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 76161e5b84f8..121b409c4f34 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -385,6 +385,28 @@ Logged at `info` from: Attributes: - `organisation.id` +### `onboarding.environment.already_evaluated` + +Logged at `info` from: + - `api/environments/onboarding/services.py:23` + +Attributes: + - `environment.id` + - `organisation.id` + - `project.id` + - `sdk.label` + +### `onboarding.environment.first_evaluated` + +Logged at `info` from: + - `api/environments/onboarding/services.py:32` + +Attributes: + - `environment.id` + - `organisation.id` + - `project.id` + - `sdk.label` + ### `platform_hub.no_analytics_database_configured` Logged at `warning` from: diff --git a/openapi.yaml b/openapi.yaml index b27f5a3cef04..ef7d55b433d1 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6507,6 +6507,25 @@ paths: - Master API Key: [] tags: - Environments + '/api/v1/environments/{environment_api_key}/onboarding-status/': + get: + operationId: api_v1_environments_onboarding_status_retrieve + description: Obtain information on whether features for this environment have been evaluated yet. + parameters: + - name: environment_api_key + in: path + required: true + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/EnvironmentOnboardingStatus' + tags: + - Environments '/api/v1/environments/{environment_api_key}/user-group-permissions/': get: operationId: api_v1_environments_user_group_permissions_list @@ -19659,6 +19678,21 @@ components: items: $ref: '#/components/schemas/MetricItem' readOnly: true + EnvironmentOnboardingStatus: + type: object + properties: + first_evaluated_at: + description: When the environment's flags were first evaluated by an SDK. + type: + - string + - 'null' + format: date-time + first_evaluated_sdk_label: + description: SDK that first evaluated the environment's flags. + type: + - string + - 'null' + maxLength: 100 EnvironmentRetrieveSerializerWithMetadata: description: Functionality for serializers that need to handle metadata type: object