Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Create split testing for multivariate #3235

Merged
merged 94 commits into from
Feb 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
94 commits
Select commit Hold shift + click to select a range
5f30d88
Add SciPy
zachaysan Jan 3, 2024
4a661d0
Create AppConfig
zachaysan Jan 3, 2024
8e70834
Add helpers
zachaysan Jan 3, 2024
4bb7ed9
Add SplitTestPermissions
zachaysan Jan 3, 2024
0401010
Create SplitTest serializers
zachaysan Jan 3, 2024
5262709
Create split testing tasks
zachaysan Jan 3, 2024
9db92ab
Create SplitTest views
zachaysan Jan 3, 2024
3d9838c
Create SplitTest models and migration
zachaysan Jan 3, 2024
73e59fd
Add db schema for FeatureEvaluationRaw
zachaysan Jan 3, 2024
dabb6df
Add split testing to installed apps
zachaysan Jan 3, 2024
eaacb64
Add identifier and typing
zachaysan Jan 3, 2024
acad9e1
Create test_set_sdk_analytics_flags_with_identifier test
zachaysan Jan 3, 2024
ee9ac6c
Add identifier to track
zachaysan Jan 3, 2024
40f59fd
Add split testing routes
zachaysan Jan 3, 2024
c8727ee
Test split testing helpers
zachaysan Jan 3, 2024
8dd9bcc
Create split testing task test
zachaysan Jan 3, 2024
6515e1d
Create tests for split testing views
zachaysan Jan 3, 2024
486c73b
Change name to identity_identifier
zachaysan Jan 8, 2024
b3c9915
Create query serializer and add comment
zachaysan Jan 8, 2024
d031fcf
Remove label since its unnecessary
zachaysan Jan 8, 2024
588b2df
Restructure helpers and add docstrings
zachaysan Jan 8, 2024
88e5d44
Update to identity_identifier
zachaysan Jan 8, 2024
b8c94c5
Minor tweaks to tests with codebase change
zachaysan Jan 8, 2024
1b9be4a
Move class meta to below fields and remove statistic
zachaysan Jan 8, 2024
1b93551
Change to identity identifier and remove Optional
zachaysan Jan 8, 2024
68bb1e8
Add query serializer and avoid 500 error if the environment couldn't …
zachaysan Jan 8, 2024
fc70e00
Create query param serializer
zachaysan Jan 8, 2024
45d9cbb
Switch to bulk update instead of bulk delete and switch to identity i…
zachaysan Jan 8, 2024
08f41fe
Switch to mixins and use query serializer
zachaysan Jan 8, 2024
19f96e5
Fix conflicts and merge branch 'main' into feat/create_split_testing_…
zachaysan Jan 8, 2024
5ecf162
Add query params to mock
zachaysan Jan 9, 2024
930d00f
Add control feature for listing split tests
zachaysan Jan 9, 2024
830f6d8
Add split test creation and update tasks and add feature control for …
zachaysan Jan 9, 2024
6fad377
Make multivariate_feature_option nullable
zachaysan Jan 9, 2024
b53f906
Sort by nulls first on multivariate order by
zachaysan Jan 9, 2024
565e306
Split multivariate feature option into nullable field
zachaysan Jan 9, 2024
ba38b62
Multivariate feature option set to null
zachaysan Jan 9, 2024
713aaec
Split up into multiple tasks
zachaysan Jan 9, 2024
f6aac25
Randomness shows lower values than I expected
zachaysan Jan 9, 2024
0748f03
Add test for enabled when evaluated
zachaysan Jan 12, 2024
fc6cb7e
Update split testing views tests
zachaysan Jan 12, 2024
901121a
Add enabled when evaluated and conversion event types to task tests
zachaysan Jan 12, 2024
04189bf
Add enabled when evaluated to view
zachaysan Jan 12, 2024
a6c80c0
Add enabled when evaluated to track feature evaluation task
zachaysan Jan 12, 2024
3527e36
Add ConversionEventType and associated relations
zachaysan Jan 12, 2024
5c04fa8
Add enabled when evaluated to FeatureEvaluationRaw
zachaysan Jan 12, 2024
6235718
Add ConversionEventType view and add serializer context for split tests
zachaysan Jan 12, 2024
63d677a
Filter for split tests with enabled when evaluated and add CET
zachaysan Jan 12, 2024
7a20869
Add mv/fsv value_data response and add ConversionEventType serializers
zachaysan Jan 12, 2024
b5d8ecc
Change split test permission check and add ConversionEventTypePermiss…
zachaysan Jan 12, 2024
6b30732
Add enabled when evaluated
zachaysan Jan 12, 2024
b13a24f
Add ConversionEventTypeView path url
zachaysan Jan 12, 2024
4306cf7
Fix wording
zachaysan Jan 12, 2024
dcfb77c
Add a test for conversion events that preceed the feature from being …
zachaysan Jan 15, 2024
5906620
Ensure that conversions follow first feature evaluation
zachaysan Jan 15, 2024
6f90d66
Fix conflicts and merge branch 'main' into feat/create_split_testing_…
zachaysan Jan 15, 2024
52e6208
Change test to test for 202 and other minor fixes
zachaysan Jan 18, 2024
6a3fb7b
Switch to CreateAPIView and 202 for default response
zachaysan Jan 18, 2024
f817877
Fix wording
zachaysan Jan 18, 2024
91d21e1
Fix conflicts and merge branch 'main' into feat/create_split_testing_…
zachaysan Jan 18, 2024
554ced3
Tweak wording, typing, and add swagger_auto_schema for response
zachaysan Jan 18, 2024
ff00bca
Remove split testing logic from repo
zachaysan Jan 23, 2024
5d8f0a1
Remove scipy and numpy requirements from repo
zachaysan Jan 23, 2024
7cd1337
Remove split testing urls
zachaysan Jan 23, 2024
727872d
Remove split testing app from INSTALLED_APPS
zachaysan Jan 23, 2024
67dfb73
Attempt #1 to run analytics tasks in CI
zachaysan Jan 23, 2024
ae058d4
Make reason a kwarg
zachaysan Jan 23, 2024
5d87fb7
Add default to test list
zachaysan Jan 23, 2024
4c8c9ac
Fix broken asserts
zachaysan Jan 23, 2024
df5ae38
Try re-enabling postgres for the test since it's on the wrong path
zachaysan Jan 23, 2024
a6c8a56
Trigger Build
zachaysan Jan 23, 2024
97427e6
Trigger build
zachaysan Jan 23, 2024
e47ab72
Trigger build
zachaysan Jan 23, 2024
412e2c7
Add split testing settings
matthewelwell Jan 24, 2024
e916398
Re-add split test views conditionally
zachaysan Jan 24, 2024
63b78c9
Manually fix poetry.lock from main
zachaysan Jan 29, 2024
ab9b35a
Merge branch 'main' into feat/create_split_testing_for_multivariate
zachaysan Jan 29, 2024
2d63586
Switch view tests to v2 endpoint
zachaysan Jan 29, 2024
6db5620
Create v2 urls
zachaysan Jan 29, 2024
dc032da
Add v2 urls to urls
zachaysan Jan 29, 2024
49e7231
Create v2 version of sdk analytics views
zachaysan Jan 29, 2024
80d038e
Create serializers for v2 sdk analytics
zachaysan Jan 29, 2024
e8f7da6
Add track_feature_evaluation_v2 to tasks
zachaysan Jan 29, 2024
2cf853b
Add v2 mirror for influxdb records
zachaysan Jan 29, 2024
006066b
Local test
zachaysan Jan 30, 2024
7edfae6
Update sdk tests with identifier and tasks
zachaysan Jan 31, 2024
bbeb4a2
Required set to False for field
zachaysan Jan 31, 2024
b121ac4
Set to use a task
zachaysan Jan 31, 2024
541850a
Fix HTTP Status value and use tasks for sdk processing with influx
zachaysan Jan 31, 2024
4e39665
Merge branch 'main' into feat/create_split_testing_for_multivariate
zachaysan Jan 31, 2024
1149ae2
Add throttle classes to fix broken tests
zachaysan Jan 31, 2024
e8e8c41
Merge branch 'main' into feat/create_split_testing_for_multivariate
zachaysan Jan 31, 2024
cf10217
Trigger build
zachaysan Jan 31, 2024
7764318
Add v2 endpoint
zachaysan Jan 31, 2024
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
37 changes: 34 additions & 3 deletions api/api/urls/v1.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from app_analytics.views import SDKAnalyticsFlags, SelfHostedTelemetryAPIView
from django.conf import settings
from django.conf.urls import url
from django.urls import include
from django.urls import include, path
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from rest_framework import authentication, permissions, routers
Expand Down Expand Up @@ -47,8 +48,12 @@
url(r"^flags/$", SDKFeatureStates.as_view(), name="flags"),
url(r"^identities/$", SDKIdentities.as_view(), name="sdk-identities"),
url(r"^traits/", include(traits_router.urls), name="traits"),
url(r"^analytics/flags/$", SDKAnalyticsFlags.as_view()),
url(r"^analytics/telemetry/$", SelfHostedTelemetryAPIView.as_view()),
url(r"^analytics/flags/$", SDKAnalyticsFlags.as_view(), name="analytics-flags"),
url(
r"^analytics/telemetry/$",
SelfHostedTelemetryAPIView.as_view(),
name="analytics-telemetry",
),
url(
r"^environment-document/$",
SDKEnvironmentAPIView.as_view(),
Expand All @@ -67,3 +72,29 @@
name="schema-swagger-ui",
),
]

if settings.SPLIT_TESTING_INSTALLED:
from split_testing.views import (
ConversionEventTypeView,
CreateConversionEventView,
SplitTestViewSet,
)

split_testing_router = routers.DefaultRouter()
split_testing_router.register(r"", SplitTestViewSet, basename="split-tests")

urlpatterns += [
url(
r"^split-testing/", include(split_testing_router.urls), name="split-testing"
),
url(
r"^split-testing/conversion-events/",
CreateConversionEventView.as_view(),
name="conversion-events",
),
path(
"conversion_event_types/",
ConversionEventTypeView.as_view(),
name="conversion-event-types",
),
]
8 changes: 8 additions & 0 deletions api/api/urls/v2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from app_analytics.views import SDKAnalyticsFlagsV2
from django.conf.urls import url

app_name = "v2"

urlpatterns = [
url(r"^analytics/flags/$", SDKAnalyticsFlagsV2.as_view(), name="analytics-flags")
]
5 changes: 5 additions & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -1121,3 +1121,8 @@

WEBHOOK_BACKOFF_BASE = env.int("WEBHOOK_BACKOFF_BASE", default=2)
WEBHOOK_BACKOFF_RETRIES = env.int("WEBHOOK_BACKOFF_RETRIES", default=3)

# Split Testing settings
SPLIT_TESTING_INSTALLED = importlib.util.find_spec("split_testing")
if SPLIT_TESTING_INSTALLED:
INSTALLED_APPS += ("split_testing",)
1 change: 1 addition & 0 deletions api/app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
urlpatterns = [
url(r"^api/v1/", include("api.urls.deprecated", namespace="api-deprecated")),
url(r"^api/v1/", include("api.urls.v1", namespace="api-v1")),
url(r"^api/v2/", include("api.urls.v2", namespace="api-v2")),
url(r"^admin/", admin.site.urls),
url(r"^health", include("health_check.urls", namespace="health")),
url(r"^version", views.version_info, name="version-info"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Generated by Django 3.2.23 on 2024-01-02 16:35

from django.db import migrations, models
from core.migration_helpers import PostgresOnlyRunSQL


class Migration(migrations.Migration):

atomic = False

dependencies = [
('app_analytics', '0001_initial'),
]

operations = [
migrations.AddField(
model_name='featureevaluationraw',
name='identity_identifier',
field=models.CharField(default=None, max_length=2000, null=True),
),
migrations.AddField(
model_name='featureevaluationraw',
name='enabled_when_evaluated',
field=models.BooleanField(null=True, default=None),
),
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.AlterField(
model_name='featureevaluationraw',
name='feature_name',
field=models.CharField(db_index=True, max_length=2000),
),
],
database_operations=[
PostgresOnlyRunSQL(
'CREATE INDEX CONCURRENTLY "app_analytics_featureevaluationraw_feature_name_idx" ON "app_analytics_featureevaluationraw" ("feature_name");',
reverse_sql='DROP INDEX CONCURRENTLY "app_analytics_featureevaluationraw_feature_name_idx";',
)
],
),
]
6 changes: 5 additions & 1 deletion api/app_analytics/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,15 @@ def check_overlapping_buckets(self):


class FeatureEvaluationRaw(models.Model):
feature_name = models.CharField(max_length=2000)
feature_name = models.CharField(db_index=True, max_length=2000)
environment_id = models.PositiveIntegerField()
evaluation_count = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)

# Both stored for tracking multivariate split testing.
identity_identifier = models.CharField(max_length=2000, null=True, default=None)
enabled_when_evaluated = models.BooleanField(null=True, default=None)


class FeatureEvaluationBucket(AbstractBucket):
feature_name = models.CharField(max_length=2000)
Expand Down
11 changes: 11 additions & 0 deletions api/app_analytics/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,14 @@ class UsageDataQuerySerializer(serializers.Serializer):

class UsageTotalCountSerializer(serializers.Serializer):
count = serializers.IntegerField()


class SDKAnalyticsFlagsSerializerDetail(serializers.Serializer):
feature_name = serializers.CharField()
identity_identifier = serializers.CharField(required=False, default=None)
enabled_when_evaluated = serializers.BooleanField()
count = serializers.IntegerField()


class SDKAnalyticsFlagsSerializer(serializers.Serializer):
evaluations = SDKAnalyticsFlagsSerializerDetail(many=True)
23 changes: 22 additions & 1 deletion api/app_analytics/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,28 @@ def clean_up_old_analytics_data():


@register_task_handler()
def track_feature_evaluation(environment_id, feature_evaluations):
def track_feature_evaluation_v2(
environment_id: int, feature_evaluations: list[dict[str, int | str | bool]]
) -> None:
feature_evaluation_objects = []
for feature_evaluation in feature_evaluations:
feature_evaluation_objects.append(
FeatureEvaluationRaw(
environment_id=environment_id,
feature_name=feature_evaluation["feature_name"],
evaluation_count=feature_evaluation["count"],
identity_identifier=feature_evaluation["identity_identifier"],
enabled_when_evaluated=feature_evaluation["enabled_when_evaluated"],
)
)
FeatureEvaluationRaw.objects.bulk_create(feature_evaluation_objects)


@register_task_handler()
def track_feature_evaluation(
environment_id: int,
feature_evaluations: dict[str, int],
) -> None:
feature_evaluation_objects = []
for feature_name, evaluation_count in feature_evaluations.items():
feature_evaluation_objects.append(
Expand Down
30 changes: 29 additions & 1 deletion api/app_analytics/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from six.moves.urllib.parse import quote # python 2/3 compatible urllib import

from environments.models import Environment
from task_processor.decorators import register_task_handler
from util.util import postpone

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -125,7 +126,10 @@ def track_request_influxdb(request):
influxdb.write()


def track_feature_evaluation_influxdb(environment_id, feature_evaluations):
@register_task_handler()
def track_feature_evaluation_influxdb(
environment_id: int, feature_evaluations: dict[str, int]
) -> None:
"""
Sends Feature analytics event data to InfluxDB

Expand All @@ -139,3 +143,27 @@ def track_feature_evaluation_influxdb(environment_id, feature_evaluations):
influxdb.add_data_point("request_count", evaluation_count, tags=tags)

influxdb.write()


@register_task_handler()
def track_feature_evaluation_influxdb_v2(
environment_id: int, feature_evaluations: list[dict[str, int | str | bool]]
) -> None:
"""
Sends Feature analytics event data to InfluxDB

:param environment_id: (int) the id of the environment the feature is being evaluated within
:param feature_evaluations: (list) A collection of feature evaluations including feature name / evaluation counts.
"""
influxdb = InfluxDBWrapper("feature_evaluation")

for feature_evaluation in feature_evaluations:
feature_name = feature_evaluation["feature_name"]
evaluation_count = feature_evaluation["count"]

# Note that "feature_id" is a misnamed as it's actually to
# the name of the feature. This was to match existing behavior.
tags = {"feature_id": feature_name, "environment_id": environment_id}
influxdb.add_data_point("request_count", evaluation_count, tags=tags)

influxdb.write()
86 changes: 82 additions & 4 deletions api/app_analytics/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,22 @@
get_total_events_count,
get_usage_data,
)
from app_analytics.tasks import track_feature_evaluation
from app_analytics.track import track_feature_evaluation_influxdb
from app_analytics.tasks import (
track_feature_evaluation,
track_feature_evaluation_v2,
)
from app_analytics.track import (
track_feature_evaluation_influxdb,
track_feature_evaluation_influxdb_v2,
)
from django.conf import settings
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.fields import IntegerField
from rest_framework.generics import CreateAPIView, GenericAPIView
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 telemetry.serializers import TelemetrySerializer
Expand All @@ -24,6 +31,7 @@

from .permissions import UsageDataPermission
from .serializers import (
SDKAnalyticsFlagsSerializer,
UsageDataQuerySerializer,
UsageDataSerializer,
UsageTotalCountSerializer,
Expand All @@ -32,6 +40,61 @@
logger = logging.getLogger(__name__)


class SDKAnalyticsFlagsV2(CreateAPIView):
permission_classes = (EnvironmentKeyPermissions,)
authentication_classes = (EnvironmentKeyAuthentication,)
serializer_class = SDKAnalyticsFlagsSerializer
throttle_classes = []

def create(self, request: Request, *args, **kwargs) -> Response:
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)

self.evaluations = serializer.validated_data["evaluations"]
if not self._is_data_valid():
return Response(
{"detail": "Invalid feature names associated with the project."},
content_type="application/json",
status=status.HTTP_400_BAD_REQUEST,
)
if settings.USE_POSTGRES_FOR_ANALYTICS:
track_feature_evaluation_v2.delay(
args=(
request.environment.id,
self.evaluations,
)
)
elif settings.INFLUXDB_TOKEN:
track_feature_evaluation_influxdb_v2.delay(
args=(
request.environment.id,
self.evaluations,
)
)

return Response(status=status.HTTP_204_NO_CONTENT)

def _is_data_valid(self) -> bool:
environment_feature_names = set(
FeatureState.objects.filter(
environment=self.request.environment,
feature_segment=None,
identity=None,
).values_list("feature__name", flat=True)
)

valid = True
for evaluation in self.evaluations:
if evaluation["feature_name"] in environment_feature_names:
continue
logger.warning(
f"Feature {evaluation['feature_name']} does not belong to project"
)
valid = False

return valid


class SDKAnalyticsFlags(GenericAPIView):
"""
Class to handle flag analytics events
Expand Down Expand Up @@ -65,6 +128,10 @@ def get_fields(self):
def post(self, request, *args, **kwargs):
"""
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.
"""
is_valid = self._is_data_valid()
if not is_valid:
Expand All @@ -74,10 +141,21 @@ def post(self, request, *args, **kwargs):
content_type="application/json",
status=status.HTTP_200_OK,
)

if settings.USE_POSTGRES_FOR_ANALYTICS:
track_feature_evaluation.delay(args=(request.environment.id, request.data))
track_feature_evaluation.delay(
args=(
request.environment.id,
request.data,
)
)
elif settings.INFLUXDB_TOKEN:
track_feature_evaluation_influxdb(request.environment.id, request.data)
track_feature_evaluation_influxdb.delay(
args=(
request.environment.id,
request.data,
)
)

return Response(status=status.HTTP_200_OK)

Expand Down
1 change: 1 addition & 0 deletions api/integrations/sentry/samplers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"/api/v1/traits/bulk",
"/api/v1/environment-document",
"/api/v1/analytics/flags",
"/api/v2/analytics/flags",
}


Expand Down
2 changes: 1 addition & 1 deletion api/tests/unit/app_analytics/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

if "analytics" not in settings.DATABASES:
pytest.skip(
"Skip test if analytics database is configured", allow_module_level=True
"Skip test if analytics database is not configured", allow_module_level=True
)


Expand Down
Loading
Loading