Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ const mockRecap = webAnalyticsRecap as jest.Mock

function makeRecap(overrides: Partial<WebAnalyticsRecapResponseApi> = {}): 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 },
Expand Down
42 changes: 42 additions & 0 deletions products/web_analytics/backend/serializers.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
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

from products.web_analytics.backend.weekly_digest import DigestDataStatus


class LlmsTxtFetchRequestSerializer(serializers.Serializer):
url = serializers.URLField(
Expand Down Expand Up @@ -69,6 +74,40 @@ 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,
help_text=(
"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."
),
)
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 the headline metrics, top pages and top sources exclude events from test accounts. "
"Goal conversions include them."
)
)
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.")
Expand All @@ -78,6 +117,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.")


Expand Down
6 changes: 6 additions & 0 deletions products/web_analytics/backend/test/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -68,13 +70,17 @@ def test_returns_digest_shape(self):
"top_pages",
"top_sources",
"goals",
"metadata",
"dashboard_url",
}
assert set(data["visitors"].keys()) == {"current", "previous", "change"}
assert isinstance(data["top_pages"], list)
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):
Expand Down
58 changes: 56 additions & 2 deletions products/web_analytics/backend/test/test_weekly_digest.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -198,6 +215,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),
}


Expand Down Expand Up @@ -432,9 +451,32 @@ 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"),
("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, 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"])
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": session_id or str(uuid7(timestamp))},
)
flush_persons_and_events()

result = build_team_digest(self.team)

assert result["team"] == self.team
Expand All @@ -446,3 +488,15 @@ 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"

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"
88 changes: 88 additions & 0 deletions products/web_analytics/backend/weekly_digest.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -38,6 +45,24 @@

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.",
"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.",
]


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)

Expand Down Expand Up @@ -79,6 +104,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}

Expand Down Expand Up @@ -254,6 +283,64 @@ 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_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_from),
"date_to": ast.Constant(value=date_to),
},
)
response = execute_hogql_query(query_type="web_analytics_digest_session_probe", query=query, team=team)
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"]:
Comment thread
posthog[bot] marked this conversation as resolved.
data_status = DigestDataStatus.OK
else:
data_status = _zero_traffic_status(team, date_from, date_to)

return {
"data_status": data_status.value,
"date_from": date_from,
"date_to": date_to,
"timezone": team.timezone,
"filter_test_accounts": True,
Comment thread
posthog[bot] marked this conversation as resolved.
"notes": DIGEST_METRIC_NOTES,
}


def build_team_digest(
team: Team,
days: int = 7,
Expand All @@ -273,6 +360,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",
}

Expand Down
Loading
Loading