Skip to content

Commit a5e89f9

Browse files
committed
fix(6905): replace dynamic serializer with SDKAnalyticsFlagsV1Serializer
The V1 SDKAnalyticsFlags view dynamically generated a DRF serializer inside get_serializer_class. Extract this into a proper SDKAnalyticsFlagsV1Serializer so the view mirrors the clean V2 pattern of serializer_class / get_serializer / is_valid / save.
1 parent 65a3f12 commit a5e89f9

File tree

3 files changed

+88
-48
lines changed

3 files changed

+88
-48
lines changed

api/app_analytics/serializers.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from django.conf import settings
55
from rest_framework import serializers
66

7+
from app_analytics.cache import FeatureEvaluationCache
78
from app_analytics.constants import LABELS
9+
from app_analytics.mappers import map_request_to_labels
810
from app_analytics.tasks import (
911
track_feature_evaluation_influxdb_v2,
1012
track_feature_evaluation_v2,
@@ -120,6 +122,53 @@ def save(self, **kwargs: Any) -> None:
120122
)
121123

122124

125+
class SDKAnalyticsFlagsV1Serializer(serializers.Serializer): # type: ignore[type-arg]
126+
"""Serializer for the V1 SDK analytics flags endpoint.
127+
128+
Accepts a flat ``{feature_name: evaluation_count}`` dict.
129+
Unknown feature names and non-integer counts are silently skipped.
130+
"""
131+
132+
def to_internal_value(self, data: Any) -> dict[str, int]:
133+
if not isinstance(data, dict):
134+
raise serializers.ValidationError("Expected a JSON object.")
135+
return {
136+
name: count
137+
for name, count in data.items()
138+
if isinstance(name, str) and isinstance(count, int)
139+
}
140+
141+
def validate(self, attrs: dict[str, int]) -> dict[str, int]:
142+
request = self.context["request"]
143+
environment_feature_names = set(
144+
using_database_replica(FeatureState.objects)
145+
.filter(
146+
environment=request.environment,
147+
feature_segment=None,
148+
identity=None,
149+
)
150+
.values_list("feature__name", flat=True)
151+
)
152+
return {
153+
name: count
154+
for name, count in attrs.items()
155+
if name in environment_feature_names
156+
}
157+
158+
def save(self, **kwargs: Any) -> None:
159+
environment: Environment = kwargs["environment"]
160+
cache: FeatureEvaluationCache = kwargs["cache"]
161+
request = self.context["request"]
162+
labels = map_request_to_labels(request)
163+
for feature_name, evaluation_count in self.validated_data.items():
164+
cache.track_feature_evaluation(
165+
environment_id=environment.id,
166+
feature_name=feature_name,
167+
evaluation_count=evaluation_count,
168+
labels=labels,
169+
)
170+
171+
123172
def _get_label_fields() -> dict[str, serializers.Field[Any, Any, Any, Any]]:
124173
return {
125174
str(label): serializers.CharField(allow_null=True, required=False)

api/app_analytics/views.py

Lines changed: 5 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,26 @@
55
from drf_spectacular.utils import extend_schema
66
from rest_framework import status
77
from rest_framework.decorators import api_view, permission_classes, throttle_classes
8-
from rest_framework.fields import IntegerField
98
from rest_framework.generics import CreateAPIView
109
from rest_framework.permissions import IsAuthenticated
1110
from rest_framework.request import Request
1211
from rest_framework.response import Response
13-
from rest_framework.serializers import Serializer
1412

1513
from app_analytics.analytics_db_service import (
1614
get_total_events_count,
1715
get_usage_data,
1816
)
1917
from app_analytics.cache import FeatureEvaluationCache
20-
from app_analytics.mappers import (
21-
map_request_to_labels,
22-
)
2318
from app_analytics.throttles import InfluxQueryThrottle
2419
from environments.authentication import EnvironmentKeyAuthentication
2520
from environments.permissions.permissions import EnvironmentKeyPermissions
26-
from features.models import FeatureState
2721
from organisations.models import Organisation
2822
from telemetry.serializers import TelemetrySerializer
2923

3024
from .permissions import UsageDataPermission
3125
from .serializers import (
3226
SDKAnalyticsFlagsSerializer,
27+
SDKAnalyticsFlagsV1Serializer,
3328
UsageDataQuerySerializer,
3429
UsageDataSerializer,
3530
UsageTotalCountSerializer,
@@ -63,55 +58,17 @@ class SDKAnalyticsFlags(CreateAPIView): # type: ignore[type-arg]
6358

6459
permission_classes = (EnvironmentKeyPermissions,)
6560
authentication_classes = (EnvironmentKeyAuthentication,)
61+
serializer_class = SDKAnalyticsFlagsV1Serializer
6662
throttle_classes = []
67-
format_kwarg = None
68-
69-
def get_serializer_class(self): # type: ignore[no-untyped-def]
70-
environment_feature_names = set(
71-
using_database_replica(FeatureState.objects)
72-
.filter(
73-
environment=self.request.environment,
74-
feature_segment=None,
75-
identity=None,
76-
)
77-
.values_list("feature__name", flat=True)
78-
)
79-
80-
class _AnalyticsSerializer(Serializer): # type: ignore[type-arg]
81-
def get_fields(self): # type: ignore[no-untyped-def]
82-
return {
83-
feature_name: IntegerField(required=False)
84-
for feature_name in environment_feature_names
85-
}
86-
87-
def save(self, **kwargs: typing.Any) -> None:
88-
request = self.context["request"]
89-
for feature_name, evaluation_count in self.validated_data.items():
90-
feature_evaluation_cache.track_feature_evaluation(
91-
environment_id=request.environment.id,
92-
feature_name=feature_name,
93-
evaluation_count=evaluation_count,
94-
labels=map_request_to_labels(request),
95-
)
96-
97-
return _AnalyticsSerializer
9863

9964
@extend_schema(
10065
request=SDKAnalyticsFlagsSerializer,
10166
responses={200: None},
10267
)
103-
def create(
104-
self, request: Request, *args: typing.Any, **kwargs: typing.Any
105-
) -> Response:
106-
"""
107-
Send flag evaluation events from the SDK back to the API for reporting.
108-
109-
TODO: Eventually replace this with the v2 version of
110-
this endpoint once SDKs have been updated.
111-
"""
68+
def create(self, request: Request, *args: typing.Any, **kwargs: typing.Any) -> Response:
11269
serializer = self.get_serializer(data=request.data)
113-
serializer.is_valid()
114-
serializer.save(environment=self.request.environment)
70+
serializer.is_valid(raise_exception=True)
71+
serializer.save(environment=request.environment, cache=feature_evaluation_cache)
11572
return Response(status=status.HTTP_200_OK)
11673

11774

api/tests/unit/app_analytics/test_unit_app_analytics_views.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,43 @@
2323
Organisation,
2424
OrganisationSubscriptionInformationCache,
2525
)
26+
from projects.models import Project
2627
from tests.types import EnableFeaturesFixture
2728

2829

30+
def test_sdk_analytics_flags_v1__feature_name_with_dots__tracks_correctly(
31+
mocker: MockerFixture,
32+
environment: Environment,
33+
project: Project,
34+
api_client: APIClient,
35+
) -> None:
36+
# Given
37+
dotted_name = "org.app.module:handler"
38+
feature = Feature.objects.create(name=dotted_name, project=project)
39+
40+
api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key)
41+
data = {dotted_name: 12}
42+
mocked_feature_eval_cache = mocker.patch(
43+
"app_analytics.views.feature_evaluation_cache"
44+
)
45+
46+
url = reverse("api-v1:analytics-flags")
47+
48+
# When
49+
response = api_client.post(
50+
url, data=json.dumps(data), content_type="application/json"
51+
)
52+
53+
# Then
54+
assert response.status_code == status.HTTP_200_OK
55+
mocked_feature_eval_cache.track_feature_evaluation.assert_called_once_with(
56+
environment_id=environment.id,
57+
feature_name=dotted_name,
58+
evaluation_count=12,
59+
labels={},
60+
)
61+
62+
2963
def test_sdk_analytics_ignores_bad_data(
3064
mocker: MockerFixture,
3165
environment: Environment,

0 commit comments

Comments
 (0)