0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-24 00:47:50 +01:00

feat: only send product_intent events if not activated (#25889)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Raquel Smith 2024-11-01 09:36:18 -07:00 committed by GitHub
parent 003ac8a029
commit 54efcdcbc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 400 additions and 85 deletions

View File

@ -143,6 +143,9 @@ export const teamLogic = kea<teamLogicType>([
return await api.create(`api/projects/${values.currentProject.id}/environments/`, { name, is_demo })
},
resetToken: async () => await api.update(`api/environments/${values.currentTeamId}/reset_token`, {}),
/**
* If adding a product intent that also represents regular product usage, see explainer in posthog.models.product_intent.product_intent.py.
*/
addProductIntent: async ({
product_type,
intent_context,

View File

@ -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: 0505_grouptypemapping_project
posthog: 0506_productintent_activated_at_and_more
sessions: 0001_initial
social_django: 0010_uid_db_index
two_factor: 0007_auto_20201201_1019

View File

@ -30,9 +30,12 @@ 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
from posthog.models.organization import OrganizationMembership
from posthog.models.scopes import APIScopeObjectOrNotSupported
from posthog.models.product_intent.product_intent import ProductIntent
from posthog.models.product_intent.product_intent import (
ProductIntent,
calculate_product_activation,
)
from posthog.models.project import Project
from posthog.models.scopes import APIScopeObjectOrNotSupported
from posthog.models.signals import mute_selected_signals
from posthog.models.team.util import delete_batch_exports, delete_bulky_postgres_data
from posthog.models.utils import UUIDT
@ -199,6 +202,7 @@ class ProjectBackwardCompatSerializer(ProjectBackwardCompatBasicSerializer, User
def get_product_intents(self, obj):
project = obj
team = project.passthrough_team
calculate_product_activation.delay(team.id, only_calc_if_days_since_last_checked=1)
return ProductIntent.objects.filter(team=team).values(
"product_type", "created_at", "onboarding_completed_at", "updated_at"
)
@ -575,10 +579,12 @@ class ProjectViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet):
product_intent, created = ProductIntent.objects.get_or_create(team=team, product_type=product_type)
if not created:
if not product_intent.activated_at:
product_intent.check_and_update_activation()
product_intent.updated_at = datetime.now(tz=UTC)
product_intent.save()
if isinstance(user, User):
if isinstance(user, User) and not product_intent.activated_at:
report_user_action(
user,
"user showed product intent",

View File

@ -28,8 +28,9 @@ 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
from posthog.models.organization import OrganizationMembership
from posthog.models.scopes import APIScopeObjectOrNotSupported
from posthog.models.product_intent.product_intent import calculate_product_activation
from posthog.models.project import Project
from posthog.models.scopes import APIScopeObjectOrNotSupported
from posthog.models.signals import mute_selected_signals
from posthog.models.team.util import delete_batch_exports, delete_bulky_postgres_data
from posthog.models.utils import UUIDT
@ -217,6 +218,7 @@ class TeamSerializer(serializers.ModelSerializer, UserPermissionsSerializerMixin
)
def get_product_intents(self, obj):
calculate_product_activation.delay(obj.id, only_calc_if_days_since_last_checked=1)
return ProductIntent.objects.filter(team=obj).values(
"product_type", "created_at", "onboarding_completed_at", "updated_at"
)
@ -587,10 +589,12 @@ class TeamViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet):
product_intent, created = ProductIntent.objects.get_or_create(team=team, product_type=product_type)
if not created:
if not product_intent.activated_at:
product_intent.check_and_update_activation()
product_intent.updated_at = datetime.now(tz=UTC)
product_intent.save()
if isinstance(user, User):
if isinstance(user, User) and not product_intent.activated_at:
report_user_action(
user,
"user showed product intent",

View File

@ -64,6 +64,30 @@
'''
# ---
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.10
'''
SELECT "posthog_productintent"."id",
"posthog_productintent"."team_id",
"posthog_productintent"."created_at",
"posthog_productintent"."updated_at",
"posthog_productintent"."product_type",
"posthog_productintent"."onboarding_completed_at",
"posthog_productintent"."activated_at",
"posthog_productintent"."activation_last_checked_at"
FROM "posthog_productintent"
WHERE "posthog_productintent"."team_id" = 99999
'''
# ---
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.11
'''
SELECT "posthog_productintent"."product_type",
"posthog_productintent"."created_at",
"posthog_productintent"."onboarding_completed_at",
"posthog_productintent"."updated_at"
FROM "posthog_productintent"
WHERE "posthog_productintent"."team_id" = 99999
'''
# ---
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.12
'''
SELECT "posthog_user"."id",
"posthog_user"."password",
@ -95,7 +119,7 @@
LIMIT 21
'''
# ---
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.11
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.13
'''
SELECT "posthog_featureflag"."id",
"posthog_featureflag"."key",
@ -118,7 +142,7 @@
AND "posthog_featureflag"."team_id" = 99999)
'''
# ---
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.12
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.14
'''
SELECT "posthog_pluginconfig"."id",
"posthog_pluginconfig"."web_token",
@ -134,74 +158,6 @@
AND "posthog_pluginconfig"."team_id" = 99999)
'''
# ---
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.13
'''
SELECT "posthog_team"."id",
"posthog_team"."uuid",
"posthog_team"."organization_id",
"posthog_team"."project_id",
"posthog_team"."api_token",
"posthog_team"."app_urls",
"posthog_team"."name",
"posthog_team"."slack_incoming_webhook",
"posthog_team"."created_at",
"posthog_team"."updated_at",
"posthog_team"."anonymize_ips",
"posthog_team"."completed_snippet_onboarding",
"posthog_team"."has_completed_onboarding_for",
"posthog_team"."ingested_event",
"posthog_team"."autocapture_opt_out",
"posthog_team"."autocapture_web_vitals_opt_in",
"posthog_team"."autocapture_web_vitals_allowed_metrics",
"posthog_team"."autocapture_exceptions_opt_in",
"posthog_team"."autocapture_exceptions_errors_to_ignore",
"posthog_team"."session_recording_opt_in",
"posthog_team"."session_recording_sample_rate",
"posthog_team"."session_recording_minimum_duration_milliseconds",
"posthog_team"."session_recording_linked_flag",
"posthog_team"."session_recording_network_payload_capture_config",
"posthog_team"."session_replay_config",
"posthog_team"."survey_config",
"posthog_team"."capture_console_log_opt_in",
"posthog_team"."capture_performance_opt_in",
"posthog_team"."surveys_opt_in",
"posthog_team"."heatmaps_opt_in",
"posthog_team"."session_recording_version",
"posthog_team"."signup_token",
"posthog_team"."is_demo",
"posthog_team"."access_control",
"posthog_team"."week_start_day",
"posthog_team"."inject_web_apps",
"posthog_team"."test_account_filters",
"posthog_team"."test_account_filters_default_checked",
"posthog_team"."path_cleaning_filters",
"posthog_team"."timezone",
"posthog_team"."data_attributes",
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
"posthog_team"."primary_dashboard_id",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
"posthog_team"."session_recording_retention_period_days",
"posthog_team"."external_data_workspace_id",
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_team"
WHERE ("posthog_team"."project_id" = 2
AND "posthog_team"."id" = 2)
LIMIT 21
'''
# ---
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.14
'''
SELECT "posthog_productintent"."product_type",
"posthog_productintent"."created_at",
"posthog_productintent"."onboarding_completed_at"
FROM "posthog_productintent"
WHERE "posthog_productintent"."team_id" = 2
'''
# ---
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.15
'''
SELECT "posthog_user"."id",
@ -523,12 +479,64 @@
# ---
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.9
'''
SELECT "posthog_productintent"."product_type",
"posthog_productintent"."created_at",
"posthog_productintent"."onboarding_completed_at",
"posthog_productintent"."updated_at"
FROM "posthog_productintent"
WHERE "posthog_productintent"."team_id" = 99999
SELECT "posthog_team"."id",
"posthog_team"."uuid",
"posthog_team"."organization_id",
"posthog_team"."project_id",
"posthog_team"."api_token",
"posthog_team"."app_urls",
"posthog_team"."name",
"posthog_team"."slack_incoming_webhook",
"posthog_team"."created_at",
"posthog_team"."updated_at",
"posthog_team"."anonymize_ips",
"posthog_team"."completed_snippet_onboarding",
"posthog_team"."has_completed_onboarding_for",
"posthog_team"."ingested_event",
"posthog_team"."autocapture_opt_out",
"posthog_team"."autocapture_web_vitals_opt_in",
"posthog_team"."autocapture_web_vitals_allowed_metrics",
"posthog_team"."autocapture_exceptions_opt_in",
"posthog_team"."autocapture_exceptions_errors_to_ignore",
"posthog_team"."person_processing_opt_out",
"posthog_team"."session_recording_opt_in",
"posthog_team"."session_recording_sample_rate",
"posthog_team"."session_recording_minimum_duration_milliseconds",
"posthog_team"."session_recording_linked_flag",
"posthog_team"."session_recording_network_payload_capture_config",
"posthog_team"."session_recording_url_trigger_config",
"posthog_team"."session_recording_url_blocklist_config",
"posthog_team"."session_replay_config",
"posthog_team"."survey_config",
"posthog_team"."capture_console_log_opt_in",
"posthog_team"."capture_performance_opt_in",
"posthog_team"."capture_dead_clicks",
"posthog_team"."surveys_opt_in",
"posthog_team"."heatmaps_opt_in",
"posthog_team"."session_recording_version",
"posthog_team"."signup_token",
"posthog_team"."is_demo",
"posthog_team"."access_control",
"posthog_team"."week_start_day",
"posthog_team"."inject_web_apps",
"posthog_team"."test_account_filters",
"posthog_team"."test_account_filters_default_checked",
"posthog_team"."path_cleaning_filters",
"posthog_team"."timezone",
"posthog_team"."data_attributes",
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
"posthog_team"."primary_dashboard_id",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
"posthog_team"."session_recording_retention_period_days",
"posthog_team"."external_data_workspace_id",
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_team"
WHERE "posthog_team"."id" = 99999
LIMIT 21
'''
# ---
# name: TestDecide.test_flag_with_behavioural_cohorts

View File

@ -1051,6 +1051,76 @@ def team_api_test_factory():
team=self.team,
)
@patch("posthog.api.team.calculate_product_activation.delay", MagicMock())
@patch("posthog.models.product_intent.ProductIntent.check_and_update_activation")
@patch("posthog.api.project.report_user_action")
@patch("posthog.api.team.report_user_action")
@freeze_time("2024-01-01T00:00:00Z")
def test_can_update_product_intent_if_already_exists(
self,
mock_report_user_action: MagicMock,
mock_report_user_action_legacy_endpoint: MagicMock,
mock_check_and_update_activation: MagicMock,
) -> None:
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)
# change the time of the existing intent
with freeze_time("2024-01-02T00:00:00Z"):
if self.client_class is EnvironmentToProjectRewriteClient:
mock_report_user_action = mock_report_user_action_legacy_endpoint
response = self.client.patch(
f"/api/environments/{self.team.id}/add_product_intent/",
{"product_type": "product_analytics"},
headers={"Referer": "https://posthogtest.com/my-url", "X-Posthog-Session-Id": "test_session_id"},
)
assert response.status_code == status.HTTP_201_CREATED
product_intent = ProductIntent.objects.get(team=self.team, product_type="product_analytics")
assert product_intent.updated_at == datetime(2024, 1, 2, 0, 0, 0, tzinfo=UTC)
assert product_intent.created_at == original_created_at
mock_check_and_update_activation.assert_called_once()
mock_report_user_action.assert_called_once_with(
self.user,
"user showed product intent",
{
"product_key": "product_analytics",
"$current_url": "https://posthogtest.com/my-url",
"$session_id": "test_session_id",
"intent_context": None,
"$set_once": {"first_onboarding_product_selected": "product_analytics"},
"is_first_intent_for_product": False,
"intent_created_at": datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC),
"intent_updated_at": datetime(2024, 1, 2, 0, 0, 0, tzinfo=UTC),
"realm": get_instance_realm(),
},
team=self.team,
)
@patch("posthog.api.team.calculate_product_activation.delay", MagicMock())
@patch("posthog.models.product_intent.ProductIntent.check_and_update_activation")
@patch("posthog.api.project.report_user_action")
@patch("posthog.api.team.report_user_action")
@freeze_time("2024-01-05T00:00:00Z")
def test_doesnt_send_event_for_already_activated_intent(
self,
mock_report_user_action: MagicMock,
mock_report_user_action_legacy_endpoint: MagicMock,
mock_check_and_update_activation: MagicMock,
) -> None:
ProductIntent.objects.create(
team=self.team, product_type="product_analytics", activated_at=datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
)
if self.client_class is EnvironmentToProjectRewriteClient:
mock_report_user_action = mock_report_user_action_legacy_endpoint
response = self.client.patch(
f"/api/environments/{self.team.id}/add_product_intent/",
{"product_type": "product_analytics"},
headers={"Referer": "https://posthogtest.com/my-url", "X-Posthog-Session-Id": "test_session_id"},
)
assert response.status_code == status.HTTP_201_CREATED
mock_check_and_update_activation.assert_not_called()
mock_report_user_action.assert_not_called()
@patch("posthog.api.project.report_user_action")
@patch("posthog.api.team.report_user_action")
def test_can_complete_product_onboarding(

View File

@ -0,0 +1,30 @@
# Generated by Django 4.2.15 on 2024-11-01 16:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("posthog", "0505_grouptypemapping_project"),
]
operations = [
migrations.AddField(
model_name="productintent",
name="activated_at",
field=models.DateTimeField(
blank=True,
help_text="The date the org completed activation for the product. Generally only used to know if we should continue updating the product_intent row.",
null=True,
),
),
migrations.AddField(
model_name="productintent",
name="activation_last_checked_at",
field=models.DateTimeField(
blank=True,
help_text="The date we last checked if the org had completed activation for the product.",
null=True,
),
),
]

View File

@ -1,6 +1,36 @@
from datetime import UTC, datetime
from celery import shared_task
from django.db import models
from posthog.models.insight import Insight
from posthog.models.team.team import Team
from posthog.models.utils import UUIDModel
from posthog.utils import get_instance_realm
"""
How to use this model:
Product intents are indicators that someone showed an interest in a given product.
They are triggered from the frontend when the user performs certain actions, like
selecting a product during onboarding or clicking on a certain button.
Some buttons that show product intent are frequently used by all users of the product,
so we need to know if it's a new product intent, or if it's just regular usage. We
can use the `activated_at` field to know if we should continue to update the product
intent row, or if we should stop because it's just regular usage.
The `activated_at` field is set by checking against certain criteria that differs for
each product. For instance, for the data warehouse product, we check if the user has
created any DataVisualizationNode insights in the 30 days after the product intent
was created. Each product needs to implement a method that checks for activation
criteria if the intent actions are the same as the general usage actions.
We shouldn't use this model and the `activated_at` field in place of sending events
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.
"""
class ProductIntent(UUIDModel):
@ -9,9 +39,78 @@ class ProductIntent(UUIDModel):
updated_at = models.DateTimeField(auto_now=True)
product_type = models.CharField(max_length=255)
onboarding_completed_at = models.DateTimeField(null=True, blank=True)
activated_at = models.DateTimeField(
null=True,
blank=True,
help_text="The date the org completed activation for the product. Generally only used to know if we should continue updating the product_intent row.",
)
activation_last_checked_at = models.DateTimeField(
null=True,
blank=True,
help_text="The date we last checked if the org had completed activation for the product.",
)
class Meta:
unique_together = ["team", "product_type"]
def __str__(self):
return f"{self.team.name} - {self.product_type}"
def has_activated_data_warehouse(self) -> bool:
insights = Insight.objects.filter(
team=self.team,
created_at__gte=datetime(2024, 6, 1, tzinfo=UTC),
query__kind="DataVisualizationNode",
)
excluded_tables = ["events", "persons", "sessions", "person_distinct_ids"]
for insight in insights:
if insight.query and insight.query.get("source", {}).get("query"):
query_text = insight.query["source"]["query"].lower()
# Check if query doesn't contain any of the excluded tables after 'from'
has_excluded_table = any(f"from {table}" in query_text.replace("\\", "") for table in excluded_tables)
if not has_excluded_table:
return True
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 report_activation(self, product_key: str) -> None:
from posthog.event_usage import report_team_action
report_team_action(
self.team,
"product intent marked activated",
{
"product_key": product_key,
"intent_created_at": self.created_at,
"intent_updated_at": self.updated_at,
"realm": get_instance_realm(),
},
)
@shared_task(ignore_result=True)
def calculate_product_activation(team_id: int, only_calc_if_days_since_last_checked: int = 1) -> None:
"""
Calculate product activation for a team.
Only calculate if it's been more than `only_calc_if_days_since_last_checked` days since the last activation check.
"""
team = Team.objects.get(id=team_id)
product_intents = ProductIntent.objects.filter(team=team)
for product_intent in product_intents:
if product_intent.activated_at:
continue
if (
product_intent.activation_last_checked_at
and (datetime.now(tz=UTC) - product_intent.activation_last_checked_at).days
<= only_calc_if_days_since_last_checked
):
continue
product_intent.check_and_update_activation()

View File

@ -0,0 +1,95 @@
from datetime import datetime, timedelta, UTC
import pytest
from freezegun import freeze_time
from posthog.models.insight import Insight
from posthog.models.product_intent.product_intent import (
ProductIntent,
calculate_product_activation,
)
from posthog.test.base import BaseTest
class TestProductIntent(BaseTest):
def setUp(self):
super().setUp()
self.product_intent = ProductIntent.objects.create(team=self.team, product_type="data_warehouse")
def test_str_representation(self):
self.assertEqual(str(self.product_intent), f"{self.team.name} - data_warehouse")
def test_unique_constraint(self):
# Test that we can't create duplicate product intents for same team/product
with pytest.raises(Exception):
ProductIntent.objects.create(team=self.team, product_type="data_warehouse")
@freeze_time("2024-06-15T12:00:00Z")
def test_has_activated_data_warehouse_with_valid_query(self):
Insight.objects.create(
team=self.team, query={"kind": "DataVisualizationNode", "source": {"query": "SELECT * FROM custom_table"}}
)
self.assertTrue(self.product_intent.has_activated_data_warehouse())
@freeze_time("2024-06-15T12:00:00Z")
def test_has_activated_data_warehouse_with_excluded_table(self):
Insight.objects.create(
team=self.team, query={"kind": "DataVisualizationNode", "source": {"query": "SELECT * FROM events"}}
)
self.assertFalse(self.product_intent.has_activated_data_warehouse())
@freeze_time("2024-06-15T12:00:00Z")
def test_has_activated_data_warehouse_with_old_insight(self):
with freeze_time("2024-05-15T12:00:00Z"): # Before June 1st, 2024
Insight.objects.create(
team=self.team,
query={"kind": "DataVisualizationNode", "source": {"query": "SELECT * FROM custom_table"}},
)
self.assertFalse(self.product_intent.has_activated_data_warehouse())
@freeze_time("2024-06-15T12:00:00Z")
def test_check_and_update_activation_sets_activated_at(self):
Insight.objects.create(
team=self.team, query={"kind": "DataVisualizationNode", "source": {"query": "SELECT * FROM custom_table"}}
)
self.assertIsNone(self.product_intent.activated_at)
self.product_intent.check_and_update_activation()
self.product_intent.refresh_from_db()
assert self.product_intent.activated_at == datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
@freeze_time("2024-06-15T12:00:00Z")
def test_calculate_product_activation_task(self):
# Create an insight that should trigger activation
Insight.objects.create(
team=self.team, query={"kind": "DataVisualizationNode", "source": {"query": "SELECT * FROM custom_table"}}
)
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_calculate_product_activation_respects_check_interval(self):
# Set last checked time to recent
self.product_intent.activation_last_checked_at = datetime.now(tz=UTC)
self.product_intent.save()
calculate_product_activation(self.team.id, only_calc_if_days_since_last_checked=1)
self.product_intent.refresh_from_db()
self.assertIsNone(self.product_intent.activated_at)
@freeze_time("2024-06-15T12:00:00Z")
def test_calculate_product_activation_skips_activated_products(self):
# Set product as already activated
self.product_intent.activated_at = datetime.now(tz=UTC)
self.product_intent.save()
with freeze_time(datetime.now(tz=UTC) + timedelta(days=2)):
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)

View File

@ -1,14 +1,14 @@
from datetime import datetime, timedelta
import json
from datetime import datetime, timedelta
from urllib.parse import quote
from django.test.client import Client
from django.urls import reverse
from freezegun import freeze_time
from rest_framework import status
from posthog.api.test.test_organization import create_organization
from posthog.api.test.test_team import create_team
from posthog.models import Action, Cohort, Dashboard, FeatureFlag, Insight
from posthog.models.organization import Organization
from posthog.models.team import Team
@ -142,7 +142,7 @@ class TestAutoProjectMiddleware(APIBaseTest):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.base_app_num_queries = 45
cls.base_app_num_queries = 47
# Create another team that the user does have access to
cls.second_team = create_team(organization=cls.organization, name="Second Life")