Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/lib/utils/eventUsageLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1353,6 +1353,7 @@ export const eventUsageLogic = kea<eventUsageLogicType>([
questions_length: survey.questions.length,
question_types: survey.questions.map((question) => question.type),
is_duplicate: isDuplicate ?? false,
linked_insight_id: survey.linked_insight_id,
events_count: survey.conditions?.events?.values.length,
recurring_survey_iteration_count: survey.iteration_count == undefined ? 0 : survey.iteration_count,
recurring_survey_iteration_interval:
Expand Down
83 changes: 83 additions & 0 deletions frontend/src/scenes/surveys/components/SurveyOpportunityButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { router } from 'kea-router'
import posthog from 'posthog-js'
import { useEffect } from 'react'

import { IconMessage } from '@posthog/icons'
import { LemonButton } from '@posthog/lemon-ui'

import { formatPercentage } from 'lib/utils'
import { ProductIntentContext, addProductIntent } from 'lib/utils/product-intents'
import { useMaxTool } from 'scenes/max/useMaxTool'
import { urls } from 'scenes/urls'

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

import { SURVEY_CREATED_SOURCE } from '../constants'
import { captureMaxAISurveyCreationException } from '../utils'
import { extractFunnelContext } from '../utils/opportunityDetection'

export interface SurveyOpportunityButtonProps {
insight: QueryBasedInsightModel
disableAutoPromptSubmit?: boolean
}

export function SurveyOpportunityButton({
insight,
disableAutoPromptSubmit,
}: SurveyOpportunityButtonProps): JSX.Element | null {
const funnelContext = extractFunnelContext(insight)
const initialMaxPrompt = funnelContext
? `${disableAutoPromptSubmit ? '' : '!'}Create a survey to help me identify and fix the root ` +
`cause for ${formatPercentage(funnelContext.conversionRate * 100)} conversion in my ` +
`"${funnelContext.insightName}" funnel (\`${insight.id}\`). Read this insight to understand the ` +
`conversion goal, and suggest the best display / targeting strategies.`
: ''

useEffect(() => {
posthog.capture('survey opportunity displayed', {
linked_insight_id: insight.id,
conversionRate: funnelContext?.conversionRate, // oxlint-disable-line react-hooks/exhaustive-deps
})
}, [insight.id])

const { openMax } = useMaxTool({
identifier: 'create_survey',
active: true,
initialMaxPrompt,
context: {
insight_id: insight.id,
...funnelContext,
},
callback: (toolOutput: { survey_id?: string; survey_name?: string; error?: string }) => {
addProductIntent({
product_type: ProductKey.SURVEYS,
intent_context: ProductIntentContext.SURVEY_CREATED,
metadata: {
survey_id: toolOutput.survey_id,
source: SURVEY_CREATED_SOURCE.INSIGHT_CROSS_SELL,
created_successfully: !toolOutput?.error,
},
})

if (toolOutput?.error || !toolOutput?.survey_id) {
return captureMaxAISurveyCreationException(toolOutput.error, SURVEY_CREATED_SOURCE.INSIGHT_CROSS_SELL)
}

router.actions.push(urls.survey(toolOutput.survey_id))
},
})

const handleClick = (): void => {
posthog.capture('survey opportunity clicked', {
linked_insight_id: insight.id,
conversionRate: funnelContext?.conversionRate,
})
openMax?.()
}

return (
<LemonButton size="xsmall" type="primary" sideIcon={<IconMessage />} onClick={handleClick}>
Ask users why
</LemonButton>
)
}
1 change: 1 addition & 0 deletions frontend/src/scenes/surveys/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,7 @@ export enum SURVEY_CREATED_SOURCE {
SURVEY_FORM = 'survey_form',
SURVEY_EMPTY_STATE = 'survey_empty_state',
EXPERIMENTS = 'experiments',
INSIGHT_CROSS_SELL = 'insight_cross_sell',
}

export enum SURVEY_EMPTY_STATE_EXPERIMENT_VARIANT {
Expand Down
98 changes: 98 additions & 0 deletions frontend/src/scenes/surveys/utils/opportunityDetection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { FunnelsQuery, InsightVizNode } from '~/queries/schema/schema-general'
import { DashboardTile, QueryBasedInsightModel } from '~/types'

export interface FunnelContext {
insightName: string
conversionRate: number
steps: string[]
}

/**
* Given a list of dashboard tiles, find the funnel with the best
* opportunity to create a survey. See {@link getBestSurveyOpportunityFromDashboard}
* for logic details
*
* @param tiles list of dashboard tiles
* @param linkedInsightIds list of surveys with linked insights
* @returns
*/
export function getBestSurveyOpportunityFunnel(
tiles: DashboardTile<QueryBasedInsightModel>[],
linkedInsightIds: Set<number> = new Set()
): DashboardTile<QueryBasedInsightModel> | null {
return getBestSurveyOpportunityFromDashboard(
tiles.filter((tile) => isFunnelInsight(tile.insight?.query)),
linkedInsightIds
)
}

/**
* Get some useful "context" data from a funnel insight
*
* @param insight funnel insight
* @returns funnel "context" object with name, conversion rate (0-1), list of step names
*/
export function extractFunnelContext(insight: Partial<QueryBasedInsightModel>): FunnelContext | null {
if (!isFunnelInsight(insight.query)) {
return null
}

const result = insight.result
if (!result || !Array.isArray(result) || result.length === 0) {
return null
}

const conversionRate = conversionRateFromInsight(result)
const steps = result.map((step: any) => step.name || 'Unknown step')

return {
insightName: insight.name || 'Funnel',
conversionRate,
steps,
}
}

/**
* Given a list of dashboard tiles (insights), find the best opportunity to
* create a survey.
*
* Logic:
* - Filter insights with existing linked surveys
* - Calculate overall conversion rate
* - Filter conversion rate < 50%
* - Return lowest conversion rate funnel
*
* @param tiles list of dashboard tiles
* @param linkedInsightIds list of surveys with linked insights
* @returns
*/
function getBestSurveyOpportunityFromDashboard(
tiles: DashboardTile<QueryBasedInsightModel>[],
linkedInsightIds: Set<number> = new Set()
): DashboardTile<QueryBasedInsightModel> | null {
const funnelTiles = tiles
.filter((tile) => tile.insight?.id && !linkedInsightIds.has(tile.insight.id))
.map((tile) => {
const result = tile.insight?.result
if (!result || !Array.isArray(result) || result.length === 0) {
return { tile, conversionRate: 1 }
}

const conversionRate = conversionRateFromInsight(result)
return { tile, conversionRate }
})
.filter(({ conversionRate }) => conversionRate < 0.5)
.sort((a, b) => a.conversionRate - b.conversionRate)

return funnelTiles[0]?.tile || null
}

function conversionRateFromInsight(result: QueryBasedInsightModel['result']): number {
const first = result[0]?.count || 1
const last = result[result.length - 1]?.count || 0
return last / first
}

function isFunnelInsight(query: any): query is InsightVizNode<FunnelsQuery> {
return query?.kind === 'InsightVizNode' && query?.source?.kind === 'FunnelsQuery'
}
1 change: 1 addition & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3253,6 +3253,7 @@ export interface Survey extends WithAccessControl {
linked_flag: FeatureFlagBasicType | null
targeting_flag: FeatureFlagBasicType | null
targeting_flag_filters?: FeatureFlagFilters
linked_insight_id?: number | null
conditions: SurveyDisplayConditions | null
appearance: SurveyAppearance | null
questions: (BasicSurveyQuestion | LinkSurveyQuestion | RatingSurveyQuestion | MultipleSurveyQuestion)[]
Expand Down
Loading