diff --git a/frontend/src/scenes/experiments/experimentLogic.tsx b/frontend/src/scenes/experiments/experimentLogic.tsx index f7340dc2a9aae..6f81ec5f96046 100644 --- a/frontend/src/scenes/experiments/experimentLogic.tsx +++ b/frontend/src/scenes/experiments/experimentLogic.tsx @@ -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' @@ -51,6 +52,7 @@ import { FunnelVizType, InsightType, MultivariateFlagVariant, + ProductKey, PropertyMathType, SecondaryMetricResults, SignificanceCode, @@ -164,6 +166,8 @@ export const experimentLogic = kea([ 'reportExperimentReleaseConditionsViewed', 'reportExperimentHoldoutAssigned', ], + teamLogic, + ['addProductIntent'], ], })), actions({ @@ -534,7 +538,10 @@ export const experimentLogic = kea([ }, ...(!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') diff --git a/frontend/src/scenes/feature-flags/featureFlagLogic.ts b/frontend/src/scenes/feature-flags/featureFlagLogic.ts index 3f73931970c85..3b4f69787fc40 100644 --- a/frontend/src/scenes/feature-flags/featureFlagLogic.ts +++ b/frontend/src/scenes/feature-flags/featureFlagLogic.ts @@ -43,6 +43,7 @@ import { MultivariateFlagVariant, NewEarlyAccessFeatureType, OrganizationFeatureFlag, + ProductKey, PropertyFilterType, PropertyOperator, QueryBasedInsightModel, @@ -269,6 +270,8 @@ export const featureFlagLogic = kea([ ['updateFlag', 'deleteFlag'], sidePanelStateLogic, ['closeSidePanel'], + teamLogic, + ['addProductIntent'], ], })), actions({ @@ -562,6 +565,7 @@ export const featureFlagLogic = kea([ 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}`, diff --git a/frontend/src/scenes/pipeline/destinations/destinationsLogic.tsx b/frontend/src/scenes/pipeline/destinations/destinationsLogic.tsx index bed880c886729..4630e26009cf0 100644 --- a/frontend/src/scenes/pipeline/destinations/destinationsLogic.tsx +++ b/frontend/src/scenes/pipeline/destinations/destinationsLogic.tsx @@ -320,7 +320,8 @@ export const pipelineDestinationsLogic = kea([ 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) diff --git a/posthog/api/team.py b/posthog/api/team.py index b473fc490ec7e..c8ca32abe5ce0 100644 --- a/posthog/api/team.py +++ b/posthog/api/team.py @@ -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 @@ -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, @@ -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", diff --git a/posthog/api/test/test_team.py b/posthog/api/test/test_team.py index 0e40b6a595d36..1da488504f57d 100644 --- a/posthog/api/test/test_team.py +++ b/posthog/api/test/test_team.py @@ -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") @@ -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) diff --git a/posthog/models/product_intent/product_intent.py b/posthog/models/product_intent/product_intent.py index 85ec938eb1129..ac307830aae8e 100644 --- a/posthog/models/product_intent/product_intent.py +++ b/posthog/models/product_intent/product_intent.py @@ -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 @@ -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. """ @@ -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 diff --git a/posthog/models/test/test_product_intent.py b/posthog/models/test/test_product_intent.py index 81b05f2326921..469f8d4845567 100644 --- a/posthog/models/test/test_product_intent.py +++ b/posthog/models/test/test_product_intent.py @@ -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, @@ -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": "test@test.com"}]}]}, + ) + 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": "test@test.com"}]}]}, + ) + 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())