Web analytics: Viewport breakdown silently drops conversions that lack $viewport_width/$viewport_height
Nobody has claimed this yet.
- 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
-
products/web_analytics/backend/hogql_queries/web_analytics_query_runner.py,event_type_exprwidens the scan to$pageview OR $screen OR <conversion goal>. The conversion event therefore enters the scan as its own row. -
products/web_analytics/backend/hogql_queries/query_constants/stats_table_queries.py,MAIN_INNER_QUERYgroups bysession_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_valueSimpleBreakdownStrategy._inner_queryappendsconversion_countas acountIfinside that same group. -
products/web_analytics/backend/hogql_queries/stats_table.py,_counts_breakdown_valuemakes 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"]), ] ) -
stats_table.py,outer_where_breakdownhard-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 returnNonein 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
- 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.
- Open Web analytics, Devices tab, Viewport tile.
- Every viewport row reports 0 conversions and 0.0% CR.
- 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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