Skip to content

Commit e712ece

Browse files
committed
fix(stats): unify the device unknown bucket across group-by and filters
The pre-existing device strategy defaulted missing docs to "Unknown" while the classifier stores "unknown", splitting the bucket and making neither half filterable. Aggregation now defaults to the classifier's value, device joins the null-sentinel filter map, and the sentinel branch keeps stored values in its $in since "unknown" is also real.
1 parent 845fb51 commit e712ece

5 files changed

Lines changed: 74 additions & 18 deletions

File tree

schemas/dto/requests/_descriptions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,8 @@
145145
"**Method 2: Individual Filter Parameter**\n\n"
146146
"Comma-separated device types. Alternative to using the `filters` JSON "
147147
"parameter.\n\n"
148-
"**Values:** `mobile`, `tablet`, `desktop`, `unknown`.\n\n"
148+
"**Values:** `mobile`, `tablet`, `desktop`, `unknown`. `unknown` also "
149+
"matches clicks recorded before device tracking existed.\n\n"
149150
"**Note:** Both `filters` JSON and individual parameters can be combined."
150151
)
151152

services/stats_service.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,14 @@
6363
_KNOWN_TIMEZONES: frozenset[str] = frozenset(available_timezones())
6464

6565
# Dimensions whose aggregation output maps null/missing to a sentinel value
66-
# ("Direct" referrers, "(none)" for untagged utm clicks). Filtering by the
67-
# sentinel must match those null/missing documents too.
66+
# ("Direct" referrers, "(none)" for untagged utm clicks, "unknown" for
67+
# clicks that predate device tracking). Filtering by the sentinel must
68+
# match those null/missing documents too. Note "unknown" is ALSO a real
69+
# stored value (the classifier's fallback) — the filter branch keeps the
70+
# sentinel in its stored-value $in for exactly that reason.
6871
_NULL_SENTINEL_FILTERS: dict[StatsDimension, str] = {
6972
StatsDimension.REFERRER: "Direct",
73+
StatsDimension.DEVICE: "unknown",
7074
StatsDimension.UTM_SOURCE: "(none)",
7175
StatsDimension.UTM_MEDIUM: "(none)",
7276
StatsDimension.UTM_CAMPAIGN: "(none)",
@@ -182,18 +186,21 @@ def _build_click_query(
182186
)
183187
continue
184188
query["meta.short_code"] = {"$in": values}
185-
elif dimension in _NULL_SENTINEL_FILTERS:
186-
sentinel = _NULL_SENTINEL_FILTERS[dimension]
187-
if sentinel in values:
188-
non_null = [v for v in values if v != sentinel]
189-
clauses: list[dict[str, Any]] = []
190-
if non_null:
191-
clauses.append({dimension: {"$in": non_null}})
192-
clauses.append({dimension: {"$in": [None, ""]}})
193-
clauses.append({dimension: {"$exists": False}})
194-
or_groups.append(clauses)
195-
else:
196-
query[dimension] = {"$in": values}
189+
elif (
190+
dimension in _NULL_SENTINEL_FILTERS
191+
and _NULL_SENTINEL_FILTERS[dimension] in values
192+
):
193+
# The stored-value $in keeps the sentinel: for device,
194+
# "unknown" is also a real stored value; for referrer/utm
195+
# the extra literal matches nothing (and if a visitor ever
196+
# sends the literal, group-by merges it with null anyway).
197+
or_groups.append(
198+
[
199+
{dimension: {"$in": values}},
200+
{dimension: {"$in": [None, ""]}},
201+
{dimension: {"$exists": False}},
202+
]
203+
)
197204
else:
198205
query[dimension] = {"$in": values}
199206

shared/aggregation_strategies.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,12 @@ class AggregationStrategyFactory:
258258
_FIELD_STRATEGIES: ClassVar[dict[str, Callable[[], AggregationStrategy]]] = {
259259
"browser": lambda: FieldAggregationStrategy("$browser", "browser", 20),
260260
"os": lambda: FieldAggregationStrategy("$os", "os", 20),
261-
"device": lambda: FieldAggregationStrategy("$device", "device", 20),
261+
# default matches the classifier's own fallback value so clicks
262+
# recorded before device tracking existed merge into the same
263+
# "unknown" bucket instead of a separate synthetic "Unknown".
264+
"device": lambda: FieldAggregationStrategy(
265+
"$device", "device", 20, default="unknown"
266+
),
262267
"country": lambda: FieldAggregationStrategy(
263268
"$country", "country", 50, transform_fn=convert_country_name
264269
),

tests/unit/services/test_stats_service.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,7 @@ def test_utm_none_sentinel_matches_missing_field(self):
530530
"all", OWNER_ID, None, START, NOW, {"utm_source": ["(none)"]}
531531
)
532532
assert q["$or"] == [
533+
{"utm_source": {"$in": ["(none)"]}},
533534
{"utm_source": {"$in": [None, ""]}},
534535
{"utm_source": {"$exists": False}},
535536
]
@@ -541,7 +542,7 @@ def test_utm_sentinel_mixed_with_values(self):
541542
"all", OWNER_ID, None, START, NOW, {"utm_medium": ["(none)", "email"]}
542543
)
543544
assert q["$or"] == [
544-
{"utm_medium": {"$in": ["email"]}},
545+
{"utm_medium": {"$in": ["(none)", "email"]}},
545546
{"utm_medium": {"$in": [None, ""]}},
546547
{"utm_medium": {"$exists": False}},
547548
]
@@ -571,3 +572,40 @@ def test_device_filter_added(self):
571572
)
572573
assert q["device"] == {"$in": ["mobile", "tablet"]}
573574
assert "$in" not in str(q.get("meta.short_code", ""))
575+
576+
def test_device_unknown_matches_stored_and_missing(self):
577+
""" "unknown" is BOTH a stored value (classifier fallback) and the
578+
sentinel for pre-device-tracking clicks — the filter must match
579+
both, or it disagrees with what group-by shows."""
580+
from services.stats_service import StatsService
581+
582+
q = StatsService._build_click_query(
583+
"all", OWNER_ID, None, START, NOW, {"device": ["unknown"]}
584+
)
585+
assert q["$or"] == [
586+
{"device": {"$in": ["unknown"]}},
587+
{"device": {"$in": [None, ""]}},
588+
{"device": {"$exists": False}},
589+
]
590+
591+
def test_device_groupby_and_filter_agree_on_missing_docs(self):
592+
"""The invariant: a click doc with no device field lands in the
593+
same "unknown" bucket for group-by (aggregation $ifNull default),
594+
for filtering (null-sentinel map), and for new writes (classifier
595+
fallback). If any of the three drifts, widget counts and filter
596+
counts stop agreeing."""
597+
from services.click.handlers import classify_device
598+
from services.stats_service import _NULL_SENTINEL_FILTERS
599+
from shared.aggregation_strategies import AggregationStrategyFactory
600+
601+
pipeline = AggregationStrategyFactory.get("device").build_pipeline({})
602+
group_expr = pipeline[1]["$group"]["_id"]
603+
assert group_expr == {"$ifNull": ["$device", "unknown"]}
604+
605+
from ua_parser import parse as ua_parse
606+
607+
classifier_fallback = classify_device(
608+
ua_parse("SomeExoticClient/1.0"), "SomeExoticClient/1.0"
609+
)
610+
assert classifier_fallback == "unknown"
611+
assert _NULL_SENTINEL_FILTERS["device"] == "unknown"

tests/unit/shared/test_aggregation_strategies.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,14 @@ def test_referrer_pipeline_uses_direct_as_null_fallback():
143143
[
144144
("browser", "Unknown"),
145145
("os", "Unknown"),
146-
("device", "Unknown"),
146+
# lowercase: must equal the classifier's own fallback so missing
147+
# docs and classified-unknown docs share one bucket
148+
("device", "unknown"),
147149
("country", "Unknown"),
148150
("city", "Unknown"),
151+
("utm_source", "(none)"),
152+
("utm_medium", "(none)"),
153+
("utm_campaign", "(none)"),
149154
],
150155
)
151156
def test_pipeline_uses_unknown_as_null_fallback(strategy_name, expected_null_fallback):

0 commit comments

Comments
 (0)