From af4127fbfcddfcee68f98cf7e2f0ed28cf86be86 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:45:23 +0000 Subject: [PATCH 1/8] fix(web-analytics): explain zero sessions in the weekly digest response The digest counts only sessions with a $pageview or $screen event from non-test accounts. A project with other session events got a zero that looked valid. The response now carries a metadata block with the period, filters, metric notes and a data_status that flags this case. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 581f02a9-b1fd-4040-a3f4-2d5237cc9512 --- products/web_analytics/backend/serializers.py | 27 ++++++++ .../web_analytics/backend/test/test_api.py | 1 + .../backend/test/test_weekly_digest.py | 25 ++++++- .../web_analytics/backend/weekly_digest.py | 66 +++++++++++++++++++ .../frontend/generated/api.schemas.ts | 36 ++++++++++ services/mcp/src/api/generated.ts | 37 +++++++++++ 6 files changed, 191 insertions(+), 1 deletion(-) diff --git a/products/web_analytics/backend/serializers.py b/products/web_analytics/backend/serializers.py index 7367e03b68bd..ef9eb0effbf5 100644 --- a/products/web_analytics/backend/serializers.py +++ b/products/web_analytics/backend/serializers.py @@ -3,6 +3,8 @@ from rest_framework import serializers +from products.web_analytics.backend.weekly_digest import DigestDataStatus + class LlmsTxtFetchRequestSerializer(serializers.Serializer): url = serializers.URLField( @@ -69,6 +71,28 @@ class GoalSerializer(serializers.Serializer): ) +class DigestMetadataSerializer(serializers.Serializer): + data_status = serializers.ChoiceField( + choices=DigestDataStatus.choices, + help_text=( + "How to read the headline numbers. 'ok': the period has web sessions. " + "'no_web_sessions': the headline is zero, but the project has sessions in the period. None of them " + "contain a $pageview or $screen event from a non-test account. Query the sessions table directly to " + "count them. 'no_sessions': the project has no sessions in the period." + ), + ) + date_from = serializers.DateTimeField(help_text="Start of the current period, in the project timezone.") + date_to = serializers.DateTimeField(help_text="End of the current period, in the project timezone.") + timezone = serializers.CharField(help_text="Project timezone for the period boundaries.") + filter_test_accounts = serializers.BooleanField( + help_text="True when every metric excludes events from test accounts." + ) + notes = serializers.ListField( + child=serializers.CharField(), + help_text="Metric definitions to use when you compare the digest with a direct query.", + ) + + class WeeklyDigestResponseSerializer(serializers.Serializer): visitors = NumericMetricSerializer(help_text="Unique visitors.") pageviews = NumericMetricSerializer(help_text="Total pageviews.") @@ -78,6 +102,9 @@ class WeeklyDigestResponseSerializer(serializers.Serializer): top_pages = TopPageSerializer(many=True, help_text="Top 5 pages by unique visitors.") top_sources = TopSourceSerializer(many=True, help_text="Top 5 traffic sources by unique visitors.") goals = GoalSerializer(many=True, help_text="Goal conversions.") + metadata = DigestMetadataSerializer( + help_text="Period, filters and metric definitions behind the numbers, and a status that explains a zero." + ) dashboard_url = serializers.URLField(help_text="Link to the Web analytics dashboard for this project.") diff --git a/products/web_analytics/backend/test/test_api.py b/products/web_analytics/backend/test/test_api.py index 8263bc477588..cf357485d3e5 100644 --- a/products/web_analytics/backend/test/test_api.py +++ b/products/web_analytics/backend/test/test_api.py @@ -68,6 +68,7 @@ def test_returns_digest_shape(self): "top_pages", "top_sources", "goals", + "metadata", "dashboard_url", } assert set(data["visitors"].keys()) == {"current", "previous", "change"} diff --git a/products/web_analytics/backend/test/test_weekly_digest.py b/products/web_analytics/backend/test/test_weekly_digest.py index 92a78657b115..6bab6a5ae02b 100644 --- a/products/web_analytics/backend/test/test_weekly_digest.py +++ b/products/web_analytics/backend/test/test_weekly_digest.py @@ -432,9 +432,29 @@ def test_returns_all_expected_keys(self): assert "dashboard_url" in result assert "utm_source=web_analytics_weekly_digest" in result["dashboard_url"] assert f"/project/{self.team.pk}/web" in result["dashboard_url"] + assert result["metadata"]["data_status"] == "ok" - def test_works_with_no_events(self): + @parameterized.expand( + [ + ("no_events", None, "no_sessions"), + ("custom_events_only", "signed_in", "no_web_sessions"), + ("pageviews_outside_period", "$pageview", "no_sessions"), + ] + ) + def test_works_with_no_web_traffic(self, _name: str, event: str | None, expected_status: str) -> None: with time_machine.travel(QUERY_TIMESTAMP, tick=False): + if event: + _create_person(team_id=self.team.pk, distinct_ids=["user_1"]) + timestamp = "2025-01-10" if event == "$pageview" else "2025-01-25" + _create_event( + team=self.team, + event=event, + distinct_id="user_1", + timestamp=timestamp, + properties={"$session_id": str(uuid7(timestamp))}, + ) + flush_persons_and_events() + result = build_team_digest(self.team) assert result["team"] == self.team @@ -446,3 +466,6 @@ def test_works_with_no_events(self): assert result["top_pages"] == [] assert result["top_sources"] == [] assert result["goals"] == [] + assert result["metadata"]["data_status"] == expected_status + assert result["metadata"]["filter_test_accounts"] is True + assert result["metadata"]["date_from"].date().isoformat() == "2025-01-22" diff --git a/products/web_analytics/backend/weekly_digest.py b/products/web_analytics/backend/weekly_digest.py index 84c2973f76a3..54c39e710b9b 100644 --- a/products/web_analytics/backend/weekly_digest.py +++ b/products/web_analytics/backend/weekly_digest.py @@ -1,7 +1,9 @@ from collections.abc import Iterable +from datetime import datetime from typing import TypeVar from django.conf import settings +from django.db import models import structlog @@ -22,10 +24,15 @@ WebStatsTableQueryResponse, ) +from posthog.hogql import ast +from posthog.hogql.parser import parse_select +from posthog.hogql.query import execute_hogql_query + from posthog.clickhouse.query_tagging import tag_queries from posthog.dataclasses import frozen from posthog.exceptions_capture import capture_exception from posthog.hogql_queries.query_runner import ExecutionMode +from posthog.hogql_queries.utils.query_date_range import QueryDateRange from posthog.models import Team from posthog.models.user import User from posthog.tasks.email_utils import compute_week_over_week_change @@ -38,6 +45,20 @@ DEFAULT_DIGEST_EXECUTION_MODE = ExecutionMode.RECENT_CACHE_CALCULATE_BLOCKING_IF_STALE +DIGEST_METRIC_NOTES = [ + "Visitors, sessions, bounce rate and session duration count only sessions that contain at least one " + "$pageview or $screen event. A direct count of the sessions table also includes sessions with other events, " + "so it can be higher.", + "Events from test accounts are excluded, as set in the project's test account filters.", + "The period starts at the start of the day `days` days ago and ends now, in the project timezone.", +] + + +class DigestDataStatus(models.TextChoices): + OK = "ok", "OK" + NO_WEB_SESSIONS = "no_web_sessions", "No web sessions" + NO_SESSIONS = "no_sessions", "No sessions" + DigestResponse = TypeVar("DigestResponse", WebOverviewQueryResponse, WebStatsTableQueryResponse, WebGoalsQueryResponse) @@ -254,6 +275,50 @@ def get_goals_for_team( return results +def _digest_date_range(team: Team, days: int) -> QueryDateRange: + return QueryDateRange( + date_range=DateRange(date_from=f"-{days}d"), + team=team, + timezone_info=team.timezone_info, + interval=None, + now=datetime.now(team.timezone_info), + ) + + +def _has_sessions_in_range(team: Team, date_range: QueryDateRange) -> bool: + tag_queries(product=ProductKey.WEB_ANALYTICS, team_id=team.pk, name="weekly_digest:session_probe") + query = parse_select( + "SELECT 1 FROM events WHERE timestamp >= {date_from} AND timestamp < {date_to} " + "AND notEmpty(events.$session_id) LIMIT 1", + placeholders={ + "date_from": ast.Constant(value=date_range.date_from()), + "date_to": ast.Constant(value=date_range.date_to()), + }, + ) + response = execute_hogql_query(query_type="web_analytics_digest_session_probe", query=query, team=team) + return bool(response.results) + + +def get_digest_metadata(team: Team, overview: dict, days: int = 7) -> dict: + date_range = _digest_date_range(team, days) + if overview["sessions"]["current"] or overview["pageviews"]["current"]: + data_status = DigestDataStatus.OK + elif _has_sessions_in_range(team, date_range): + # A plain zero reads as "no traffic", but the project has sessions that the web definition excludes. + data_status = DigestDataStatus.NO_WEB_SESSIONS + else: + data_status = DigestDataStatus.NO_SESSIONS + + return { + "data_status": data_status.value, + "date_from": date_range.date_from(), + "date_to": date_range.date_to(), + "timezone": team.timezone, + "filter_test_accounts": True, + "notes": DIGEST_METRIC_NOTES, + } + + def build_team_digest( team: Team, days: int = 7, @@ -273,6 +338,7 @@ def build_team_digest( "top_pages": top_pages, "top_sources": top_sources, "goals": goals, + "metadata": get_digest_metadata(team, overview, days=days), "dashboard_url": f"{settings.SITE_URL}/project/{team.pk}/web?utm_source=web_analytics_weekly_digest&utm_medium=email", } diff --git a/products/web_analytics/frontend/generated/api.schemas.ts b/products/web_analytics/frontend/generated/api.schemas.ts index 526beae46c5a..a4384eb62eb3 100644 --- a/products/web_analytics/frontend/generated/api.schemas.ts +++ b/products/web_analytics/frontend/generated/api.schemas.ts @@ -563,6 +563,38 @@ export interface GoalApi { change: WoWChangeApi | null } +/** + * * `ok` - OK + * * `no_web_sessions` - No web sessions + * * `no_sessions` - No sessions + */ +export type DigestDataStatusEnumApi = (typeof DigestDataStatusEnumApi)[keyof typeof DigestDataStatusEnumApi] + +export const DigestDataStatusEnumApi = { + Ok: 'ok', + NoWebSessions: 'no_web_sessions', + NoSessions: 'no_sessions', +} as const + +export interface DigestMetadataApi { + /** How to read the headline numbers. 'ok': the period has web sessions. 'no_web_sessions': the headline is zero, but the project has sessions in the period. None of them contain a $pageview or $screen event from a non-test account. Query the sessions table directly to count them. 'no_sessions': the project has no sessions in the period. + * + * * `ok` - OK + * * `no_web_sessions` - No web sessions + * * `no_sessions` - No sessions */ + data_status: DigestDataStatusEnumApi + /** Start of the current period, in the project timezone. */ + date_from: string + /** End of the current period, in the project timezone. */ + date_to: string + /** Project timezone for the period boundaries. */ + timezone: string + /** True when every metric excludes events from test accounts. */ + filter_test_accounts: boolean + /** Metric definitions to use when you compare the digest with a direct query. */ + notes: string[] +} + export interface RecapPersonaApi { /** Stable persona identifier. One of: just_getting_started, conversion_machine, traffic_magnet, crowd_favorite, search_hog, word_of_mouth, loyal_following, rising_star, steady_hog. */ id: string @@ -606,6 +638,8 @@ export interface WebAnalyticsRecapResponseApi { top_sources: TopSourceApi[] /** Goal conversions. */ goals: GoalApi[] + /** Period, filters and metric definitions behind the numbers, and a status that explains a zero. */ + metadata: DigestMetadataApi /** Link to the Web analytics dashboard for this project. */ dashboard_url: string /** The single weekly persona assigned from this week's data. */ @@ -641,6 +675,8 @@ export interface WeeklyDigestResponseApi { top_sources: TopSourceApi[] /** Goal conversions. */ goals: GoalApi[] + /** Period, filters and metric definitions behind the numbers, and a status that explains a zero. */ + metadata: DigestMetadataApi /** Link to the Web analytics dashboard for this project. */ dashboard_url: string } diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 02a32f836f1f..3d9badc91687 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -32660,6 +32660,39 @@ export namespace Schemas { checks: DiagnosticCheckResult[]; } + /** + * * `ok` - OK + * * `no_web_sessions` - No web sessions + * * `no_sessions` - No sessions + */ + export type DigestDataStatusEnum = typeof DigestDataStatusEnum[keyof typeof DigestDataStatusEnum]; + + + export const DigestDataStatusEnum = { + Ok: 'ok', + NoWebSessions: 'no_web_sessions', + NoSessions: 'no_sessions', + } as const; + + export interface DigestMetadata { + /** How to read the headline numbers. 'ok': the period has web sessions. 'no_web_sessions': the headline is zero, but the project has sessions in the period. None of them contain a $pageview or $screen event from a non-test account. Query the sessions table directly to count them. 'no_sessions': the project has no sessions in the period. + * + * * `ok` - OK + * * `no_web_sessions` - No web sessions + * * `no_sessions` - No sessions */ + data_status: DigestDataStatusEnum; + /** Start of the current period, in the project timezone. */ + date_from: string; + /** End of the current period, in the project timezone. */ + date_to: string; + /** Project timezone for the period boundaries. */ + timezone: string; + /** True when every metric excludes events from test accounts. */ + filter_test_accounts: boolean; + /** Metric definitions to use when you compare the digest with a direct query. */ + notes: string[]; + } + /** * * `manual` - MANUAL * * `slack_name_match` - SLACK_NAME_MATCH @@ -101576,6 +101609,8 @@ export namespace Schemas { top_sources: TopSource[]; /** Goal conversions. */ goals: Goal[]; + /** Period, filters and metric definitions behind the numbers, and a status that explains a zero. */ + metadata: DigestMetadata; /** Link to the Web analytics dashboard for this project. */ dashboard_url: string; /** The single weekly persona assigned from this week's data. */ @@ -101716,6 +101751,8 @@ export namespace Schemas { top_sources: TopSource[]; /** Goal conversions. */ goals: Goal[]; + /** Period, filters and metric definitions behind the numbers, and a status that explains a zero. */ + metadata: DigestMetadata; /** Link to the Web analytics dashboard for this project. */ dashboard_url: string; } From 23470e03085c2d48361f6fd023eaa918a6b51bf3 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Mon, 28 Sep 2026 12:12:30 +0100 Subject: [PATCH 2/8] chore(web-analytics): add digest metadata to recap fixtures Generated-By: PostHog Desktop Task-Id: 3e8e5e92-7256-4175-a7e5-792e1e8f8b60 --- .../recap/WebAnalyticsRecapScene.stories.tsx | 8 ++++++++ .../web-analytics/recap/webAnalyticsRecapLogic.test.ts | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/frontend/src/scenes/web-analytics/recap/WebAnalyticsRecapScene.stories.tsx b/frontend/src/scenes/web-analytics/recap/WebAnalyticsRecapScene.stories.tsx index 8da4f3dd88ae..609c787324ca 100644 --- a/frontend/src/scenes/web-analytics/recap/WebAnalyticsRecapScene.stories.tsx +++ b/frontend/src/scenes/web-analytics/recap/WebAnalyticsRecapScene.stories.tsx @@ -17,6 +17,14 @@ const up = (percent: number): WoWChangeApi => ({ }) const recapMock: WebAnalyticsRecapResponseApi = { + metadata: { + data_status: 'ok', + date_from: '2023-01-25T00:00:00Z', + date_to: '2023-02-01T00:00:00Z', + timezone: 'UTC', + filter_test_accounts: true, + notes: [], + }, visitors: { current: 12402, previous: 10510, change: up(18) }, pageviews: { current: 38211, previous: 33100, change: up(15) }, sessions: { current: 15890, previous: 14002, change: up(13) }, diff --git a/frontend/src/scenes/web-analytics/recap/webAnalyticsRecapLogic.test.ts b/frontend/src/scenes/web-analytics/recap/webAnalyticsRecapLogic.test.ts index b3d72409e0ea..9d9270b5802c 100644 --- a/frontend/src/scenes/web-analytics/recap/webAnalyticsRecapLogic.test.ts +++ b/frontend/src/scenes/web-analytics/recap/webAnalyticsRecapLogic.test.ts @@ -23,6 +23,14 @@ const mockRecap = webAnalyticsRecap as jest.Mock function makeRecap(overrides: Partial = {}): WebAnalyticsRecapResponseApi { return { + metadata: { + data_status: 'ok', + date_from: '2025-01-22T00:00:00Z', + date_to: '2025-01-29T00:00:00Z', + timezone: 'UTC', + filter_test_accounts: true, + notes: [], + }, visitors: { current: 100, previous: 80, change: null }, pageviews: { current: 200, previous: 150, change: null }, sessions: { current: 90, previous: 70, change: null }, From cb5b06e3def3d63cb7147e139e123d366ffd117d Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:37:50 +0000 Subject: [PATCH 3/8] fix(web-analytics): keep the project offset in digest period dates DRF converts aware datetimes to the active timezone (UTC) when it serializes them. The digest metadata documents date_from and date_to in the project timezone, so the response now keeps the offset of the value. The OpenAPI schema does not change. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: a052359a-f3f4-4a44-b306-2bd516988b8b --- products/web_analytics/backend/serializers.py | 15 +++++++++++++-- products/web_analytics/backend/test/test_api.py | 5 +++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/products/web_analytics/backend/serializers.py b/products/web_analytics/backend/serializers.py index ef9eb0effbf5..110334341e5d 100644 --- a/products/web_analytics/backend/serializers.py +++ b/products/web_analytics/backend/serializers.py @@ -1,5 +1,8 @@ +from datetime import datetime + from django.core.validators import URLValidator from django.db import models +from django.utils import timezone from rest_framework import serializers @@ -71,6 +74,14 @@ class GoalSerializer(serializers.Serializer): ) +class ProjectTimezoneDateTimeField(serializers.DateTimeField): + def enforce_timezone(self, value: datetime) -> datetime: + # DRF converts aware values to the active timezone, which is UTC, so the project offset is lost. + if timezone.is_aware(value): + return value + return super().enforce_timezone(value) + + class DigestMetadataSerializer(serializers.Serializer): data_status = serializers.ChoiceField( choices=DigestDataStatus.choices, @@ -81,8 +92,8 @@ class DigestMetadataSerializer(serializers.Serializer): "count them. 'no_sessions': the project has no sessions in the period." ), ) - date_from = serializers.DateTimeField(help_text="Start of the current period, in the project timezone.") - date_to = serializers.DateTimeField(help_text="End of the current period, in the project timezone.") + date_from = ProjectTimezoneDateTimeField(help_text="Start of the current period, in the project timezone.") + date_to = ProjectTimezoneDateTimeField(help_text="End of the current period, in the project timezone.") timezone = serializers.CharField(help_text="Project timezone for the period boundaries.") filter_test_accounts = serializers.BooleanField( help_text="True when every metric excludes events from test accounts." diff --git a/products/web_analytics/backend/test/test_api.py b/products/web_analytics/backend/test/test_api.py index cf357485d3e5..5af6cd8fbd02 100644 --- a/products/web_analytics/backend/test/test_api.py +++ b/products/web_analytics/backend/test/test_api.py @@ -50,6 +50,8 @@ def _url(self, team_id=None): return self.ENDPOINT.format(team_id=team_id or self.team.id) def test_returns_digest_shape(self): + self.team.timezone = "Asia/Tokyo" + self.team.save() with time_machine.travel(QUERY_TIMESTAMP, tick=False): _create_person(team_id=self.team.pk, distinct_ids=["user_1"]) _create_pageview(self.team, distinct_id="user_1", url="https://example.com/", timestamp="2025-01-25") @@ -76,6 +78,9 @@ def test_returns_digest_shape(self): assert isinstance(data["top_sources"], list) assert isinstance(data["goals"], list) assert "/web" in data["dashboard_url"] + assert data["metadata"]["timezone"] == "Asia/Tokyo" + assert data["metadata"]["date_from"] == "2025-01-22T00:00:00+09:00" + assert data["metadata"]["date_to"].endswith("+09:00") def test_empty_team_returns_zero_metrics(self): with time_machine.travel(QUERY_TIMESTAMP, tick=False): From 126733c6aeeb533959940097f86e3a52127f3777 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:41:55 +0000 Subject: [PATCH 4/8] fix(web-analytics): say which digest metrics exclude test accounts The goals query does not filter test accounts, but the digest metadata said every metric does. The notes and the filter_test_accounts help_text now name the metrics that exclude test accounts and say that goal conversions include them. Goal counts do not change. Regenerated the OpenAPI types. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: a052359a-f3f4-4a44-b306-2bd516988b8b --- products/web_analytics/backend/serializers.py | 5 ++++- products/web_analytics/backend/weekly_digest.py | 3 ++- products/web_analytics/frontend/generated/api.schemas.ts | 2 +- services/mcp/src/api/generated.ts | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/products/web_analytics/backend/serializers.py b/products/web_analytics/backend/serializers.py index 110334341e5d..c220661eaa26 100644 --- a/products/web_analytics/backend/serializers.py +++ b/products/web_analytics/backend/serializers.py @@ -96,7 +96,10 @@ class DigestMetadataSerializer(serializers.Serializer): date_to = ProjectTimezoneDateTimeField(help_text="End of the current period, in the project timezone.") timezone = serializers.CharField(help_text="Project timezone for the period boundaries.") filter_test_accounts = serializers.BooleanField( - help_text="True when every metric excludes events from test accounts." + help_text=( + "True when the headline metrics, top pages and top sources exclude events from test accounts. " + "Goal conversions include them." + ) ) notes = serializers.ListField( child=serializers.CharField(), diff --git a/products/web_analytics/backend/weekly_digest.py b/products/web_analytics/backend/weekly_digest.py index 54c39e710b9b..acd27289ca99 100644 --- a/products/web_analytics/backend/weekly_digest.py +++ b/products/web_analytics/backend/weekly_digest.py @@ -49,7 +49,8 @@ "Visitors, sessions, bounce rate and session duration count only sessions that contain at least one " "$pageview or $screen event. A direct count of the sessions table also includes sessions with other events, " "so it can be higher.", - "Events from test accounts are excluded, as set in the project's test account filters.", + "Visitors, pageviews, sessions, bounce rate, session duration, top pages and top sources exclude events from " + "test accounts, as set in the project's test account filters. Goal conversions include them.", "The period starts at the start of the day `days` days ago and ends now, in the project timezone.", ] diff --git a/products/web_analytics/frontend/generated/api.schemas.ts b/products/web_analytics/frontend/generated/api.schemas.ts index a4384eb62eb3..f9e8656a1687 100644 --- a/products/web_analytics/frontend/generated/api.schemas.ts +++ b/products/web_analytics/frontend/generated/api.schemas.ts @@ -589,7 +589,7 @@ export interface DigestMetadataApi { date_to: string /** Project timezone for the period boundaries. */ timezone: string - /** True when every metric excludes events from test accounts. */ + /** True when the headline metrics, top pages and top sources exclude events from test accounts. Goal conversions include them. */ filter_test_accounts: boolean /** Metric definitions to use when you compare the digest with a direct query. */ notes: string[] diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 1e2c4c6d294f..857b92fba697 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -32750,7 +32750,7 @@ export namespace Schemas { date_to: string; /** Project timezone for the period boundaries. */ timezone: string; - /** True when every metric excludes events from test accounts. */ + /** True when the headline metrics, top pages and top sources exclude events from test accounts. Goal conversions include them. */ filter_test_accounts: boolean; /** Metric definitions to use when you compare the digest with a direct query. */ notes: string[]; From 982a086a912a7423ff27879012cc56b13623f4fc Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:46:06 +0000 Subject: [PATCH 5/8] fix(web-analytics): probe only session ids that become session rows The sessions tables keep only UUIDv7 session IDs. The digest session probe matched any non-empty $session_id, so a project with custom session IDs and no web events got no_web_sessions, but a direct sessions query returned no rows. The probe now requires a UUIDv7 session ID and stays a LIMIT 1 events scan. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: a052359a-f3f4-4a44-b306-2bd516988b8b --- products/web_analytics/backend/test/test_weekly_digest.py | 7 +++++-- products/web_analytics/backend/weekly_digest.py | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/products/web_analytics/backend/test/test_weekly_digest.py b/products/web_analytics/backend/test/test_weekly_digest.py index 6bab6a5ae02b..5f9302d578f2 100644 --- a/products/web_analytics/backend/test/test_weekly_digest.py +++ b/products/web_analytics/backend/test/test_weekly_digest.py @@ -439,9 +439,12 @@ def test_returns_all_expected_keys(self): ("no_events", None, "no_sessions"), ("custom_events_only", "signed_in", "no_web_sessions"), ("pageviews_outside_period", "$pageview", "no_sessions"), + ("custom_events_with_non_uuidv7_session_id", "signed_in", "no_sessions", "custom-session-1"), ] ) - def test_works_with_no_web_traffic(self, _name: str, event: str | None, expected_status: str) -> None: + def test_works_with_no_web_traffic( + self, _name: str, event: str | None, expected_status: str, session_id: str | None = None + ) -> None: with time_machine.travel(QUERY_TIMESTAMP, tick=False): if event: _create_person(team_id=self.team.pk, distinct_ids=["user_1"]) @@ -451,7 +454,7 @@ def test_works_with_no_web_traffic(self, _name: str, event: str | None, expected event=event, distinct_id="user_1", timestamp=timestamp, - properties={"$session_id": str(uuid7(timestamp))}, + properties={"$session_id": session_id or str(uuid7(timestamp))}, ) flush_persons_and_events() diff --git a/products/web_analytics/backend/weekly_digest.py b/products/web_analytics/backend/weekly_digest.py index acd27289ca99..103ba1f7b3bd 100644 --- a/products/web_analytics/backend/weekly_digest.py +++ b/products/web_analytics/backend/weekly_digest.py @@ -288,9 +288,10 @@ def _digest_date_range(team: Team, days: int) -> QueryDateRange: def _has_sessions_in_range(team: Team, date_range: QueryDateRange) -> bool: tag_queries(product=ProductKey.WEB_ANALYTICS, team_id=team.pk, name="weekly_digest:session_probe") + # The sessions tables keep only UUIDv7 session IDs, so events with other IDs never become session rows. query = parse_select( "SELECT 1 FROM events WHERE timestamp >= {date_from} AND timestamp < {date_to} " - "AND notEmpty(events.$session_id) LIMIT 1", + "AND bitAnd(bitShiftRight(events.$session_id_uuid, 76), 15) = 7 LIMIT 1", placeholders={ "date_from": ast.Constant(value=date_range.date_from()), "date_to": ast.Constant(value=date_range.date_to()), From 4f40cabd7b8f69e4bd4ae7eaef95176d2c2da24f Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:53:44 +0000 Subject: [PATCH 6/8] fix(web-analytics): take digest metadata period from the overview The digest serves the overview from a cache that can hold a result from the previous day. The metadata recomputed its period from the current time, so after local midnight the reported dates and the session probe could cover a different day from the headline numbers. The overview now returns the period of its response, and the metadata and the probe use it. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: a052359a-f3f4-4a44-b306-2bd516988b8b --- .../backend/test/test_weekly_digest.py | 13 ++++++++++++- .../web_analytics/backend/weekly_digest.py | 18 ++++++++++++------ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/products/web_analytics/backend/test/test_weekly_digest.py b/products/web_analytics/backend/test/test_weekly_digest.py index 5f9302d578f2..aa4181dae5ab 100644 --- a/products/web_analytics/backend/test/test_weekly_digest.py +++ b/products/web_analytics/backend/test/test_weekly_digest.py @@ -1,5 +1,5 @@ from collections.abc import Callable -from datetime import timedelta +from datetime import UTC, datetime, timedelta import time_machine from posthog.test.base import APIBaseTest, ClickhouseTestMixin, _create_event, _create_person, flush_persons_and_events @@ -198,6 +198,8 @@ def test_returns_zero_values_for_team_with_no_events(self): "sessions": {"current": 0, "previous": None, "change": None}, "bounce_rate": {"current": 0.0, "previous": None, "change": None}, "avg_session_duration": {"current": "0s", "previous": "0s", "change": None}, + "date_from": datetime(2025, 1, 22, tzinfo=UTC), + "date_to": datetime(2025, 1, 29, 23, 59, 59, tzinfo=UTC), } @@ -472,3 +474,12 @@ def test_works_with_no_web_traffic( assert result["metadata"]["data_status"] == expected_status assert result["metadata"]["filter_test_accounts"] is True assert result["metadata"]["date_from"].date().isoformat() == "2025-01-22" + + def test_metadata_period_matches_a_cached_overview(self) -> None: + with time_machine.travel("2025-01-28T23:00:00Z", tick=False): + build_team_digest(self.team) + with time_machine.travel("2025-01-29T01:00:00Z", tick=False): + result = build_team_digest(self.team) + + assert result["metadata"]["date_from"].date().isoformat() == "2025-01-21" + assert result["metadata"]["date_to"].date().isoformat() == "2025-01-28" diff --git a/products/web_analytics/backend/weekly_digest.py b/products/web_analytics/backend/weekly_digest.py index 103ba1f7b3bd..36b67fe6749f 100644 --- a/products/web_analytics/backend/weekly_digest.py +++ b/products/web_analytics/backend/weekly_digest.py @@ -101,6 +101,10 @@ def get_overview_for_team( ) runner = WebOverviewQueryRunner(team=team, query=query) response = _require_digest_response(runner.run(execution_mode=execution_mode, user=user)) + if response.dateFrom and response.dateTo: + # A cached response can come from an earlier day, so keep the period that the numbers cover. + result["date_from"] = datetime.fromisoformat(response.dateFrom).replace(tzinfo=team.timezone_info) + result["date_to"] = datetime.fromisoformat(response.dateTo).replace(tzinfo=team.timezone_info) items_by_key = {item.key: item for item in response.results} @@ -286,15 +290,15 @@ def _digest_date_range(team: Team, days: int) -> QueryDateRange: ) -def _has_sessions_in_range(team: Team, date_range: QueryDateRange) -> bool: +def _has_sessions_in_range(team: Team, date_from: datetime, date_to: datetime) -> bool: tag_queries(product=ProductKey.WEB_ANALYTICS, team_id=team.pk, name="weekly_digest:session_probe") # The sessions tables keep only UUIDv7 session IDs, so events with other IDs never become session rows. query = parse_select( "SELECT 1 FROM events WHERE timestamp >= {date_from} AND timestamp < {date_to} " "AND bitAnd(bitShiftRight(events.$session_id_uuid, 76), 15) = 7 LIMIT 1", placeholders={ - "date_from": ast.Constant(value=date_range.date_from()), - "date_to": ast.Constant(value=date_range.date_to()), + "date_from": ast.Constant(value=date_from), + "date_to": ast.Constant(value=date_to), }, ) response = execute_hogql_query(query_type="web_analytics_digest_session_probe", query=query, team=team) @@ -303,9 +307,11 @@ def _has_sessions_in_range(team: Team, date_range: QueryDateRange) -> bool: def get_digest_metadata(team: Team, overview: dict, days: int = 7) -> dict: date_range = _digest_date_range(team, days) + date_from = overview.get("date_from", date_range.date_from()) + date_to = overview.get("date_to", date_range.date_to()) if overview["sessions"]["current"] or overview["pageviews"]["current"]: data_status = DigestDataStatus.OK - elif _has_sessions_in_range(team, date_range): + elif _has_sessions_in_range(team, date_from, date_to): # A plain zero reads as "no traffic", but the project has sessions that the web definition excludes. data_status = DigestDataStatus.NO_WEB_SESSIONS else: @@ -313,8 +319,8 @@ def get_digest_metadata(team: Team, overview: dict, days: int = 7) -> dict: return { "data_status": data_status.value, - "date_from": date_range.date_from(), - "date_to": date_range.date_to(), + "date_from": date_from, + "date_to": date_to, "timezone": team.timezone, "filter_test_accounts": True, "notes": DIGEST_METRIC_NOTES, From e0d37de3fd9353a360032c2d91915b5c71f2dc2f Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:02:48 +0000 Subject: [PATCH 7/8] fix(web-analytics): keep the digest when the session check fails The session check only explains a zero headline, but its errors aborted a digest whose metric queries had succeeded. The API returned an error, and batch runs marked a zero-traffic project as failed. The check now logs and captures the error and reports a new 'unknown' data status, so a failed check never reads as 'no_sessions'. Regenerated the OpenAPI types for the new status. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 44eed5d1-e5dc-46c6-bc36-90f1fa33ab50 --- products/web_analytics/backend/serializers.py | 3 ++- .../backend/test/test_weekly_digest.py | 17 ++++++++++++++++ .../web_analytics/backend/weekly_digest.py | 20 +++++++++++++++---- .../frontend/generated/api.schemas.ts | 7 +++++-- services/mcp/src/api/generated.ts | 7 +++++-- 5 files changed, 45 insertions(+), 9 deletions(-) diff --git a/products/web_analytics/backend/serializers.py b/products/web_analytics/backend/serializers.py index c220661eaa26..964478f0afbb 100644 --- a/products/web_analytics/backend/serializers.py +++ b/products/web_analytics/backend/serializers.py @@ -89,7 +89,8 @@ class DigestMetadataSerializer(serializers.Serializer): "How to read the headline numbers. 'ok': the period has web sessions. " "'no_web_sessions': the headline is zero, but the project has sessions in the period. None of them " "contain a $pageview or $screen event from a non-test account. Query the sessions table directly to " - "count them. 'no_sessions': the project has no sessions in the period." + "count them. 'no_sessions': the project has no sessions in the period. 'unknown': the headline is zero, " + "and the check for other sessions in the period failed. Query the sessions table directly to count them." ), ) date_from = ProjectTimezoneDateTimeField(help_text="Start of the current period, in the project timezone.") diff --git a/products/web_analytics/backend/test/test_weekly_digest.py b/products/web_analytics/backend/test/test_weekly_digest.py index aa4181dae5ab..a6849bb73a5f 100644 --- a/products/web_analytics/backend/test/test_weekly_digest.py +++ b/products/web_analytics/backend/test/test_weekly_digest.py @@ -86,6 +86,23 @@ def test_an_incomplete_section_stops_digest_construction(self) -> None: with self.assertRaises(TimeoutError): build_team_digest(Team(pk=1)) + def test_a_failed_session_check_keeps_the_digest(self) -> None: + with ( + patch( + "products.web_analytics.backend.weekly_digest.get_overview_for_team", return_value=_default_overview() + ), + patch("products.web_analytics.backend.weekly_digest.get_top_pages", return_value=[]), + patch("products.web_analytics.backend.weekly_digest.get_top_sources", return_value=[]), + patch("products.web_analytics.backend.weekly_digest.get_goals_for_team", return_value=[]), + patch("products.web_analytics.backend.weekly_digest.execute_hogql_query", side_effect=TimeoutError), + patch("products.web_analytics.backend.weekly_digest.capture_exception") as capture, + ): + digest = build_team_digest(Team(pk=1)) + + assert digest["sessions"] == {"current": 0, "previous": None, "change": None} + assert digest["metadata"]["data_status"] == "unknown" + capture.assert_called_once() + def _create_pageview( team, diff --git a/products/web_analytics/backend/weekly_digest.py b/products/web_analytics/backend/weekly_digest.py index 36b67fe6749f..97d490dae9c5 100644 --- a/products/web_analytics/backend/weekly_digest.py +++ b/products/web_analytics/backend/weekly_digest.py @@ -59,6 +59,7 @@ class DigestDataStatus(models.TextChoices): OK = "ok", "OK" NO_WEB_SESSIONS = "no_web_sessions", "No web sessions" NO_SESSIONS = "no_sessions", "No sessions" + UNKNOWN = "unknown", "Unknown" DigestResponse = TypeVar("DigestResponse", WebOverviewQueryResponse, WebStatsTableQueryResponse, WebGoalsQueryResponse) @@ -305,17 +306,28 @@ def _has_sessions_in_range(team: Team, date_from: datetime, date_to: datetime) - return bool(response.results) +def _zero_traffic_status(team: Team, date_from: datetime, date_to: datetime) -> DigestDataStatus: + try: + has_sessions = _has_sessions_in_range(team, date_from, date_to) + except Exception as e: + # The status only explains a zero, so a failed check must not discard metrics that loaded. + logger.warning("WA digest could not check for sessions", team_id=team.id, error=str(e)) + capture_exception(e, {"team_id": team.id}) + return DigestDataStatus.UNKNOWN + if has_sessions: + # A plain zero reads as "no traffic", but the project has sessions that the web definition excludes. + return DigestDataStatus.NO_WEB_SESSIONS + return DigestDataStatus.NO_SESSIONS + + def get_digest_metadata(team: Team, overview: dict, days: int = 7) -> dict: date_range = _digest_date_range(team, days) date_from = overview.get("date_from", date_range.date_from()) date_to = overview.get("date_to", date_range.date_to()) if overview["sessions"]["current"] or overview["pageviews"]["current"]: data_status = DigestDataStatus.OK - elif _has_sessions_in_range(team, date_from, date_to): - # A plain zero reads as "no traffic", but the project has sessions that the web definition excludes. - data_status = DigestDataStatus.NO_WEB_SESSIONS else: - data_status = DigestDataStatus.NO_SESSIONS + data_status = _zero_traffic_status(team, date_from, date_to) return { "data_status": data_status.value, diff --git a/products/web_analytics/frontend/generated/api.schemas.ts b/products/web_analytics/frontend/generated/api.schemas.ts index f9e8656a1687..d8bdfb9d5c77 100644 --- a/products/web_analytics/frontend/generated/api.schemas.ts +++ b/products/web_analytics/frontend/generated/api.schemas.ts @@ -567,6 +567,7 @@ export interface GoalApi { * * `ok` - OK * * `no_web_sessions` - No web sessions * * `no_sessions` - No sessions + * * `unknown` - Unknown */ export type DigestDataStatusEnumApi = (typeof DigestDataStatusEnumApi)[keyof typeof DigestDataStatusEnumApi] @@ -574,14 +575,16 @@ export const DigestDataStatusEnumApi = { Ok: 'ok', NoWebSessions: 'no_web_sessions', NoSessions: 'no_sessions', + Unknown: 'unknown', } as const export interface DigestMetadataApi { - /** How to read the headline numbers. 'ok': the period has web sessions. 'no_web_sessions': the headline is zero, but the project has sessions in the period. None of them contain a $pageview or $screen event from a non-test account. Query the sessions table directly to count them. 'no_sessions': the project has no sessions in the period. + /** How to read the headline numbers. 'ok': the period has web sessions. 'no_web_sessions': the headline is zero, but the project has sessions in the period. None of them contain a $pageview or $screen event from a non-test account. Query the sessions table directly to count them. 'no_sessions': the project has no sessions in the period. 'unknown': the headline is zero, and the check for other sessions in the period failed. Query the sessions table directly to count them. * * * `ok` - OK * * `no_web_sessions` - No web sessions - * * `no_sessions` - No sessions */ + * * `no_sessions` - No sessions + * * `unknown` - Unknown */ data_status: DigestDataStatusEnumApi /** Start of the current period, in the project timezone. */ date_from: string diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 857b92fba697..de67eadb2b39 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -32727,6 +32727,7 @@ export namespace Schemas { * * `ok` - OK * * `no_web_sessions` - No web sessions * * `no_sessions` - No sessions + * * `unknown` - Unknown */ export type DigestDataStatusEnum = typeof DigestDataStatusEnum[keyof typeof DigestDataStatusEnum]; @@ -32735,14 +32736,16 @@ export namespace Schemas { Ok: 'ok', NoWebSessions: 'no_web_sessions', NoSessions: 'no_sessions', + Unknown: 'unknown', } as const; export interface DigestMetadata { - /** How to read the headline numbers. 'ok': the period has web sessions. 'no_web_sessions': the headline is zero, but the project has sessions in the period. None of them contain a $pageview or $screen event from a non-test account. Query the sessions table directly to count them. 'no_sessions': the project has no sessions in the period. + /** How to read the headline numbers. 'ok': the period has web sessions. 'no_web_sessions': the headline is zero, but the project has sessions in the period. None of them contain a $pageview or $screen event from a non-test account. Query the sessions table directly to count them. 'no_sessions': the project has no sessions in the period. 'unknown': the headline is zero, and the check for other sessions in the period failed. Query the sessions table directly to count them. * * * `ok` - OK * * `no_web_sessions` - No web sessions - * * `no_sessions` - No sessions */ + * * `no_sessions` - No sessions + * * `unknown` - Unknown */ data_status: DigestDataStatusEnum; /** Start of the current period, in the project timezone. */ date_from: string; From 3a0fc5be69ebfcf7f88d07edf7a1ee69f4008eca Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:06:44 +0000 Subject: [PATCH 8/8] fix(web-analytics): describe the digest ok status by what it checks The 'ok' status said the period has web sessions, but it only checks that pageviews or sessions are above zero. Sessions count by start time, so a period can have pageviews and zero sessions when every pageview belongs to a session that started earlier. The status text now matches the check, and a metric note explains that case. Regenerated the OpenAPI types for the help text. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 44eed5d1-e5dc-46c6-bc36-90f1fa33ab50 --- products/web_analytics/backend/serializers.py | 2 +- products/web_analytics/backend/weekly_digest.py | 2 ++ products/web_analytics/frontend/generated/api.schemas.ts | 2 +- services/mcp/src/api/generated.ts | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/products/web_analytics/backend/serializers.py b/products/web_analytics/backend/serializers.py index 964478f0afbb..912670a94d49 100644 --- a/products/web_analytics/backend/serializers.py +++ b/products/web_analytics/backend/serializers.py @@ -86,7 +86,7 @@ class DigestMetadataSerializer(serializers.Serializer): data_status = serializers.ChoiceField( choices=DigestDataStatus.choices, help_text=( - "How to read the headline numbers. 'ok': the period has web sessions. " + "How to read the headline numbers. 'ok': the headline has pageviews or sessions in the period. " "'no_web_sessions': the headline is zero, but the project has sessions in the period. None of them " "contain a $pageview or $screen event from a non-test account. Query the sessions table directly to " "count them. 'no_sessions': the project has no sessions in the period. 'unknown': the headline is zero, " diff --git a/products/web_analytics/backend/weekly_digest.py b/products/web_analytics/backend/weekly_digest.py index 97d490dae9c5..43ead1096196 100644 --- a/products/web_analytics/backend/weekly_digest.py +++ b/products/web_analytics/backend/weekly_digest.py @@ -51,6 +51,8 @@ "so it can be higher.", "Visitors, pageviews, sessions, bounce rate, session duration, top pages and top sources exclude events from " "test accounts, as set in the project's test account filters. Goal conversions include them.", + "Sessions, bounce rate and session duration count only sessions that start in the period. Pageviews can be " + "above zero while sessions are zero, when every pageview belongs to a session that started earlier.", "The period starts at the start of the day `days` days ago and ends now, in the project timezone.", ] diff --git a/products/web_analytics/frontend/generated/api.schemas.ts b/products/web_analytics/frontend/generated/api.schemas.ts index d8bdfb9d5c77..a6356fa1f0d3 100644 --- a/products/web_analytics/frontend/generated/api.schemas.ts +++ b/products/web_analytics/frontend/generated/api.schemas.ts @@ -579,7 +579,7 @@ export const DigestDataStatusEnumApi = { } as const export interface DigestMetadataApi { - /** How to read the headline numbers. 'ok': the period has web sessions. 'no_web_sessions': the headline is zero, but the project has sessions in the period. None of them contain a $pageview or $screen event from a non-test account. Query the sessions table directly to count them. 'no_sessions': the project has no sessions in the period. 'unknown': the headline is zero, and the check for other sessions in the period failed. Query the sessions table directly to count them. + /** How to read the headline numbers. 'ok': the headline has pageviews or sessions in the period. 'no_web_sessions': the headline is zero, but the project has sessions in the period. None of them contain a $pageview or $screen event from a non-test account. Query the sessions table directly to count them. 'no_sessions': the project has no sessions in the period. 'unknown': the headline is zero, and the check for other sessions in the period failed. Query the sessions table directly to count them. * * * `ok` - OK * * `no_web_sessions` - No web sessions diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index de67eadb2b39..f912682c7dc5 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -32740,7 +32740,7 @@ export namespace Schemas { } as const; export interface DigestMetadata { - /** How to read the headline numbers. 'ok': the period has web sessions. 'no_web_sessions': the headline is zero, but the project has sessions in the period. None of them contain a $pageview or $screen event from a non-test account. Query the sessions table directly to count them. 'no_sessions': the project has no sessions in the period. 'unknown': the headline is zero, and the check for other sessions in the period failed. Query the sessions table directly to count them. + /** How to read the headline numbers. 'ok': the headline has pageviews or sessions in the period. 'no_web_sessions': the headline is zero, but the project has sessions in the period. None of them contain a $pageview or $screen event from a non-test account. Query the sessions table directly to count them. 'no_sessions': the project has no sessions in the period. 'unknown': the headline is zero, and the check for other sessions in the period failed. Query the sessions table directly to count them. * * * `ok` - OK * * `no_web_sessions` - No web sessions