Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions api/app_analytics/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
49 changes: 4 additions & 45 deletions api/app_analytics/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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)


Expand Down
76 changes: 76 additions & 0 deletions api/tests/unit/app_analytics/test_unit_app_analytics_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading