diff --git a/frontend/src/queries/nodes/WebOverview/WebOverview.tsx b/frontend/src/queries/nodes/WebOverview/WebOverview.tsx index a98d5a9999444..8900e3ba5d4ce 100644 --- a/frontend/src/queries/nodes/WebOverview/WebOverview.tsx +++ b/frontend/src/queries/nodes/WebOverview/WebOverview.tsx @@ -40,6 +40,8 @@ export function WebOverview(props: { const samplingRate = webOverviewQueryResponse?.samplingRate + const numSkeletons = props.query.conversionGoal ? 4 : 5 + return ( <> {responseLoading - ? range(5).map((i) => ) + ? range(numSkeletons).map((i) => ) : webOverviewQueryResponse?.results?.map((item) => ( )) || []} @@ -170,6 +172,12 @@ const labelFromKey = (key: string): string => { return 'Session duration' case 'bounce rate': return 'Bounce rate' + case 'conversion rate': + return 'Conversion rate' + case 'total conversions': + return 'Total conversions' + case 'unique conversions': + return 'Unique conversions' default: return key .split(' ') diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index a35c632f681ba..5250f6c46bb55 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -10670,6 +10670,16 @@ }, "type": "object" }, + "WebAnalyticsConversionGoal": { + "additionalProperties": false, + "properties": { + "actionId": { + "type": "integer" + } + }, + "required": ["actionId"], + "type": "object" + }, "WebAnalyticsPropertyFilter": { "anyOf": [ { @@ -10692,6 +10702,16 @@ "WebExternalClicksTableQuery": { "additionalProperties": false, "properties": { + "conversionGoal": { + "anyOf": [ + { + "$ref": "#/definitions/WebAnalyticsConversionGoal" + }, + { + "type": "null" + } + ] + }, "dateRange": { "$ref": "#/definitions/DateRange" }, @@ -10795,6 +10815,16 @@ "WebGoalsQuery": { "additionalProperties": false, "properties": { + "conversionGoal": { + "anyOf": [ + { + "$ref": "#/definitions/WebAnalyticsConversionGoal" + }, + { + "type": "null" + } + ] + }, "dateRange": { "$ref": "#/definitions/DateRange" }, @@ -10924,6 +10954,16 @@ "compare": { "type": "boolean" }, + "conversionGoal": { + "anyOf": [ + { + "$ref": "#/definitions/WebAnalyticsConversionGoal" + }, + { + "type": "null" + } + ] + }, "dateRange": { "$ref": "#/definitions/DateRange" }, @@ -11038,6 +11078,16 @@ "breakdownBy": { "$ref": "#/definitions/WebStatsBreakdown" }, + "conversionGoal": { + "anyOf": [ + { + "$ref": "#/definitions/WebAnalyticsConversionGoal" + }, + { + "type": "null" + } + ] + }, "dateRange": { "$ref": "#/definitions/DateRange" }, @@ -11147,6 +11197,16 @@ "WebTopClicksQuery": { "additionalProperties": false, "properties": { + "conversionGoal": { + "anyOf": [ + { + "$ref": "#/definitions/WebAnalyticsConversionGoal" + }, + { + "type": "null" + } + ] + }, "dateRange": { "$ref": "#/definitions/DateRange" }, diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index 939510de4de46..0a408af967b5b 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -1336,10 +1336,13 @@ export interface SessionsTimelineQuery extends DataNode> extends DataNode { dateRange?: DateRange properties: WebAnalyticsPropertyFilters + conversionGoal?: WebAnalyticsConversionGoal | null sampling?: { enabled?: boolean forceSamplingRate?: SamplingRate diff --git a/frontend/src/scenes/web-analytics/WebConversionGoal.tsx b/frontend/src/scenes/web-analytics/WebConversionGoal.tsx new file mode 100644 index 0000000000000..b562e0008c410 --- /dev/null +++ b/frontend/src/scenes/web-analytics/WebConversionGoal.tsx @@ -0,0 +1,38 @@ +import { useActions, useValues } from 'kea' +import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' +import { TaxonomicPopover } from 'lib/components/TaxonomicPopover/TaxonomicPopover' +import { webAnalyticsLogic } from 'scenes/web-analytics/webAnalyticsLogic' + +import { actionsModel } from '~/models/actionsModel' + +export const WebConversionGoal = (): JSX.Element | null => { + const { conversionGoal } = useValues(webAnalyticsLogic) + const { setConversionGoal } = useActions(webAnalyticsLogic) + const { actions } = useValues(actionsModel) + + return ( + + data-attr="web-analytics-conversion-filter" + groupType={TaxonomicFilterGroupType.Actions} + value={conversionGoal?.actionId} + onChange={(changedValue: number | '') => { + if (typeof changedValue === 'number') { + setConversionGoal({ actionId: changedValue }) + } else { + setConversionGoal(null) + } + }} + renderValue={(value) => { + const conversionGoalAction = actions.find((a) => a.id === value) + return ( + {conversionGoalAction?.name ?? 'Conversion goal'} + ) + }} + groupTypes={[TaxonomicFilterGroupType.Actions]} + placeholder="Add conversion goal" + placeholderClass="" + allowClear={true} + size="small" + /> + ) +} diff --git a/frontend/src/scenes/web-analytics/WebDashboard.tsx b/frontend/src/scenes/web-analytics/WebDashboard.tsx index 3829f828da756..fc3c250760583 100644 --- a/frontend/src/scenes/web-analytics/WebDashboard.tsx +++ b/frontend/src/scenes/web-analytics/WebDashboard.tsx @@ -3,12 +3,14 @@ import clsx from 'clsx' import { BindLogic, useActions, useValues } from 'kea' import { DateFilter } from 'lib/components/DateFilter/DateFilter' import { VersionCheckerBanner } from 'lib/components/VersionChecker/VersionCheckerBanner' +import { FEATURE_FLAGS } from 'lib/constants' import { IconOpenInNew } from 'lib/lemon-ui/icons' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { LemonSegmentedButton } from 'lib/lemon-ui/LemonSegmentedButton' import { LemonSelect } from 'lib/lemon-ui/LemonSelect' import { PostHogComDocsURL } from 'lib/lemon-ui/Link/Link' import { Popover } from 'lib/lemon-ui/Popover' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { isNotNil } from 'lib/utils' import React, { useState } from 'react' import { WebAnalyticsErrorTrackingTile } from 'scenes/web-analytics/tiles/WebAnalyticsErrorTracking' @@ -23,6 +25,7 @@ import { webAnalyticsLogic, } from 'scenes/web-analytics/webAnalyticsLogic' import { WebAnalyticsModal } from 'scenes/web-analytics/WebAnalyticsModal' +import { WebConversionGoal } from 'scenes/web-analytics/WebConversionGoal' import { WebPropertyFilters } from 'scenes/web-analytics/WebPropertyFilters' import { navigationLogic } from '~/layout/navigation/navigationLogic' @@ -39,6 +42,8 @@ const Filters = (): JSX.Element => { } = useValues(webAnalyticsLogic) const { setWebAnalyticsFilters, setDates } = useActions(webAnalyticsLogic) const { mobileLayout } = useValues(navigationLogic) + const { conversionGoal } = useValues(webAnalyticsLogic) + const { featureFlags } = useValues(featureFlagLogic) return (
{ setWebAnalyticsFilters={setWebAnalyticsFilters} webAnalyticsFilters={webAnalyticsFilters} /> + {featureFlags[FEATURE_FLAGS.WEB_ANALYTICS_CONVERSION_GOALS] || conversionGoal ? ( + + ) : null}
diff --git a/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx b/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx index 18977f12d9f7d..764db1972094a 100644 --- a/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx +++ b/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx @@ -1,4 +1,4 @@ -import { actions, afterMount, connect, kea, path, reducers, selectors } from 'kea' +import { actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' import { actionToUrl, urlToAction } from 'kea-router' import { windowValues } from 'kea-window-values' @@ -14,6 +14,7 @@ import { urls } from 'scenes/urls' import { NodeKind, QuerySchema, + WebAnalyticsConversionGoal, WebAnalyticsPropertyFilter, WebAnalyticsPropertyFilters, WebStatsBreakdown, @@ -143,6 +144,9 @@ export enum GraphsTab { UNIQUE_USERS = 'UNIQUE_USERS', PAGE_VIEWS = 'PAGE_VIEWS', NUM_SESSION = 'NUM_SESSION', + UNIQUE_CONVERSIONS = 'UNIQUE_CONVERSIONS', + TOTAL_CONVERSIONS = 'TOTAL_CONVERSIONS', + CONVERSION_RATE = 'CONVERSION_RATE', } export enum SourceTab { @@ -249,6 +253,7 @@ export const webAnalyticsLogic = kea([ setShouldStripQueryParams: (shouldStripQueryParams: boolean) => ({ shouldStripQueryParams, }), + setConversionGoal: (conversionGoal: WebAnalyticsConversionGoal | null) => ({ conversionGoal }), setStateFromUrl: (state: { filters: WebAnalyticsPropertyFilters dateFrom: string | null @@ -467,6 +472,12 @@ export const webAnalyticsLogic = kea([ setShouldStripQueryParams: (_, { shouldStripQueryParams }) => shouldStripQueryParams, }, ], + conversionGoal: [ + null as WebAnalyticsConversionGoal | null, + { + setConversionGoal: (_, { conversionGoal }) => conversionGoal, + }, + ], }), selectors(({ actions, values }) => ({ graphsTab: [(s) => [s._graphsTab], (graphsTab: string | null) => graphsTab || GraphsTab.UNIQUE_USERS], @@ -475,41 +486,48 @@ export const webAnalyticsLogic = kea([ pathTab: [(s) => [s._pathTab], (pathTab: string | null) => pathTab || PathTab.PATH], geographyTab: [(s) => [s._geographyTab], (geographyTab: string | null) => geographyTab || GeographyTab.MAP], tabs: [ - (s) => [s.graphsTab, s.sourceTab, s.deviceTab, s.pathTab, s.geographyTab], - (graphsTab, sourceTab, deviceTab, pathTab, geographyTab) => ({ + (s) => [ + s.graphsTab, + s.sourceTab, + s.deviceTab, + s.pathTab, + s.geographyTab, + () => values.shouldShowGeographyTile, + ], + (graphsTab, sourceTab, deviceTab, pathTab, geographyTab, shouldShowGeographyTile) => ({ graphsTab, sourceTab, deviceTab, pathTab, geographyTab, + shouldShowGeographyTile, }), ], - tiles: [ - (s) => [ - s.webAnalyticsFilters, - s.replayFilters, - s.tabs, - s.dateFilter, - s.isPathCleaningEnabled, - s.shouldFilterTestAccounts, - () => values.statusCheck, - () => values.isGreaterThanMd, - () => values.shouldShowGeographyTile, - () => values.featureFlags, - () => values.shouldStripQueryParams, - ], - ( - webAnalyticsFilters, - replayFilters, - { graphsTab, sourceTab, deviceTab, pathTab, geographyTab }, - { dateFrom, dateTo, interval }, + controls: [ + (s) => [s.isPathCleaningEnabled, s.shouldFilterTestAccounts, s.shouldStripQueryParams], + (isPathCleaningEnabled, filterTestAccounts, shouldStripQueryParams) => ({ isPathCleaningEnabled, filterTestAccounts, - _statusCheck, - isGreaterThanMd, - shouldShowGeographyTile, + shouldStripQueryParams, + }), + ], + filters: [ + (s) => [s.webAnalyticsFilters, s.replayFilters, s.dateFilter, () => values.conversionGoal], + (webAnalyticsFilters, replayFilters, dateFilter, conversionGoal) => ({ + webAnalyticsFilters, + replayFilters, + dateFilter, + conversionGoal, + }), + ], + tiles: [ + (s) => [s.tabs, s.controls, s.filters, () => values.featureFlags, () => values.isGreaterThanMd], + ( + { graphsTab, sourceTab, deviceTab, pathTab, geographyTab, shouldShowGeographyTile }, + { isPathCleaningEnabled, filterTestAccounts, shouldStripQueryParams }, + { webAnalyticsFilters, replayFilters, dateFilter: { dateFrom, dateTo, interval }, conversionGoal }, featureFlags, - shouldStripQueryParams + isGreaterThanMd ): WebDashboardTile[] => { const dateRange = { date_from: dateFrom, @@ -544,6 +562,7 @@ export const webAnalyticsLogic = kea([ sampling, compare, filterTestAccounts, + conversionGoal, }, insightProps: createInsightProps(TileId.OVERVIEW), canOpenModal: false, @@ -557,114 +576,240 @@ export const webAnalyticsLogic = kea([ }, activeTabId: graphsTab, setTabId: actions.setGraphsTab, - tabs: [ - { - id: GraphsTab.UNIQUE_USERS, - title: 'Unique visitors', - linkText: 'Visitors', - query: { - kind: NodeKind.InsightVizNode, - source: { - kind: NodeKind.TrendsQuery, - dateRange, - interval, - series: [ - { - event: '$pageview', - kind: NodeKind.EventsNode, - math: BaseMathType.UniqueUsers, - name: 'Pageview', - custom_name: 'Unique visitors', - }, - ], - trendsFilter: { - display: ChartDisplayType.ActionsLineGraph, - }, - compareFilter: { - compare: compare, - }, - filterTestAccounts, - properties: webAnalyticsFilters, - }, - hidePersonsModal: true, - embedded: true, - }, - showIntervalSelect: true, - insightProps: createInsightProps(TileId.GRAPHS, GraphsTab.UNIQUE_USERS), - canOpenInsight: true, - }, - { - id: GraphsTab.PAGE_VIEWS, - title: 'Page views', - linkText: 'Views', - query: { - kind: NodeKind.InsightVizNode, - source: { - kind: NodeKind.TrendsQuery, - dateRange, - interval, - series: [ - { - event: '$pageview', - kind: NodeKind.EventsNode, - math: BaseMathType.TotalCount, - name: '$pageview', - custom_name: 'Page views', + tabs: ( + [ + { + id: GraphsTab.UNIQUE_USERS, + title: 'Unique visitors', + linkText: 'Visitors', + query: { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + dateRange, + interval, + series: [ + { + event: '$pageview', + kind: NodeKind.EventsNode, + math: BaseMathType.UniqueUsers, + name: 'Pageview', + custom_name: 'Unique visitors', + }, + ], + trendsFilter: { + display: ChartDisplayType.ActionsLineGraph, }, - ], - trendsFilter: { - display: ChartDisplayType.ActionsLineGraph, - }, - compareFilter: { - compare: compare, - }, - filterTestAccounts, - properties: webAnalyticsFilters, - }, - hidePersonsModal: true, - embedded: true, - }, - showIntervalSelect: true, - insightProps: createInsightProps(TileId.GRAPHS, GraphsTab.PAGE_VIEWS), - canOpenInsight: true, - }, - { - id: GraphsTab.NUM_SESSION, - title: 'Sessions', - linkText: 'Sessions', - query: { - kind: NodeKind.InsightVizNode, - source: { - kind: NodeKind.TrendsQuery, - dateRange, - interval, - series: [ - { - event: '$pageview', - kind: NodeKind.EventsNode, - math: BaseMathType.UniqueSessions, - name: '$pageview', - custom_name: 'Sessions', + compareFilter: { + compare: compare, }, - ], - trendsFilter: { - display: ChartDisplayType.ActionsLineGraph, - }, - compareFilter: { - compare: compare, + filterTestAccounts, + properties: webAnalyticsFilters, }, - filterTestAccounts, - properties: webAnalyticsFilters, + hidePersonsModal: true, + embedded: true, }, - suppressSessionAnalysisWarning: true, - hidePersonsModal: true, - embedded: true, + showIntervalSelect: true, + insightProps: createInsightProps(TileId.GRAPHS, GraphsTab.UNIQUE_USERS), + canOpenInsight: true, }, - showIntervalSelect: true, - insightProps: createInsightProps(TileId.GRAPHS, GraphsTab.NUM_SESSION), - canOpenInsight: true, - }, - ], + !conversionGoal + ? { + id: GraphsTab.PAGE_VIEWS, + title: 'Page views', + linkText: 'Views', + query: { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + dateRange, + interval, + series: [ + { + event: '$pageview', + kind: NodeKind.EventsNode, + math: BaseMathType.TotalCount, + name: '$pageview', + custom_name: 'Page views', + }, + ], + trendsFilter: { + display: ChartDisplayType.ActionsLineGraph, + }, + compareFilter: { + compare: compare, + }, + filterTestAccounts, + properties: webAnalyticsFilters, + }, + hidePersonsModal: true, + embedded: true, + }, + showIntervalSelect: true, + insightProps: createInsightProps(TileId.GRAPHS, GraphsTab.PAGE_VIEWS), + canOpenInsight: true, + } + : null, + !conversionGoal + ? { + id: GraphsTab.NUM_SESSION, + title: 'Sessions', + linkText: 'Sessions', + query: { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + dateRange, + interval, + series: [ + { + event: '$pageview', + kind: NodeKind.EventsNode, + math: BaseMathType.UniqueSessions, + name: '$pageview', + custom_name: 'Sessions', + }, + ], + trendsFilter: { + display: ChartDisplayType.ActionsLineGraph, + }, + compareFilter: { + compare: compare, + }, + filterTestAccounts, + properties: webAnalyticsFilters, + }, + suppressSessionAnalysisWarning: true, + hidePersonsModal: true, + embedded: true, + }, + showIntervalSelect: true, + insightProps: createInsightProps(TileId.GRAPHS, GraphsTab.NUM_SESSION), + canOpenInsight: true, + } + : null, + conversionGoal + ? { + id: GraphsTab.UNIQUE_CONVERSIONS, + title: 'Unique conversions', + linkText: 'Unique conversions', + query: { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + dateRange, + interval, + series: [ + { + kind: NodeKind.ActionsNode, + id: conversionGoal.actionId, + math: BaseMathType.UniqueUsers, + name: 'Unique conversions', + custom_name: 'Unique conversions', + }, + ], + trendsFilter: { + display: ChartDisplayType.ActionsLineGraph, + }, + compareFilter: { + compare: compare, + }, + filterTestAccounts, + properties: webAnalyticsFilters, + }, + hidePersonsModal: true, + embedded: true, + }, + showIntervalSelect: true, + insightProps: createInsightProps(TileId.GRAPHS, GraphsTab.UNIQUE_USERS), + canOpenInsight: true, + } + : null, + conversionGoal + ? { + id: GraphsTab.TOTAL_CONVERSIONS, + title: 'Total conversions', + linkText: 'Total conversions', + query: { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + dateRange, + interval, + series: [ + { + kind: NodeKind.ActionsNode, + id: conversionGoal.actionId, + math: BaseMathType.TotalCount, + name: 'Total conversions', + custom_name: 'Total conversions', + }, + ], + trendsFilter: { + display: ChartDisplayType.ActionsLineGraph, + }, + compareFilter: { + compare: compare, + }, + filterTestAccounts, + properties: webAnalyticsFilters, + }, + hidePersonsModal: true, + embedded: true, + }, + showIntervalSelect: true, + insightProps: createInsightProps(TileId.GRAPHS, GraphsTab.UNIQUE_USERS), + canOpenInsight: true, + } + : null, + conversionGoal + ? { + id: GraphsTab.CONVERSION_RATE, + title: 'Conversion rate', + linkText: 'Conversion rate', + query: { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + dateRange, + interval, + series: [ + { + kind: NodeKind.ActionsNode, + id: conversionGoal.actionId, + math: BaseMathType.UniqueUsers, + name: 'Unique conversions', + custom_name: 'Unique conversions', + }, + { + event: '$pageview', + kind: NodeKind.EventsNode, + math: BaseMathType.UniqueUsers, + name: 'Pageview', + custom_name: 'Unique visitors', + }, + ], + trendsFilter: { + display: ChartDisplayType.ActionsLineGraph, + formula: 'A / B', + aggregationAxisFormat: 'percentage_scaled', + }, + compareFilter: { + compare: compare, + }, + filterTestAccounts, + properties: webAnalyticsFilters, + }, + hidePersonsModal: true, + embedded: true, + }, + showIntervalSelect: true, + insightProps: createInsightProps(TileId.GRAPHS, GraphsTab.UNIQUE_USERS), + canOpenInsight: true, + } + : null, + ] as (TabsTileTab | null)[] + ).filter(isNotNil), }, { kind: 'tabs', @@ -1490,6 +1635,7 @@ export const webAnalyticsLogic = kea([ const stateToUrl = (): string => { const { webAnalyticsFilters, + conversionGoal, dateFilter: { dateTo, dateFrom, interval }, _sourceTab, _deviceTab, @@ -1504,6 +1650,9 @@ export const webAnalyticsLogic = kea([ if (webAnalyticsFilters.length > 0) { urlParams.set('filters', JSON.stringify(webAnalyticsFilters)) } + if (conversionGoal) { + urlParams.set('conversionGoal', JSON.stringify(conversionGoal)) + } if (dateFrom !== initialDateFrom || dateTo !== initialDateTo || interval !== initialInterval) { urlParams.set('date_from', dateFrom ?? '') urlParams.set('date_to', dateTo ?? '') @@ -1536,6 +1685,7 @@ export const webAnalyticsLogic = kea([ return { setWebAnalyticsFilters: stateToUrl, togglePropertyFilter: stateToUrl, + setConversionGoal: stateToUrl, setDates: stateToUrl, setInterval: stateToUrl, setDeviceTab: stateToUrl, @@ -1551,6 +1701,7 @@ export const webAnalyticsLogic = kea([ _, { filters, + conversionGoal, date_from, date_to, interval, @@ -1568,6 +1719,9 @@ export const webAnalyticsLogic = kea([ if (parsedFilters) { actions.setWebAnalyticsFilters(parsedFilters) } + if (conversionGoal) { + actions.setConversionGoal(conversionGoal) + } if (date_from || date_to || interval) { actions.setDatesAndInterval(date_from, date_to, interval) } @@ -1594,6 +1748,34 @@ export const webAnalyticsLogic = kea([ } }, })), + listeners(({ values, actions }) => { + const checkGraphsTabIsCompatibleWithConversionGoal = ( + tab: string, + conversionGoal: WebAnalyticsConversionGoal | null + ): void => { + if (conversionGoal) { + if (tab === GraphsTab.PAGE_VIEWS || tab === GraphsTab.NUM_SESSION) { + actions.setGraphsTab(GraphsTab.UNIQUE_USERS) + } + } else { + if ( + tab === GraphsTab.TOTAL_CONVERSIONS || + tab === GraphsTab.CONVERSION_RATE || + tab === GraphsTab.UNIQUE_CONVERSIONS + ) { + actions.setGraphsTab(GraphsTab.UNIQUE_USERS) + } + } + } + return { + setGraphsTab: ({ tab }) => { + checkGraphsTabIsCompatibleWithConversionGoal(tab, values.conversionGoal) + }, + setConversionGoal: ({ conversionGoal }) => { + checkGraphsTabIsCompatibleWithConversionGoal(values.graphsTab, conversionGoal) + }, + } + }), ]) const isDefinitionStale = (definition: EventDefinition | PropertyDefinition): boolean => { diff --git a/posthog/hogql_queries/web_analytics/test/test_web_overview.py b/posthog/hogql_queries/web_analytics/test/test_web_overview.py index fba2d56ac9cdc..5ce4107fd5c5b 100644 --- a/posthog/hogql_queries/web_analytics/test/test_web_overview.py +++ b/posthog/hogql_queries/web_analytics/test/test_web_overview.py @@ -7,8 +7,15 @@ from posthog.clickhouse.client.execute import sync_execute from posthog.hogql.constants import LimitContext from posthog.hogql_queries.web_analytics.web_overview import WebOverviewQueryRunner +from posthog.models import Action, Element from posthog.models.utils import uuid7 -from posthog.schema import WebOverviewQuery, DateRange, SessionTableVersion, HogQLQueryModifiers +from posthog.schema import ( + WebOverviewQuery, + DateRange, + SessionTableVersion, + HogQLQueryModifiers, + WebAnalyticsConversionGoal, +) from posthog.settings import HOGQL_INCREASED_MAX_EXECUTION_TIME from posthog.test.base import ( APIBaseTest, @@ -33,13 +40,24 @@ def _create_events(self, data, event="$pageview"): }, ) ) - for timestamp, session_id in timestamps: + for timestamp, session_id, *extra in timestamps: + if event == "$pageview": + url = extra[0] if extra else None + elements = None + elif event == "$autocapture": + url = None + elements = extra[0] if extra else None + else: + url = None + elements = None + _create_event( team=self.team, event=event, distinct_id=id, timestamp=timestamp, - properties={"$session_id": session_id}, + properties={"$session_id": session_id, "$current_url": url}, + elements=elements, ) return person_result @@ -51,6 +69,7 @@ def _run_web_overview_query( compare: bool = True, limit_context: Optional[LimitContext] = None, filter_test_accounts: Optional[bool] = False, + action: Optional[Action] = None, ): modifiers = HogQLQueryModifiers(sessionTableVersion=session_table_version) query = WebOverviewQuery( @@ -59,6 +78,7 @@ def _run_web_overview_query( compare=compare, modifiers=modifiers, filterTestAccounts=filter_test_accounts, + conversionGoal=WebAnalyticsConversionGoal(actionId=action.id) if action else None, ) runner = WebOverviewQueryRunner(team=self.team, query=query, limit_context=limit_context) return runner.calculate() @@ -70,7 +90,35 @@ def test_no_crash_when_no_data(self, session_table_version: SessionTableVersion) "2023-12-15", session_table_version=session_table_version, ).results - self.assertEqual(5, len(results)) + assert [item.key for item in results] == [ + "visitors", + "views", + "sessions", + "session duration", + "bounce rate", + ] + + action = Action.objects.create( + team=self.team, + name="Visited Foo", + steps_json=[ + { + "event": "$pageview", + "url": "https://www.example.com/foo", + "url_matching": "regex", + } + ], + ) + results = self._run_web_overview_query( + "2023-12-08", "2023-12-15", session_table_version=session_table_version, action=action + ).results + + assert [item.key for item in results] == [ + "visitors", + "total conversions", + "unique conversions", + "conversion rate", + ] @parameterized.expand([[SessionTableVersion.V1], [SessionTableVersion.V2]]) def test_increase_in_users(self, session_table_version: SessionTableVersion): @@ -234,6 +282,193 @@ def test_correctly_counts_pageviews_in_long_running_session(self, session_table_ sessions = results[2] self.assertEqual(1, sessions.value) + def test_conversion_goal_no_conversions(self): + s1 = str(uuid7("2023-12-01")) + self._create_events( + [ + ("p1", [("2023-12-01", s1, "https://www.example.com/foo")]), + ] + ) + + action = Action.objects.create( + team=self.team, + name="Visited Foo", + steps_json=[ + { + "event": "$pageview", + "url": "https://www.example.com/bar", + "url_matching": "regex", + } + ], + ) + + results = self._run_web_overview_query("2023-12-01", "2023-12-03", action=action).results + + visitors = results[0] + assert visitors.value == 1 + + conversion = results[1] + assert conversion.value == 0 + + unique_conversions = results[2] + assert unique_conversions.value == 0 + + conversion_rate = results[3] + assert conversion_rate.value == 0 + + def test_conversion_goal_one_pageview_conversion(self): + s1 = str(uuid7("2023-12-01")) + self._create_events( + [ + ("p1", [("2023-12-01", s1, "https://www.example.com/foo")]), + ] + ) + + action = Action.objects.create( + team=self.team, + name="Visited Foo", + steps_json=[ + { + "event": "$pageview", + "url": "https://www.example.com/foo", + "url_matching": "regex", + } + ], + ) + + results = self._run_web_overview_query("2023-12-01", "2023-12-03", action=action).results + + visitors = results[0] + assert visitors.value == 1 + + conversion = results[1] + assert conversion.value == 1 + + unique_conversions = results[2] + assert unique_conversions.value == 1 + + conversion_rate = results[3] + assert conversion_rate.value == 100 + + def test_conversion_goal_one_custom_conversion(self): + s1 = str(uuid7("2023-12-01")) + self._create_events( + [ + ("p1", [("2023-12-01", s1)]), + ], + event="custom_event", + ) + + action = Action.objects.create( + team=self.team, + name="Did Custom Event", + steps_json=[ + { + "event": "custom_event", + } + ], + ) + + results = self._run_web_overview_query("2023-12-01", "2023-12-03", action=action).results + + visitors = results[0] + assert visitors.value == 1 + + conversion = results[1] + assert conversion.value == 1 + + unique_conversions = results[2] + assert unique_conversions.value == 1 + + conversion_rate = results[3] + assert conversion_rate.value == 100 + + def test_conversion_goal_one_autocapture_conversion(self): + s1 = str(uuid7("2023-12-01")) + self._create_events( + [ + ("p1", [("2023-12-01", s1, [Element(nth_of_type=1, nth_child=0, tag_name="button", text="Pay $10")])]), + ], + event="$autocapture", + ) + + action = Action.objects.create( + team=self.team, + name="Paid $10", + steps_json=[ + { + "event": "$autocapture", + "tag_name": "button", + "text": "Pay $10", + } + ], + ) + + results = self._run_web_overview_query("2023-12-01", "2023-12-03", action=action).results + + visitors = results[0] + assert visitors.value == 1 + + conversion = results[1] + assert conversion.value == 1 + + unique_conversions = results[2] + assert unique_conversions.value == 1 + + conversion_rate = results[3] + assert conversion_rate.value == 100 + + def test_conversion_rate(self): + s1 = str(uuid7("2023-12-01")) + s2 = str(uuid7("2023-12-01")) + s3 = str(uuid7("2023-12-01")) + + self._create_events( + [ + ( + "p1", + [ + ("2023-12-01", s1, "https://www.example.com/foo"), + ("2023-12-01", s1, "https://www.example.com/foo"), + ], + ), + ( + "p2", + [ + ("2023-12-01", s2, "https://www.example.com/foo"), + ("2023-12-01", s2, "https://www.example.com/bar"), + ], + ), + ("p3", [("2023-12-01", s3, "https://www.example.com/bar")]), + ] + ) + + action = Action.objects.create( + team=self.team, + name="Visited Foo", + steps_json=[ + { + "event": "$pageview", + "url": "https://www.example.com/foo", + "url_matching": "regex", + } + ], + ) + + results = self._run_web_overview_query("2023-12-01", "2023-12-03", action=action).results + + visitors = results[0] + assert visitors.value == 3 + + conversion = results[1] + assert conversion.value == 3 + + unique_conversions = results[2] + assert unique_conversions.value == 2 + + conversion_rate = results[3] + self.assertAlmostEqual(conversion_rate.value, 100 * 2 / 3) + @patch("posthog.hogql.query.sync_execute", wraps=sync_execute) def test_limit_is_context_aware(self, mock_sync_execute: MagicMock): self._run_web_overview_query("2023-12-01", "2023-12-03", limit_context=LimitContext.QUERY_ASYNC) diff --git a/posthog/hogql_queries/web_analytics/web_overview.py b/posthog/hogql_queries/web_analytics/web_overview.py index 01508965d191c..63b01e6955278 100644 --- a/posthog/hogql_queries/web_analytics/web_overview.py +++ b/posthog/hogql_queries/web_analytics/web_overview.py @@ -1,15 +1,17 @@ from typing import Optional +import math from django.utils.timezone import datetime from posthog.hogql import ast from posthog.hogql.parser import parse_select -from posthog.hogql.property import property_to_expr, get_property_type +from posthog.hogql.property import property_to_expr, get_property_type, action_to_expr from posthog.hogql.query import execute_hogql_query from posthog.hogql_queries.utils.query_date_range import QueryDateRange from posthog.hogql_queries.web_analytics.web_analytics_query_runner import ( WebAnalyticsQueryRunner, ) +from posthog.models import Action from posthog.models.filters.mixins.utils import cached_property from posthog.schema import CachedWebOverviewQueryResponse, WebOverviewQueryResponse, WebOverviewQuery @@ -20,103 +22,7 @@ class WebOverviewQueryRunner(WebAnalyticsQueryRunner): cached_response: CachedWebOverviewQueryResponse def to_query(self) -> ast.SelectQuery | ast.SelectUnionQuery: - with self.timings.measure("date_expr"): - start = self.query_date_range.previous_period_date_from_as_hogql() - mid = self.query_date_range.date_from_as_hogql() - end = self.query_date_range.date_to_as_hogql() - - if self.query.compare: - return parse_select( - """ -SELECT - uniq(if(start_timestamp >= {mid} AND start_timestamp < {end}, person_id, NULL)) AS unique_users, - uniq(if(start_timestamp >= {start} AND start_timestamp < {mid}, person_id, NULL)) AS previous_unique_users, - sumIf(filtered_pageview_count, start_timestamp >= {mid} AND start_timestamp < {end}) AS current_pageviews, - sumIf(filtered_pageview_count, start_timestamp >= {start} AND start_timestamp < {mid}) AS previous_pageviews, - uniq(if(start_timestamp >= {mid} AND start_timestamp < {end}, session_id, NULL)) AS unique_sessions, - uniq(if(start_timestamp >= {start} AND start_timestamp < {mid}, session_id, NULL)) AS previous_unique_sessions, - avg(if(start_timestamp >= {mid}, session_duration, NULL)) AS avg_duration_s, - avg(if(start_timestamp < {mid}, session_duration, NULL)) AS prev_avg_duration_s, - avg(if(start_timestamp >= {mid}, is_bounce, NULL)) AS bounce_rate, - avg(if(start_timestamp < {mid}, is_bounce, NULL)) AS prev_bounce_rate -FROM ( - SELECT - any(events.person_id) as person_id, - session.session_id as session_id, - min(session.$start_timestamp) as start_timestamp, - any(session.$session_duration) as session_duration, - count() as filtered_pageview_count, - any(session.$is_bounce) as is_bounce - FROM events - WHERE and( - events.`$session_id` IS NOT NULL, - event = '$pageview', - timestamp >= {start}, - timestamp < {end}, - {event_properties}, - {session_properties} - ) - GROUP BY session_id - HAVING and( - start_timestamp >= {start}, - start_timestamp < {end} - ) -) - - """, - placeholders={ - "start": start, - "mid": mid, - "end": end, - "event_properties": self.event_properties(), - "session_properties": self.session_properties(), - }, - ) - else: - return parse_select( - """ - SELECT - uniq(person_id) AS unique_users, - NULL as previous_unique_users, - sum(filtered_pageview_count) AS current_pageviews, - NULL as previous_pageviews, - uniq(session_id) AS unique_sessions, - NULL as previous_unique_sessions, - avg(session_duration) AS avg_duration_s, - NULL as prev_avg_duration_s, - avg(is_bounce) AS bounce_rate, - NULL as prev_bounce_rate -FROM ( - SELECT - any(events.person_id) as person_id, - session.session_id as session_id, - min(session.$start_timestamp) as start_timestamp, - any(session.$session_duration) as session_duration, - count() as filtered_pageview_count, - any(session.$is_bounce) as is_bounce - FROM events - WHERE and( - events.`$session_id` IS NOT NULL, - event = '$pageview', - timestamp >= {mid}, - timestamp < {end}, - {event_properties}, - {session_properties} - ) - GROUP BY session_id - HAVING and( - start_timestamp >= {mid}, - start_timestamp < {end} - ) -) - """, - placeholders={ - "mid": mid, - "end": end, - "event_properties": self.event_properties(), - "session_properties": self.session_properties(), - }, - ) + return self.outer_select def calculate(self): response = execute_hogql_query( @@ -131,14 +37,24 @@ def calculate(self): row = response.results[0] - return WebOverviewQueryResponse( - results=[ + if self.query.conversionGoal: + results = [ + to_data("visitors", "unit", self._unsample(row[0]), self._unsample(row[1])), + to_data("total conversions", "unit", self._unsample(row[2]), self._unsample(row[3])), + to_data("unique conversions", "unit", self._unsample(row[4]), self._unsample(row[5])), + to_data("conversion rate", "percentage", row[6], row[7]), + ] + else: + results = [ to_data("visitors", "unit", self._unsample(row[0]), self._unsample(row[1])), to_data("views", "unit", self._unsample(row[2]), self._unsample(row[3])), to_data("sessions", "unit", self._unsample(row[4]), self._unsample(row[5])), to_data("session duration", "duration_s", row[6], row[7]), to_data("bounce rate", "percentage", row[8], row[9], is_increase_bad=True), - ], + ] + + return WebOverviewQueryResponse( + results=results, samplingRate=self._sample_rate, modifiers=self.modifiers, dateFrom=self.query_date_range.date_from_str, @@ -170,6 +86,233 @@ def session_properties(self) -> ast.Expr: ] return property_to_expr(properties, team=self.team, scope="event") + @cached_property + def conversion_goal_action(self) -> Optional[Action]: + if self.query.conversionGoal: + return Action.objects.get(pk=self.query.conversionGoal.actionId) + else: + return None + + @cached_property + def conversion_person_id_expr(self) -> ast.Expr: + if self.conversion_goal_action: + action_expr = action_to_expr(self.conversion_goal_action) + return ast.Call( + name="any", + args=[ + ast.Call( + name="if", + args=[action_expr, ast.Field(chain=["events", "person_id"]), ast.Constant(value=None)], + ) + ], + ) + else: + return ast.Constant(value=None) + + @cached_property + def pageview_count_expression(self) -> ast.Expr: + if self.conversion_goal_action: + return ast.Call( + name="countIf", + args=[ + ast.CompareOperation( + left=ast.Field(chain=["event"]), + op=ast.CompareOperationOp.Eq, + right=ast.Constant(value="$pageview"), + ) + ], + ) + else: + return ast.Call(name="count", args=[]) + + @cached_property + def conversion_count_expr(self) -> ast.Expr: + if self.conversion_goal_action: + action_expr = action_to_expr(self.conversion_goal_action) + return ast.Call(name="countIf", args=[action_expr]) + else: + return ast.Constant(value=None) + + @cached_property + def event_type_expr(self) -> ast.Expr: + pageview_expr = ast.CompareOperation( + op=ast.CompareOperationOp.Eq, left=ast.Field(chain=["event"]), right=ast.Constant(value="$pageview") + ) + + if self.conversion_goal_action: + return ast.Call(name="or", args=[pageview_expr, action_to_expr(self.conversion_goal_action)]) + else: + return pageview_expr + + @cached_property + def inner_select(self) -> ast.SelectQuery: + start = self.query_date_range.previous_period_date_from_as_hogql() + mid = self.query_date_range.date_from_as_hogql() + end = self.query_date_range.date_to_as_hogql() + + parsed_select = parse_select( + """ +SELECT + any(events.person_id) as person_id, + session.session_id as session_id, + min(session.$start_timestamp) as start_timestamp +FROM events +WHERE and( + events.`$session_id` IS NOT NULL, + {event_type_expr}, + timestamp >= {date_range_start}, + timestamp < {date_range_end}, + {event_properties}, + {session_properties} +) +GROUP BY session_id +HAVING and( + start_timestamp >= {date_range_start}, + start_timestamp < {date_range_end} +) + """, + placeholders={ + "date_range_start": start if self.query.compare else mid, + "date_range_end": end, + "event_properties": self.event_properties(), + "session_properties": self.session_properties(), + "conversion_person_id_expr": self.conversion_person_id_expr, + "event_type_expr": self.event_type_expr, + }, + ) + assert isinstance(parsed_select, ast.SelectQuery) + + if self.query.conversionGoal: + parsed_select.select.append(ast.Alias(alias="conversion_count", expr=self.conversion_count_expr)) + parsed_select.select.append(ast.Alias(alias="conversion_person_id", expr=self.conversion_person_id_expr)) + else: + parsed_select.select.append( + ast.Alias( + alias="session_duration", + expr=ast.Call(name="any", args=[ast.Field(chain=["session", "$session_duration"])]), + ) + ) + parsed_select.select.append( + ast.Alias(alias="filtered_pageview_count", expr=ast.Call(name="count", args=[])) + ) + parsed_select.select.append( + ast.Alias( + alias="is_bounce", expr=ast.Call(name="any", args=[ast.Field(chain=["session", "$is_bounce"])]) + ) + ) + + return parsed_select + + @cached_property + def outer_select(self) -> ast.SelectQuery: + start = self.query_date_range.previous_period_date_from_as_hogql() + mid = self.query_date_range.date_from_as_hogql() + end = self.query_date_range.date_to_as_hogql() + + def current_period_aggregate(function_name, column_name, alias): + if self.query.compare: + return ast.Alias( + alias=alias, + expr=ast.Call( + name=function_name + "If", + args=[ + ast.Field(chain=[column_name]), + ast.Call( + name="and", + args=[ + ast.CompareOperation( + op=ast.CompareOperationOp.GtEq, + left=ast.Field(chain=["start_timestamp"]), + right=mid, + ), + ast.CompareOperation( + op=ast.CompareOperationOp.Lt, + left=ast.Field(chain=["start_timestamp"]), + right=end, + ), + ], + ), + ], + ), + ) + else: + return ast.Alias(alias=alias, expr=ast.Call(name=function_name, args=[ast.Field(chain=[column_name])])) + + def previous_period_aggregate(function_name, column_name, alias): + if self.query.compare: + return ast.Alias( + alias=alias, + expr=ast.Call( + name=function_name + "If", + args=[ + ast.Field(chain=[column_name]), + ast.Call( + name="and", + args=[ + ast.CompareOperation( + op=ast.CompareOperationOp.GtEq, + left=ast.Field(chain=["start_timestamp"]), + right=start, + ), + ast.CompareOperation( + op=ast.CompareOperationOp.Lt, + left=ast.Field(chain=["start_timestamp"]), + right=mid, + ), + ], + ), + ], + ), + ) + else: + return ast.Alias(alias=alias, expr=ast.Constant(value=None)) + + if self.query.conversionGoal: + select = [ + current_period_aggregate("uniq", "person_id", "unique_users"), + previous_period_aggregate("uniq", "person_id", "previous_unique_users"), + current_period_aggregate("sum", "conversion_count", "total_conversion_count"), + previous_period_aggregate("sum", "conversion_count", "previous_total_conversion_count"), + current_period_aggregate("uniq", "conversion_person_id", "unique_conversions"), + previous_period_aggregate("uniq", "conversion_person_id", "previous_unique_conversions"), + ast.Alias( + alias="conversion_rate", + expr=ast.Call( + name="divide", args=[ast.Field(chain=["unique_conversions"]), ast.Field(chain=["unique_users"])] + ), + ), + ast.Alias( + alias="previous_conversion_rate", + expr=ast.Call( + name="divide", + args=[ + ast.Field(chain=["previous_unique_conversions"]), + ast.Field(chain=["previous_unique_users"]), + ], + ), + ), + ] + else: + select = [ + current_period_aggregate("uniq", "person_id", "unique_users"), + previous_period_aggregate("uniq", "person_id", "previous_unique_users"), + current_period_aggregate("sum", "filtered_pageview_count", "total_filtered_pageview_count"), + previous_period_aggregate("sum", "filtered_pageview_count", "previous_filtered_pageview_count"), + current_period_aggregate("uniq", "session_id", "unique_sessions"), + previous_period_aggregate("uniq", "session_id", "previous_unique_sessions"), + current_period_aggregate("avg", "session_duration", "avg_duration_s"), + previous_period_aggregate("avg", "session_duration", "prev_avg_duration_s"), + current_period_aggregate("avg", "is_bounce", "bounce_rate"), + previous_period_aggregate("avg", "is_bounce", "prev_bounce_rate"), + ] + + query = ast.SelectQuery( + select=select, + select_from=ast.JoinExpr(table=self.inner_select), + ) + assert isinstance(query, ast.SelectQuery) + return query + def to_data( key: str, @@ -178,19 +321,29 @@ def to_data( previous: Optional[float], is_increase_bad: Optional[bool] = None, ) -> dict: + if value is not None and math.isnan(value): + value = None + if previous is not None and math.isnan(previous): + previous = None if kind == "percentage": if value is not None: value = value * 100 if previous is not None: previous = previous * 100 + try: + if value is not None and previous: + change_from_previous_pct = round(100 * (value - previous) / previous) + else: + change_from_previous_pct = None + except ValueError: + change_from_previous_pct = None + return { "key": key, "kind": kind, "isIncreaseBad": is_increase_bad, "value": value, "previous": previous, - "changeFromPreviousPct": round(100 * (value - previous) / previous) - if value is not None and previous is not None and previous != 0 - else None, + "changeFromPreviousPct": change_from_previous_pct, } diff --git a/posthog/schema.py b/posthog/schema.py index 69b0e6870f77c..cb6f2e8faf5c6 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -1417,6 +1417,13 @@ class VizSpecificOptions(BaseModel): RETENTION: Optional[RETENTION] = None +class WebAnalyticsConversionGoal(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + actionId: int + + class Sampling(BaseModel): model_config = ConfigDict( extra="forbid", @@ -3697,6 +3704,7 @@ class WebExternalClicksTableQuery(BaseModel): model_config = ConfigDict( extra="forbid", ) + conversionGoal: Optional[WebAnalyticsConversionGoal] = None dateRange: Optional[DateRange] = None filterTestAccounts: Optional[bool] = None kind: Literal["WebExternalClicksTableQuery"] = "WebExternalClicksTableQuery" @@ -3715,6 +3723,7 @@ class WebGoalsQuery(BaseModel): model_config = ConfigDict( extra="forbid", ) + conversionGoal: Optional[WebAnalyticsConversionGoal] = None dateRange: Optional[DateRange] = None filterTestAccounts: Optional[bool] = None kind: Literal["WebGoalsQuery"] = "WebGoalsQuery" @@ -3733,6 +3742,7 @@ class WebOverviewQuery(BaseModel): extra="forbid", ) compare: Optional[bool] = None + conversionGoal: Optional[WebAnalyticsConversionGoal] = None dateRange: Optional[DateRange] = None filterTestAccounts: Optional[bool] = None kind: Literal["WebOverviewQuery"] = "WebOverviewQuery" @@ -3750,6 +3760,7 @@ class WebStatsTableQuery(BaseModel): extra="forbid", ) breakdownBy: WebStatsBreakdown + conversionGoal: Optional[WebAnalyticsConversionGoal] = None dateRange: Optional[DateRange] = None doPathCleaning: Optional[bool] = None filterTestAccounts: Optional[bool] = None @@ -3770,6 +3781,7 @@ class WebTopClicksQuery(BaseModel): model_config = ConfigDict( extra="forbid", ) + conversionGoal: Optional[WebAnalyticsConversionGoal] = None dateRange: Optional[DateRange] = None filterTestAccounts: Optional[bool] = None kind: Literal["WebTopClicksQuery"] = "WebTopClicksQuery"