Skip to content

Commit a2164a6

Browse files
fix: handle (ignore) unrecognised analytics data (#5564)
1 parent c9a287d commit a2164a6

File tree

3 files changed

+100
-100
lines changed

3 files changed

+100
-100
lines changed

api/app_analytics/serializers.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import typing
22

3+
from django.conf import settings
34
from rest_framework import serializers
45

6+
from app_analytics.tasks import (
7+
track_feature_evaluation_influxdb_v2,
8+
track_feature_evaluation_v2,
9+
)
10+
from environments.models import Environment
11+
from features.models import FeatureState
12+
513
from .types import PERIOD_TYPE
614

715

@@ -37,3 +45,38 @@ class SDKAnalyticsFlagsSerializerDetail(serializers.Serializer): # type: ignore
3745

3846
class SDKAnalyticsFlagsSerializer(serializers.Serializer): # type: ignore[type-arg]
3947
evaluations = SDKAnalyticsFlagsSerializerDetail(many=True)
48+
49+
def validate(self, attrs: dict[str, typing.Any]) -> dict[str, typing.Any]:
50+
request = self.context["request"]
51+
environment_feature_names = set(
52+
FeatureState.objects.filter(
53+
environment=request.environment,
54+
feature_segment=None,
55+
identity=None,
56+
).values_list("feature__name", flat=True)
57+
)
58+
return {
59+
"evaluations": [
60+
evaluation
61+
for evaluation in attrs["evaluations"]
62+
if evaluation["feature_name"] in environment_feature_names
63+
]
64+
}
65+
66+
def save(self, **kwargs: typing.Any) -> None:
67+
environment: Environment = kwargs["environment"]
68+
69+
if settings.USE_POSTGRES_FOR_ANALYTICS:
70+
track_feature_evaluation_v2.delay(
71+
args=(
72+
environment.id,
73+
self.validated_data["evaluations"],
74+
)
75+
)
76+
elif settings.INFLUXDB_TOKEN:
77+
track_feature_evaluation_influxdb_v2.delay(
78+
args=(
79+
environment.id,
80+
self.validated_data["evaluations"],
81+
)
82+
)

api/app_analytics/views.py

Lines changed: 26 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import logging
2+
import typing
23

3-
from django.conf import settings
44
from drf_yasg.utils import swagger_auto_schema # type: ignore[import-untyped]
55
from rest_framework import status
66
from rest_framework.decorators import api_view, permission_classes
77
from rest_framework.fields import IntegerField
8-
from rest_framework.generics import CreateAPIView, GenericAPIView
8+
from rest_framework.generics import CreateAPIView
99
from rest_framework.permissions import IsAuthenticated
1010
from rest_framework.request import Request
1111
from rest_framework.response import Response
@@ -16,10 +16,6 @@
1616
get_usage_data,
1717
)
1818
from app_analytics.cache import FeatureEvaluationCache
19-
from app_analytics.tasks import (
20-
track_feature_evaluation_influxdb_v2,
21-
track_feature_evaluation_v2,
22-
)
2319
from environments.authentication import EnvironmentKeyAuthentication
2420
from environments.permissions.permissions import EnvironmentKeyPermissions
2521
from features.models import FeatureState
@@ -44,60 +40,26 @@ class SDKAnalyticsFlagsV2(CreateAPIView): # type: ignore[type-arg]
4440
serializer_class = SDKAnalyticsFlagsSerializer
4541
throttle_classes = []
4642

43+
@swagger_auto_schema( # type: ignore[misc]
44+
request_body=SDKAnalyticsFlagsSerializer(),
45+
responses={204: Response(status=status.HTTP_204_NO_CONTENT)},
46+
)
4747
def create(self, request: Request, *args, **kwargs) -> Response: # type: ignore[no-untyped-def]
4848
serializer = self.get_serializer(data=request.data)
4949
serializer.is_valid(raise_exception=True)
50-
51-
self.evaluations = serializer.validated_data["evaluations"]
52-
if not self._is_data_valid():
53-
return Response(
54-
{"detail": "Invalid feature names associated with the project."},
55-
content_type="application/json",
56-
status=status.HTTP_400_BAD_REQUEST,
57-
)
58-
if settings.USE_POSTGRES_FOR_ANALYTICS:
59-
track_feature_evaluation_v2.delay(
60-
args=(
61-
request.environment.id,
62-
self.evaluations,
63-
)
64-
)
65-
elif settings.INFLUXDB_TOKEN:
66-
track_feature_evaluation_influxdb_v2.delay(
67-
args=(
68-
request.environment.id,
69-
self.evaluations,
70-
)
71-
)
72-
50+
serializer.save(environment=self.request.environment)
7351
return Response(status=status.HTTP_204_NO_CONTENT)
7452

75-
def _is_data_valid(self) -> bool:
76-
environment_feature_names = set(
77-
FeatureState.objects.filter(
78-
environment=self.request.environment,
79-
feature_segment=None,
80-
identity=None,
81-
).values_list("feature__name", flat=True)
82-
)
83-
84-
valid = True
85-
for evaluation in self.evaluations:
86-
if evaluation["feature_name"] in environment_feature_names:
87-
continue
88-
valid = False
89-
90-
return valid
91-
9253

93-
class SDKAnalyticsFlags(GenericAPIView): # type: ignore[type-arg]
54+
class SDKAnalyticsFlags(CreateAPIView): # type: ignore[type-arg]
9455
"""
9556
Class to handle flag analytics events
9657
"""
9758

9859
permission_classes = (EnvironmentKeyPermissions,)
9960
authentication_classes = (EnvironmentKeyAuthentication,)
10061
throttle_classes = []
62+
format_kwarg = None
10163

10264
def get_serializer_class(self): # type: ignore[no-untyped-def]
10365
if getattr(self, "swagger_fake_view", False):
@@ -118,57 +80,33 @@ def get_fields(self): # type: ignore[no-untyped-def]
11880
for feature_name in environment_feature_names
11981
}
12082

83+
def save(self, **kwargs: typing.Any) -> None:
84+
request = self.context["request"]
85+
for feature_name, eval_count in self.validated_data.items():
86+
feature_evaluation_cache.track_feature_evaluation(
87+
request.environment.id, feature_name, eval_count
88+
)
89+
12190
return _AnalyticsSerializer
12291

123-
def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
92+
@swagger_auto_schema( # type: ignore[misc]
93+
request_body=SDKAnalyticsFlagsSerializer(),
94+
responses={200: Response(status=status.HTTP_200_OK)},
95+
)
96+
def create(
97+
self, request: Request, *args: typing.Any, **kwargs: typing.Any
98+
) -> Response:
12499
"""
125100
Send flag evaluation events from the SDK back to the API for reporting.
126101
127-
128102
TODO: Eventually replace this with the v2 version of
129103
this endpoint once SDKs have been updated.
130104
"""
131-
is_valid = self._is_data_valid()
132-
if not is_valid:
133-
# for now, return 200 to avoid breaking client integrations
134-
return Response(
135-
{"detail": "Invalid data. Not logged."},
136-
content_type="application/json",
137-
status=status.HTTP_200_OK,
138-
)
139-
for feature_name, eval_count in self.request.data.items():
140-
feature_evaluation_cache.track_feature_evaluation(
141-
request.environment.id, feature_name, eval_count
142-
)
105+
serializer = self.get_serializer(data=request.data)
106+
serializer.is_valid()
107+
serializer.save(environment=self.request.environment)
143108
return Response(status=status.HTTP_200_OK)
144109

145-
def _is_data_valid(self) -> bool:
146-
environment_feature_names = set(
147-
FeatureState.objects.filter(
148-
environment=self.request.environment,
149-
feature_segment=None,
150-
identity=None,
151-
).values_list("feature__name", flat=True)
152-
)
153-
154-
is_valid = True
155-
for feature_name, request_count in self.request.data.items():
156-
if not (
157-
isinstance(feature_name, str)
158-
and feature_name in environment_feature_names
159-
):
160-
logger.warning("Feature %s does not belong to project", feature_name)
161-
is_valid = False
162-
163-
if not (isinstance(request_count, int)):
164-
logger.error(
165-
"Analytics data contains non integer request count. User agent: %s",
166-
self.request.headers.get("User-Agent", "Not found"),
167-
)
168-
is_valid = False
169-
170-
return is_valid
171-
172110

173111
class SelfHostedTelemetryAPIView(CreateAPIView): # type: ignore[type-arg]
174112
"""

api/tests/unit/app_analytics/test_unit_app_analytics_views.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
)
1818
from app_analytics.dataclasses import UsageData
1919
from app_analytics.models import FeatureEvaluationRaw
20-
from app_analytics.views import SDKAnalyticsFlags
2120
from environments.identities.models import Identity
2221
from environments.models import Environment
2322
from features.models import Feature
@@ -27,25 +26,34 @@
2726
)
2827

2928

30-
def test_sdk_analytics_does_not_allow_bad_data(mocker, settings, environment): # type: ignore[no-untyped-def]
29+
def test_sdk_analytics_ignores_bad_data(
30+
mocker: MockerFixture,
31+
environment: Environment,
32+
feature: Feature,
33+
api_client: APIClient,
34+
) -> None:
3135
# Given
32-
settings.INFLUXDB_TOKEN = "some-token"
33-
34-
data = {"bad": "data"}
35-
request = mocker.MagicMock(data=data, environment=environment)
36-
37-
view = SDKAnalyticsFlags(request=request)
36+
api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key)
3837

38+
data = {"invalid_feature_name": 20, feature.name: 2}
3939
mocked_feature_eval_cache = mocker.patch(
4040
"app_analytics.views.feature_evaluation_cache"
4141
)
4242

43+
url = reverse("api-v1:analytics-flags")
44+
4345
# When
44-
response = view.post(request) # type: ignore[no-untyped-call]
46+
response = api_client.post(
47+
url, data=json.dumps(data), content_type="application/json"
48+
)
4549

4650
# Then
4751
assert response.status_code == status.HTTP_200_OK
48-
mocked_feature_eval_cache.track_feature_evaluation.assert_not_called()
52+
assert mocked_feature_eval_cache.track_feature_evaluation.call_count == 1
53+
54+
mocked_feature_eval_cache.track_feature_evaluation.assert_called_once_with(
55+
environment.id, feature.name, data[feature.name]
56+
)
4957

5058

5159
def test_get_usage_data(mocker, admin_client, organisation): # type: ignore[no-untyped-def]
@@ -387,7 +395,12 @@ def test_set_sdk_analytics_flags_without_identifier(
387395
"feature_name": feature.name,
388396
"count": feature_request_count,
389397
"enabled_when_evaluated": True,
390-
}
398+
},
399+
{
400+
"feature_name": "invalid_feature_name",
401+
"count": 10,
402+
"enabled_when_evaluated": True,
403+
},
391404
]
392405
}
393406

@@ -433,7 +446,13 @@ def test_set_sdk_analytics_flags_with_identifier__influx__calls_expected(
433446
"identity_identifier": identity.identifier,
434447
"count": feature_request_count,
435448
"enabled_when_evaluated": True,
436-
}
449+
},
450+
{
451+
"feature_name": "invalid_feature_name",
452+
"identity_identifier": identity.identifier,
453+
"count": 10,
454+
"enabled_when_evaluated": True,
455+
},
437456
]
438457
}
439458

0 commit comments

Comments
 (0)