-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(experiments): API to manage web_experiments for no-code experime…
…nts (#24872) This PR adds a new endpoint that provides the ability to retrieve/create/edit and delete web experiments from the Toolbar. The GET endpoint is used by a new component in posthog-js (PR incoming) which will apply the transforms to a page. Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
- Loading branch information
1 parent
e768f2b
commit 2c9e1db
Showing
13 changed files
with
324 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
from rest_framework import status | ||
|
||
from posthog.models import WebExperiment | ||
from posthog.test.base import APIBaseTest | ||
|
||
|
||
class TestWebExperiment(APIBaseTest): | ||
def _create_web_experiment(self): | ||
return self.client.post( | ||
f"/api/projects/{self.team.id}/web_experiments/", | ||
data={ | ||
"name": "Zero to Web Experiment", | ||
"variants": { | ||
"control": { | ||
"transforms": [ | ||
{"html": "", "text": "There goes Superman!", "selector": "#page > #body > .header h1"} | ||
], | ||
"rollout_percentage": 70, | ||
}, | ||
"test": { | ||
"transforms": [ | ||
{"html": "", "text": "Up, UP and Away!", "selector": "#page > #body > .header h1"} | ||
], | ||
"rollout_percentage": 30, | ||
}, | ||
}, | ||
}, | ||
format="json", | ||
) | ||
|
||
def test_can_create_basic_web_experiment(self): | ||
response = self._create_web_experiment() | ||
response_data = response.json() | ||
assert response.status_code == status.HTTP_201_CREATED, response_data | ||
id = response_data["id"] | ||
web_experiment = WebExperiment.objects.get(id=id) | ||
assert web_experiment is not None | ||
linked_flag = web_experiment.feature_flag | ||
assert linked_flag is not None | ||
assert linked_flag.filters is not None | ||
multivariate = linked_flag.filters.get("multivariate", None) | ||
assert multivariate is not None | ||
variants = multivariate.get("variants", None) | ||
assert variants is not None | ||
assert variants[0].get("key") == "control" | ||
assert variants[0].get("rollout_percentage") == 70 | ||
assert variants[1].get("key") == "test" | ||
assert variants[1].get("rollout_percentage") == 30 | ||
|
||
assert web_experiment.variants is not None | ||
assert web_experiment.type == "web" | ||
assert web_experiment.variants.get("control") is not None | ||
assert web_experiment.variants.get("test") is not None | ||
|
||
def test_can_delete_web_experiment(self): | ||
response = self._create_web_experiment() | ||
response_data = response.json() | ||
assert response.status_code == status.HTTP_201_CREATED, response_data | ||
experiment_id = response_data["id"] | ||
assert WebExperiment.objects.filter(id=experiment_id).exists() | ||
del_response = self.client.delete(f"/api/projects/{self.team.id}/web_experiments/{experiment_id}") | ||
assert del_response.status_code == status.HTTP_204_NO_CONTENT | ||
assert WebExperiment.objects.filter(id=experiment_id).exists() is False |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,189 @@ | ||
from typing import Any | ||
from django.http import HttpResponse, JsonResponse | ||
from rest_framework import status, serializers, viewsets | ||
from rest_framework.decorators import action | ||
from rest_framework.exceptions import ValidationError | ||
from rest_framework.request import Request | ||
|
||
from posthog.api.feature_flag import FeatureFlagSerializer | ||
from posthog.api.routing import TeamAndOrgViewSetMixin | ||
from posthog.api.utils import get_token | ||
from django.views.decorators.csrf import csrf_exempt | ||
from posthog.auth import ( | ||
TemporaryTokenAuthentication, | ||
) | ||
from posthog.exceptions import generate_exception_response | ||
from posthog.models import Team, WebExperiment | ||
from posthog.utils_cors import cors_response | ||
|
||
|
||
class WebExperimentsAPISerializer(serializers.ModelSerializer): | ||
""" | ||
Serializer for the exposed /api/web_experiments endpoint, to be used in posthog-js and for headless APIs. | ||
""" | ||
|
||
feature_flag_key = serializers.CharField(source="feature_flag.key", read_only=True) | ||
|
||
class Meta: | ||
model = WebExperiment | ||
fields = ["id", "name", "feature_flag_key", "variants"] | ||
|
||
# Validates that the `variants` property in the request follows this known object format. | ||
# { | ||
# "name": "create-params-debug", | ||
# "variants": { | ||
# "control": { | ||
# "transforms": [ | ||
# { | ||
# "text": "Here comes Superman!", | ||
# "html": "", | ||
# "selector": "#page > #body > .header h1" | ||
# } | ||
# ], | ||
# "conditions": "None", | ||
# "rollout_percentage": 50 | ||
# }, | ||
# } | ||
# } | ||
def validate(self, attrs): | ||
variants = attrs.get("variants") | ||
if variants is None: | ||
raise ValidationError("Experiment does not have any variants") | ||
if variants and not isinstance(variants, dict): | ||
raise ValidationError("Experiment variants should be a dictionary of keys -> transforms") | ||
if "control" not in variants: | ||
raise ValidationError("Experiment should contain a control variant") | ||
for name, variant in variants.items(): | ||
if variant.get("rollout_percentage") is None: | ||
raise ValidationError(f"Experiment variant '{name}' does not have any rollout percentage") | ||
transforms = variant.get("transforms") | ||
if transforms is None: | ||
raise ValidationError(f"Experiment variant '{name}' does not have any element transforms") | ||
for idx, transform in enumerate(transforms): | ||
if transform.get("selector") is None: | ||
raise ValidationError( | ||
f"Experiment transform [${idx}] variant '{name}' does not have a valid selector" | ||
) | ||
|
||
return attrs | ||
|
||
def create(self, validated_data: dict[str, Any]) -> WebExperiment: | ||
create_params = { | ||
"name": validated_data.get("name", ""), | ||
"description": "", | ||
"type": "web", | ||
"variants": validated_data.get("variants", None), | ||
"filters": { | ||
"events": [{"type": "events", "id": "$pageview", "order": 0, "name": "$pageview"}], | ||
"layout": "horizontal", | ||
"date_to": "2024-09-05T23:59", | ||
"insight": "FUNNELS", | ||
"interval": "day", | ||
"date_from": "2024-08-22T10:44", | ||
"entity_type": "events", | ||
"funnel_viz_type": "steps", | ||
"filter_test_accounts": True, | ||
}, | ||
} | ||
|
||
filters = { | ||
"groups": [{"properties": [], "rollout_percentage": 100}], | ||
"multivariate": self.get_variants_for_feature_flag(validated_data), | ||
} | ||
|
||
feature_flag_serializer = FeatureFlagSerializer( | ||
data={ | ||
"key": self.get_feature_flag_name(validated_data.get("name", "")), | ||
"name": f'Feature Flag for Experiment {validated_data["name"]}', | ||
"filters": filters, | ||
"active": False, | ||
}, | ||
context=self.context, | ||
) | ||
|
||
feature_flag_serializer.is_valid(raise_exception=True) | ||
feature_flag = feature_flag_serializer.save() | ||
|
||
experiment = WebExperiment.objects.create( | ||
team_id=self.context["team_id"], feature_flag=feature_flag, **create_params | ||
) | ||
return experiment | ||
|
||
def update(self, instance: WebExperiment, validated_data: dict[str, Any]) -> WebExperiment: | ||
variants = validated_data.get("variants", None) | ||
if variants is not None and isinstance(variants, dict): | ||
feature_flag = instance.feature_flag | ||
filters = { | ||
"groups": feature_flag.filters.get("groups", None), | ||
"multivariate": self.get_variants_for_feature_flag(validated_data), | ||
} | ||
|
||
existing_flag_serializer = FeatureFlagSerializer( | ||
feature_flag, | ||
data={"filters": filters}, | ||
partial=True, | ||
context=self.context, | ||
) | ||
existing_flag_serializer.is_valid(raise_exception=True) | ||
existing_flag_serializer.save() | ||
|
||
instance = super().update(instance, validated_data) | ||
return instance | ||
|
||
def get_variants_for_feature_flag(self, validated_data: dict[str, Any]): | ||
variant_names = [] | ||
variants = validated_data.get("variants", None) | ||
if variants is not None and isinstance(variants, dict): | ||
for variant, transforms in variants.items(): | ||
variant_names.append({"key": variant, "rollout_percentage": transforms.get("rollout_percentage", 0)}) | ||
return {"variants": variant_names} | ||
|
||
def get_feature_flag_name(self, experiment_name: str) -> str: | ||
return experiment_name.replace(" ", "-").lower() + "-web-experiment-feature" | ||
|
||
|
||
class WebExperimentViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): | ||
scope_object = "experiment" | ||
serializer_class = WebExperimentsAPISerializer | ||
authentication_classes = [TemporaryTokenAuthentication] | ||
queryset = WebExperiment.objects.select_related("feature_flag").all() | ||
|
||
|
||
@csrf_exempt | ||
@action(methods=["GET"], detail=True) | ||
def web_experiments(request: Request): | ||
token = get_token(None, request) | ||
if request.method == "OPTIONS": | ||
return cors_response(request, HttpResponse("")) | ||
if not token: | ||
return cors_response( | ||
request, | ||
generate_exception_response( | ||
"experiments", | ||
"API key not provided. You can find your project API key in your PostHog project settings.", | ||
type="authentication_error", | ||
code="missing_api_key", | ||
status_code=status.HTTP_401_UNAUTHORIZED, | ||
), | ||
) | ||
|
||
if request.method == "GET": | ||
team = Team.objects.get_team_from_cache_or_token(token) | ||
if team is None: | ||
return cors_response( | ||
request, | ||
generate_exception_response( | ||
"experiments", | ||
"Project API key invalid. You can find your project API key in your PostHog project settings.", | ||
type="authentication_error", | ||
code="invalid_api_key", | ||
status_code=status.HTTP_401_UNAUTHORIZED, | ||
), | ||
) | ||
|
||
result = WebExperimentsAPISerializer( | ||
WebExperiment.objects.filter(team_id=team.id).exclude(archived=True).select_related("feature_flag"), | ||
many=True, | ||
).data | ||
|
||
return cors_response(request, JsonResponse({"experiments": result})) |
38 changes: 38 additions & 0 deletions
38
posthog/migrations/0471_webexperiment_experiment_type_experiment_variants.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
# Generated by Django 4.2.15 on 2024-09-12 16:21 | ||
|
||
from django.db import migrations, models | ||
|
||
|
||
class Migration(migrations.Migration): | ||
dependencies = [ | ||
("posthog", "0470_integration_google_cloud_storage"), | ||
] | ||
|
||
operations = [ | ||
migrations.CreateModel( | ||
name="WebExperiment", | ||
fields=[], | ||
options={ | ||
"proxy": True, | ||
"indexes": [], | ||
"constraints": [], | ||
}, | ||
bases=("posthog.experiment",), | ||
), | ||
migrations.AddField( | ||
model_name="experiment", | ||
name="type", | ||
field=models.CharField( | ||
blank=True, | ||
choices=[("web", "web"), ("product", "product")], | ||
default="product", | ||
max_length=40, | ||
null=True, | ||
), | ||
), | ||
migrations.AddField( | ||
model_name="experiment", | ||
name="variants", | ||
field=models.JSONField(blank=True, default=dict, null=True), | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
from django.db import models | ||
from posthog.models import Experiment | ||
|
||
|
||
class WebExperimentManager(models.Manager): | ||
def get_queryset(self): | ||
return super().get_queryset().filter(type="web") | ||
|
||
|
||
class WebExperiment(Experiment): | ||
objects = WebExperimentManager() # type: ignore | ||
|
||
class Meta: | ||
proxy = True |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.