Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: track product usage intents #25419

Merged
merged 14 commits into from
Oct 7, 2024
Merged
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
94 changes: 88 additions & 6 deletions posthog/api/team.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import json
from datetime import UTC, datetime, timedelta
from functools import cached_property
from typing import Any, Optional, cast
from datetime import timedelta
from uuid import UUID

from django.shortcuts import get_object_or_404
from loginas.utils import is_impersonated_session
from posthog.jwt import PosthogJwtAudience, encode_jwt
from rest_framework.permissions import BasePermission, IsAuthenticated
from posthog.api.utils import action
from rest_framework import exceptions, request, response, serializers, viewsets
from rest_framework.permissions import BasePermission, IsAuthenticated

from posthog.geoip import get_geoip_properties
from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.api.shared import TeamBasicSerializer
from posthog.api.utils import action
from posthog.constants import AvailableFeature
from posthog.event_usage import report_user_action
from posthog.models import Team, User
from posthog.geoip import get_geoip_properties
from posthog.jwt import PosthogJwtAudience, encode_jwt
from posthog.models import ProductIntent, Team, User
from posthog.models.activity_logging.activity_log import (
Detail,
dict_changes_between,
Expand Down Expand Up @@ -117,6 +117,7 @@
effective_membership_level = serializers.SerializerMethodField()
has_group_types = serializers.SerializerMethodField()
live_events_token = serializers.SerializerMethodField()
product_intents = serializers.SerializerMethodField()

class Meta:
model = Team
Expand Down Expand Up @@ -171,6 +172,7 @@
"surveys_opt_in",
"heatmaps_opt_in",
"live_events_token",
"product_intents",
)
read_only_fields = (
"id",
Expand Down Expand Up @@ -201,6 +203,9 @@
PosthogJwtAudience.LIVESTREAM,
)

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

@staticmethod
def validate_session_recording_linked_flag(value) -> dict | None:
if value is None:
Expand Down Expand Up @@ -512,6 +517,83 @@
)
return activity_page_response(activity_page, limit, page, request)

@action(
methods=["PATCH"],
detail=True,
)
def add_product_intent(self, request: request.Request, *args, **kwargs):
team = self.get_object()
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:
report_user_action(
user,

Check failure on line 541 in posthog/api/team.py

View workflow job for this annotation

GitHub Actions / Python code quality checks

Argument 1 to "report_user_action" has incompatible type "User | AnonymousUser"; expected "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):
team = self.get_object()
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:
report_user_action(
user,

Check failure on line 570 in posthog/api/team.py

View workflow job for this annotation

GitHub Actions / Python code quality checks

Argument 1 to "report_user_action" has incompatible type "User | AnonymousUser"; expected "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()

report_user_action(
user,

Check failure on line 585 in posthog/api/team.py

View workflow job for this annotation

GitHub Actions / Python code quality checks

Argument 1 to "report_user_action" has incompatible type "User | AnonymousUser"; expected "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):
team = self.get_object() if self.action == "reset_token" else None
Expand Down
Loading
Loading