From 73f3bdfb91d640041c941998c25e356ddab07c1a Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Mon, 30 Oct 2023 16:08:15 +0000 Subject: [PATCH] Add geographic query --- frontend/src/queries/schema.json | 5 +- frontend/src/queries/schema.ts | 3 + .../web-analytics/WebAnalyticsDataTable.tsx | 82 ++++++++-- .../src/scenes/web-analytics/WebDashboard.tsx | 3 +- .../scenes/web-analytics/webAnalyticsLogic.ts | 146 ++++++++++++++---- .../web_analytics/stats_table.py | 32 ++++ posthog/schema.py | 3 + 7 files changed, 231 insertions(+), 43 deletions(-) diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 9d145a188683e..87053dda3304b 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -3176,7 +3176,10 @@ "InitialUTMCampaign", "Browser", "OS", - "DeviceType" + "DeviceType", + "Country", + "Region", + "City" ], "type": "string" }, diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index be180d72e422d..5fa9ca9cdb10e 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -619,6 +619,9 @@ export enum WebStatsBreakdown { Browser = 'Browser', OS = 'OS', DeviceType = 'DeviceType', + Country = 'Country', + Region = 'Region', + City = 'City', } export interface WebStatsTableQuery extends WebAnalyticsQueryBase { kind: NodeKind.WebStatsTableQuery diff --git a/frontend/src/scenes/web-analytics/WebAnalyticsDataTable.tsx b/frontend/src/scenes/web-analytics/WebAnalyticsDataTable.tsx index a291258b012dc..5d896b88f583f 100644 --- a/frontend/src/scenes/web-analytics/WebAnalyticsDataTable.tsx +++ b/frontend/src/scenes/web-analytics/WebAnalyticsDataTable.tsx @@ -5,6 +5,7 @@ import { useActions } from 'kea' import { webAnalyticsLogic } from 'scenes/web-analytics/webAnalyticsLogic' import { useCallback, useMemo } from 'react' import { Query } from '~/queries/Query/Query' +import { countryCodeToFlag, countryCodeToName } from 'scenes/insights/views/WorldMap' const PercentageCell: QueryContextColumnComponent = ({ value }) => { if (typeof value === 'number') { @@ -42,6 +43,12 @@ const BreakdownValueTitle: QueryContextColumnTitleComponent = (props) => { return <>OS case WebStatsBreakdown.DeviceType: return <>Device Type + case WebStatsBreakdown.Country: + return <>Country + case WebStatsBreakdown.Region: + return <>Region + case WebStatsBreakdown.City: + return <>City default: throw new UnexpectedNeverError(breakdownBy) } @@ -53,11 +60,47 @@ const BreakdownValueCell: QueryContextColumnComponent = (props) => { if (source.kind !== NodeKind.WebStatsTableQuery) { return null } - if (typeof value !== 'string') { - return null + const { breakdownBy } = source + + switch (breakdownBy) { + case WebStatsBreakdown.Country: + if (typeof value === 'string') { + const countryCode = value + return ( + <> + {countryCodeToFlag(countryCode)} {countryCodeToName[countryCode] || countryCode} + + ) + } + break + case WebStatsBreakdown.Region: + if (Array.isArray(value)) { + const [countryCode, regionCode, regionName] = value + return ( + <> + {countryCodeToFlag(countryCode)} {countryCodeToName[countryCode] || countryCode} -{' '} + {regionName || regionCode} + + ) + } + break + case WebStatsBreakdown.City: + if (Array.isArray(value)) { + const [countryCode, cityName] = value + return ( + <> + {countryCodeToFlag(countryCode)} {countryCodeToName[countryCode] || countryCode} - {cityName} + + ) + } + break } - return + if (typeof value === 'string') { + return <>{value} + } else { + return null + } } export const webStatsBreakdownToPropertyName = (breakdownBy: WebStatsBreakdown): string => { @@ -78,15 +121,17 @@ export const webStatsBreakdownToPropertyName = (breakdownBy: WebStatsBreakdown): return '$os' case WebStatsBreakdown.DeviceType: return '$device_type' + case WebStatsBreakdown.Country: + return '$geoip_country_code' + case WebStatsBreakdown.Region: + return '$geoip_subdivision_1_code' + case WebStatsBreakdown.City: + return '$geoip_city_name' default: throw new UnexpectedNeverError(breakdownBy) } } -const BreakdownValueCellInner = ({ value }: { value: string }): JSX.Element => { - return {value} -} - export const webAnalyticsDataTableQueryContext: QueryContext = { columns: { breakdown_value: { @@ -130,7 +175,7 @@ export const WebStatsTableTile = ({ const context = useMemo((): QueryContext => { const rowProps: QueryContext['rowProps'] = (record: unknown) => { - const breakdownValue = getBreakdownValue(record) + const breakdownValue = getBreakdownValue(record, breakdownBy) if (breakdownValue === undefined) { return {} } @@ -147,7 +192,7 @@ export const WebStatsTableTile = ({ return } -const getBreakdownValue = (record: unknown): string | undefined => { +const getBreakdownValue = (record: unknown, breakdownBy: WebStatsBreakdown): string | undefined => { if (typeof record !== 'object' || !record || !('result' in record)) { return undefined } @@ -157,6 +202,25 @@ const getBreakdownValue = (record: unknown): string | undefined => { } // assume that the first element is the value const breakdownValue = result[0] + + switch (breakdownBy) { + case WebStatsBreakdown.Country: + if (Array.isArray(breakdownValue)) { + return breakdownValue[0] + } + break + case WebStatsBreakdown.Region: + if (Array.isArray(breakdownValue)) { + return breakdownValue[1] + } + break + case WebStatsBreakdown.City: + if (Array.isArray(breakdownValue)) { + return breakdownValue[1] + } + break + } + if (typeof breakdownValue !== 'string') { return undefined } diff --git a/frontend/src/scenes/web-analytics/WebDashboard.tsx b/frontend/src/scenes/web-analytics/WebDashboard.tsx index 14746c11d7336..6dd31d89923dd 100644 --- a/frontend/src/scenes/web-analytics/WebDashboard.tsx +++ b/frontend/src/scenes/web-analytics/WebDashboard.tsx @@ -2,13 +2,13 @@ import { Query } from '~/queries/Query/Query' import { useActions, useValues } from 'kea' import { TabsTile, webAnalyticsLogic } from 'scenes/web-analytics/webAnalyticsLogic' import { PropertyFilters } from 'lib/components/PropertyFilters/PropertyFilters' -import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' import { isEventPropertyFilter } from 'lib/components/PropertyFilters/utils' import { NodeKind, QuerySchema } from '~/queries/schema' import { DateFilter } from 'lib/components/DateFilter/DateFilter' import { WebAnalyticsNotice } from 'scenes/web-analytics/WebAnalyticsNotice' import { webAnalyticsDataTableQueryContext, WebStatsTableTile } from 'scenes/web-analytics/WebAnalyticsDataTable' import { WebTabs } from 'scenes/web-analytics/WebTabs' +import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' const Filters = (): JSX.Element => { const { webAnalyticsFilters, dateTo, dateFrom } = useValues(webAnalyticsLogic) @@ -22,6 +22,7 @@ const Filters = (): JSX.Element => { onChange={(filters) => setWebAnalyticsFilters(filters.filter(isEventPropertyFilter))} propertyFilters={webAnalyticsFilters} pageKey={'web-analytics'} + eventNames={['$pageview', '$pageleave', '$autocapture']} />
diff --git a/frontend/src/scenes/web-analytics/webAnalyticsLogic.ts b/frontend/src/scenes/web-analytics/webAnalyticsLogic.ts index 744750bfa21de..dcf7b16b4d45c 100644 --- a/frontend/src/scenes/web-analytics/webAnalyticsLogic.ts +++ b/frontend/src/scenes/web-analytics/webAnalyticsLogic.ts @@ -2,7 +2,14 @@ import { actions, connect, kea, listeners, path, reducers, selectors, sharedList import type { webAnalyticsLogicType } from './webAnalyticsLogicType' import { NodeKind, QuerySchema, WebAnalyticsPropertyFilters, WebStatsBreakdown } from '~/queries/schema' -import { EventPropertyFilter, HogQLPropertyFilter, PropertyFilterType, PropertyOperator } from '~/types' +import { + BaseMathType, + ChartDisplayType, + EventPropertyFilter, + HogQLPropertyFilter, + PropertyFilterType, + PropertyOperator, +} from '~/types' import { isNotNil } from 'lib/utils' export interface WebTileLayout { @@ -49,6 +56,13 @@ export enum PathTab { INITIAL_PATH = 'INITIAL_PATH', } +export enum GeographyTab { + MAP = 'MAP', + COUNTRIES = 'COUNTRIES', + REGIONS = 'REGIONS', + CITIES = 'CITIES', +} + export const initialWebAnalyticsFilter = [] as WebAnalyticsPropertyFilters const setOncePropertyNames = ['$initial_pathname', '$initial_referrer', '$initial_utm_source', '$initial_utm_campaign'] @@ -71,6 +85,7 @@ export const webAnalyticsLogic = kea([ setPathTab: (tab: string) => ({ tab, }), + setGeographyTab: (tab: string) => ({ tab }), setDates: (dateFrom: string | null, dateTo: string | null) => ({ dateFrom, dateTo }), }), reducers({ @@ -177,6 +192,12 @@ export const webAnalyticsLogic = kea([ setPathTab: (_, { tab }) => tab, }, ], + geographyTab: [ + GeographyTab.COUNTRIES as string, + { + setGeographyTab: (_, { tab }) => tab, + }, + ], dateFrom: [ '-7d' as string | null, { @@ -192,8 +213,16 @@ export const webAnalyticsLogic = kea([ }), selectors(({ actions }) => ({ tiles: [ - (s) => [s.webAnalyticsFilters, s.pathTab, s.deviceTab, s.sourceTab, s.dateFrom, s.dateTo], - (webAnalyticsFilters, pathTab, deviceTab, sourceTab, dateFrom, dateTo): WebDashboardTile[] => { + (s) => [s.webAnalyticsFilters, s.pathTab, s.deviceTab, s.sourceTab, s.geographyTab, s.dateFrom, s.dateTo], + ( + webAnalyticsFilters, + pathTab, + deviceTab, + sourceTab, + geographyTab, + dateFrom, + dateTo + ): WebDashboardTile[] => { const dateRange = { date_from: dateFrom, date_to: dateTo, @@ -384,35 +413,88 @@ export const webAnalyticsLogic = kea([ // }, // }, // }, - // { - // title: 'World Map (Unique Users)', - // layout: { - // colSpan: 6, - // }, - // query: { - // kind: NodeKind.InsightVizNode, - // source: { - // kind: NodeKind.TrendsQuery, - // breakdown: { - // breakdown: '$geoip_country_code', - // breakdown_type: 'person', - // }, - // dateRange, - // series: [ - // { - // event: '$pageview', - // kind: NodeKind.EventsNode, - // math: BaseMathType.UniqueUsers, - // }, - // ], - // trendsFilter: { - // display: ChartDisplayType.WorldMap, - // }, - // filterTestAccounts: true, - // properties: webAnalyticsFilters, - // }, - // }, - // }, + { + layout: { + colSpan: 6, + }, + activeTabId: geographyTab, + setTabId: actions.setGeographyTab, + tabs: [ + { + id: GeographyTab.MAP, + title: 'World Map', + linkText: 'Map', + query: { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + breakdown: { + breakdown: '$geoip_country_code', + breakdown_type: 'person', + }, + dateRange, + series: [ + { + event: '$pageview', + kind: NodeKind.EventsNode, + math: BaseMathType.UniqueUsers, + }, + ], + trendsFilter: { + display: ChartDisplayType.WorldMap, + }, + filterTestAccounts: true, + properties: webAnalyticsFilters, + }, + }, + }, + { + id: GeographyTab.COUNTRIES, + title: 'Top Countries', + linkText: 'Countries', + query: { + full: true, + kind: NodeKind.DataTableNode, + source: { + kind: NodeKind.WebStatsTableQuery, + properties: webAnalyticsFilters, + breakdownBy: WebStatsBreakdown.Country, + dateRange, + }, + }, + }, + { + id: GeographyTab.REGIONS, + title: 'Top Regions', + linkText: 'Regions', + query: { + full: true, + kind: NodeKind.DataTableNode, + source: { + kind: NodeKind.WebStatsTableQuery, + properties: webAnalyticsFilters, + breakdownBy: WebStatsBreakdown.Region, + dateRange, + }, + }, + }, + { + id: GeographyTab.CITIES, + title: 'Top Cities', + linkText: 'Cities', + query: { + full: true, + kind: NodeKind.DataTableNode, + source: { + kind: NodeKind.WebStatsTableQuery, + properties: webAnalyticsFilters, + breakdownBy: WebStatsBreakdown.City, + dateRange, + }, + }, + }, + ], + }, ] }, ], diff --git a/posthog/hogql_queries/web_analytics/stats_table.py b/posthog/hogql_queries/web_analytics/stats_table.py index dbd92defd2814..24a18b7d48abe 100644 --- a/posthog/hogql_queries/web_analytics/stats_table.py +++ b/posthog/hogql_queries/web_analytics/stats_table.py @@ -55,6 +55,8 @@ def to_query(self) -> ast.SelectQuery | ast.SelectUnionQuery: {bounce_rate_query} AS bounce_rate ON counts.breakdown_value = bounce_rate.breakdown_value +WHERE + {where_breakdown} ORDER BY "context.columns.views" DESC LIMIT 10 @@ -63,6 +65,7 @@ def to_query(self) -> ast.SelectQuery | ast.SelectUnionQuery: placeholders={ "counts_query": counts_query, "bounce_rate_query": bounce_rate_query, + "where_breakdown": self.where_breakdown(), }, backend="cpp", ) @@ -102,6 +105,14 @@ def counts_breakdown(self): return parse_expr("properties.$os") case WebStatsBreakdown.DeviceType: return parse_expr("properties.$device_type") + case WebStatsBreakdown.Country: + return parse_expr("properties.$geoip_country_code") + case WebStatsBreakdown.Region: + return parse_expr( + "tuple(properties.$geoip_country_code, properties.$geoip_subdivision_1_code, properties.$geoip_subdivision_1_name)" + ) + case WebStatsBreakdown.City: + return parse_expr("tuple(properties.$geoip_country_code, properties.$geoip_city_name)") case _: raise NotImplementedError("Breakdown not implemented") @@ -123,5 +134,26 @@ def bounce_breakdown(self): return parse_expr("any(properties.$os)") case WebStatsBreakdown.DeviceType: return parse_expr("any(properties.$device_type)") + case WebStatsBreakdown.Country: + return parse_expr("any(properties.$geoip_country_code)") + case WebStatsBreakdown.Region: + return parse_expr( + "any(tuple(properties.$geoip_country_code, properties.$geoip_subdivision_1_code, properties.$geoip_subdivision_1_name))" + ) + case WebStatsBreakdown.City: + return parse_expr("any(tuple(properties.$geoip_country_code, properties.$geoip_city_name))") case _: raise NotImplementedError("Breakdown not implemented") + + def where_breakdown(self): + match self.query.breakdownBy: + case WebStatsBreakdown.Region: + return parse_expr('tupleElement("context.columns.breakdown_value", 2) IS NOT NULL') + case WebStatsBreakdown.City: + return parse_expr('tupleElement("context.columns.breakdown_value", 2) IS NOT NULL') + case WebStatsBreakdown.InitialUTMSource: + return parse_expr("TRUE") # actually show null values + case WebStatsBreakdown.InitialUTMCampaign: + return parse_expr("TRUE") # actually show null values + case _: + return parse_expr('"context.columns.breakdown_value" IS NOT NULL') diff --git a/posthog/schema.py b/posthog/schema.py index c4b33e70eb11a..e9690957c2793 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -651,6 +651,9 @@ class WebStatsBreakdown(str, Enum): Browser = "Browser" OS = "OS" DeviceType = "DeviceType" + Country = "Country" + Region = "Region" + City = "City" class WebStatsTableQueryResponse(BaseModel):