diff --git a/api/app_analytics/serializers.py b/api/app_analytics/serializers.py index e914308737a8..7f4d12cf431a 100644 --- a/api/app_analytics/serializers.py +++ b/api/app_analytics/serializers.py @@ -4,7 +4,9 @@ from django.conf import settings from rest_framework import serializers +from app_analytics.cache import FeatureEvaluationCache from app_analytics.constants import LABELS +from app_analytics.mappers import map_request_to_labels from app_analytics.tasks import ( track_feature_evaluation_influxdb_v2, track_feature_evaluation_v2, @@ -120,6 +122,55 @@ def save(self, **kwargs: Any) -> None: ) +class SDKAnalyticsFlagsV1Serializer(serializers.Serializer): # type: ignore[type-arg] + """Serializer for the V1 SDK analytics flags endpoint. + + Accepts a flat ``{feature_name: evaluation_count}`` dict. + Unknown feature names and non-integer counts are silently skipped. + """ + + def to_internal_value(self, data: Any) -> dict[str, int]: + if not isinstance(data, dict): + raise serializers.ValidationError( + {"non_field_errors": ["Expected a JSON object."]} + ) + return { + name: count + for name, count in data.items() + if isinstance(name, str) and type(count) is int + } + + def validate(self, attrs: dict[str, int]) -> dict[str, int]: + request = self.context["request"] + environment_feature_names = set( + using_database_replica(FeatureState.objects) + .filter( + environment=request.environment, + feature_segment=None, + identity=None, + ) + .values_list("feature__name", flat=True) + ) + return { + name: count + for name, count in attrs.items() + if name in environment_feature_names + } + + def save(self, **kwargs: Any) -> None: + environment: Environment = kwargs["environment"] + cache: FeatureEvaluationCache = kwargs["cache"] + request = self.context["request"] + labels = map_request_to_labels(request) + for feature_name, evaluation_count in self.validated_data.items(): + cache.track_feature_evaluation( + environment_id=environment.id, + feature_name=feature_name, + evaluation_count=evaluation_count, + labels=labels, + ) + + def _get_label_fields() -> dict[str, serializers.Field[Any, Any, Any, Any]]: return { str(label): serializers.CharField(allow_null=True, required=False) diff --git a/api/app_analytics/views.py b/api/app_analytics/views.py index b03e4529bfd5..b71bf9896536 100644 --- a/api/app_analytics/views.py +++ b/api/app_analytics/views.py @@ -5,31 +5,26 @@ from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.decorators import api_view, permission_classes, throttle_classes -from rest_framework.fields import IntegerField from rest_framework.generics import CreateAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.serializers import Serializer from app_analytics.analytics_db_service import ( get_total_events_count, get_usage_data, ) from app_analytics.cache import FeatureEvaluationCache -from app_analytics.mappers import ( - map_request_to_labels, -) from app_analytics.throttles import InfluxQueryThrottle from environments.authentication import EnvironmentKeyAuthentication from environments.permissions.permissions import EnvironmentKeyPermissions -from features.models import FeatureState from organisations.models import Organisation from telemetry.serializers import TelemetrySerializer from .permissions import UsageDataPermission from .serializers import ( SDKAnalyticsFlagsSerializer, + SDKAnalyticsFlagsV1Serializer, UsageDataQuerySerializer, UsageDataSerializer, UsageTotalCountSerializer, @@ -63,38 +58,8 @@ class SDKAnalyticsFlags(CreateAPIView): # type: ignore[type-arg] permission_classes = (EnvironmentKeyPermissions,) authentication_classes = (EnvironmentKeyAuthentication,) + serializer_class = SDKAnalyticsFlagsV1Serializer throttle_classes = [] - format_kwarg = None - - def get_serializer_class(self): # type: ignore[no-untyped-def] - environment_feature_names = set( - using_database_replica(FeatureState.objects) - .filter( - environment=self.request.environment, - feature_segment=None, - identity=None, - ) - .values_list("feature__name", flat=True) - ) - - class _AnalyticsSerializer(Serializer): # type: ignore[type-arg] - def get_fields(self): # type: ignore[no-untyped-def] - return { - feature_name: IntegerField(required=False) - for feature_name in environment_feature_names - } - - def save(self, **kwargs: typing.Any) -> None: - request = self.context["request"] - for feature_name, evaluation_count in self.validated_data.items(): - feature_evaluation_cache.track_feature_evaluation( - environment_id=request.environment.id, - feature_name=feature_name, - evaluation_count=evaluation_count, - labels=map_request_to_labels(request), - ) - - return _AnalyticsSerializer @extend_schema( request=SDKAnalyticsFlagsSerializer, @@ -103,15 +68,9 @@ def save(self, **kwargs: typing.Any) -> None: def create( self, request: Request, *args: typing.Any, **kwargs: typing.Any ) -> Response: - """ - Send flag evaluation events from the SDK back to the API for reporting. - - TODO: Eventually replace this with the v2 version of - this endpoint once SDKs have been updated. - """ serializer = self.get_serializer(data=request.data) - serializer.is_valid() - serializer.save(environment=self.request.environment) + serializer.is_valid(raise_exception=True) + serializer.save(environment=request.environment, cache=feature_evaluation_cache) return Response(status=status.HTTP_200_OK) diff --git a/api/tests/unit/app_analytics/test_unit_app_analytics_views.py b/api/tests/unit/app_analytics/test_unit_app_analytics_views.py index 4e1eef22539e..32973a1ee87a 100644 --- a/api/tests/unit/app_analytics/test_unit_app_analytics_views.py +++ b/api/tests/unit/app_analytics/test_unit_app_analytics_views.py @@ -23,9 +23,85 @@ Organisation, OrganisationSubscriptionInformationCache, ) +from projects.models import Project from tests.types import EnableFeaturesFixture +def test_sdk_analytics_flags_v1__feature_name_with_dots__tracks_correctly( + mocker: MockerFixture, + environment: Environment, + project: Project, + api_client: APIClient, +) -> None: + # Given + dotted_name = "org.app.module:handler" + Feature.objects.create(name=dotted_name, project=project) + + api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) + data = {dotted_name: 12} + mocked_feature_eval_cache = mocker.patch( + "app_analytics.views.feature_evaluation_cache" + ) + + url = reverse("api-v1:analytics-flags") + + # When + response = api_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_200_OK + mocked_feature_eval_cache.track_feature_evaluation.assert_called_once_with( + environment_id=environment.id, + feature_name=dotted_name, + evaluation_count=12, + labels={}, + ) + + +def test_sdk_analytics_flags_v1__non_dict_payload__returns_400( + environment: Environment, + api_client: APIClient, +) -> None: + # Given + api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) + url = reverse("api-v1:analytics-flags") + + # When + response = api_client.post( + url, data=json.dumps([1, 2, 3]), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_sdk_analytics_flags_v1__boolean_count__is_skipped( + mocker: MockerFixture, + environment: Environment, + feature: Feature, + api_client: APIClient, +) -> None: + # Given + api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) + data = {feature.name: True} + mocked_feature_eval_cache = mocker.patch( + "app_analytics.views.feature_evaluation_cache" + ) + + url = reverse("api-v1:analytics-flags") + + # When + response = api_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_200_OK + mocked_feature_eval_cache.track_feature_evaluation.assert_not_called() + + def test_sdk_analytics_ignores_bad_data( mocker: MockerFixture, environment: Environment,