PostHog / PostHog/posthog

Web analytics: Viewport breakdown silently drops conversions that lack $viewport_width/$viewport_height

Open
#82,157 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug team/web-analytics
Dominant language
Python
Stars
39.9k
Forks
3.4k
Avg merge
6h 51m
Merged PRs (30d)
232

Description

Bug Description

In web analytics, a breakdown tile with a conversion goal attributes each conversion to the conversion event's own property value, not to the session's pageview value. For most breakdowns an unattributed conversion still appears in a (none) row. For Viewport it disappears with no indication at all, so every row reads 0 conversions and 0.0% CR.

Source read at commit 06bb7c4bdc90eb6994cf62df88d0756c0258c034.

Mechanism
  1. products/web_analytics/backend/hogql_queries/web_analytics_query_runner.py, event_type_expr widens the scan to $pageview OR $screen OR <conversion goal>. The conversion event therefore enters the scan as its own row.

  2. products/web_analytics/backend/hogql_queries/query_constants/stats_table_queries.py, MAIN_INNER_QUERY groups by session_id, breakdown_value:

    SELECT
        any(person_id) AS filtered_person_id,
        count() AS filtered_pageview_count,
        {breakdown_value} AS breakdown_value,
        session.session_id AS session_id,
        ...
    FROM events
    WHERE and({inside_periods}, {event_where}, {all_properties})
    GROUP BY session_id, breakdown_value
    

    SimpleBreakdownStrategy._inner_query appends conversion_count as a countIf inside that same group.

  3. products/web_analytics/backend/hogql_queries/stats_table.py, _counts_breakdown_value makes the VIEWPORT breakdown value a per-event-row tuple:

    case WebStatsBreakdown.VIEWPORT:
        return ast.Tuple(
            exprs=[
                ast.Field(chain=["properties", "$viewport_width"]),
                ast.Field(chain=["properties", "$viewport_height"]),
            ]
        )
    
  4. stats_table.py, outer_where_breakdown hard-drops the NULL row for VIEWPORT:

    case WebStatsBreakdown.VIEWPORT:
        return parse_expr(
            "tupleElement(`context.columns.breakdown_value`, 1) IS NOT NULL AND tupleElement(`context.columns.breakdown_value`, 2) IS NOT NULL AND "
            "tupleElement(`context.columns.breakdown_value`, 1) != 0 AND tupleElement(`context.columns.breakdown_value`, 2) != 0"
        )
    

    while COUNTRY, BROWSER, OS, DEVICE_TYPE, LANGUAGE, TIMEZONE and the INITIAL_* / FIRST_PAGEVIEW_* breakdowns return None in the branch immediately below, keeping their NULL row.

A conversion event without $viewport_width / $viewport_height therefore forms its own (NULL, NULL) group, and step 4 deletes that group. It does not fall back to the session's pageview viewport, and no (none) row surfaces it.

How to reproduce
  1. Set a conversion goal on a custom event captured without viewport properties. A server-side SDK or a direct call to the capture API both produce this.
  2. Open Web analytics, Devices tab, Viewport tile.
  3. Every viewport row reports 0 conversions and 0.0% CR.
  4. Switch the same tile to Browser or OS with the same conversion goal. The conversions appear in a (none) row.
Expected behavior

Viewport should behave like Browser, OS, Device Type and Country, and surface unattributed conversions in a (none) row.

The comment above that branch already states the intent:

Breakdowns where missing data is real and worth surfacing as "(not set)" rather than silently dropped - keeps totals consistent with the overview tile and parent breakdowns.

Viewport meets that description but sits in the drop branch instead.

Impact

Silent zeros read as a broken funnel or a dead traffic segment. A user investigating "0 conversions across tens of thousands of sessions, broken down by viewport" cannot tell this apart from a real instrumentation failure, because the tile gives no signal that conversions were dropped. The Browser and OS tiles answer the same question correctly, so the two tiles disagree with no explanation.

This surfaced from a real customer report where the user asked whether their SDK setup was broken. It was not.

Note on the != 0 condition

The VIEWPORT filter also drops 0, presumably to exclude junk 0x0 measurements. If a (none) row lands, 0 needs the same treatment as NULL, meaning both fold into (none) rather than a hard drop. Otherwise the totals still fail to reconcile with the overview tile.

Secondary point, lower priority

Independent of the NULL row: because the breakdown value is evaluated per event row, the Visitors column and the Conversions column describe different event populations that happen to share a breakdown string. A session whose pageviews report one viewport and whose conversion event reports another contributes to two different rows. The resulting per-row conversion rate is approximate for every event-property breakdown, not only Viewport. Worth documenting even if the behavior stays.

Debug info
  • PostHog Cloud US

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in products/web_analytics/backend/hogql_queries/stats_table.py, especially _counts_breakdown_value and outer_where_breakdown, then trace how MAIN_INNER_QUERY in query_constants/stats_table_queries.py receives conversion_count. Verify the behavior through web_analytics_query_runner.py and the Viewport tile reproduction. Done means conversions without valid viewport properties appear in a (none) row and totals reconcile with the overview and comparable breakdowns.

Written by the indexing model from the issue text.

Assessment

Tech stack
python, sql
Domain
analytics, backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.