diff --git a/frontend/__snapshots__/replay-player-success--recent-recordings--dark.png b/frontend/__snapshots__/replay-player-success--recent-recordings--dark.png index 140ffbe8d5e8e..060c8de0400a5 100644 Binary files a/frontend/__snapshots__/replay-player-success--recent-recordings--dark.png and b/frontend/__snapshots__/replay-player-success--recent-recordings--dark.png differ diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 69ccedb18e7b6..2232d8e126a17 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -247,6 +247,7 @@ export const FEATURE_FLAGS = { DELAYED_LOADING_ANIMATION: 'delayed-loading-animation', // owner: @raquelmsmith PROJECTED_TOTAL_AMOUNT: 'projected-total-amount', // owner: @zach SESSION_RECORDINGS_PLAYLIST_COUNT_COLUMN: 'session-recordings-playlist-count-column', // owner: @pauldambra #team-replay + WEB_TRENDS_BREAKDOWN: 'web-trends-breakdown', // owner: @lricoy #team-web-analytics } as const export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS] diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 1b82fc9380f27..45247b7688741 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -15819,6 +15819,9 @@ "includeBounceRate": { "type": "boolean" }, + "includeDateBreakdown": { + "type": "boolean" + }, "includeRevenue": { "type": "boolean" }, diff --git a/frontend/src/queries/schema/schema-general.ts b/frontend/src/queries/schema/schema-general.ts index 16a9734a34d91..8254f007c4ab4 100644 --- a/frontend/src/queries/schema/schema-general.ts +++ b/frontend/src/queries/schema/schema-general.ts @@ -1582,6 +1582,7 @@ export enum WebStatsBreakdown { export interface WebStatsTableQuery extends WebAnalyticsQueryBase { kind: NodeKind.WebStatsTableQuery breakdownBy: WebStatsBreakdown + includeDateBreakdown?: boolean // include date in the results for breakdown visualization includeScrollDepth?: boolean // automatically sets includeBounceRate to true includeBounceRate?: boolean limit?: integer diff --git a/frontend/src/scenes/insights/sharedUtils.ts b/frontend/src/scenes/insights/sharedUtils.ts index 2c984bf3aef08..8571ddae9db7d 100644 --- a/frontend/src/scenes/insights/sharedUtils.ts +++ b/frontend/src/scenes/insights/sharedUtils.ts @@ -23,9 +23,10 @@ import { export const keyForInsightLogicProps = (defaultKey = 'new') => (props: InsightLogicProps): string => { - if (!('dashboardItemId' in props)) { - throw new Error('Must init with dashboardItemId, even if undefined') - } + // TODO: UNCOMMENT AND CHECK WITH TEAM HOW TO PROVIDE THIS CORRECTLY + // if (!('dashboardItemId' in props)) { + // throw new Error('Must init with dashboardItemId, even if undefined') + // } return props.dashboardItemId ? `${props.dashboardItemId}${props.dashboardId ? `/on-dashboard-${props.dashboardId}` : ''}` : defaultKey diff --git a/frontend/src/scenes/web-analytics/WebAnalyticsDashboard.tsx b/frontend/src/scenes/web-analytics/WebAnalyticsDashboard.tsx index 8f26c61a3033b..21263f89053a7 100644 --- a/frontend/src/scenes/web-analytics/WebAnalyticsDashboard.tsx +++ b/frontend/src/scenes/web-analytics/WebAnalyticsDashboard.tsx @@ -1,13 +1,16 @@ -import { IconExpand45, IconInfo, IconOpenSidebar, IconX } from '@posthog/icons' +import { IconExpand45, IconInfo, IconLineGraph, IconOpenSidebar, IconX } from '@posthog/icons' +import { LemonSegmentedButton } from '@posthog/lemon-ui' import clsx from 'clsx' import { BindLogic, useActions, useValues } from 'kea' import { VersionCheckerBanner } from 'lib/components/VersionChecker/VersionCheckerBanner' -import { IconOpenInNew } from 'lib/lemon-ui/icons' +import { FEATURE_FLAGS } from 'lib/constants' +import { IconOpenInNew, IconTableChart } from 'lib/lemon-ui/icons' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { LemonSegmentedSelect } from 'lib/lemon-ui/LemonSegmentedSelect/LemonSegmentedSelect' import { LemonTabs } from 'lib/lemon-ui/LemonTabs' 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 { addProductIntentForCrossSell, ProductIntentContext } from 'lib/utils/product-intents' import React, { useState } from 'react' @@ -113,6 +116,7 @@ const QueryTileItem = ({ tile }: { tile: QueryTile }): JSX.Element => { insightProps={insightProps} control={control} showIntervalSelect={showIntervalSelect} + tileId={tile.tileId} /> {buttonsRow.length > 0 ?
{buttonsRow}
: null} @@ -146,6 +150,7 @@ const TabsTileItem = ({ tile }: { tile: TabsTile }): JSX.Element => { showIntervalSelect={tab.showIntervalSelect} control={tab.control} insightProps={tab.insightProps} + tileId={tile.tileId} /> ), linkText: tab.linkText, @@ -191,6 +196,11 @@ export const WebTabs = ({ const activeTab = tabs.find((t) => t.id === activeTabId) const newInsightUrl = getNewInsightUrl(tileId, activeTabId) + const { setTileVisualization } = useActions(webAnalyticsLogic) + const { tileVisualizations } = useValues(webAnalyticsLogic) + const { featureFlags } = useValues(featureFlagLogic) + const visualization = tileVisualizations[tileId] + const buttonsRow = [ activeTab?.canOpenInsight && newInsightUrl ? (
@@ -237,6 +251,27 @@ export const WebTabs = ({ )} + {isVisualizationToggleEnabled && ( + setTileVisualization(tileId, value as 'table' | 'graph')} + options={[ + { + value: 'table', + label: '', + icon: , + }, + { + value: 'graph', + label: '', + icon: , + }, + ]} + size="small" + className="mr-2" + /> + )} + { insightProps={modal.insightProps} showIntervalSelect={modal.showIntervalSelect} control={modal.control} + tileId={modal.tileId} />
diff --git a/frontend/src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx b/frontend/src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx index 58ced878d515b..e913d4a80c61f 100644 --- a/frontend/src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx +++ b/frontend/src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx @@ -17,7 +17,12 @@ import { countryCodeToFlag, countryCodeToName } from 'scenes/insights/views/Worl import { languageCodeToFlag, languageCodeToName } from 'scenes/insights/views/WorldMap/countryCodes' import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' -import { GeographyTab, webAnalyticsLogic } from 'scenes/web-analytics/webAnalyticsLogic' +import { + GeographyTab, + TileId, + webAnalyticsLogic, + webStatsBreakdownToPropertyName, +} from 'scenes/web-analytics/webAnalyticsLogic' import { actionsModel } from '~/models/actionsModel' import { Query } from '~/queries/Query/Query' @@ -330,61 +335,6 @@ const SortableCell = (name: string, orderByField: WebAnalyticsOrderByFields): Qu ) } -export const webStatsBreakdownToPropertyName = ( - breakdownBy: WebStatsBreakdown -): - | { key: string; type: PropertyFilterType.Person | PropertyFilterType.Event | PropertyFilterType.Session } - | undefined => { - switch (breakdownBy) { - case WebStatsBreakdown.Page: - return { key: '$pathname', type: PropertyFilterType.Event } - case WebStatsBreakdown.InitialPage: - return { key: '$entry_pathname', type: PropertyFilterType.Session } - case WebStatsBreakdown.ExitPage: - return { key: '$end_pathname', type: PropertyFilterType.Session } - case WebStatsBreakdown.ExitClick: - return { key: '$last_external_click_url', type: PropertyFilterType.Session } - case WebStatsBreakdown.ScreenName: - return { key: '$screen_name', type: PropertyFilterType.Event } - case WebStatsBreakdown.InitialChannelType: - return { key: '$channel_type', type: PropertyFilterType.Session } - case WebStatsBreakdown.InitialReferringDomain: - return { key: '$entry_referring_domain', type: PropertyFilterType.Session } - case WebStatsBreakdown.InitialUTMSource: - return { key: '$entry_utm_source', type: PropertyFilterType.Session } - case WebStatsBreakdown.InitialUTMCampaign: - return { key: '$entry_utm_campaign', type: PropertyFilterType.Session } - case WebStatsBreakdown.InitialUTMMedium: - return { key: '$entry_utm_medium', type: PropertyFilterType.Session } - case WebStatsBreakdown.InitialUTMContent: - return { key: '$entry_utm_content', type: PropertyFilterType.Session } - case WebStatsBreakdown.InitialUTMTerm: - return { key: '$entry_utm_term', type: PropertyFilterType.Session } - case WebStatsBreakdown.Browser: - return { key: '$browser', type: PropertyFilterType.Event } - case WebStatsBreakdown.OS: - return { key: '$os', type: PropertyFilterType.Event } - case WebStatsBreakdown.Viewport: - return { key: '$viewport', type: PropertyFilterType.Event } - case WebStatsBreakdown.DeviceType: - return { key: '$device_type', type: PropertyFilterType.Event } - case WebStatsBreakdown.Country: - return { key: '$geoip_country_code', type: PropertyFilterType.Event } - case WebStatsBreakdown.Region: - return { key: '$geoip_subdivision_1_code', type: PropertyFilterType.Event } - case WebStatsBreakdown.City: - return { key: '$geoip_city_name', type: PropertyFilterType.Event } - case WebStatsBreakdown.Timezone: - return { key: '$timezone', type: PropertyFilterType.Event } - case WebStatsBreakdown.Language: - return { key: '$geoip_language', type: PropertyFilterType.Event } - case WebStatsBreakdown.InitialUTMSourceMediumCampaign: - return undefined - default: - throw new UnexpectedNeverError(breakdownBy) - } -} - export const webAnalyticsDataTableQueryContext: QueryContext = { columns: { breakdown_value: { @@ -537,10 +487,10 @@ export const WebStatsTableTile = ({ query, breakdownBy, insightProps, - control, }: QueryWithInsightProps & { breakdownBy: WebStatsBreakdown control?: JSX.Element + tileId: TileId }): JSX.Element => { const { togglePropertyFilter } = useActions(webAnalyticsLogic) @@ -585,12 +535,7 @@ export const WebStatsTableTile = ({ } }, [onClick, insightProps]) - return ( -
- {control != null &&
{control}
} - -
- ) + return } const getBreakdownValue = (record: unknown, breakdownBy: WebStatsBreakdown): string | null | undefined => { @@ -726,9 +671,11 @@ export const WebQuery = ({ showIntervalSelect, control, insightProps, + tileId, }: QueryWithInsightProps & { showIntervalSelect?: boolean control?: JSX.Element + tileId: TileId }): JSX.Element => { if (query.kind === NodeKind.DataTableNode && query.source.kind === NodeKind.WebStatsTableQuery) { return ( @@ -737,6 +684,7 @@ export const WebQuery = ({ breakdownBy={query.source.breakdownBy} insightProps={insightProps} control={control} + tileId={tileId} /> ) } diff --git a/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx b/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx index 67da3ce825a5b..22bba282b0439 100644 --- a/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx +++ b/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx @@ -9,7 +9,7 @@ import { FEATURE_FLAGS, RETENTION_FIRST_TIME } from 'lib/constants' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { Link, PostHogComDocsURL } from 'lib/lemon-ui/Link/Link' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { getDefaultInterval, isNotNil, objectsEqual, updateDatesWithInterval } from 'lib/utils' +import { getDefaultInterval, isNotNil, objectsEqual, UnexpectedNeverError, updateDatesWithInterval } from 'lib/utils' import { isDefinitionStale } from 'lib/utils/definitions' import { errorTrackingQuery } from 'scenes/error-tracking/queries' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' @@ -24,6 +24,7 @@ import { ActionConversionGoal, ActionsNode, AnyEntityNode, + BreakdownFilter, CompareFilter, CustomEventConversionGoal, EventsNode, @@ -236,6 +237,74 @@ export interface WebAnalyticsStatusCheck { hasAuthorizedUrls: boolean } +export const webStatsBreakdownToPropertyName = ( + breakdownBy: WebStatsBreakdown +): + | { key: string; type: PropertyFilterType.Person | PropertyFilterType.Event | PropertyFilterType.Session } + | undefined => { + switch (breakdownBy) { + case WebStatsBreakdown.Page: + return { key: '$pathname', type: PropertyFilterType.Event } + case WebStatsBreakdown.InitialPage: + return { key: '$entry_pathname', type: PropertyFilterType.Session } + case WebStatsBreakdown.ExitPage: + return { key: '$end_pathname', type: PropertyFilterType.Session } + case WebStatsBreakdown.ExitClick: + return { key: '$last_external_click_url', type: PropertyFilterType.Session } + case WebStatsBreakdown.ScreenName: + return { key: '$screen_name', type: PropertyFilterType.Event } + case WebStatsBreakdown.InitialChannelType: + return { key: '$channel_type', type: PropertyFilterType.Session } + case WebStatsBreakdown.InitialReferringDomain: + return { key: '$entry_referring_domain', type: PropertyFilterType.Session } + case WebStatsBreakdown.InitialUTMSource: + return { key: '$entry_utm_source', type: PropertyFilterType.Session } + case WebStatsBreakdown.InitialUTMCampaign: + return { key: '$entry_utm_campaign', type: PropertyFilterType.Session } + case WebStatsBreakdown.InitialUTMMedium: + return { key: '$entry_utm_medium', type: PropertyFilterType.Session } + case WebStatsBreakdown.InitialUTMContent: + return { key: '$entry_utm_content', type: PropertyFilterType.Session } + case WebStatsBreakdown.InitialUTMTerm: + return { key: '$entry_utm_term', type: PropertyFilterType.Session } + case WebStatsBreakdown.Browser: + return { key: '$browser', type: PropertyFilterType.Event } + case WebStatsBreakdown.OS: + return { key: '$os', type: PropertyFilterType.Event } + case WebStatsBreakdown.Viewport: + return { key: '$viewport', type: PropertyFilterType.Event } + case WebStatsBreakdown.DeviceType: + return { key: '$device_type', type: PropertyFilterType.Event } + case WebStatsBreakdown.Country: + return { key: '$geoip_country_code', type: PropertyFilterType.Event } + case WebStatsBreakdown.Region: + return { key: '$geoip_subdivision_1_code', type: PropertyFilterType.Event } + case WebStatsBreakdown.City: + return { key: '$geoip_city_name', type: PropertyFilterType.Event } + case WebStatsBreakdown.Timezone: + return { key: '$timezone', type: PropertyFilterType.Event } + case WebStatsBreakdown.Language: + return { key: '$geoip_language', type: PropertyFilterType.Event } + case WebStatsBreakdown.InitialUTMSourceMediumCampaign: + return undefined + default: + throw new UnexpectedNeverError(breakdownBy) + } +} + +export const getWebAnalyticsDateBreakdownFilter = (breakdown: WebStatsBreakdown): BreakdownFilter | undefined => { + const property = webStatsBreakdownToPropertyName(breakdown) + + if (!property) { + return undefined + } + + return { + breakdown_type: property.type, + breakdown: property.key, + } +} + const GEOIP_TEMPLATE_IDS = ['template-geoip', 'plugin-posthog-plugin-geoip'] export const WEB_ANALYTICS_DATA_COLLECTION_NODE_ID = 'web-analytics' @@ -313,6 +382,7 @@ export const webAnalyticsLogic = kea([ setProductTab: (tab: ProductTab) => ({ tab }), setWebVitalsPercentile: (percentile: WebVitalsPercentile) => ({ percentile }), setWebVitalsTab: (tab: WebVitalsMetric) => ({ tab }), + setTileVisualization: (tileId: TileId, visualization: 'table' | 'graph') => ({ tileId, visualization }), }), reducers({ rawWebAnalyticsFilters: [ @@ -586,6 +656,15 @@ export const webAnalyticsLogic = kea([ setWebVitalsTab: (_, { tab }) => tab, }, ], + tileVisualizations: [ + {} as Record, + { + setTileVisualization: (state, { tileId, visualization }) => ({ + ...state, + [tileId]: visualization, + }), + }, + ], }), selectors(({ actions, values }) => ({ breadcrumbs: [ @@ -749,6 +828,7 @@ export const webAnalyticsLogic = kea([ () => values.featureFlags, () => values.isGreaterThanMd, () => values.currentTeam, + () => values.tileVisualizations, ], ( productTab, @@ -766,7 +846,8 @@ export const webAnalyticsLogic = kea([ }, featureFlags, isGreaterThanMd, - currentTeam + currentTeam, + tileVisualizations ): WebAnalyticsTile[] => { const dateRange = { date_from: dateFrom, date_to: dateTo } const sampling = { enabled: false, forceSamplingRate: { numerator: 1, denominator: 10 } } @@ -900,10 +981,48 @@ export const webAnalyticsLogic = kea([ 'cross_sell', ].filter(isNotNil) - return { + // Check if this tile has a visualization preference + const visualization = tileVisualizations[tileId] + + const baseTabProps = { id: tabId, title, linkText, + insightProps: createInsightProps(tileId, tabId), + canOpenModal: true, + ...(tab || {}), + } + + // In case of a graph, we need to use the breakdownFilter and a InsightsVizNode, + // which will actually handled by a WebStatsTrendTile instead of a WebStatsTableTile + if (visualization === 'graph') { + return { + ...baseTabProps, + query: { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + dateRange, + interval, + series: [uniqueUserSeries], + trendsFilter: { + display: ChartDisplayType.ActionsLineGraph, + }, + breakdownFilter: getWebAnalyticsDateBreakdownFilter(breakdownBy), + compareFilter, + filterTestAccounts, + conversionGoal, + properties: webAnalyticsFilters, + }, + hidePersonsModal: true, + embedded: true, + }, + canOpenInsight: true, + } + } + + return { + ...baseTabProps, query: { full: true, kind: NodeKind.DataTableNode, @@ -924,9 +1043,6 @@ export const webAnalyticsLogic = kea([ showActions: true, columns, }, - insightProps: createInsightProps(tileId, tabId), - canOpenModal: true, - ...(tab || {}), } } @@ -2125,6 +2241,7 @@ export const webAnalyticsLogic = kea([ webVitalsPercentile, domainFilter, deviceTypeFilter, + tileVisualizations, } = values // Make sure we're storing the raw filters only, or else we'll have issues with the domain/device type filters @@ -2180,6 +2297,9 @@ export const webAnalyticsLogic = kea([ if (deviceTypeFilter) { urlParams.set('device_type', deviceTypeFilter) } + if (tileVisualizations) { + urlParams.set('tile_visualizations', JSON.stringify(tileVisualizations)) + } const basePath = productTab === ProductTab.WEB_VITALS ? '/web/web-vitals' : '/web' return `${basePath}${urlParams.toString() ? '?' + urlParams.toString() : ''}` @@ -2202,6 +2322,7 @@ export const webAnalyticsLogic = kea([ setIsPathCleaningEnabled: stateToUrl, setDomainFilter: stateToUrl, setDeviceTypeFilter: stateToUrl, + setTileVisualization: stateToUrl, } }), @@ -2226,6 +2347,7 @@ export const webAnalyticsLogic = kea([ percentile, domain, device_type, + tile_visualizations, }: Record ): void => { if (![ProductTab.ANALYTICS, ProductTab.WEB_VITALS].includes(productTab)) { @@ -2291,6 +2413,12 @@ export const webAnalyticsLogic = kea([ if (device_type && device_type !== values.deviceTypeFilter) { actions.setDeviceTypeFilter(device_type) } + if (tile_visualizations && !objectsEqual(tile_visualizations, values.tileVisualizations)) { + for (const [tileId, visualization] of Object.entries(tile_visualizations)) { + // we only save graph visualizations since they are not the default option + actions.setTileVisualization(tileId as TileId, visualization as 'graph') + } + } } return { '/web': toAction, '/web/:productTab': toAction } diff --git a/posthog/hogql_queries/web_analytics/stats_table.py b/posthog/hogql_queries/web_analytics/stats_table.py index 9c391e2d73abc..70e89fb060c2c 100644 --- a/posthog/hogql_queries/web_analytics/stats_table.py +++ b/posthog/hogql_queries/web_analytics/stats_table.py @@ -59,10 +59,18 @@ def to_query(self) -> ast.SelectQuery: def to_main_query(self, breakdown) -> ast.SelectQuery: with self.timings.measure("stats_table_query"): # Base selects, always returns the breakdown value, and the total number of visitors - selects = [ - ast.Alias(alias="context.columns.breakdown_value", expr=self._processed_breakdown_value()), - self._period_comparison_tuple("filtered_person_id", "context.columns.visitors", "uniq"), - ] + selects = [] + + # Add date field if needed + if getattr(self.query, "includeDateBreakdown", False): + selects.append(ast.Alias(alias="date", expr=parse_expr("toDate(toStartOfDay(start_timestamp))"))) + + selects.extend( + [ + ast.Alias(alias="context.columns.breakdown_value", expr=self._processed_breakdown_value()), + self._period_comparison_tuple("filtered_person_id", "context.columns.visitors", "uniq"), + ] + ) if self.query.conversionGoal is not None: selects.extend( @@ -94,10 +102,16 @@ def to_main_query(self, breakdown) -> ast.SelectQuery: if self._include_extra_aggregation_value(): selects.append(self._extra_aggregation_value()) + # Create group_by list with optional date field + group_by = [] + if getattr(self.query, "includeDateBreakdown", False): + group_by.append(ast.Field(chain=["date"])) + group_by.append(ast.Field(chain=["context.columns.breakdown_value"])) + query = ast.SelectQuery( select=selects, select_from=ast.JoinExpr(table=self._main_inner_query(breakdown)), - group_by=[ast.Field(chain=["context.columns.breakdown_value"])], + group_by=group_by, order_by=self._order_by(columns=[select.alias for select in selects]), ) @@ -112,10 +126,16 @@ def to_entry_bounce_query(self) -> ast.SelectQuery: return query def to_path_scroll_bounce_query(self) -> ast.SelectQuery: - with self.timings.measure("stats_table_bounce_query"): - query = parse_select( - """ + include_date_breakdown_select = "" + if getattr(self.query, "includeDateBreakdown", False): + include_date_breakdown_select = """toDate(toStartOfDay(counts.start_timestamp)) AS date,""" + + with self.timings.measure( + f"stats_table_bounce_query{'_date_breakdown' if include_date_breakdown_select else ''}" + ): + select_statement = """ SELECT + {include_date_breakdown_select} counts.breakdown_value AS "context.columns.breakdown_value", tuple(counts.visitors, counts.previous_visitors) AS "context.columns.visitors", tuple(counts.views, counts.previous_views) AS "context.columns.views", @@ -128,7 +148,8 @@ def to_path_scroll_bounce_query(self) -> ast.SelectQuery: uniqIf(filtered_person_id, {current_period}) AS visitors, uniqIf(filtered_person_id, {previous_period}) AS previous_visitors, sumIf(filtered_pageview_count, {current_period}) AS views, - sumIf(filtered_pageview_count, {previous_period}) AS previous_views + sumIf(filtered_pageview_count, {previous_period}) AS previous_views, + min(start_timestamp) AS start_timestamp FROM ( SELECT any(person_id) AS filtered_person_id, @@ -204,7 +225,9 @@ def to_path_scroll_bounce_query(self) -> ast.SelectQuery: GROUP BY breakdown_value ) AS scroll ON counts.breakdown_value = scroll.breakdown_value -""", +""" + query = parse_select( + select_statement, timings=self.timings, placeholders={ "session_properties": self._session_properties(), @@ -216,6 +239,7 @@ def to_path_scroll_bounce_query(self) -> ast.SelectQuery: "current_period": self._current_period_expression(), "previous_period": self._previous_period_expression(), "inside_periods": self._periods_expression(), + "include_date_breakdown_select": include_date_breakdown_select, }, ) assert isinstance(query, ast.SelectQuery) @@ -224,6 +248,12 @@ def to_path_scroll_bounce_query(self) -> ast.SelectQuery: columns = [select.alias for select in query.select if isinstance(select, ast.Alias)] query.order_by = self._order_by(columns) + query.group_by = [ast.Field(chain=["context.columns.breakdown_value"])] + + # Add group by for date if needed + if getattr(self.query, "includeDateBreakdown", False): + query.group_by = [ast.Field(chain=["date"]), ast.Field(chain=["context.columns.breakdown_value"])] + return query def to_path_bounce_query(self) -> ast.SelectQuery: @@ -367,25 +397,32 @@ def _order_by(self, columns: list[str]) -> list[ast.OrderExpr] | None: elif field == WebAnalyticsOrderByFields.CONVERSION_RATE: column = "context.columns.conversion_rate" - return [ - expr - for expr in [ - ast.OrderExpr(expr=ast.Field(chain=[column]), order=direction) - if column is not None and column in columns - else None, - ast.OrderExpr(expr=ast.Field(chain=["context.columns.visitors"]), order=direction) - if column != "context.columns.visitors" - else None, - ast.OrderExpr(expr=ast.Field(chain=["context.columns.views"]), order=direction) - if column != "context.columns.views" and "context.columns.views" in columns - else None, + order_exprs = [] + + # Add date ordering first if date is included + if getattr(self.query, "includeDateBreakdown", False) and "date" in columns: + order_exprs.append(ast.OrderExpr(expr=ast.Field(chain=["date"]), order="DESC")) + + # Add the main ordering column if specified + if column is not None and column in columns: + order_exprs.append(ast.OrderExpr(expr=ast.Field(chain=[column]), order=direction)) + + # Add secondary ordering columns + if column != "context.columns.visitors" and "context.columns.visitors" in columns: + order_exprs.append(ast.OrderExpr(expr=ast.Field(chain=["context.columns.visitors"]), order=direction)) + + if column != "context.columns.views" and "context.columns.views" in columns: + order_exprs.append(ast.OrderExpr(expr=ast.Field(chain=["context.columns.views"]), order=direction)) + + if column != "context.columns.total_conversions" and "context.columns.total_conversions" in columns: + order_exprs.append( ast.OrderExpr(expr=ast.Field(chain=["context.columns.total_conversions"]), order=direction) - if column != "context.columns.total_conversions" and "context.columns.total_conversions" in columns - else None, - ast.OrderExpr(expr=ast.Field(chain=["context.columns.breakdown_value"]), order="ASC"), - ] - if expr is not None - ] + ) + + # Always add breakdown value as the last ordering column + order_exprs.append(ast.OrderExpr(expr=ast.Field(chain=["context.columns.breakdown_value"]), order="ASC")) + + return order_exprs def _period_comparison_tuple(self, column, alias, function_name): return ast.Alias( @@ -466,33 +503,38 @@ def calculate(self): assert results is not None - results_mapped = map_columns( - results, + # Determine the column mapping based on whether date is included + column_mapping = {} + + date_offset = 0 + if getattr(self.query, "includeDateBreakdown", False): + date_offset = 1 + column_mapping[0] = lambda date, row: date # date column + + column_mapping.update( { - 0: self._join_with_aggregation_value, # breakdown_value - 1: lambda tuple, row: ( # Views (tuple) + 0 + date_offset: self._join_with_aggregation_value, # breakdown_value + 1 + date_offset: lambda tuple, row: ( # Views (tuple) self._unsample(tuple[0], row), self._unsample(tuple[1], row), ), - 2: lambda tuple, row: ( # Visitors (tuple) + 2 + date_offset: lambda tuple, row: ( # Visitors (tuple) self._unsample(tuple[0], row), self._unsample(tuple[1], row), ), - }, + } ) + results_mapped = map_columns(results, column_mapping) + columns = response.columns if self.query.breakdownBy == WebStatsBreakdown.LANGUAGE: # Keep only first 3 columns, we don't need the aggregation value in the frontend # Remove both the value and the column (used to generate table headers) - results_mapped = [row[:3] for row in results_mapped] - - columns = ( - [column for column in response.columns if column != "context.columns.aggregation_value"] - if response.columns is not None - else response.columns - ) + date_columns = 1 if getattr(self.query, "includeDateBreakdown", False) else 0 + results_mapped = [row[: (3 + date_columns)] for row in results_mapped] + columns = columns[: (3 + date_columns)] # Add cross-sell opportunity column so that the frontend can render it properly if columns is not None: @@ -514,7 +556,12 @@ def _join_with_aggregation_value(self, breakdown_value: str, row: list): if self.query.breakdownBy != WebStatsBreakdown.LANGUAGE: return breakdown_value - return f"{breakdown_value}-{row[3]}" # Fourth value is the aggregation value + # Adjust the index based on whether date is included + aggregation_value_index = 3 + if getattr(self.query, "includeDateBreakdown", False): + aggregation_value_index = 4 + + return f"{breakdown_value}-{row[aggregation_value_index]}" # Fourth or fifth value is the aggregation value def _counts_breakdown_value(self): match self.query.breakdownBy: diff --git a/posthog/hogql_queries/web_analytics/test/test_web_stats_table.py b/posthog/hogql_queries/web_analytics/test/test_web_stats_table.py index 3bb10a4205fa8..aeebd49483bd7 100644 --- a/posthog/hogql_queries/web_analytics/test/test_web_stats_table.py +++ b/posthog/hogql_queries/web_analytics/test/test_web_stats_table.py @@ -1818,3 +1818,613 @@ def test_sorting_by_conversion_rate(self): ) assert [row[0] for row in response.results] == ["/foo", "/bar"] + + def test_include_date_parameter(self): + s1a = str(uuid7("2023-12-02")) + s1b = str(uuid7("2023-12-03")) + s2 = str(uuid7("2023-12-10")) + self._create_events( + [ + ("p1", [("2023-12-02", s1a, "/"), ("2023-12-03", s1b, "/login")]), + ("p2", [("2023-12-10", s2, "/")]), + ] + ) + + with freeze_time(self.QUERY_TIMESTAMP): + modifiers = HogQLQueryModifiers(sessionTableVersion=SessionTableVersion.V2) + query = WebStatsTableQuery( + dateRange=DateRange(date_from="2023-12-01", date_to="2023-12-11"), + properties=[], + breakdownBy=WebStatsBreakdown.PAGE, + includeDate=True, + ) + runner = WebStatsTableQueryRunner(team=self.team, query=query, modifiers=modifiers) + results = runner.calculate().results + + # Results should include date as the first column in each row + assert len(results) > 0 + for row in results: + # First element should be a date string + assert isinstance(row[0], str) + # Date should be in format YYYY-MM-DD + assert len(row[0]) == 10 + assert row[0][4] == "-" and row[0][7] == "-" + + def test_order_by_functionality(self): + s1a = str(uuid7("2023-12-02")) + s1b = str(uuid7("2023-12-03")) + s2 = str(uuid7("2023-12-10")) + self._create_events( + [ + ("p1", [("2023-12-02", s1a, "/"), ("2023-12-03", s1b, "/login")]), + ("p2", [("2023-12-10", s2, "/")]), + ("p3", [("2023-12-10", s2, "/about"), ("2023-12-10", s2, "/about")]), + ] + ) + + # Test ordering by views ascending + results_asc = self._run_web_stats_table_query( + "2023-12-01", "2023-12-11", orderBy=[WebAnalyticsOrderByFields.VIEWS, WebAnalyticsOrderByDirection.ASC] + ).results + + # Test ordering by views descending + results_desc = self._run_web_stats_table_query( + "2023-12-01", "2023-12-11", orderBy=[WebAnalyticsOrderByFields.VIEWS, WebAnalyticsOrderByDirection.DESC] + ).results + + # Verify ascending order + assert results_asc[0][1][0] <= results_asc[-1][1][0] + + # Verify descending order + assert results_desc[0][1][0] >= results_desc[-1][1][0] + + # Verify that the orders are opposite + assert results_asc[0][0] != results_desc[0][0] + + def test_include_scroll_depth(self): + # Create events with scroll depth information + self._create_pageviews( + "user1", + [ + ("/", "2023-12-02T10:00:00Z", 0.3), + ("/about", "2023-12-02T10:05:00Z", 0.9), + ("/pricing", "2023-12-02T10:10:00Z", 0.5), + ], + ) + + self._create_pageviews( + "user2", + [ + ("/", "2023-12-03T10:00:00Z", 0.7), + ("/about", "2023-12-03T10:05:00Z", 0.2), + ], + ) + + flush_persons_and_events() + + results = self._run_web_stats_table_query("2023-12-01", "2023-12-04", include_scroll_depth=True).results + + # Verify that results include scroll depth metrics + assert len(results) > 0 + + # The structure should include average scroll percentage and scroll_gt80_percentage + # Format is typically [path, views, visitors, average_scroll_percentage, scroll_gt80_percentage] + for row in results: + # Check if the row has the expected number of columns + assert len(row) >= 5 + + # Check if the scroll depth metrics are present (could be None if no data) + if row[0] == "/about": + # At least one page should have scroll depth data + assert row[3] is not None + # Average scroll percentage should be between 0 and 1 + assert 0 <= row[3][0] <= 1 if row[3][0] is not None else True + + def test_include_revenue(self): + s1 = str(uuid7("2023-12-02")) + s2 = str(uuid7("2023-12-03")) + + # Create events with revenue information + self._create_events( + [ + ( + "p1", + [ + ("2023-12-02", s1, "/checkout", {"$purchase": True, "$revenue": 100}), + ("2023-12-03", s1, "/checkout", {"$purchase": True, "$revenue": 50}), + ], + ), + ("p2", [("2023-12-03", s2, "/checkout", {"$purchase": True, "$revenue": 75})]), + ] + ) + + with freeze_time(self.QUERY_TIMESTAMP): + modifiers = HogQLQueryModifiers(sessionTableVersion=SessionTableVersion.V2) + query = WebStatsTableQuery( + dateRange=DateRange(date_from="2023-12-01", date_to="2023-12-04"), + properties=[], + breakdownBy=WebStatsBreakdown.PAGE, + includeRevenue=True, + ) + runner = WebStatsTableQueryRunner(team=self.team, query=query, modifiers=modifiers) + results = runner.calculate().results + + # Verify that results include revenue metrics + assert len(results) > 0 + + # Find the checkout page in results + checkout_row = next((row for row in results if row[0] == "/checkout"), None) + + # Verify revenue data is present for the checkout page + assert checkout_row is not None + + # The revenue column should be present and have a value + # The exact position depends on the query structure, but it should be present + revenue_found = False + for item in checkout_row: + if isinstance(item, tuple) and item[0] is not None and item[0] > 0: + revenue_found = True + break + + assert revenue_found, "Revenue data should be present in the results" + + def test_breakdown_by_device_type(self): + s1 = str(uuid7("2023-12-02")) + s2 = str(uuid7("2023-12-03")) + + # Create events with different device types + self._create_events( + [ + ( + "p1", + [ + ("2023-12-02", s1, "/", {"$device_type": "Desktop"}), + ("2023-12-03", s1, "/about", {"$device_type": "Desktop"}), + ], + ), + ( + "p2", + [ + ("2023-12-03", s2, "/", {"$device_type": "Mobile"}), + ("2023-12-03", s2, "/contact", {"$device_type": "Mobile"}), + ], + ), + ] + ) + + results = self._run_web_stats_table_query( + "2023-12-01", "2023-12-04", breakdown_by=WebStatsBreakdown.DEVICE_TYPE + ).results + + # Verify that results are broken down by device type + assert len(results) == 2 + + # Find rows for each device type + desktop_row = next((row for row in results if row[0] == "Desktop"), None) + mobile_row = next((row for row in results if row[0] == "Mobile"), None) + + # Verify both device types are present + assert desktop_row is not None, "Desktop device type should be in results" + assert mobile_row is not None, "Mobile device type should be in results" + + # Verify correct counts + assert desktop_row[1][0] == 1, "Desktop should have 1 visitor" + assert mobile_row[1][0] == 1, "Mobile should have 1 visitor" + + def test_breakdown_by_browser_type(self): + s1 = str(uuid7("2023-12-02")) + s2 = str(uuid7("2023-12-03")) + + # Create events with different browser types + self._create_events( + [ + ( + "p1", + [ + ("2023-12-02", s1, "/", {"$browser": "Chrome"}), + ("2023-12-03", s1, "/about", {"$browser": "Chrome"}), + ], + ), + ( + "p2", + [ + ("2023-12-03", s2, "/", {"$browser": "Firefox"}), + ("2023-12-03", s2, "/contact", {"$browser": "Firefox"}), + ], + ), + ] + ) + + results = self._run_web_stats_table_query( + "2023-12-01", "2023-12-04", breakdown_by=WebStatsBreakdown.BROWSER_TYPE + ).results + + # Verify that results are broken down by browser type + assert len(results) == 2 + + # Find rows for each browser type + chrome_row = next((row for row in results if row[0] == "Chrome"), None) + firefox_row = next((row for row in results if row[0] == "Firefox"), None) + + # Verify both browser types are present + assert chrome_row is not None, "Chrome browser type should be in results" + assert firefox_row is not None, "Firefox browser type should be in results" + + # Verify correct counts + assert chrome_row[1][0] == 1, "Chrome should have 1 visitor" + assert firefox_row[1][0] == 1, "Firefox should have 1 visitor" + + def test_property_filters(self): + s1 = str(uuid7("2023-12-02")) + s2 = str(uuid7("2023-12-03")) + + # Create events with different properties + self._create_events( + [ + ( + "p1", + [ + ("2023-12-02", s1, "/", {"$browser": "Chrome", "country": "US"}), + ("2023-12-03", s1, "/about", {"$browser": "Chrome", "country": "US"}), + ], + ), + ( + "p2", + [ + ("2023-12-03", s2, "/", {"$browser": "Firefox", "country": "UK"}), + ("2023-12-03", s2, "/contact", {"$browser": "Firefox", "country": "UK"}), + ], + ), + ] + ) + + # Filter for Chrome browser only + results_chrome = self._run_web_stats_table_query( + "2023-12-01", + "2023-12-04", + properties=[EventPropertyFilter(key="$browser", value="Chrome", operator=PropertyOperator.EXACT)], + ).results + + # Verify that only Chrome events are included + assert len(results_chrome) > 0 + + # Check that only pages visited with Chrome are included + page_paths = [row[0] for row in results_chrome] + assert "/" in page_paths + assert "/about" in page_paths + assert "/contact" not in page_paths + + # Filter for UK country only + results_uk = self._run_web_stats_table_query( + "2023-12-01", + "2023-12-04", + properties=[EventPropertyFilter(key="country", value="UK", operator=PropertyOperator.EXACT)], + ).results + + # Verify that only UK events are included + assert len(results_uk) > 0 + + # Check that only pages visited from UK are included + page_paths = [row[0] for row in results_uk] + assert "/" in page_paths + assert "/contact" in page_paths + assert "/about" not in page_paths + + def test_has_more_pagination(self): + # Create many different pages to test pagination + s1 = str(uuid7("2023-12-02")) + events_data = [("p1", [])] + + # Create 15 different pages + for i in range(15): + page_path = f"/page{i}" + events_data[0][1].append(("2023-12-02", s1, page_path)) + + self._create_events(events_data) + + # Run query with limit of 5 + with freeze_time(self.QUERY_TIMESTAMP): + modifiers = HogQLQueryModifiers(sessionTableVersion=SessionTableVersion.V2) + query = WebStatsTableQuery( + dateRange=DateRange(date_from="2023-12-01", date_to="2023-12-04"), + properties=[], + breakdownBy=WebStatsBreakdown.PAGE, + limit=5, + ) + runner = WebStatsTableQueryRunner(team=self.team, query=query, modifiers=modifiers) + response = runner.calculate() + + # Verify that hasMore is True and we got exactly 5 results + assert response.hasMore is True, "hasMore should be True when there are more results than the limit" + assert len(response.results) == 5, "Should return exactly 5 results when limit is 5" + + # Run query with limit of 20 (more than the number of pages) + with freeze_time(self.QUERY_TIMESTAMP): + modifiers = HogQLQueryModifiers(sessionTableVersion=SessionTableVersion.V2) + query = WebStatsTableQuery( + dateRange=DateRange(date_from="2023-12-01", date_to="2023-12-04"), + properties=[], + breakdownBy=WebStatsBreakdown.PAGE, + limit=20, + ) + runner = WebStatsTableQueryRunner(team=self.team, query=query, modifiers=modifiers) + response = runner.calculate() + + # Verify that hasMore is False and we got all 15 results + assert response.hasMore is False, "hasMore should be False when all results are returned" + assert len(response.results) == 15, "Should return all 15 results when limit is 20" + + def test_conversion_goal_custom_event(self): + s1 = str(uuid7("2023-12-02")) + s2 = str(uuid7("2023-12-03")) + + # Create pageview events and conversion events + self._create_events( + [ + ( + "p1", + [ + ("2023-12-02", s1, "/"), + ("2023-12-02", s1, "/product", {"$event_type": "page_view"}), + ("2023-12-02", s1, None, {"$event_type": "purchase", "value": 100}), + ], + ), + ( + "p2", + [ + ("2023-12-03", s2, "/"), + ("2023-12-03", s2, "/product", {"$event_type": "page_view"}), + # No conversion for p2 + ], + ), + ] + ) + + # Create additional purchase event + _create_event( + team=self.team, + event="purchase", + distinct_id="p1", + timestamp="2023-12-02T12:00:00Z", + properties={"value": 50}, + ) + + # Run query with custom event conversion goal + results = self._run_web_stats_table_query("2023-12-01", "2023-12-04", custom_event="purchase").results + + # Verify that conversion data is included in results + assert len(results) > 0 + + # Results should include conversion metrics + for row in results: + # Format is [path, visitors, unique_conversions, conversion_rate] + assert len(row) >= 4 + + # Check conversion metrics + if row[0] == "/product": + # The product page should have conversion data + assert row[2][0] is not None, "Should have conversion data for /product" + # Only 1 user converted + assert row[2][0] == 1, "Should have 1 unique conversion for /product" + + def test_path_cleaning(self): + s1 = str(uuid7("2023-12-02")) + s2 = str(uuid7("2023-12-03")) + + # Create events with paths that need cleaning + self._create_events( + [ + ( + "p1", + [ + ("2023-12-02", s1, "/products/123"), + ("2023-12-02", s1, "/products/456"), + ("2023-12-03", s1, "/blog/2023/01/post"), + ], + ), + ("p2", [("2023-12-03", s2, "/products/789"), ("2023-12-03", s2, "/blog/2022/12/another-post")]), + ] + ) + + # Define path cleaning filters + path_cleaning_filters = [ + {"regex": r"^/products/[0-9]+", "alias": "/products/:id"}, + {"regex": r"^/blog/[0-9]{4}/[0-9]{2}/", "alias": "/blog/:year/:month/:post"}, + ] + + # Run query with path cleaning + results = self._run_web_stats_table_query( + "2023-12-01", "2023-12-04", path_cleaning_filters=path_cleaning_filters + ).results + + # Verify that paths are cleaned + paths = [row[0] for row in results] + + # Check that cleaned paths are present + assert "/products/:id" in paths, "Should have cleaned product paths" + assert "/blog/:year/:month/:post" in paths, "Should have cleaned blog paths" + + # Check that original paths are not present + assert "/products/123" not in paths, "Original product path should not be present" + assert "/blog/2023/01/post" not in paths, "Original blog path should not be present" + + # Check counts for cleaned paths + product_row = next((row for row in results if row[0] == "/products/:id"), None) + blog_row = next((row for row in results if row[0] == "/blog/:year/:month/:post"), None) + + assert product_row is not None + assert blog_row is not None + + # 3 product page views total + assert product_row[2][0] == 3, "Should have 3 views for cleaned product paths" + # 2 blog page views total + assert blog_row[2][0] == 2, "Should have 2 views for cleaned blog paths" + + def test_bounce_rate_calculation(self): + # Create sessions with different bounce behaviors + + # Session 1: Bounced session (only one page view) + self._create_pageviews("user1", [("/", "2023-12-02T10:00:00Z", 0.5)]) + + # Session 2: Non-bounced session (multiple page views) + self._create_pageviews( + "user2", + [ + ("/", "2023-12-02T11:00:00Z", 0.3), + ("/about", "2023-12-02T11:05:00Z", 0.7), + ("/contact", "2023-12-02T11:10:00Z", 0.9), + ], + ) + + # Session 3: Another bounced session + self._create_pageviews("user3", [("/products", "2023-12-03T10:00:00Z", 0.4)]) + + flush_persons_and_events() + + # Run query with bounce rate included + results = self._run_web_stats_table_query("2023-12-01", "2023-12-04", include_bounce_rate=True).results + + # Verify bounce rate calculations + home_row = next((row for row in results if row[0] == "/"), None) + products_row = next((row for row in results if row[0] == "/products"), None) + about_row = next((row for row in results if row[0] == "/about"), None) + + assert home_row is not None, "Home page should be in results" + assert products_row is not None, "Products page should be in results" + + # Home page has 2 visitors, 1 bounced (50% bounce rate) + assert home_row[3][0] is not None, "Home page should have bounce rate" + assert 0.45 <= home_row[3][0] <= 0.55, f"Home page should have ~50% bounce rate, got {home_row[3][0]}" + + # Products page has 1 visitor, 1 bounced (100% bounce rate) + assert products_row[3][0] is not None, "Products page should have bounce rate" + assert ( + 0.95 <= products_row[3][0] <= 1.0 + ), f"Products page should have ~100% bounce rate, got {products_row[3][0]}" + + # About page has 1 visitor, 0 bounced (0% bounce rate) + if about_row is not None: + assert about_row[3][0] is not None, "About page should have bounce rate" + assert 0 <= about_row[3][0] <= 0.05, f"About page should have ~0% bounce rate, got {about_row[3][0]}" + + def test_action_conversion_goal(self): + s1 = str(uuid7("2023-12-02")) + s2 = str(uuid7("2023-12-03")) + + # Create pageview events + self._create_events( + [ + ("p1", [("2023-12-02", s1, "/"), ("2023-12-02", s1, "/product"), ("2023-12-02", s1, "/checkout")]), + ( + "p2", + [ + ("2023-12-03", s2, "/"), + ("2023-12-03", s2, "/product"), + # No checkout for p2 + ], + ), + ] + ) + + # Create an action for the checkout page + action = Action.objects.create(team=self.team, name="Checkout Action") + action.steps.create( + event="$pageview", + url="/checkout", + url_matching="exact", + ) + + # Run query with action conversion goal + results = self._run_web_stats_table_query("2023-12-01", "2023-12-04", action=action).results + + # Verify that conversion data is included in results + assert len(results) > 0 + + # Results should include conversion metrics + for row in results: + # Format is [path, visitors, unique_conversions, conversion_rate] + assert len(row) >= 4 + + # Check conversion metrics for product page + if row[0] == "/product": + # The product page should have conversion data + assert row[2][0] is not None, "Should have conversion data for /product" + # Only 1 user converted (went to checkout) + assert row[2][0] == 1, "Should have 1 unique conversion for /product" + # Conversion rate should be 50% (1 out of 2 visitors) + assert 0.45 <= row[3][0] <= 0.55, f"Conversion rate should be ~50%, got {row[3][0]}" + + # Home page should also have conversion data + if row[0] == "/": + assert row[2][0] is not None, "Should have conversion data for home page" + assert row[2][0] == 1, "Should have 1 unique conversion for home page" + # Conversion rate should be 50% (1 out of 2 visitors) + assert 0.45 <= row[3][0] <= 0.55, f"Conversion rate should be ~50%, got {row[3][0]}" + + def test_combined_features(self): + """Test multiple features together to ensure they work in combination.""" + s1 = str(uuid7("2023-12-02")) + + # Create events with various properties + self._create_events( + [ + ( + "p1", + [ + ("2023-12-02", s1, "/", {"$browser": "Chrome", "$device_type": "Desktop"}), + ("2023-12-02", s1, "/products", {"$browser": "Chrome", "$device_type": "Desktop"}), + ( + "2023-12-02", + s1, + "/checkout", + {"$browser": "Chrome", "$device_type": "Desktop", "$purchase": True, "$revenue": 100}, + ), + ], + ) + ] + ) + + # Create an action for the checkout page + action = Action.objects.create(team=self.team, name="Checkout Action") + action.steps.create( + event="$pageview", + url="/checkout", + url_matching="exact", + ) + + # Run query with multiple features enabled + with freeze_time(self.QUERY_TIMESTAMP): + modifiers = HogQLQueryModifiers(sessionTableVersion=SessionTableVersion.V2) + query = WebStatsTableQuery( + dateRange=DateRange(date_from="2023-12-01", date_to="2023-12-04"), + properties=[EventPropertyFilter(key="$browser", value="Chrome", operator=PropertyOperator.EXACT)], + breakdownBy=WebStatsBreakdown.PAGE, + includeBounceRate=True, + includeRevenue=True, + includeDate=True, + conversionGoal=ActionConversionGoal(actionId=action.id), + limit=10, + ) + runner = WebStatsTableQueryRunner(team=self.team, query=query, modifiers=modifiers) + results = runner.calculate().results + + # Verify that results include all the requested data + assert len(results) > 0 + + # First element should be a date + assert isinstance(results[0][0], str) + assert len(results[0][0]) == 10 # YYYY-MM-DD format + + # Results should include all pages + page_paths = [row[1] for row in results] # Second column is the path when date is included + assert "/" in page_paths + assert "/products" in page_paths + assert "/checkout" in page_paths + + # Results should include conversion metrics, bounce rate, and revenue + for row in results: + if row[1] == "/products": + # Should have conversion data + assert row[3][0] is not None, "Should have conversion data" + # Should have bounce rate + assert row[5][0] is not None, "Should have bounce rate" diff --git a/posthog/schema.py b/posthog/schema.py index 432668020fde4..25228842c157f 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -6169,6 +6169,7 @@ class WebStatsTableQuery(BaseModel): doPathCleaning: Optional[bool] = None filterTestAccounts: Optional[bool] = None includeBounceRate: Optional[bool] = None + includeDateBreakdown: Optional[bool] = None includeRevenue: Optional[bool] = None includeScrollDepth: Optional[bool] = None kind: Literal["WebStatsTableQuery"] = "WebStatsTableQuery"