Skip to content

Commit

Permalink
feat: add experiments and flags product intents (#26661)
Browse files Browse the repository at this point in the history
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
raquelmsmith and github-actions[bot] authored Dec 7, 2024
1 parent 89352a8 commit 5764870
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 16 deletions.
9 changes: 8 additions & 1 deletion frontend/src/scenes/experiments/experimentLogic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { cleanFilters, getDefaultEvent } from 'scenes/insights/utils/cleanFilter
import { projectLogic } from 'scenes/projectLogic'
import { sceneLogic } from 'scenes/sceneLogic'
import { Scene } from 'scenes/sceneTypes'
import { teamLogic } from 'scenes/teamLogic'
import { trendsDataLogic } from 'scenes/trends/trendsDataLogic'
import { urls } from 'scenes/urls'

Expand Down Expand Up @@ -51,6 +52,7 @@ import {
FunnelVizType,
InsightType,
MultivariateFlagVariant,
ProductKey,
PropertyMathType,
SecondaryMetricResults,
SignificanceCode,
Expand Down Expand Up @@ -164,6 +166,8 @@ export const experimentLogic = kea<experimentLogicType>([
'reportExperimentReleaseConditionsViewed',
'reportExperimentHoldoutAssigned',
],
teamLogic,
['addProductIntent'],
],
})),
actions({
Expand Down Expand Up @@ -534,7 +538,10 @@ export const experimentLogic = kea<experimentLogicType>([
},
...(!draft && { start_date: dayjs() }),
})
response && actions.reportExperimentCreated(response)
if (response) {
actions.reportExperimentCreated(response)
actions.addProductIntent({ product_type: ProductKey.EXPERIMENTS })
}
}
} catch (error: any) {
lemonToast.error(error.detail || 'Failed to create experiment')
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/scenes/feature-flags/featureFlagLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
MultivariateFlagVariant,
NewEarlyAccessFeatureType,
OrganizationFeatureFlag,
ProductKey,
PropertyFilterType,
PropertyOperator,
QueryBasedInsightModel,
Expand Down Expand Up @@ -269,6 +270,8 @@ export const featureFlagLogic = kea<featureFlagLogicType>([
['updateFlag', 'deleteFlag'],
sidePanelStateLogic,
['closeSidePanel'],
teamLogic,
['addProductIntent'],
],
})),
actions({
Expand Down Expand Up @@ -562,6 +565,7 @@ export const featureFlagLogic = kea<featureFlagLogicType>([
if (values.roleBasedAccessEnabled && savedFlag.id) {
featureFlagPermissionsLogic({ flagId: null })?.actions.addAssociatedRoles(savedFlag.id)
}
actions.addProductIntent({ product_type: ProductKey.FEATURE_FLAGS })
} else {
savedFlag = await api.update(
`api/projects/${values.currentProjectId}/feature_flags/${updatedFlag.id}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,8 @@ export const pipelineDestinationsLogic = kea<pipelineDestinationsLogicType>([
deleteNode: ({ destination }) => {
switch (destination.backend) {
case PipelineBackend.Plugin:
actions.deleteNodeWebhook(destination as WebhookDestination)
// @ts-expect-error - type coercion is ignored, siteApps are just missing the interval prop
actions.deleteNodeWebhook(destination)
break
case PipelineBackend.BatchExport:
actions.deleteNodeBatchExport(destination)
Expand Down
22 changes: 16 additions & 6 deletions posthog/api/team.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@
load_activity,
log_activity,
)
from posthog.rbac.access_control_api_mixin import AccessControlViewSetMixin
from posthog.rbac.user_access_control import UserAccessControlSerializerMixin
from posthog.models.activity_logging.activity_page import activity_page_response
from posthog.models.async_deletion import AsyncDeletion, DeletionType
from posthog.models.group_type_mapping import GroupTypeMapping
Expand All @@ -38,14 +36,16 @@
from posthog.models.utils import UUIDT
from posthog.permissions import (
CREATE_ACTIONS,
APIScopePermission,
AccessControlPermission,
APIScopePermission,
OrganizationAdminWritePermissions,
OrganizationMemberPermissions,
TeamMemberLightManagementPermission,
TeamMemberStrictManagementPermission,
get_organization_from_view,
)
from posthog.rbac.access_control_api_mixin import AccessControlViewSetMixin
from posthog.rbac.user_access_control import UserAccessControlSerializerMixin
from posthog.user_permissions import UserPermissions, UserPermissionsSerializerMixin
from posthog.utils import (
get_instance_realm,
Expand Down Expand Up @@ -610,18 +610,28 @@ def add_product_intent(self, request: request.Request, *args, **kwargs):
product_type = request.data.get("product_type")
current_url = request.headers.get("Referer")
session_id = request.headers.get("X-Posthog-Session-Id")
should_report_product_intent = False

if not product_type:
return response.Response({"error": "product_type is required"}, status=400)

product_intent, created = ProductIntent.objects.get_or_create(team=team, product_type=product_type)
if not created:

if created:
# For new intents, check activation immediately but skip reporting
was_already_activated = product_intent.check_and_update_activation(skip_reporting=True)
# Only report the action if they haven't already activated
if isinstance(user, User) and not was_already_activated:
should_report_product_intent = True
else:
if not product_intent.activated_at:
product_intent.check_and_update_activation()
is_activated = product_intent.check_and_update_activation()
if not is_activated:
should_report_product_intent = True
product_intent.updated_at = datetime.now(tz=UTC)
product_intent.save()

if isinstance(user, User) and not product_intent.activated_at:
if should_report_product_intent and isinstance(user, User):
report_user_action(
user,
"user showed product intent",
Expand Down
6 changes: 5 additions & 1 deletion posthog/api/test/test_team.py
Original file line number Diff line number Diff line change
Expand Up @@ -1073,7 +1073,7 @@ def test_can_add_product_intent(
)

@patch("posthog.api.team.calculate_product_activation.delay", MagicMock())
@patch("posthog.models.product_intent.ProductIntent.check_and_update_activation")
@patch("posthog.models.product_intent.ProductIntent.check_and_update_activation", return_value=False)
@patch("posthog.api.project.report_user_action")
@patch("posthog.api.team.report_user_action")
@freeze_time("2024-01-01T00:00:00Z")
Expand All @@ -1083,6 +1083,10 @@ def test_can_update_product_intent_if_already_exists(
mock_report_user_action_legacy_endpoint: MagicMock,
mock_check_and_update_activation: MagicMock,
) -> None:
"""
Intent already exists, but hasn't been activated yet. It should update the intent
and send a new event for the user showing the intent.
"""
intent = ProductIntent.objects.create(team=self.team, product_type="product_analytics")
original_created_at = intent.created_at
assert original_created_at == datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
Expand Down
57 changes: 51 additions & 6 deletions posthog/models/product_intent/product_intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
from celery import shared_task
from django.db import models

from posthog.models.experiment import Experiment
from posthog.models.feature_flag.feature_flag import FeatureFlag
from posthog.models.feedback.survey import Survey
from posthog.models.insight import Insight
from posthog.models.team.team import Team
from posthog.models.utils import UUIDModel
Expand Down Expand Up @@ -30,6 +33,10 @@
about product usage because that limits our data exploration later. Definitely continue
sending events for product usage that we may want to track for any reason, along with
calculating activation here.
Note: single-event activation metrics that can also happen at the same time the intent
is created won't have tracking events sent for them. Unless you want to solve this,
make activation metrics require multiple things to happen.
"""


Expand Down Expand Up @@ -74,12 +81,50 @@ def has_activated_data_warehouse(self) -> bool:

return False

def check_and_update_activation(self) -> None:
if self.product_type == "data_warehouse":
if self.has_activated_data_warehouse():
self.activated_at = datetime.now(tz=UTC)
self.save()
self.report_activation("data_warehouse")
def has_activated_experiments(self) -> bool:
# the team has any launched experiments
return Experiment.objects.filter(team=self.team, start_date__isnull=False).exists()

def has_activated_feature_flags(self) -> bool:
# Get feature flags that have at least one filter group, excluding ones used by experiments and surveys
experiment_flags = Experiment.objects.filter(team=self.team).values_list("feature_flag_id", flat=True)
survey_flags = Survey.objects.filter(team=self.team).values_list("targeting_flag_id", flat=True)

feature_flags = (
FeatureFlag.objects.filter(
team=self.team,
filters__groups__0__properties__isnull=False,
)
.exclude(id__in=experiment_flags)
.exclude(id__in=survey_flags)
.only("id", "filters")
)

# To activate we need at least 2 feature flags
if feature_flags.count() < 2:
return False

# To activate we need at least 2 filter groups across all flags
total_groups = 0
for flag in feature_flags:
total_groups += len(flag.filters.get("groups", []))

return total_groups >= 2

def check_and_update_activation(self, skip_reporting: bool = False) -> bool:
activation_checks = {
"data_warehouse": self.has_activated_data_warehouse,
"experiments": self.has_activated_experiments,
"feature_flags": self.has_activated_feature_flags,
}

if self.product_type in activation_checks and activation_checks[self.product_type]():
self.activated_at = datetime.now(tz=UTC)
self.save()
if not skip_reporting:
self.report_activation(self.product_type)
return True
return False

def report_activation(self, product_key: str) -> None:
from posthog.event_usage import report_team_action
Expand Down
79 changes: 78 additions & 1 deletion posthog/models/test/test_product_intent.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from datetime import datetime, timedelta, UTC
from datetime import UTC, datetime, timedelta

import pytest
from freezegun import freeze_time

from posthog.models.experiment import Experiment
from posthog.models.feature_flag import FeatureFlag
from posthog.models.feedback.survey import Survey
from posthog.models.insight import Insight
from posthog.models.product_intent.product_intent import (
ProductIntent,
Expand Down Expand Up @@ -93,3 +96,77 @@ def test_calculate_product_activation_skips_activated_products(self):
calculate_product_activation(self.team.id)
self.product_intent.refresh_from_db()
assert self.product_intent.activated_at == datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)

def test_has_activated_experiments_with_launched_experiment(self):
self.product_intent.product_type = "experiments"
self.product_intent.save()

# Create a feature flag for the experiment
feature_flag = FeatureFlag.objects.create(
team=self.team,
key="test-flag",
name="Test Flag",
filters={"groups": [{"properties": []}]},
)

# Create an experiment without a start date (not launched)
Experiment.objects.create(team=self.team, name="Not launched", feature_flag=feature_flag)
self.assertFalse(self.product_intent.has_activated_experiments())

# Create another feature flag for the launched experiment
launched_flag = FeatureFlag.objects.create(
team=self.team,
key="launched-flag",
name="Launched Flag",
filters={"groups": [{"properties": []}]},
)

# Create an experiment with a start date (launched)
Experiment.objects.create(
team=self.team, name="Launched", start_date=datetime.now(tz=UTC), feature_flag=launched_flag
)
self.assertTrue(self.product_intent.has_activated_experiments())

def test_has_activated_feature_flags(self):
self.product_intent.product_type = "feature_flags"
self.product_intent.save()

# Create a feature flag with one filter group
FeatureFlag.objects.create(
team=self.team,
key="flag-1",
name="Flag 1",
filters={"groups": [{"properties": [{"key": "email", "value": "[email protected]"}]}]},
)
self.assertFalse(self.product_intent.has_activated_feature_flags())

# Create a feature flag with another filter group
FeatureFlag.objects.create(
team=self.team,
key="flag-2",
name="Flag 2",
filters={"groups": [{"properties": [{"key": "country", "value": "US"}]}]},
)
self.assertTrue(self.product_intent.has_activated_feature_flags())

def test_has_activated_feature_flags_excludes_experiment_and_survey_flags(self):
self.product_intent.product_type = "feature_flags"
self.product_intent.save()

# Create excluded feature flags
feature_flag = FeatureFlag.objects.create(
team=self.team,
key="feature-flag-for-experiment-test",
name="Feature Flag for Experiment Test",
filters={"groups": [{"properties": [{"key": "email", "value": "[email protected]"}]}]},
)
Experiment.objects.create(team=self.team, name="Experiment Test", feature_flag=feature_flag)
survey_flag = FeatureFlag.objects.create(
team=self.team,
key="targeting-flag-for-survey-test",
name="Targeting flag for survey Test",
filters={"groups": [{"properties": [{"key": "country", "value": "US"}]}]},
)
Survey.objects.create(team=self.team, name="Survey Test", targeting_flag=survey_flag)

self.assertFalse(self.product_intent.has_activated_feature_flags())

0 comments on commit 5764870

Please sign in to comment.