feat(web-analytics): allow stats table to be displayed as trend (#29497)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Robbie <robbie.coomber@gmail.com>
This commit is contained in:
Lucas Ricoy
2025-03-05 21:35:17 +00:00
committed by GitHub
parent d247f6a3f1
commit b40311bfa9
6 changed files with 197 additions and 73 deletions

View File

@@ -245,6 +245,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_ANALYTICS_TREND_VIZ_TOGGLE: 'web-analytics-trend-viz-toggle', // owner: @lricoy #team-web-analytics
} as const
export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]

View File

@@ -6,15 +6,17 @@ import React from 'react'
import { useSliderPositioning } from '../hooks'
import { LemonButton, LemonButtonProps } from '../LemonButton'
export interface LemonSegmentedButtonOption<T extends React.Key> {
value: T
label: string | JSX.Element
icon?: React.ReactElement
/** Like plain `disabled`, except we enforce a reason to be shown in the tooltip. */
disabledReason?: string
tooltip?: string | JSX.Element
'data-attr'?: string
}
// Expects at least one of label or icon to be provided
export type LemonSegmentedButtonOption<T extends React.Key> = { value: T } & (
| { label: string | JSX.Element }
| { icon: JSX.Element }
) & {
label?: string | JSX.Element
icon?: JSX.Element
disabledReason?: string
tooltip?: string | JSX.Element
'data-attr'?: string
}
export interface LemonSegmentedButtonProps<T extends React.Key> {
value?: T

View File

@@ -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'
@@ -20,6 +23,7 @@ import {
QueryTile,
TabsTile,
TileId,
TileVsualizationOption,
WEB_ANALYTICS_DATA_COLLECTION_NODE_ID,
webAnalyticsLogic,
} from 'scenes/web-analytics/webAnalyticsLogic'
@@ -113,6 +117,7 @@ const QueryTileItem = ({ tile }: { tile: QueryTile }): JSX.Element => {
insightProps={insightProps}
control={control}
showIntervalSelect={showIntervalSelect}
tileId={tile.tileId}
/>
{buttonsRow.length > 0 ? (
@@ -148,6 +153,7 @@ const TabsTileItem = ({ tile }: { tile: TabsTile }): JSX.Element => {
showIntervalSelect={tab.showIntervalSelect}
control={tab.control}
insightProps={tab.insightProps}
tileId={tile.tileId}
/>
),
linkText: tab.linkText,
@@ -193,6 +199,16 @@ export const WebTabs = ({
const activeTab = tabs.find((t) => t.id === activeTabId)
const newInsightUrl = getNewInsightUrl(tileId, activeTabId)
const { featureFlags } = useValues(featureFlagLogic)
const { setTileVisualization } = useActions(webAnalyticsLogic)
const { tileVisualizations } = useValues(webAnalyticsLogic)
const visualization = tileVisualizations[tileId]
const isVisualizationToggleEnabled =
featureFlags[FEATURE_FLAGS.WEB_ANALYTICS_TREND_VIZ_TOGGLE] &&
[TileId.SOURCES, TileId.DEVICES, TileId.PATHS].includes(tileId)
const buttonsRow = [
activeTab?.canOpenInsight && newInsightUrl ? (
<LemonButton
@@ -239,6 +255,25 @@ export const WebTabs = ({
)}
</h2>
{isVisualizationToggleEnabled && (
<LemonSegmentedButton
value={visualization || 'table'}
onChange={(value) => setTileVisualization(tileId, value as TileVsualizationOption)}
options={[
{
value: 'table',
icon: <IconTableChart />,
},
{
value: 'graph',
icon: <IconLineGraph />,
},
]}
size="small"
className="mr-2"
/>
)}
<LemonSegmentedSelect
shrinkOn={7}
size="small"

View File

@@ -43,6 +43,7 @@ export const WebAnalyticsModal = (): JSX.Element | null => {
insightProps={modal.insightProps}
showIntervalSelect={modal.showIntervalSelect}
control={modal.control}
tileId={modal.tileId}
/>
</LemonModal.Content>
<div className="flex flex-row justify-end">

View File

@@ -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'
@@ -315,61 +320,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: {
@@ -526,6 +476,7 @@ export const WebStatsTableTile = ({
}: QueryWithInsightProps<DataTableNode> & {
breakdownBy: WebStatsBreakdown
control?: JSX.Element
tileId: TileId
}): JSX.Element => {
const { togglePropertyFilter } = useActions(webAnalyticsLogic)
@@ -711,9 +662,11 @@ export const WebQuery = ({
showIntervalSelect,
control,
insightProps,
tileId,
}: QueryWithInsightProps<QuerySchema> & {
showIntervalSelect?: boolean
control?: JSX.Element
tileId: TileId
}): JSX.Element => {
if (query.kind === NodeKind.DataTableNode && query.source.kind === NodeKind.WebStatsTableQuery) {
return (
@@ -722,6 +675,7 @@ export const WebQuery = ({
breakdownBy={query.source.breakdownBy}
insightProps={insightProps}
control={control}
tileId={tileId}
/>
)
}

View File

@@ -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,
@@ -113,6 +114,8 @@ const loadPriorityMap: Record<TileId, number> = {
[TileId.WEB_VITALS_PATH_BREAKDOWN]: 12,
}
export type TileVsualizationOption = 'table' | 'graph'
export interface BaseTile {
tileId: TileId
layout: WebTileLayout
@@ -236,6 +239,76 @@ export interface WebAnalyticsStatusCheck {
hasAuthorizedUrls: boolean
}
export type TileVisualizationOption = 'table' | 'graph'
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 getWebAnalyticsBreakdownFilter = (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 +386,7 @@ export const webAnalyticsLogic = kea<webAnalyticsLogicType>([
setProductTab: (tab: ProductTab) => ({ tab }),
setWebVitalsPercentile: (percentile: WebVitalsPercentile) => ({ percentile }),
setWebVitalsTab: (tab: WebVitalsMetric) => ({ tab }),
setTileVisualization: (tileId: TileId, visualization: TileVsualizationOption) => ({ tileId, visualization }),
}),
reducers({
rawWebAnalyticsFilters: [
@@ -586,6 +660,15 @@ export const webAnalyticsLogic = kea<webAnalyticsLogicType>([
setWebVitalsTab: (_, { tab }) => tab,
},
],
tileVisualizations: [
{} as Record<TileId, TileVisualizationOption>,
{
setTileVisualization: (state, { tileId, visualization }) => ({
...state,
[tileId]: visualization,
}),
},
],
}),
selectors(({ actions, values }) => ({
breadcrumbs: [
@@ -749,6 +832,7 @@ export const webAnalyticsLogic = kea<webAnalyticsLogicType>([
() => values.featureFlags,
() => values.isGreaterThanMd,
() => values.currentTeam,
() => values.tileVisualizations,
],
(
productTab,
@@ -766,7 +850,8 @@ export const webAnalyticsLogic = kea<webAnalyticsLogicType>([
},
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 +985,48 @@ export const webAnalyticsLogic = kea<webAnalyticsLogicType>([
'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 be handled by a WebStatsTrendTile instead of a WebStatsTableTile
if (featureFlags[FEATURE_FLAGS.WEB_ANALYTICS_TREND_VIZ_TOGGLE] && visualization === 'graph') {
return {
...baseTabProps,
query: {
kind: NodeKind.InsightVizNode,
source: {
kind: NodeKind.TrendsQuery,
dateRange,
interval,
series: [uniqueUserSeries],
trendsFilter: {
display: ChartDisplayType.ActionsLineGraph,
},
breakdownFilter: getWebAnalyticsBreakdownFilter(breakdownBy),
filterTestAccounts,
conversionGoal,
properties: webAnalyticsFilters,
},
hidePersonsModal: true,
embedded: true,
},
canOpenInsight: true,
canOpenModal: false,
}
}
return {
...baseTabProps,
query: {
full: true,
kind: NodeKind.DataTableNode,
@@ -924,9 +1047,6 @@ export const webAnalyticsLogic = kea<webAnalyticsLogicType>([
showActions: true,
columns,
},
insightProps: createInsightProps(tileId, tabId),
canOpenModal: true,
...(tab || {}),
}
}
@@ -2125,6 +2245,7 @@ export const webAnalyticsLogic = kea<webAnalyticsLogicType>([
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 +2301,9 @@ export const webAnalyticsLogic = kea<webAnalyticsLogicType>([
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 +2326,7 @@ export const webAnalyticsLogic = kea<webAnalyticsLogicType>([
setIsPathCleaningEnabled: stateToUrl,
setDomainFilter: stateToUrl,
setDeviceTypeFilter: stateToUrl,
setTileVisualization: stateToUrl,
}
}),
@@ -2226,6 +2351,7 @@ export const webAnalyticsLogic = kea<webAnalyticsLogicType>([
percentile,
domain,
device_type,
tile_visualizations,
}: Record<string, any>
): void => {
if (![ProductTab.ANALYTICS, ProductTab.WEB_VITALS].includes(productTab)) {
@@ -2291,6 +2417,11 @@ export const webAnalyticsLogic = kea<webAnalyticsLogicType>([
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)) {
actions.setTileVisualization(tileId as TileId, visualization as TileVisualizationOption)
}
}
}
return { '/web': toAction, '/web/:productTab': toAction }