diff --git a/frontend/src/queries/nodes/DataTable/queryFeatures.ts b/frontend/src/queries/nodes/DataTable/queryFeatures.ts index 066774effad9a..ae0351ace46b3 100644 --- a/frontend/src/queries/nodes/DataTable/queryFeatures.ts +++ b/frontend/src/queries/nodes/DataTable/queryFeatures.ts @@ -5,6 +5,7 @@ import { isHogQLQuery, isPersonsNode, isSessionAttributionExplorerQuery, + isWebExternalClicksQuery, isWebGoalsQuery, isWebOverviewQuery, isWebStatsTableQuery, @@ -62,6 +63,7 @@ export function getQueryFeatures(query: Node): Set { if ( isWebOverviewQuery(query) || isWebTopClicksQuery(query) || + isWebExternalClicksQuery(query) || isWebStatsTableQuery(query) || isWebGoalsQuery(query) ) { diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 3a9ea2ab136b2..a043f09145e5c 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -366,6 +366,9 @@ { "$ref": "#/definitions/WebStatsTableQuery" }, + { + "$ref": "#/definitions/WebExternalClicksTableQuery" + }, { "$ref": "#/definitions/WebTopClicksQuery" }, @@ -1901,6 +1904,92 @@ ], "type": "object" }, + "CachedWebExternalClicksTableQueryResponse": { + "additionalProperties": false, + "properties": { + "cache_key": { + "type": "string" + }, + "cache_target_age": { + "format": "date-time", + "type": "string" + }, + "calculation_trigger": { + "description": "What triggered the calculation of the query, leave empty if user/immediate", + "type": "string" + }, + "columns": { + "items": {}, + "type": "array" + }, + "error": { + "description": "Query error. Returned only if 'explain' or `modifiers.debug` is true. Throws an error otherwise.", + "type": "string" + }, + "hasMore": { + "type": "boolean" + }, + "hogql": { + "description": "Generated HogQL query.", + "type": "string" + }, + "is_cached": { + "type": "boolean" + }, + "last_refresh": { + "format": "date-time", + "type": "string" + }, + "limit": { + "type": "integer" + }, + "modifiers": { + "$ref": "#/definitions/HogQLQueryModifiers", + "description": "Modifiers used when performing the query" + }, + "next_allowed_client_refresh": { + "format": "date-time", + "type": "string" + }, + "offset": { + "type": "integer" + }, + "query_status": { + "$ref": "#/definitions/QueryStatus", + "description": "Query status indicates whether next to the provided data, a query is still running." + }, + "results": { + "items": {}, + "type": "array" + }, + "samplingRate": { + "$ref": "#/definitions/SamplingRate" + }, + "timezone": { + "type": "string" + }, + "timings": { + "description": "Measured timings for different parts of the query generation process", + "items": { + "$ref": "#/definitions/QueryTiming" + }, + "type": "array" + }, + "types": { + "items": {}, + "type": "array" + } + }, + "required": [ + "cache_key", + "is_cached", + "last_refresh", + "next_allowed_client_refresh", + "results", + "timezone" + ], + "type": "object" + }, "CachedWebGoalsQueryResponse": { "additionalProperties": false, "properties": { @@ -2764,6 +2853,60 @@ "required": ["results"], "type": "object" }, + { + "additionalProperties": false, + "properties": { + "columns": { + "items": {}, + "type": "array" + }, + "error": { + "description": "Query error. Returned only if 'explain' or `modifiers.debug` is true. Throws an error otherwise.", + "type": "string" + }, + "hasMore": { + "type": "boolean" + }, + "hogql": { + "description": "Generated HogQL query.", + "type": "string" + }, + "limit": { + "type": "integer" + }, + "modifiers": { + "$ref": "#/definitions/HogQLQueryModifiers", + "description": "Modifiers used when performing the query" + }, + "offset": { + "type": "integer" + }, + "query_status": { + "$ref": "#/definitions/QueryStatus", + "description": "Query status indicates whether next to the provided data, a query is still running." + }, + "results": { + "items": {}, + "type": "array" + }, + "samplingRate": { + "$ref": "#/definitions/SamplingRate" + }, + "timings": { + "description": "Measured timings for different parts of the query generation process", + "items": { + "$ref": "#/definitions/QueryTiming" + }, + "type": "array" + }, + "types": { + "items": {}, + "type": "array" + } + }, + "required": ["results"], + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -3061,6 +3204,9 @@ { "$ref": "#/definitions/WebStatsTableQuery" }, + { + "$ref": "#/definitions/WebExternalClicksTableQuery" + }, { "$ref": "#/definitions/WebTopClicksQuery" }, @@ -6372,6 +6518,7 @@ "WebOverviewQuery", "WebTopClicksQuery", "WebStatsTableQuery", + "WebExternalClicksTableQuery", "WebGoalsQuery", "DatabaseSchemaQuery" ], @@ -7368,6 +7515,60 @@ "required": ["results"], "type": "object" }, + { + "additionalProperties": false, + "properties": { + "columns": { + "items": {}, + "type": "array" + }, + "error": { + "description": "Query error. Returned only if 'explain' or `modifiers.debug` is true. Throws an error otherwise.", + "type": "string" + }, + "hasMore": { + "type": "boolean" + }, + "hogql": { + "description": "Generated HogQL query.", + "type": "string" + }, + "limit": { + "type": "integer" + }, + "modifiers": { + "$ref": "#/definitions/HogQLQueryModifiers", + "description": "Modifiers used when performing the query" + }, + "offset": { + "type": "integer" + }, + "query_status": { + "$ref": "#/definitions/QueryStatus", + "description": "Query status indicates whether next to the provided data, a query is still running." + }, + "results": { + "items": {}, + "type": "array" + }, + "samplingRate": { + "$ref": "#/definitions/SamplingRate" + }, + "timings": { + "description": "Measured timings for different parts of the query generation process", + "items": { + "$ref": "#/definitions/QueryTiming" + }, + "type": "array" + }, + "types": { + "items": {}, + "type": "array" + } + }, + "required": ["results"], + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -7859,6 +8060,60 @@ "required": ["results"], "type": "object" }, + { + "additionalProperties": false, + "properties": { + "columns": { + "items": {}, + "type": "array" + }, + "error": { + "description": "Query error. Returned only if 'explain' or `modifiers.debug` is true. Throws an error otherwise.", + "type": "string" + }, + "hasMore": { + "type": "boolean" + }, + "hogql": { + "description": "Generated HogQL query.", + "type": "string" + }, + "limit": { + "type": "integer" + }, + "modifiers": { + "$ref": "#/definitions/HogQLQueryModifiers", + "description": "Modifiers used when performing the query" + }, + "offset": { + "type": "integer" + }, + "query_status": { + "$ref": "#/definitions/QueryStatus", + "description": "Query status indicates whether next to the provided data, a query is still running." + }, + "results": { + "items": {}, + "type": "array" + }, + "samplingRate": { + "$ref": "#/definitions/SamplingRate" + }, + "timings": { + "description": "Measured timings for different parts of the query generation process", + "items": { + "$ref": "#/definitions/QueryTiming" + }, + "type": "array" + }, + "types": { + "items": {}, + "type": "array" + } + }, + "required": ["results"], + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -8402,6 +8657,9 @@ { "$ref": "#/definitions/WebStatsTableQuery" }, + { + "$ref": "#/definitions/WebExternalClicksTableQuery" + }, { "$ref": "#/definitions/WebTopClicksQuery" }, @@ -10020,6 +10278,109 @@ }, "type": "array" }, + "WebExternalClicksTableQuery": { + "additionalProperties": false, + "properties": { + "dateRange": { + "$ref": "#/definitions/DateRange" + }, + "filterTestAccounts": { + "type": "boolean" + }, + "kind": { + "const": "WebExternalClicksTableQuery", + "type": "string" + }, + "limit": { + "type": "integer" + }, + "modifiers": { + "$ref": "#/definitions/HogQLQueryModifiers", + "description": "Modifiers used when performing the query" + }, + "properties": { + "$ref": "#/definitions/WebAnalyticsPropertyFilters" + }, + "response": { + "$ref": "#/definitions/WebExternalClicksTableQueryResponse" + }, + "sampling": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "forceSamplingRate": { + "$ref": "#/definitions/SamplingRate" + } + }, + "type": "object" + }, + "stripQueryParams": { + "type": "boolean" + }, + "useSessionsTable": { + "deprecated": "ignored, always treated as enabled *", + "type": "boolean" + } + }, + "required": ["kind", "properties"], + "type": "object" + }, + "WebExternalClicksTableQueryResponse": { + "additionalProperties": false, + "properties": { + "columns": { + "items": {}, + "type": "array" + }, + "error": { + "description": "Query error. Returned only if 'explain' or `modifiers.debug` is true. Throws an error otherwise.", + "type": "string" + }, + "hasMore": { + "type": "boolean" + }, + "hogql": { + "description": "Generated HogQL query.", + "type": "string" + }, + "limit": { + "type": "integer" + }, + "modifiers": { + "$ref": "#/definitions/HogQLQueryModifiers", + "description": "Modifiers used when performing the query" + }, + "offset": { + "type": "integer" + }, + "query_status": { + "$ref": "#/definitions/QueryStatus", + "description": "Query status indicates whether next to the provided data, a query is still running." + }, + "results": { + "items": {}, + "type": "array" + }, + "samplingRate": { + "$ref": "#/definitions/SamplingRate" + }, + "timings": { + "description": "Measured timings for different parts of the query generation process", + "items": { + "$ref": "#/definitions/QueryTiming" + }, + "type": "array" + }, + "types": { + "items": {}, + "type": "array" + } + }, + "required": ["results"], + "type": "object" + }, "WebGoalsQuery": { "additionalProperties": false, "properties": { diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index 176eb9d8579ab..303e01036a35f 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -92,6 +92,7 @@ export enum NodeKind { WebOverviewQuery = 'WebOverviewQuery', WebTopClicksQuery = 'WebTopClicksQuery', WebStatsTableQuery = 'WebStatsTableQuery', + WebExternalClicksTableQuery = 'WebExternalClicksTableQuery', WebGoalsQuery = 'WebGoalsQuery', // Database metadata @@ -113,6 +114,7 @@ export type AnyDataNode = | HogQLAutocomplete | WebOverviewQuery | WebStatsTableQuery + | WebExternalClicksTableQuery | WebTopClicksQuery | WebGoalsQuery | SessionAttributionExplorerQuery @@ -138,6 +140,7 @@ export type QuerySchema = | HogQLAutocomplete | WebOverviewQuery | WebStatsTableQuery + | WebExternalClicksTableQuery | WebTopClicksQuery | WebGoalsQuery | SessionAttributionExplorerQuery @@ -557,6 +560,7 @@ export interface DataTableNode | HogQLQuery | WebOverviewQuery | WebStatsTableQuery + | WebExternalClicksTableQuery | WebTopClicksQuery | WebGoalsQuery | SessionAttributionExplorerQuery @@ -575,6 +579,7 @@ export interface DataTableNode | HogQLQuery | WebOverviewQuery | WebStatsTableQuery + | WebExternalClicksTableQuery | WebTopClicksQuery | WebGoalsQuery | SessionAttributionExplorerQuery @@ -1331,6 +1336,22 @@ export interface WebStatsTableQueryResponse extends AnalyticsQueryResponseBase +export interface WebExternalClicksTableQuery extends WebAnalyticsQueryBase { + kind: NodeKind.WebExternalClicksTableQuery + limit?: integer + stripQueryParams?: boolean +} +export interface WebExternalClicksTableQueryResponse extends AnalyticsQueryResponseBase { + types?: unknown[] + columns?: unknown[] + hogql?: string + samplingRate?: SamplingRate + hasMore?: boolean + limit?: integer + offset?: integer +} +export type CachedWebExternalClicksTableQueryResponse = CachedQueryResponse + export interface WebGoalsQuery extends WebAnalyticsQueryBase { kind: NodeKind.WebGoalsQuery limit?: integer diff --git a/frontend/src/queries/utils.ts b/frontend/src/queries/utils.ts index db8c0d505ae93..2d140f843a037 100644 --- a/frontend/src/queries/utils.ts +++ b/frontend/src/queries/utils.ts @@ -126,6 +126,10 @@ export function isWebStatsTableQuery(node?: Record | null): node is return node?.kind === NodeKind.WebStatsTableQuery } +export function isWebExternalClicksQuery(node?: Record | null): boolean { + return node?.kind === NodeKind.WebExternalClicksTableQuery +} + export function isWebTopClicksQuery(node?: Record | null): node is WebTopClicksQuery { return node?.kind === NodeKind.WebTopClicksQuery } diff --git a/frontend/src/scenes/saved-insights/SavedInsights.tsx b/frontend/src/scenes/saved-insights/SavedInsights.tsx index 1ef05e3b953aa..63a65094d6431 100644 --- a/frontend/src/scenes/saved-insights/SavedInsights.tsx +++ b/frontend/src/scenes/saved-insights/SavedInsights.tsx @@ -304,6 +304,12 @@ export const QUERY_TYPES_METADATA: Record = { icon: IconPieChart, inMenu: true, }, + [NodeKind.WebExternalClicksTableQuery]: { + name: 'External click urls', + description: 'View clicks on external links', + icon: IconPieChart, + inMenu: true, + }, [NodeKind.HogQuery]: { name: 'Hog', description: 'Hog query', diff --git a/frontend/src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx b/frontend/src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx index 4ce8a66fa7514..93dfe265b699b 100644 --- a/frontend/src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx +++ b/frontend/src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx @@ -190,6 +190,11 @@ export const webAnalyticsDataTableQueryContext: QueryContext = { render: NumericCell, align: 'right', }, + clicks: { + title: 'Clicks', + render: NumericCell, + align: 'right', + }, visitors: { title: 'Visitors', render: NumericCell, @@ -509,6 +514,37 @@ export const WebGoalsTile = ({ ) } + +export const WebExternalClicksTile = ({ + query, + insightProps, +}: { + query: DataTableNode + insightProps: InsightLogicProps +}): JSX.Element | null => { + const { shouldStripQueryParams } = useValues(webAnalyticsLogic) + const { setShouldStripQueryParams } = useActions(webAnalyticsLogic) + return ( +
+
+
+ + Strip query parameters +
+ } + checked={!!shouldStripQueryParams} + onChange={setShouldStripQueryParams} + className="h-full" + /> +
+
+ + + ) +} + export const WebQuery = ({ query, showIntervalSelect, @@ -530,6 +566,9 @@ export const WebQuery = ({ /> ) } + if (query.kind === NodeKind.DataTableNode && query.source.kind === NodeKind.WebExternalClicksTableQuery) { + return + } if (query.kind === NodeKind.InsightVizNode) { return } else if (query.kind === NodeKind.DataTableNode && query.source.kind === NodeKind.WebGoalsQuery) { diff --git a/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx b/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx index 2db3b94cefed9..db4aa46333dd8 100644 --- a/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx +++ b/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx @@ -241,6 +241,9 @@ export const webAnalyticsLogic = kea([ setShouldFilterTestAccounts: (shouldFilterTestAccounts: boolean) => ({ shouldFilterTestAccounts, }), + setShouldStripQueryParams: (shouldStripQueryParams: boolean) => ({ + shouldStripQueryParams, + }), setStateFromUrl: (state: { filters: WebAnalyticsPropertyFilters dateFrom: string | null @@ -441,6 +444,13 @@ export const webAnalyticsLogic = kea([ setShouldFilterTestAccounts: (_, { shouldFilterTestAccounts }) => shouldFilterTestAccounts, }, ], + shouldStripQueryParams: [ + false as boolean, + { persist: true }, + { + setShouldStripQueryParams: (_, { shouldStripQueryParams }) => shouldStripQueryParams, + }, + ], }), selectors(({ actions, values }) => ({ graphsTab: [(s) => [s._graphsTab], (graphsTab: string | null) => graphsTab || GraphsTab.UNIQUE_USERS], @@ -470,6 +480,7 @@ export const webAnalyticsLogic = kea([ () => values.isGreaterThanMd, () => values.shouldShowGeographyTile, () => values.featureFlags, + () => values.shouldStripQueryParams, ], ( webAnalyticsFilters, @@ -481,7 +492,8 @@ export const webAnalyticsLogic = kea([ _statusCheck, isGreaterThanMd, shouldShowGeographyTile, - featureFlags + featureFlags, + shouldStripQueryParams ): WebDashboardTile[] => { const dateRange = { date_from: dateFrom, @@ -725,20 +737,19 @@ export const webAnalyticsLogic = kea([ featureFlags[FEATURE_FLAGS.WEB_ANALYTICS_LAST_CLICK] ? { id: PathTab.EXIT_CLICK, - title: 'Exit clicks', - linkText: 'Exit clicks', + title: 'Outbound link clicks', + linkText: 'Outbound clicks', query: { full: true, kind: NodeKind.DataTableNode, source: { - kind: NodeKind.WebStatsTableQuery, + kind: NodeKind.WebExternalClicksTableQuery, properties: webAnalyticsFilters, - breakdownBy: WebStatsBreakdown.ExitClick, dateRange, - includeScrollDepth: false, sampling, limit: 10, filterTestAccounts, + stripQueryParams: shouldStripQueryParams, }, embedded: false, }, diff --git a/posthog/hogql_queries/query_runner.py b/posthog/hogql_queries/query_runner.py index 824e2e1e91177..4b036a63f9a6b 100644 --- a/posthog/hogql_queries/query_runner.py +++ b/posthog/hogql_queries/query_runner.py @@ -330,6 +330,17 @@ def get_query_runner( limit_context=limit_context, ) + if kind == "WebExternalClicksTableQuery": + from .web_analytics.external_clicks import WebExternalClicksTableQueryRunner + + return WebExternalClicksTableQueryRunner( + query=query, + team=team, + timings=timings, + modifiers=modifiers, + limit_context=limit_context, + ) + if kind == "SessionAttributionExplorerQuery": from .web_analytics.session_attribution_explorer_query_runner import SessionAttributionExplorerQueryRunner diff --git a/posthog/hogql_queries/web_analytics/external_clicks.py b/posthog/hogql_queries/web_analytics/external_clicks.py new file mode 100644 index 0000000000000..f4d46249446de --- /dev/null +++ b/posthog/hogql_queries/web_analytics/external_clicks.py @@ -0,0 +1,119 @@ +from posthog.hogql import ast +from posthog.hogql.constants import LimitContext +from posthog.hogql.parser import parse_select +from posthog.hogql.property import ( + property_to_expr, +) +from posthog.hogql_queries.insights.paginators import HogQLHasMorePaginator +from posthog.hogql_queries.web_analytics.web_analytics_query_runner import ( + WebAnalyticsQueryRunner, + map_columns, +) +from posthog.schema import ( + CachedWebStatsTableQueryResponse, + WebStatsTableQueryResponse, + WebExternalClicksTableQuery, + WebExternalClicksTableQueryResponse, +) + + +class WebExternalClicksTableQueryRunner(WebAnalyticsQueryRunner): + query: WebExternalClicksTableQuery + response: WebExternalClicksTableQueryResponse + cached_response: CachedWebStatsTableQueryResponse + paginator: HogQLHasMorePaginator + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.paginator = HogQLHasMorePaginator.from_limit_context( + limit_context=LimitContext.QUERY, limit=self.query.limit if self.query.limit else None + ) + + def to_query(self) -> ast.SelectQuery: + if self.query.stripQueryParams: + url_expr = ast.Call( + name="cutQueryStringAndFragment", + args=[ast.Field(chain=["properties", "$external_click_url"])], + ) + else: + url_expr = ast.Field(chain=["properties", "$external_click_url"]) + + with self.timings.measure("stats_table_query"): + query = parse_select( + """ +SELECT + url AS "context.columns.url", + uniq(filtered_person_id) AS "context.columns.visitors", + sum(filtered_click_count) AS "context.columns.clicks" +FROM ( + SELECT + any(person_id) AS filtered_person_id, + count() AS filtered_click_count, + {url_expr} AS url + FROM events + WHERE and( + timestamp >= {date_from}, + timestamp < {date_to}, + events.event == '$autocapture', + events.properties.$event_type == 'click', + url IS NOT NULL, + url != '', + {all_properties} + ) + GROUP BY events.`$session_id`, url +) +GROUP BY "context.columns.url" +ORDER BY "context.columns.visitors" DESC, +"context.columns.url" ASC +""", + timings=self.timings, + placeholders={ + "url_expr": url_expr, + "all_properties": self._all_properties(), + "date_from": self._date_from(), + "date_to": self._date_to(), + }, + ) + assert isinstance(query, ast.SelectQuery) + return query + + def _all_properties(self) -> ast.Expr: + properties = self.query.properties + self._test_account_filters + return property_to_expr(properties, team=self.team) + + def _date_to(self) -> ast.Expr: + return self.query_date_range.date_to_as_hogql() + + def _date_from(self) -> ast.Expr: + return self.query_date_range.date_from_as_hogql() + + def calculate(self): + query = self.to_query() + response = self.paginator.execute_hogql_query( + query_type="stats_table_query", + query=query, + team=self.team, + timings=self.timings, + modifiers=self.modifiers, + ) + results = self.paginator.results + + assert results is not None + + results_mapped = map_columns( + results, + { + 1: self._unsample, # views + 2: self._unsample, # visitors + }, + ) + + return WebStatsTableQueryResponse( + columns=response.columns, + results=results_mapped, + timings=response.timings, + types=response.types, + hogql=response.hogql, + modifiers=self.modifiers, + **self.paginator.response_params(), + ) diff --git a/posthog/hogql_queries/web_analytics/test/test_external_clicks_table.py b/posthog/hogql_queries/web_analytics/test/test_external_clicks_table.py new file mode 100644 index 0000000000000..19258264e0aed --- /dev/null +++ b/posthog/hogql_queries/web_analytics/test/test_external_clicks_table.py @@ -0,0 +1,211 @@ +from typing import Optional + +from freezegun import freeze_time + +from posthog.hogql_queries.web_analytics.external_clicks import WebExternalClicksTableQueryRunner +from posthog.models.utils import uuid7 +from posthog.schema import ( + DateRange, + SessionTableVersion, + HogQLQueryModifiers, + WebExternalClicksTableQuery, +) +from posthog.test.base import ( + APIBaseTest, + ClickhouseTestMixin, + _create_event, + _create_person, +) + + +class TestExternalClicksTableQueryRunner(ClickhouseTestMixin, APIBaseTest): + def _create_events(self, data, event="$autocapture"): + person_result = [] + for id, timestamps in data: + with freeze_time(timestamps[0][0]): + person_result.append( + _create_person( + team_id=self.team.pk, + distinct_ids=[id], + properties={ + "name": id, + **({"email": "test@posthog.com"} if id == "test" else {}), + }, + ) + ) + for timestamp, session_id, pathname, click in timestamps: + _create_event( + team=self.team, + event=event, + distinct_id=id, + timestamp=timestamp, + properties={ + "$session_id": session_id, + "$pathname": pathname, + "$event_type": "click", + "$external_click_url": click, + }, + elements_chain=f'a:href="{click}"', + ) + return person_result + + def _run_external_clicks_table_query( + self, + date_from, + date_to, + limit=None, + properties=None, + session_table_version: SessionTableVersion = SessionTableVersion.V2, + filter_test_accounts: Optional[bool] = False, + strip_query_params: Optional[bool] = False, + ): + modifiers = HogQLQueryModifiers(sessionTableVersion=session_table_version) + query = WebExternalClicksTableQuery( + dateRange=DateRange(date_from=date_from, date_to=date_to), + properties=properties or [], + limit=limit, + filterTestAccounts=filter_test_accounts, + stripQueryParams=strip_query_params, + ) + runner = WebExternalClicksTableQueryRunner(team=self.team, query=query, modifiers=modifiers) + return runner.calculate() + + def test_no_crash_when_no_data(self): + results = self._run_external_clicks_table_query("2023-12-08", "2023-12-15").results + self.assertEqual([], results) + + def test_increase_in_users( + self, + ): + s1a = str(uuid7("2023-12-02")) + s1b = str(uuid7("2023-12-13")) + s2 = str(uuid7("2023-12-10")) + self._create_events( + [ + ( + "p1", + [ + ("2023-12-02", s1a, "/", "https://www.example.com/"), + ("2023-12-03", s1a, "/login", "https://www.example.com/login"), + ("2023-12-13", s1b, "/docs", "https://www.example.com/docs"), + ], + ), + ("p2", [("2023-12-10", s2, "/", "https://www.example.com/")]), + ] + ) + + results = self._run_external_clicks_table_query("2023-12-01", "2023-12-11").results + + self.assertEqual( + [ + ["https://www.example.com/", 2, 2], + ["https://www.example.com/login", 1, 1], + ], + results, + ) + + def test_all_time(self): + s1a = str(uuid7("2023-12-02")) + s1b = str(uuid7("2023-12-13")) + s2 = str(uuid7("2023-12-10")) + self._create_events( + [ + ( + "p1", + [ + ("2023-12-02", s1a, "/", "https://www.example.com/"), + ("2023-12-03", s1a, "/login", "https://www.example.com/login"), + ("2023-12-13", s1b, "/docs", "https://www.example.com/docs"), + ], + ), + ("p2", [("2023-12-10", s2, "/", "https://www.example.com/")]), + ] + ) + + results = self._run_external_clicks_table_query("all", "2023-12-15").results + + self.assertEqual( + [ + ["https://www.example.com/", 2, 2], + ["https://www.example.com/docs", 1, 1], + ["https://www.example.com/login", 1, 1], + ], + results, + ) + + def test_filter_test_accounts(self): + s1 = str(uuid7("2023-12-02")) + # Create 1 test account + self._create_events( + [ + ( + "test", + [ + ("2023-12-02", s1, "/", "https://www.example.com/"), + ("2023-12-03", s1, "/login", "https://www.example.com/login"), + ], + ) + ] + ) + + results = self._run_external_clicks_table_query("2023-12-01", "2023-12-03", filter_test_accounts=True).results + + self.assertEqual( + [], + results, + ) + + def test_dont_filter_test_accounts(self): + s1 = str(uuid7("2023-12-02")) + # Create 1 test account + self._create_events( + [ + ( + "test", + [ + ("2023-12-02", s1, "/", "https://www.example.com/"), + ("2023-12-03", s1, "/login", "https://www.example.com/login"), + ], + ) + ] + ) + + results = self._run_external_clicks_table_query("2023-12-01", "2023-12-03", filter_test_accounts=False).results + + self.assertEqual( + [["https://www.example.com/", 1, 1], ["https://www.example.com/login", 1, 1]], + results, + ) + + def test_strip_query_params(self): + s1 = str(uuid7("2023-12-02")) + # Create 1 test account + self._create_events( + [ + ( + "test", + [ + ("2023-12-02", s1, "/login", "https://www.example.com/login?test=1#foo"), + ("2023-12-03", s1, "/login", "https://www.example.com/login#bar"), + ], + ) + ] + ) + + results_strip = self._run_external_clicks_table_query( + "2023-12-01", "2023-12-03", filter_test_accounts=False, strip_query_params=True + ).results + + self.assertEqual( + [["https://www.example.com/login", 1, 2]], + results_strip, + ) + + results_no_strip = self._run_external_clicks_table_query( + "2023-12-01", "2023-12-03", filter_test_accounts=False, strip_query_params=False + ).results + + self.assertEqual( + [["https://www.example.com/login#bar", 1, 1], ["https://www.example.com/login?test=1#foo", 1, 1]], + results_no_strip, + ) diff --git a/posthog/hogql_queries/web_analytics/web_analytics_query_runner.py b/posthog/hogql_queries/web_analytics/web_analytics_query_runner.py index 7f0f2790fd5b2..d3e2271e6a3ed 100644 --- a/posthog/hogql_queries/web_analytics/web_analytics_query_runner.py +++ b/posthog/hogql_queries/web_analytics/web_analytics_query_runner.py @@ -25,10 +25,13 @@ SamplingRate, SessionPropertyFilter, WebGoalsQuery, + WebExternalClicksTableQuery, ) from posthog.utils import generate_cache_key, get_safe_cache -WebQueryNode = Union[WebOverviewQuery, WebTopClicksQuery, WebStatsTableQuery, WebGoalsQuery] +WebQueryNode = Union[ + WebOverviewQuery, WebTopClicksQuery, WebStatsTableQuery, WebGoalsQuery, WebExternalClicksTableQuery +] class WebAnalyticsQueryRunner(QueryRunner, ABC): diff --git a/posthog/schema.py b/posthog/schema.py index 557a1fb215f17..68b74e711e2a8 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -819,6 +819,7 @@ class NodeKind(StrEnum): WEB_OVERVIEW_QUERY = "WebOverviewQuery" WEB_TOP_CLICKS_QUERY = "WebTopClicksQuery" WEB_STATS_TABLE_QUERY = "WebStatsTableQuery" + WEB_EXTERNAL_CLICKS_TABLE_QUERY = "WebExternalClicksTableQuery" WEB_GOALS_QUERY = "WebGoalsQuery" DATABASE_SCHEMA_QUERY = "DatabaseSchemaQuery" @@ -1393,6 +1394,33 @@ class Sampling(BaseModel): forceSamplingRate: Optional[SamplingRate] = None +class WebExternalClicksTableQueryResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + columns: Optional[list] = None + error: Optional[str] = Field( + default=None, + description="Query error. Returned only if 'explain' or `modifiers.debug` is true. Throws an error otherwise.", + ) + hasMore: Optional[bool] = None + hogql: Optional[str] = Field(default=None, description="Generated HogQL query.") + limit: Optional[int] = None + modifiers: Optional[HogQLQueryModifiers] = Field( + default=None, description="Modifiers used when performing the query" + ) + offset: Optional[int] = None + query_status: Optional[QueryStatus] = Field( + default=None, description="Query status indicates whether next to the provided data, a query is still running." + ) + results: list + samplingRate: Optional[SamplingRate] = None + timings: Optional[list[QueryTiming]] = Field( + default=None, description="Measured timings for different parts of the query generation process" + ) + types: Optional[list] = None + + class WebGoalsQueryResponse(BaseModel): model_config = ConfigDict( extra="forbid", @@ -1972,6 +2000,42 @@ class CachedTrendsQueryResponse(BaseModel): ) +class CachedWebExternalClicksTableQueryResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + cache_key: str + cache_target_age: Optional[AwareDatetime] = None + calculation_trigger: Optional[str] = Field( + default=None, description="What triggered the calculation of the query, leave empty if user/immediate" + ) + columns: Optional[list] = None + error: Optional[str] = Field( + default=None, + description="Query error. Returned only if 'explain' or `modifiers.debug` is true. Throws an error otherwise.", + ) + hasMore: Optional[bool] = None + hogql: Optional[str] = Field(default=None, description="Generated HogQL query.") + is_cached: bool + last_refresh: AwareDatetime + limit: Optional[int] = None + modifiers: Optional[HogQLQueryModifiers] = Field( + default=None, description="Modifiers used when performing the query" + ) + next_allowed_client_refresh: AwareDatetime + offset: Optional[int] = None + query_status: Optional[QueryStatus] = Field( + default=None, description="Query status indicates whether next to the provided data, a query is still running." + ) + results: list + samplingRate: Optional[SamplingRate] = None + timezone: str + timings: Optional[list[QueryTiming]] = Field( + default=None, description="Measured timings for different parts of the query generation process" + ) + types: Optional[list] = None + + class CachedWebGoalsQueryResponse(BaseModel): model_config = ConfigDict( extra="forbid", @@ -2245,7 +2309,7 @@ class Response4(BaseModel): types: Optional[list] = None -class Response5(BaseModel): +class Response6(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -2269,7 +2333,7 @@ class Response5(BaseModel): types: Optional[list] = None -class Response6(BaseModel): +class Response7(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -2296,7 +2360,7 @@ class Response6(BaseModel): types: Optional[list] = None -class Response7(BaseModel): +class Response8(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -2322,7 +2386,7 @@ class Response7(BaseModel): types: Optional[list] = None -class Response8(BaseModel): +class Response9(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -2951,7 +3015,7 @@ class QueryResponseAlternative10(BaseModel): types: Optional[list] = None -class QueryResponseAlternative11(BaseModel): +class QueryResponseAlternative12(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -2975,7 +3039,7 @@ class QueryResponseAlternative11(BaseModel): types: Optional[list] = None -class QueryResponseAlternative12(BaseModel): +class QueryResponseAlternative13(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3002,7 +3066,7 @@ class QueryResponseAlternative12(BaseModel): types: Optional[list] = None -class QueryResponseAlternative13(BaseModel): +class QueryResponseAlternative14(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3028,7 +3092,7 @@ class QueryResponseAlternative13(BaseModel): types: Optional[list] = None -class QueryResponseAlternative14(BaseModel): +class QueryResponseAlternative15(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3053,7 +3117,7 @@ class QueryResponseAlternative14(BaseModel): ) -class QueryResponseAlternative15(BaseModel): +class QueryResponseAlternative16(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3079,7 +3143,7 @@ class QueryResponseAlternative15(BaseModel): types: list[str] -class QueryResponseAlternative16(BaseModel): +class QueryResponseAlternative17(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3106,7 +3170,7 @@ class QueryResponseAlternative16(BaseModel): types: list[str] -class QueryResponseAlternative17(BaseModel): +class QueryResponseAlternative18(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3136,7 +3200,7 @@ class QueryResponseAlternative17(BaseModel): types: Optional[list] = Field(default=None, description="Types of returned columns") -class QueryResponseAlternative18(BaseModel): +class QueryResponseAlternative19(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3160,7 +3224,7 @@ class QueryResponseAlternative18(BaseModel): ) -class QueryResponseAlternative19(BaseModel): +class QueryResponseAlternative20(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3187,7 +3251,7 @@ class QueryResponseAlternative19(BaseModel): types: Optional[list] = None -class QueryResponseAlternative20(BaseModel): +class QueryResponseAlternative22(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3211,7 +3275,7 @@ class QueryResponseAlternative20(BaseModel): types: Optional[list] = None -class QueryResponseAlternative21(BaseModel): +class QueryResponseAlternative23(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3238,7 +3302,7 @@ class QueryResponseAlternative21(BaseModel): types: Optional[list] = None -class QueryResponseAlternative22(BaseModel): +class QueryResponseAlternative24(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3264,7 +3328,7 @@ class QueryResponseAlternative22(BaseModel): types: Optional[list] = None -class QueryResponseAlternative23(BaseModel): +class QueryResponseAlternative25(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3289,7 +3353,7 @@ class QueryResponseAlternative23(BaseModel): ) -class QueryResponseAlternative24(BaseModel): +class QueryResponseAlternative26(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3311,7 +3375,7 @@ class QueryResponseAlternative24(BaseModel): ) -class QueryResponseAlternative25(BaseModel): +class QueryResponseAlternative27(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3332,7 +3396,7 @@ class QueryResponseAlternative25(BaseModel): ) -class QueryResponseAlternative27(BaseModel): +class QueryResponseAlternative29(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3353,7 +3417,7 @@ class QueryResponseAlternative27(BaseModel): ) -class QueryResponseAlternative30(BaseModel): +class QueryResponseAlternative32(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -3552,6 +3616,24 @@ class TableSettings(BaseModel): columns: Optional[list[ChartAxis]] = None +class WebExternalClicksTableQuery(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + dateRange: Optional[DateRange] = None + filterTestAccounts: Optional[bool] = None + kind: Literal["WebExternalClicksTableQuery"] = "WebExternalClicksTableQuery" + limit: Optional[int] = None + modifiers: Optional[HogQLQueryModifiers] = Field( + default=None, description="Modifiers used when performing the query" + ) + properties: list[Union[EventPropertyFilter, PersonPropertyFilter, SessionPropertyFilter]] + response: Optional[WebExternalClicksTableQueryResponse] = None + sampling: Optional[Sampling] = None + stripQueryParams: Optional[bool] = None + useSessionsTable: Optional[bool] = None + + class WebGoalsQuery(BaseModel): model_config = ConfigDict( extra="forbid", @@ -4352,7 +4434,7 @@ class PropertyGroupFilterValue(BaseModel): ] -class QueryResponseAlternative26(BaseModel): +class QueryResponseAlternative28(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -5051,7 +5133,7 @@ class LifecycleQuery(BaseModel): ) -class QueryResponseAlternative31(BaseModel): +class QueryResponseAlternative33(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -5080,26 +5162,26 @@ class QueryResponseAlternative( QueryResponseAlternative8, QueryResponseAlternative9, QueryResponseAlternative10, - QueryResponseAlternative11, QueryResponseAlternative12, QueryResponseAlternative13, QueryResponseAlternative14, - Any, QueryResponseAlternative15, + Any, QueryResponseAlternative16, QueryResponseAlternative17, QueryResponseAlternative18, QueryResponseAlternative19, QueryResponseAlternative20, - QueryResponseAlternative21, QueryResponseAlternative22, QueryResponseAlternative23, QueryResponseAlternative24, QueryResponseAlternative25, QueryResponseAlternative26, QueryResponseAlternative27, - QueryResponseAlternative30, - QueryResponseAlternative31, + QueryResponseAlternative28, + QueryResponseAlternative29, + QueryResponseAlternative32, + QueryResponseAlternative33, ] ] ): @@ -5115,26 +5197,26 @@ class QueryResponseAlternative( QueryResponseAlternative8, QueryResponseAlternative9, QueryResponseAlternative10, - QueryResponseAlternative11, QueryResponseAlternative12, QueryResponseAlternative13, QueryResponseAlternative14, - Any, QueryResponseAlternative15, + Any, QueryResponseAlternative16, QueryResponseAlternative17, QueryResponseAlternative18, QueryResponseAlternative19, QueryResponseAlternative20, - QueryResponseAlternative21, QueryResponseAlternative22, QueryResponseAlternative23, QueryResponseAlternative24, QueryResponseAlternative25, QueryResponseAlternative26, QueryResponseAlternative27, - QueryResponseAlternative30, - QueryResponseAlternative31, + QueryResponseAlternative28, + QueryResponseAlternative29, + QueryResponseAlternative32, + QueryResponseAlternative33, ] @@ -5422,10 +5504,10 @@ class DataTableNode(BaseModel): Response2, Response3, Response4, - Response5, Response6, Response7, Response8, + Response9, ] ] = None showActions: Optional[bool] = Field(default=None, description="Show the kebab menu at the end of the row") @@ -5462,6 +5544,7 @@ class DataTableNode(BaseModel): HogQLQuery, WebOverviewQuery, WebStatsTableQuery, + WebExternalClicksTableQuery, WebTopClicksQuery, WebGoalsQuery, SessionAttributionExplorerQuery, @@ -5499,6 +5582,7 @@ class HogQLAutocomplete(BaseModel): HogQLAutocomplete, WebOverviewQuery, WebStatsTableQuery, + WebExternalClicksTableQuery, WebTopClicksQuery, WebGoalsQuery, SessionAttributionExplorerQuery, @@ -5540,6 +5624,7 @@ class HogQLMetadata(BaseModel): HogQLAutocomplete, WebOverviewQuery, WebStatsTableQuery, + WebExternalClicksTableQuery, WebTopClicksQuery, WebGoalsQuery, SessionAttributionExplorerQuery, @@ -5583,6 +5668,7 @@ class QueryRequest(BaseModel): HogQLAutocomplete, WebOverviewQuery, WebStatsTableQuery, + WebExternalClicksTableQuery, WebTopClicksQuery, WebGoalsQuery, SessionAttributionExplorerQuery, @@ -5630,6 +5716,7 @@ class QuerySchemaRoot( HogQLAutocomplete, WebOverviewQuery, WebStatsTableQuery, + WebExternalClicksTableQuery, WebTopClicksQuery, WebGoalsQuery, SessionAttributionExplorerQuery, @@ -5665,6 +5752,7 @@ class QuerySchemaRoot( HogQLAutocomplete, WebOverviewQuery, WebStatsTableQuery, + WebExternalClicksTableQuery, WebTopClicksQuery, WebGoalsQuery, SessionAttributionExplorerQuery,