From 11401a904c2eda5f0098b60168118a416b57d277 Mon Sep 17 00:00:00 2001 From: Robbie Date: Tue, 1 Oct 2024 19:30:09 +0100 Subject: [PATCH] feat(web-analytics): LCP Score in web analytics (#25252) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- frontend/src/lib/constants.tsx | 1 + frontend/src/lib/taxonomy.tsx | 10 +++ .../queries/nodes/WebOverview/WebOverview.tsx | 21 ++++++- frontend/src/queries/schema.json | 3 + frontend/src/queries/schema.ts | 1 + .../web-analytics/webAnalyticsLogic.tsx | 1 + posthog/api/test/test_session.py | 1 + posthog/demo/products/hedgebox/matrix.py | 3 + posthog/hogql/database/schema/sessions_v2.py | 5 ++ .../database/schema/test/test_sessions_v2.py | 48 ++++++++++++++ .../test/__snapshots__/test_database.ambr | 32 ++++++++-- .../web_analytics/test/test_web_overview.py | 63 +++++++++++++++++-- .../web_analytics/web_overview.py | 47 ++++++++++++-- posthog/schema.py | 1 + 14 files changed, 219 insertions(+), 18 deletions(-) diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index babbd71319979..879c36b5acd63 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -213,6 +213,7 @@ export const FEATURE_FLAGS = { DATA_MODELING: 'data-modeling', // owner: @EDsCODE #team-data-warehouse WEB_ANALYTICS_CONVERSION_GOALS: 'web-analytics-conversion-goals', // owner: @robbie-c WEB_ANALYTICS_LAST_CLICK: 'web-analytics-last-click', // owner: @robbie-c + WEB_ANALYTICS_LCP_SCORE: 'web-analytics-lcp-score', // owner: @robbie-c HEDGEHOG_SKIN_SPIDERHOG: 'hedgehog-skin-spiderhog', // owner: @benjackwhite INSIGHT_VARIABLES: 'insight_variables', // owner: @Gilbert09 #team-data-warehouse WEB_EXPERIMENTS: 'web-experiments', // owner: @team-feature-success diff --git a/frontend/src/lib/taxonomy.tsx b/frontend/src/lib/taxonomy.tsx index 429d6cf4187ab..1ca2ea8a55c50 100644 --- a/frontend/src/lib/taxonomy.tsx +++ b/frontend/src/lib/taxonomy.tsx @@ -1133,6 +1133,16 @@ export const CORE_FILTER_DEFINITIONS_BY_GROUP = { description: The last external URL clicked in this session, examples: ['https://example.com/interesting-article?parameter=true'], }, + $vitals_lcp: { + label: 'Web vitals LCP', + description: ( + + The time it took for the Largest Contentful Paint on the page. This captures the perceived load time + of the page, and measure how long it took for the main content of the page to be visible to users. + + ), + examples: ['2.2'], + }, }, groups: { $group_key: { diff --git a/frontend/src/queries/nodes/WebOverview/WebOverview.tsx b/frontend/src/queries/nodes/WebOverview/WebOverview.tsx index 8900e3ba5d4ce..f9aeb562c3433 100644 --- a/frontend/src/queries/nodes/WebOverview/WebOverview.tsx +++ b/frontend/src/queries/nodes/WebOverview/WebOverview.tsx @@ -2,9 +2,11 @@ import { IconTrending } from '@posthog/icons' import { LemonSkeleton } from '@posthog/lemon-ui' import { useValues } from 'kea' import { getColorVar } from 'lib/colors' +import { FEATURE_FLAGS } from 'lib/constants' import { IconTrendingDown, IconTrendingFlat } from 'lib/lemon-ui/icons' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { Tooltip } from 'lib/lemon-ui/Tooltip' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { humanFriendlyDuration, humanFriendlyLargeNumber, isNotNil, range } from 'lib/utils' import { useState } from 'react' @@ -34,13 +36,14 @@ export function WebOverview(props: { onData, dataNodeCollectionId: dataNodeCollectionId ?? key, }) + const { featureFlags } = useValues(featureFlagLogic) const { response, responseLoading } = useValues(logic) const webOverviewQueryResponse = response as WebOverviewQueryResponse | undefined const samplingRate = webOverviewQueryResponse?.samplingRate - const numSkeletons = props.query.conversionGoal ? 4 : 5 + const numSkeletons = props.query.conversionGoal ? 4 : featureFlags[FEATURE_FLAGS.WEB_ANALYTICS_LCP_SCORE] ? 6 : 5 return ( <> @@ -136,7 +139,19 @@ const formatPercentage = (x: number, options?: { precise?: boolean }): string => return (x / 100).toLocaleString(undefined, { style: 'percent', maximumFractionDigits: 0 }) } -const formatSeconds = (x: number): string => humanFriendlyDuration(Math.round(x)) +const formatSeconds = (x: number): string => { + // if over than a minute, show minutes and seconds + if (x >= 60) { + return humanFriendlyDuration(x) + } + // if over 1 second, show 3 significant figures + if (x >= 1) { + return `${x.toPrecision(3)}s` + } + + // show the number of milliseconds + return `${x * 1000}ms` +} const formatUnit = (x: number, options?: { precise?: boolean }): string => { if (options?.precise) { @@ -172,6 +187,8 @@ const labelFromKey = (key: string): string => { return 'Session duration' case 'bounce rate': return 'Bounce rate' + case 'lcp score': + return 'LCP Score' case 'conversion rate': return 'Conversion rate' case 'total conversions': diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index d1ec64d7c6178..6d51e721690ea 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -11181,6 +11181,9 @@ "filterTestAccounts": { "type": "boolean" }, + "includeLCPScore": { + "type": "boolean" + }, "kind": { "const": "WebOverviewQuery", "type": "string" diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index b053b59a29f55..6c9f8d53d3065 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -1395,6 +1395,7 @@ interface WebAnalyticsQueryBase> extends DataNode< export interface WebOverviewQuery extends WebAnalyticsQueryBase { kind: NodeKind.WebOverviewQuery compare?: boolean + includeLCPScore?: boolean } export interface WebOverviewItem { diff --git a/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx b/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx index c66902130fd57..b2c6705b146e2 100644 --- a/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx +++ b/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx @@ -654,6 +654,7 @@ export const webAnalyticsLogic = kea([ compare, filterTestAccounts, conversionGoal, + includeLCPScore: featureFlags[FEATURE_FLAGS.WEB_ANALYTICS_LCP_SCORE] ? true : undefined, }, insightProps: createInsightProps(TileId.OVERVIEW), canOpenModal: false, diff --git a/posthog/api/test/test_session.py b/posthog/api/test/test_session.py index 502cf2e429a3c..7199f5d8fd557 100644 --- a/posthog/api/test/test_session.py +++ b/posthog/api/test/test_session.py @@ -53,6 +53,7 @@ def test_expected_session_properties(self): "$start_timestamp", "$is_bounce", "$last_external_click_url", + "$vitals_lcp", } assert actual_properties == expected_properties diff --git a/posthog/demo/products/hedgebox/matrix.py b/posthog/demo/products/hedgebox/matrix.py index 0a106d0866313..1ae9da06c1fc2 100644 --- a/posthog/demo/products/hedgebox/matrix.py +++ b/posthog/demo/products/hedgebox/matrix.py @@ -882,3 +882,6 @@ def set_project_up(self, team, user): ) except IntegrityError: pass # This can happen if demo data generation is re-run for the same project + + # autocapture + team.autocapture_web_vitals_opt_in = True diff --git a/posthog/hogql/database/schema/sessions_v2.py b/posthog/hogql/database/schema/sessions_v2.py index 04497072e08a2..30bf920eba26d 100644 --- a/posthog/hogql/database/schema/sessions_v2.py +++ b/posthog/hogql/database/schema/sessions_v2.py @@ -56,6 +56,7 @@ "screen_uniq": DatabaseField(name="screen_uniq"), "last_external_click_url": StringDatabaseField(name="last_external_click_url"), "page_screen_autocapture_uniq_up_to": DatabaseField(name="page_screen_autocapture_uniq_up_to"), + "vitals_lcp": DatabaseField(name="vitals_lcp"), } LAZY_SESSIONS_FIELDS: dict[str, FieldOrTable] = { @@ -96,6 +97,7 @@ # some aliases for people upgrading from v1 to v2 "$exit_current_url": StringDatabaseField(name="$exit_current_url"), "$exit_pathname": StringDatabaseField(name="$exit_pathname"), + "$vitals_lcp": FloatDatabaseField(name="vitals_lcp", nullable=True), } @@ -128,6 +130,7 @@ def avoid_asterisk_fields(self) -> list[str]: "screen_uniq", "last_external_click_url", "page_screen_autocapture_uniq_up_to", + "vitals_lcp", ] @@ -216,6 +219,7 @@ def arg_max_merge_field(field_name: str) -> ast.Call: params=[ast.Constant(value=1)], args=[ast.Field(chain=[table_name, "page_screen_autocapture_uniq_up_to"])], ), + "$vitals_lcp": ast.Call(name="argMinMerge", args=[ast.Field(chain=[table_name, "vitals_lcp"])]), } # Alias aggregate_fields["id"] = aggregate_fields["session_id"] @@ -476,6 +480,7 @@ def is_match(field_name: str) -> bool: "$end_current_url": "finalizeAggregation(end_url)", "$end_pathname": "path(finalizeAggregation(end_url))", "$last_external_click_url": "finalizeAggregation(last_external_click_url)", + "$vitals_lcp": "finalizeAggregation(vitals_lcp)", } diff --git a/posthog/hogql/database/schema/test/test_sessions_v2.py b/posthog/hogql/database/schema/test/test_sessions_v2.py index b5c2d71249317..da6cfb0f40ede 100644 --- a/posthog/hogql/database/schema/test/test_sessions_v2.py +++ b/posthog/hogql/database/schema/test/test_sessions_v2.py @@ -541,6 +541,53 @@ def test_last_external_click_url(self): [row1] = response.results or [] self.assertEqual(row1, ("https://example.com/2",)) + def test_lcp(self): + s1 = str(uuid7("2024-10-01")) + s2 = str(uuid7("2024-10-02")) + s3 = str(uuid7("2024-10-03")) + s4 = str(uuid7("2024-10-04")) + + # should be null if there's no $web_vitals_LCP_value + _create_event( + event="$pageview", + team=self.team, + distinct_id=s1, + properties={"$session_id": s1, "$pathname": "/1"}, + ) + # should be able to read the property off the regular "$web_vitals" event + _create_event( + event="$web_vitals", + team=self.team, + distinct_id=s2, + properties={"$session_id": s2, "$web_vitals_LCP_value": "2.0"}, + ) + # should be able to read the property off any event + _create_event( + event="$pageview", + team=self.team, + distinct_id=s3, + properties={"$session_id": s3, "$web_vitals_LCP_value": "3.0"}, + ) + # should take the first value if there's multiple + _create_event( + event="$web_vitals", + team=self.team, + distinct_id=s4, + properties={"$session_id": s4, "$web_vitals_LCP_value": "4.1"}, + ) + _create_event( + event="$web_vitals", + team=self.team, + distinct_id=s4, + properties={"$session_id": s4, "$web_vitals_LCP_value": "4.2"}, + ) + response = self.__execute( + parse_select("select $vitals_lcp from sessions order by session_id"), + ) + + rows = response.results or [] + assert rows == [(None,), (2.0,), (3.0,), (4.1,)] + def test_can_use_v1_and_v2_fields(self): session_id = str(uuid7()) @@ -604,6 +651,7 @@ def test_all(self): "$screen_count", "$session_duration", "$start_timestamp", + "$vitals_lcp", }, ) self.assertEqual( diff --git a/posthog/hogql/database/test/__snapshots__/test_database.ambr b/posthog/hogql/database/test/__snapshots__/test_database.ambr index ebd12e015c813..e3939d569468f 100644 --- a/posthog/hogql/database/test/__snapshots__/test_database.ambr +++ b/posthog/hogql/database/test/__snapshots__/test_database.ambr @@ -404,7 +404,8 @@ "$last_external_click_url", "$page_screen_autocapture_count_up_to", "$exit_current_url", - "$exit_pathname" + "$exit_pathname", + "$vitals_lcp" ], "hogql_value": "session", "id": "session", @@ -962,7 +963,8 @@ "$last_external_click_url", "$page_screen_autocapture_count_up_to", "$exit_current_url", - "$exit_pathname" + "$exit_pathname", + "$vitals_lcp" ], "hogql_value": "session", "id": "session", @@ -1456,6 +1458,16 @@ "schema_valid": true, "table": null, "type": "string" + }, + "$vitals_lcp": { + "chain": null, + "fields": null, + "hogql_value": "`$vitals_lcp`", + "id": null, + "name": "$vitals_lcp", + "schema_valid": true, + "table": null, + "type": "float" } }, "id": "sessions", @@ -1967,7 +1979,8 @@ "$last_external_click_url", "$page_screen_autocapture_count_up_to", "$exit_current_url", - "$exit_pathname" + "$exit_pathname", + "$vitals_lcp" ], "hogql_value": "session", "id": "session", @@ -2498,7 +2511,8 @@ "$last_external_click_url", "$page_screen_autocapture_count_up_to", "$exit_current_url", - "$exit_pathname" + "$exit_pathname", + "$vitals_lcp" ], "hogql_value": "session", "id": "session", @@ -2992,6 +3006,16 @@ "schema_valid": true, "table": null, "type": "string" + }, + "$vitals_lcp": { + "chain": null, + "fields": null, + "hogql_value": "`$vitals_lcp`", + "id": null, + "name": "$vitals_lcp", + "schema_valid": true, + "table": null, + "type": "float" } }, "id": "sessions", diff --git a/posthog/hogql_queries/web_analytics/test/test_web_overview.py b/posthog/hogql_queries/web_analytics/test/test_web_overview.py index aecdabd913fc8..bc41d4d0a6785 100644 --- a/posthog/hogql_queries/web_analytics/test/test_web_overview.py +++ b/posthog/hogql_queries/web_analytics/test/test_web_overview.py @@ -42,22 +42,28 @@ def _create_events(self, data, event="$pageview"): ) ) for timestamp, session_id, *extra in timestamps: + url = None + elements = None + lcp_score = None if event == "$pageview": url = extra[0] if extra else None - elements = None elif event == "$autocapture": - url = None elements = extra[0] if extra else None - else: - url = None - elements = None + elif event == "$web_vitals": + lcp_score = extra[0] if extra else None + properties = extra[1] if extra and len(extra) > 1 else {} _create_event( team=self.team, event=event, distinct_id=id, timestamp=timestamp, - properties={"$session_id": session_id, "$current_url": url}, + properties={ + "$session_id": session_id, + "$current_url": url, + "$web_vitals_LCP_value": lcp_score, + **properties, + }, elements=elements, ) return person_result @@ -72,6 +78,7 @@ def _run_web_overview_query( filter_test_accounts: Optional[bool] = False, action: Optional[Action] = None, custom_event: Optional[str] = None, + includeLCPScore: Optional[bool] = False, ): modifiers = HogQLQueryModifiers(sessionTableVersion=session_table_version) query = WebOverviewQuery( @@ -85,6 +92,7 @@ def _run_web_overview_query( else CustomEventConversionGoal(customEventName=custom_event) if custom_event else None, + includeLCPScore=includeLCPScore, ) runner = WebOverviewQueryRunner(team=self.team, query=query, limit_context=limit_context) return runner.calculate() @@ -96,12 +104,21 @@ def test_no_crash_when_no_data(self, session_table_version: SessionTableVersion) "2023-12-15", session_table_version=session_table_version, ).results + assert [item.key for item in results] == ["visitors", "views", "sessions", "session duration", "bounce rate"] + + results = self._run_web_overview_query( + "2023-12-08", + "2023-12-15", + session_table_version=session_table_version, + includeLCPScore=True, + ).results assert [item.key for item in results] == [ "visitors", "views", "sessions", "session duration", "bounce rate", + "lcp score", ] action = Action.objects.create( @@ -498,6 +515,40 @@ def test_conversion_rate(self): conversion_rate = results[3] self.assertAlmostEqual(conversion_rate.value, 100 * 2 / 3) + def test_lcp_score(self): + s1a = str(uuid7("2023-12-02")) + s1b = str(uuid7("2023-12-12")) + s2 = str(uuid7("2023-12-11")) + s3 = str(uuid7("2023-12-11")) + + self._create_events( + [ + ( + "p1", + [ + ("2023-12-02", s1a, "/", {"$web_vitals_LCP_value": 1000}), + ("2023-12-03", s1a, "/", {"$web_vitals_LCP_value": 400}), + ("2023-12-12", s1b, "/", {"$web_vitals_LCP_value": 320}), + ], + ), + ("p2", [("2023-12-11", s2, "/", {"$web_vitals_LCP_value": 200})]), + ("p3", [("2023-12-11", s3, "/")]), # no LCP value + ], + ) + + results = self._run_web_overview_query( + "2023-12-08", + "2023-12-15", + session_table_version=SessionTableVersion.V2, + includeLCPScore=True, + ).results + + lcp_score = results[5] + assert lcp_score.key == "lcp score" + assert lcp_score.value == 0.29 + assert lcp_score.previous == 1 + assert lcp_score.changeFromPreviousPct == -71 + @patch("posthog.hogql.query.sync_execute", wraps=sync_execute) def test_limit_is_context_aware(self, mock_sync_execute: MagicMock): self._run_web_overview_query("2023-12-01", "2023-12-03", limit_context=LimitContext.QUERY_ASYNC) diff --git a/posthog/hogql_queries/web_analytics/web_overview.py b/posthog/hogql_queries/web_analytics/web_overview.py index fec7cb8d4c4e5..31d0bb43d337a 100644 --- a/posthog/hogql_queries/web_analytics/web_overview.py +++ b/posthog/hogql_queries/web_analytics/web_overview.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Union import math from django.utils.timezone import datetime @@ -19,6 +19,7 @@ WebOverviewQuery, ActionConversionGoal, CustomEventConversionGoal, + SessionTableVersion, ) @@ -58,6 +59,10 @@ def calculate(self): to_data("session duration", "duration_s", row[6], row[7]), to_data("bounce rate", "percentage", row[8], row[9], is_increase_bad=True), ] + if self.query.includeLCPScore: + results.append( + to_data("lcp score", "duration_ms", row[10], row[11], is_increase_bad=True), + ) return WebOverviewQueryResponse( results=results, @@ -215,6 +220,13 @@ def inner_select(self) -> ast.SelectQuery: alias="is_bounce", expr=ast.Call(name="any", args=[ast.Field(chain=["session", "$is_bounce"])]) ) ) + if self.query.includeLCPScore: + lcp = ( + ast.Call(name="toFloat", args=[ast.Constant(value=None)]) + if self.modifiers.sessionTableVersion == SessionTableVersion.V1 + else ast.Call(name="any", args=[ast.Field(chain=["session", "$vitals_lcp"])]) + ) + parsed_select.select.append(ast.Alias(alias="lcp", expr=lcp)) return parsed_select @@ -224,12 +236,13 @@ def outer_select(self) -> ast.SelectQuery: mid = self.query_date_range.date_from_as_hogql() end = self.query_date_range.date_to_as_hogql() - def current_period_aggregate(function_name, column_name, alias): + def current_period_aggregate(function_name, column_name, alias, params=None): if self.query.compare: return ast.Alias( alias=alias, expr=ast.Call( name=function_name + "If", + params=params, args=[ ast.Field(chain=[column_name]), ast.Call( @@ -251,14 +264,17 @@ def current_period_aggregate(function_name, column_name, alias): ), ) else: - return ast.Alias(alias=alias, expr=ast.Call(name=function_name, args=[ast.Field(chain=[column_name])])) + return ast.Alias( + alias=alias, expr=ast.Call(name=function_name, params=params, args=[ast.Field(chain=[column_name])]) + ) - def previous_period_aggregate(function_name, column_name, alias): + def previous_period_aggregate(function_name, column_name, alias, params=None): if self.query.compare: return ast.Alias( alias=alias, expr=ast.Call( name=function_name + "If", + params=params, args=[ ast.Field(chain=[column_name]), ast.Call( @@ -320,6 +336,15 @@ def previous_period_aggregate(function_name, column_name, alias): current_period_aggregate("avg", "is_bounce", "bounce_rate"), previous_period_aggregate("avg", "is_bounce", "prev_bounce_rate"), ] + if self.query.includeLCPScore: + select.extend( + [ + current_period_aggregate("quantiles", "lcp", "lcp_p75", params=[ast.Constant(value=0.75)]), + previous_period_aggregate( + "quantiles", "lcp", "prev_lcp_p75", params=[ast.Constant(value=0.75)] + ), + ] + ) query = ast.SelectQuery( select=select, @@ -332,10 +357,14 @@ def previous_period_aggregate(function_name, column_name, alias): def to_data( key: str, kind: str, - value: Optional[float], - previous: Optional[float], + value: Optional[Union[float, list[float]]], + previous: Optional[Union[float, list[float]]], is_increase_bad: Optional[bool] = None, ) -> dict: + if isinstance(value, list): + value = value[0] + if isinstance(previous, list): + previous = previous[0] if value is not None and math.isnan(value): value = None if previous is not None and math.isnan(previous): @@ -345,6 +374,12 @@ def to_data( value = value * 100 if previous is not None: previous = previous * 100 + if kind == "duration_ms": + kind = "duration_s" + if value is not None: + value = value / 1000 + if previous is not None: + previous = previous / 1000 try: if value is not None and previous: diff --git a/posthog/schema.py b/posthog/schema.py index 10b358154819b..199ab8df07999 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -3818,6 +3818,7 @@ class WebOverviewQuery(BaseModel): conversionGoal: Optional[Union[ActionConversionGoal, CustomEventConversionGoal]] = None dateRange: Optional[DateRange] = None filterTestAccounts: Optional[bool] = None + includeLCPScore: Optional[bool] = None kind: Literal["WebOverviewQuery"] = "WebOverviewQuery" modifiers: Optional[HogQLQueryModifiers] = Field( default=None, description="Modifiers used when performing the query"