Skip to content

Commit

Permalink
feat(experiments): API to manage web_experiments for no-code experime…
Browse files Browse the repository at this point in the history
…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
Phanatic and github-actions[bot] authored Sep 12, 2024
1 parent e768f2b commit 2c9e1db
Show file tree
Hide file tree
Showing 13 changed files with 324 additions and 4 deletions.
2 changes: 1 addition & 1 deletion latest_migrations.manifest
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ contenttypes: 0002_remove_content_type_name
ee: 0016_rolemembership_organization_member
otp_static: 0002_throttling
otp_totp: 0002_auto_20190420_0723
posthog: 0470_integration_google_cloud_storage
posthog: 0471_webexperiment_experiment_type_experiment_variants
sessions: 0001_initial
social_django: 0010_uid_db_index
two_factor: 0007_auto_20201201_1019
2 changes: 2 additions & 0 deletions posthog/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ def register_grandfathered_environment_nested_viewset(

from posthog.api.action import ActionViewSet # noqa: E402
from posthog.api.cohort import CohortViewSet, LegacyCohortViewSet # noqa: E402
from posthog.api.web_experiment import WebExperimentViewSet # noqa: E402
from posthog.api.element import ElementViewSet, LegacyElementViewSet # noqa: E402
from posthog.api.event import EventViewSet, LegacyEventViewSet # noqa: E402
from posthog.api.insight import InsightViewSet # noqa: E402
Expand All @@ -388,6 +389,7 @@ def register_grandfathered_environment_nested_viewset(
# Nested endpoints CH
register_grandfathered_environment_nested_viewset(r"events", EventViewSet, "environment_events", ["team_id"])
projects_router.register(r"actions", ActionViewSet, "project_actions", ["project_id"])
projects_router.register(r"web_experiments", WebExperimentViewSet, "web_experiments", ["project_id"])
projects_router.register(r"cohorts", CohortViewSet, "project_cohorts", ["project_id"])
register_grandfathered_environment_nested_viewset(
r"elements",
Expand Down
1 change: 1 addition & 0 deletions posthog/api/test/__snapshots__/test_api_docs.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
'/home/runner/work/posthog/posthog/ee/session_recordings/session_recording_playlist.py: Warning [SessionRecordingPlaylistViewSet]: could not derive type of path parameter "session_recording_id" because model "posthog.session_recordings.models.session_recording_playlist.SessionRecordingPlaylist" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/posthog/api/survey.py: Warning [SurveyViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.feedback.survey.Survey" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/posthog/api/survey.py: Warning [SurveyViewSet > SurveySerializer]: unable to resolve type hint for function "get_conditions". Consider using a type hint or @extend_schema_field. Defaulting to string.',
'/home/runner/work/posthog/posthog/posthog/api/web_experiment.py: Warning [WebExperimentViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.web_experiment.WebExperiment" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
'Warning: encountered multiple names for the same choice set (HrefMatchingEnum). This may be unwanted even though the generated schema is technically correct. Add an entry to ENUM_NAME_OVERRIDES to fix the naming.',
'Warning: encountered multiple names for the same choice set (EffectivePrivilegeLevelEnum). This may be unwanted even though the generated schema is technically correct. Add an entry to ENUM_NAME_OVERRIDES to fix the naming.',
'Warning: encountered multiple names for the same choice set (MembershipLevelEnum). This may be unwanted even though the generated schema is technically correct. Add an entry to ENUM_NAME_OVERRIDES to fix the naming.',
Expand Down
4 changes: 3 additions & 1 deletion posthog/api/test/__snapshots__/test_feature_flag.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -1783,7 +1783,9 @@
"posthog_experiment"."end_date",
"posthog_experiment"."created_at",
"posthog_experiment"."updated_at",
"posthog_experiment"."archived"
"posthog_experiment"."archived",
"posthog_experiment"."type",
"posthog_experiment"."variants"
FROM "posthog_experiment"
WHERE "posthog_experiment"."exposure_cohort_id" = 2
'''
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1033,7 +1033,9 @@
"posthog_experiment"."end_date",
"posthog_experiment"."created_at",
"posthog_experiment"."updated_at",
"posthog_experiment"."archived"
"posthog_experiment"."archived",
"posthog_experiment"."type",
"posthog_experiment"."variants"
FROM "posthog_experiment"
WHERE "posthog_experiment"."feature_flag_id" = 2
'''
Expand Down
63 changes: 63 additions & 0 deletions posthog/api/test/test_web_experiment.py
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
189 changes: 189 additions & 0 deletions posthog/api/web_experiment.py
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}))
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),
),
]
1 change: 1 addition & 0 deletions posthog/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from .event_definition import EventDefinition
from .event_property import EventProperty
from .experiment import Experiment
from .web_experiment import WebExperiment
from .exported_asset import ExportedAsset
from .feature_flag import FeatureFlag
from .feedback.survey import Survey
Expand Down
6 changes: 6 additions & 0 deletions posthog/models/experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@


class Experiment(models.Model):
class ExperimentType(models.TextChoices):
WEB = "web", "web"
PRODUCT = "product", "product"

name = models.CharField(max_length=400)
description = models.CharField(max_length=400, null=True, blank=True)
team = models.ForeignKey("Team", on_delete=models.CASCADE)
Expand Down Expand Up @@ -31,6 +35,8 @@ class Experiment(models.Model):
created_at = models.DateTimeField(default=timezone.now)
updated_at = models.DateTimeField(auto_now=True)
archived = models.BooleanField(default=False)
type = models.CharField(max_length=40, choices=ExperimentType.choices, null=True, blank=True, default="product")
variants = models.JSONField(default=dict, null=True, blank=True)

def get_feature_flag_key(self):
return self.feature_flag.key
Expand Down
14 changes: 14 additions & 0 deletions posthog/models/web_experiment.py
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
2 changes: 1 addition & 1 deletion posthog/settings/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@
LOGOUT_URL = "/logout"
LOGIN_REDIRECT_URL = "/"
APPEND_SLASH = False
CORS_URLS_REGEX = r"^/api/(?!early_access_features|surveys).*$"
CORS_URLS_REGEX = r"^/api/(?!early_access_features|surveys|web_experiments).*$"
CORS_ALLOW_HEADERS = default_headers + CORS_ALLOWED_TRACING_HEADERS
X_FRAME_OPTIONS = "SAMEORIGIN"

Expand Down
Loading

0 comments on commit 2c9e1db

Please sign in to comment.