Skip to content

Commit

Permalink
feat: track product usage intents (#25419)
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 Oct 7, 2024
1 parent eb55c47 commit cf0375c
Show file tree
Hide file tree
Showing 17 changed files with 362 additions and 69 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 0 additions & 24 deletions frontend/src/lib/utils/eventUsageLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,15 +571,6 @@ export const eventUsageLogic = kea<eventUsageLogicType>([
reportSurveyTemplateClicked: (template: SurveyTemplateType) => ({ template }),
reportSurveyCycleDetected: (survey: Survey | NewSurvey) => ({ survey }),
reportProductUnsubscribed: (product: string) => ({ product }),
// onboarding
reportOnboardingProductSelected: (
productKey: string,
includeFirstOnboardingProductOnUserProperties: boolean
) => ({
productKey,
includeFirstOnboardingProductOnUserProperties,
}),
reportOnboardingCompleted: (productKey: string) => ({ productKey }),
reportSubscribedDuringOnboarding: (productKey: string) => ({ productKey }),
// command bar
reportCommandBarStatusChanged: (status: BarStatus) => ({ status }),
Expand Down Expand Up @@ -1300,21 +1291,6 @@ export const eventUsageLogic = kea<eventUsageLogicType>([
})
},
// onboarding
reportOnboardingProductSelected: ({ productKey, includeFirstOnboardingProductOnUserProperties }) => {
posthog.capture('onboarding product selected', {
product_key: productKey,
$set_once: {
first_onboarding_product_selected: includeFirstOnboardingProductOnUserProperties
? productKey
: undefined,
},
})
},
reportOnboardingCompleted: ({ productKey }) => {
posthog.capture('onboarding completed', {
product_key: productKey,
})
},
reportSubscribedDuringOnboarding: ({ productKey }) => {
posthog.capture('subscribed during onboarding', {
product_key: productKey,
Expand Down
29 changes: 14 additions & 15 deletions frontend/src/scenes/onboarding/OnboardingProductIntroduction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@ import { IconCheck, IconMap, IconMessage, IconStack } from '@posthog/icons'
import { LemonButton, Link, Spinner } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { WavingHog } from 'lib/components/hedgehogs'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import posthog from 'posthog-js'
import React from 'react'
import { convertLargeNumberToWords } from 'scenes/billing/billing-utils'
import { billingProductLogic } from 'scenes/billing/billingProductLogic'
import { ProductPricingModal } from 'scenes/billing/ProductPricingModal'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
import { getProductIcon } from 'scenes/products/Products'
import { userLogic } from 'scenes/userLogic'
import { teamLogic } from 'scenes/teamLogic'

import { BillingFeatureType, BillingProductV2Type, ProductKey } from '~/types'

Expand Down Expand Up @@ -43,8 +41,7 @@ export const Subfeature = ({ name, description, icon_key }: BillingFeatureType):
}

const GetStartedButton = ({ product }: { product: BillingProductV2Type }): JSX.Element => {
const { user } = useValues(userLogic)
const { reportOnboardingProductSelected } = useActions(eventUsageLogic)
const { addProductIntent } = useActions(teamLogic)
const { completeOnboarding, setTeamPropertiesForProduct, goToNextStep } = useActions(onboardingLogic)
const { isFirstProductOnboarding } = useValues(onboardingLogic)
const { hasSnippetEvents } = useValues(sdksLogic)
Expand All @@ -53,9 +50,6 @@ const GetStartedButton = ({ product }: { product: BillingProductV2Type }): JSX.E
[ProductKey.FEATURE_FLAGS]: 'Create a feature flag or experiment',
[ProductKey.SURVEYS]: 'Create a survey',
}
const includeFirstOnboardingProductOnUserProperties = user?.date_joined
? new Date(user?.date_joined) > new Date('2024-01-10T00:00:00Z')
: false

return (
<div className="flex gap-x-4 items-center">
Expand All @@ -69,7 +63,10 @@ const GetStartedButton = ({ product }: { product: BillingProductV2Type }): JSX.E
className="max-w-max"
onClick={() => {
setTeamPropertiesForProduct(product.type as ProductKey)
reportOnboardingProductSelected(product.type, includeFirstOnboardingProductOnUserProperties)
addProductIntent({
product_type: product.type as ProductKey,
intent_context: 'onboarding product selected',
})
goToNextStep()
}}
>
Expand All @@ -86,8 +83,10 @@ const GetStartedButton = ({ product }: { product: BillingProductV2Type }): JSX.E
className="max-w-max"
onClick={() => {
setTeamPropertiesForProduct(product.type as ProductKey)
reportOnboardingProductSelected(product.type, includeFirstOnboardingProductOnUserProperties)
posthog.capture('product onboarding skipped', { product_key: product.type })
addProductIntent({
product_type: product.type as ProductKey,
intent_context: 'onboarding product selected',
})
completeOnboarding()
}}
>
Expand All @@ -99,10 +98,10 @@ const GetStartedButton = ({ product }: { product: BillingProductV2Type }): JSX.E
data-attr="start-onboarding-sdk"
onClick={() => {
setTeamPropertiesForProduct(product.type as ProductKey)
reportOnboardingProductSelected(
product.type,
includeFirstOnboardingProductOnUserProperties
)
addProductIntent({
product_type: product.type as ProductKey,
intent_context: 'onboarding product selected',
})
goToNextStep()
}}
>
Expand Down
21 changes: 13 additions & 8 deletions frontend/src/scenes/onboarding/onboardingLogic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,12 @@ export const onboardingLogic = kea<onboardingLogicType>([
preflightLogic,
['isCloudOrDev'],
],
actions: [billingLogic, ['loadBillingSuccess'], teamLogic, ['updateCurrentTeam', 'updateCurrentTeamSuccess']],
actions: [
billingLogic,
['loadBillingSuccess'],
teamLogic,
['updateCurrentTeam', 'updateCurrentTeamSuccess', 'recordProductIntentOnboardingComplete'],
],
}),
actions({
setProduct: (product: OnboardingProduct | null) => ({ product }),
Expand Down Expand Up @@ -346,18 +351,18 @@ export const onboardingLogic = kea<onboardingLogicType>([
actions.setOnCompleteOnboardingRedirectUrl(redirectUrlOverride)
}
if (values.productKey) {
const product = values.productKey
eventUsageLogic.actions.reportOnboardingCompleted(product)
if (nextProductKey) {
actions.setProductKey(nextProductKey)
router.actions.push(urls.onboarding(nextProductKey))
}
const productKey = values.productKey
actions.recordProductIntentOnboardingComplete({ product_type: productKey as ProductKey })
teamLogic.actions.updateCurrentTeam({
has_completed_onboarding_for: {
...values.currentTeam?.has_completed_onboarding_for,
[product]: true,
[productKey]: true,
},
})
if (nextProductKey) {
actions.setProductKey(nextProductKey)
router.actions.push(urls.onboarding(nextProductKey))
}
}
},
setAllOnboardingSteps: () => {
Expand Down
13 changes: 5 additions & 8 deletions frontend/src/scenes/products/Products.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@ import clsx from 'clsx'
import { useActions, useValues } from 'kea'
import { router } from 'kea-router'
import { LemonCard } from 'lib/lemon-ui/LemonCard/LemonCard'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { availableOnboardingProducts, getProductUri, onboardingLogic } from 'scenes/onboarding/onboardingLogic'
import { SceneExport } from 'scenes/sceneTypes'
import { inviteLogic } from 'scenes/settings/organization/inviteLogic'
import { teamLogic } from 'scenes/teamLogic'
import { urls } from 'scenes/urls'
import { userLogic } from 'scenes/userLogic'

import { OnboardingProduct, ProductKey } from '~/types'

Expand Down Expand Up @@ -38,8 +36,7 @@ export function ProductCard({
}): JSX.Element {
const { currentTeam } = useValues(teamLogic)
const { setIncludeIntro } = useActions(onboardingLogic)
const { user } = useValues(userLogic)
const { reportOnboardingProductSelected } = useActions(eventUsageLogic)
const { addProductIntent } = useActions(teamLogic)
const onboardingCompleted = currentTeam?.has_completed_onboarding_for?.[productKey]
const vertical = orientation === 'vertical'

Expand All @@ -51,13 +48,13 @@ export function ProductCard({
onClick={() => {
setIncludeIntro(false)
if (!onboardingCompleted) {
const includeFirstOnboardingProductOnUserProperties = user?.date_joined
? new Date(user?.date_joined) > new Date('2024-01-10T00:00:00Z')
: false
reportOnboardingProductSelected(productKey, includeFirstOnboardingProductOnUserProperties)
getStartedActionOverride && getStartedActionOverride()
}
router.actions.push(urls.onboarding(productKey))
addProductIntent({
product_type: productKey as ProductKey,
intent_context: 'onboarding product selected',
})
}}
>
{onboardingCompleted && (
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/scenes/teamLogic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { identifierToHuman, isUserLoggedIn, resolveWebhookService } from 'lib/ut
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { getAppContext } from 'lib/utils/getAppContext'

import { CorrelationConfigType, ProjectType, TeamPublicType, TeamType } from '~/types'
import { CorrelationConfigType, ProductKey, ProjectType, TeamPublicType, TeamType } from '~/types'

import { organizationLogic } from './organizationLogic'
import { projectLogic } from './projectLogic'
Expand Down Expand Up @@ -143,6 +143,20 @@ 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`, {}),
addProductIntent: async ({
product_type,
}: {
product_type: ProductKey
intent_context?: string | null
}) =>
await api.update(`api/environments/${values.currentTeamId}/add_product_intent`, {
product_type,
intent_context: null,
}),
recordProductIntentOnboardingComplete: async ({ product_type }: { product_type: ProductKey }) =>
await api.update(`api/environments/${values.currentTeamId}/complete_product_onboarding`, {
product_type,
}),
},
],
})),
Expand Down
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: 0483_datawarehousesavedquery_table
posthog: 0484_productintent
sessions: 0001_initial
social_django: 0010_uid_db_index
two_factor: 0007_auto_20201201_1019
99 changes: 96 additions & 3 deletions posthog/api/project.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import timedelta
from datetime import UTC, datetime, timedelta
from functools import cached_property
from typing import Any, Optional, cast

Expand All @@ -8,11 +8,15 @@
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated

from posthog.geoip import get_geoip_properties
from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.api.shared import ProjectBackwardCompatBasicSerializer
from posthog.api.team import PremiumMultiProjectPermissions, TeamSerializer, validate_team_attrs
from posthog.api.team import (
PremiumMultiProjectPermissions,
TeamSerializer,
validate_team_attrs,
)
from posthog.event_usage import report_user_action
from posthog.geoip import get_geoip_properties
from posthog.jwt import PosthogJwtAudience, encode_jwt
from posthog.models import User
from posthog.models.activity_logging.activity_log import (
Expand All @@ -26,6 +30,7 @@
from posthog.models.group_type_mapping import GroupTypeMapping
from posthog.models.organization import OrganizationMembership
from posthog.models.personal_api_key import APIScopeObjectOrNotSupported
from posthog.models.product_intent.product_intent import ProductIntent
from posthog.models.project import Project
from posthog.models.signals import mute_selected_signals
from posthog.models.team.util import delete_batch_exports, delete_bulky_postgres_data
Expand All @@ -52,6 +57,7 @@ class ProjectBackwardCompatSerializer(ProjectBackwardCompatBasicSerializer, User
effective_membership_level = serializers.SerializerMethodField() # Compat with TeamSerializer
has_group_types = serializers.SerializerMethodField() # Compat with TeamSerializer
live_events_token = serializers.SerializerMethodField() # Compat with TeamSerializer
product_intents = serializers.SerializerMethodField() # Compat with TeamSerializer

class Meta:
model = Project
Expand Down Expand Up @@ -105,6 +111,7 @@ class Meta:
"has_completed_onboarding_for", # Compat with TeamSerializer
"surveys_opt_in", # Compat with TeamSerializer
"heatmaps_opt_in", # Compat with TeamSerializer
"product_intents", # Compat with TeamSerializer
)
read_only_fields = (
"id",
Expand All @@ -119,6 +126,7 @@ class Meta:
"ingested_event",
"default_modifiers",
"person_on_events_querying_enabled",
"product_intents",
)

team_passthrough_fields = {
Expand Down Expand Up @@ -181,6 +189,11 @@ def get_live_events_token(self, project: Project) -> Optional[str]:
PosthogJwtAudience.LIVESTREAM,
)

def get_product_intents(self, obj):
project = obj
team = project.passthrough_team
return ProductIntent.objects.filter(team=team).values("product_type", "created_at", "onboarding_completed_at")

@staticmethod
def validate_session_recording_linked_flag(value) -> dict | None:
return TeamSerializer.validate_session_recording_linked_flag(value)
Expand Down Expand Up @@ -501,6 +514,86 @@ def activity(self, request: request.Request, **kwargs):
)
return activity_page_response(activity_page, limit, page, request)

@action(
methods=["PATCH"],
detail=True,
)
def add_product_intent(self, request: request.Request, *args, **kwargs):
project = self.get_object()
team = project.passthrough_team
user = request.user
product_type = request.data.get("product_type")
current_url = request.headers.get("Referer")
session_id = request.headers.get("X-Posthog-Session-Id")

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:
product_intent.updated_at = datetime.now(tz=UTC)
product_intent.save()

if created and isinstance(user, User):
report_user_action(
user,
"user showed product intent",
{
"product_key": product_type,
"$set_once": {"first_onboarding_product_selected": product_type},
"$current_url": current_url,
"$session_id": session_id,
"intent_context": request.data.get("intent_context"),
},
team=team,
)

return response.Response(TeamSerializer(team, context=self.get_serializer_context()).data, status=201)

@action(methods=["PATCH"], detail=True)
def complete_product_onboarding(self, request: request.Request, *args, **kwargs):
project = self.get_object()
team = project.passthrough_team
product_type = request.data.get("product_type")
user = request.user
current_url = request.headers.get("Referer")
session_id = request.headers.get("X-Posthog-Session-Id")

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 created and isinstance(user, User):
report_user_action(
user,
"user showed product intent",
{
"product_key": product_type,
"$set_once": {"first_onboarding_product_selected": product_type},
"$current_url": current_url,
"$session_id": session_id,
"intent_context": request.data.get("intent_context"),
},
team=team,
)
product_intent.onboarding_completed_at = datetime.now(tz=UTC)
product_intent.save()

if isinstance(user, User): # typing
report_user_action(
user,
"product onboarding completed",
{
"product_key": product_type,
"$current_url": current_url,
"$session_id": session_id,
},
team=team,
)

return response.Response(TeamSerializer(team, context=self.get_serializer_context()).data)

@cached_property
def user_permissions(self):
project = self.get_object() if self.action == "reset_token" else None
Expand Down
Loading

0 comments on commit cf0375c

Please sign in to comment.