Skip to content

Commit ffd5417

Browse files
authored
Merge pull request #259 from spoo-me/feat/click-dimensions
feat(analytics): add device and UTM click dimensions
2 parents 468b179 + e712ece commit ffd5417

24 files changed

Lines changed: 596 additions & 29 deletions

routes/redirect_routes.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,11 @@ async def redirect_url(
246246
cf_city=cf_city,
247247
resolved_country=resolved_country,
248248
geo_matched=geo_matched,
249+
# Raw capture only — ClickEvent sanitises and bounds these
250+
# structurally, same as the password-hash strip.
251+
utm_source=request.query_params.get("utm_source"),
252+
utm_medium=request.query_params.get("utm_medium"),
253+
utm_campaign=request.query_params.get("utm_campaign"),
249254
redirect_ms=int((time.perf_counter() - start_time) * 1000),
250255
)
251256
try:

schemas/dto/requests/_descriptions.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,15 @@
4141
"- `time` — group by time buckets (day/week/month, auto-selected based on range)\n"
4242
"- `browser` — group by browser name (e.g., Chrome, Firefox, Safari)\n"
4343
"- `os` — group by operating system (e.g., Windows, macOS, Linux)\n"
44+
"- `device` — group by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n"
4445
"- `country` — group by country\n"
4546
"- `city` — group by city\n"
4647
"- `referrer` — group by referrer URL\n"
47-
"- `short_code` — group by URL alias (only with `scope=all`)\n\n"
48+
"- `short_code` — group by URL alias (only with `scope=all`)\n"
49+
"- `utm_source` — group by the `utm_source` tag on the short link "
50+
"(untagged clicks appear as `(none)`)\n"
51+
"- `utm_medium` — group by the `utm_medium` tag\n"
52+
"- `utm_campaign` — group by the `utm_campaign` tag\n\n"
4853
"Multiple dimensions can be combined: `time,browser` returns time series "
4954
"broken down by browser."
5055
)
@@ -69,11 +74,14 @@
6974
"**Available filter dimensions:**\n\n"
7075
"- `browser` — Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n"
7176
"- `os` — Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n"
77+
"- `device` — Filter by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n"
7278
"- `country` — Filter by country name (e.g., United States, Canada, Germany)\n"
7379
"- `city` — Filter by city name (e.g., New York, London, Mumbai)\n"
7480
"- `referrer` — Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n"
7581
"- `short_code` — Filter by URL alias (e.g., mylink, promo2024) — "
76-
"**not allowed** with `scope=anon`\n\n"
82+
"**not allowed** with `scope=anon`\n"
83+
"- `utm_source` / `utm_medium` / `utm_campaign` — Filter by campaign tags; "
84+
"`(none)` matches untagged clicks\n\n"
7785
"**Value format:** Array of strings for each dimension.\n\n"
7886
"**Important:** Filter values are case-sensitive. Use exact capitalization "
7987
"as stored in the database.\n\n"
@@ -133,6 +141,24 @@
133141
"**Note:** Both `filters` JSON and individual parameters can be combined."
134142
)
135143

144+
STATS_DEVICE_DESC = (
145+
"**Method 2: Individual Filter Parameter**\n\n"
146+
"Comma-separated device types. Alternative to using the `filters` JSON "
147+
"parameter.\n\n"
148+
"**Values:** `mobile`, `tablet`, `desktop`, `unknown`. `unknown` also "
149+
"matches clicks recorded before device tracking existed.\n\n"
150+
"**Note:** Both `filters` JSON and individual parameters can be combined."
151+
)
152+
153+
STATS_UTM_DESC = (
154+
"**Method 2: Individual Filter Parameter**\n\n"
155+
"Comma-separated campaign tag values. Alternative to using the `filters` "
156+
"JSON parameter.\n\n"
157+
"**Important:** Values are case-sensitive. `(none)` matches clicks with "
158+
"no tag.\n\n"
159+
"**Note:** Both `filters` JSON and individual parameters can be combined."
160+
)
161+
136162

137163
# ── ListUrlsQuery ────────────────────────────────────────────────────────────
138164

schemas/dto/requests/stats.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
STATS_BROWSER_DESC,
2626
STATS_CITY_DESC,
2727
STATS_COUNTRY_DESC,
28+
STATS_DEVICE_DESC,
2829
STATS_END_DATE_DESC,
2930
STATS_FILTERS_DESC,
3031
STATS_GROUP_BY_DESC,
@@ -35,6 +36,7 @@
3536
STATS_SHORT_CODE_DESC,
3637
STATS_START_DATE_DESC,
3738
STATS_TIMEZONE_DESC,
39+
STATS_UTM_DESC,
3840
)
3941
from schemas.enums.stats import (
4042
ALLOWED_EXPORT_FORMATS,
@@ -128,6 +130,12 @@ class StatsQuery(RequestBase):
128130
description=STATS_OS_DESC,
129131
examples=["Windows,macOS"],
130132
)
133+
device: str | None = Field(
134+
default=None,
135+
max_length=200,
136+
description=STATS_DEVICE_DESC,
137+
examples=["mobile,desktop"],
138+
)
131139
country: str | None = Field(
132140
default=None,
133141
max_length=1000,
@@ -146,6 +154,24 @@ class StatsQuery(RequestBase):
146154
description=STATS_REFERRER_DESC,
147155
examples=["https://google.com,https://twitter.com"],
148156
)
157+
utm_source: str | None = Field(
158+
default=None,
159+
max_length=1000,
160+
description=STATS_UTM_DESC,
161+
examples=["newsletter,twitter"],
162+
)
163+
utm_medium: str | None = Field(
164+
default=None,
165+
max_length=1000,
166+
description=STATS_UTM_DESC,
167+
examples=["email,social"],
168+
)
169+
utm_campaign: str | None = Field(
170+
default=None,
171+
max_length=1000,
172+
description=STATS_UTM_DESC,
173+
examples=["summer-launch"],
174+
)
149175

150176
# --- Parsed/validated results (private — not exposed as query params) ---
151177
_parsed_group_by: list[str] = PrivateAttr(default_factory=list)
@@ -201,7 +227,18 @@ def _parse_multi_value_fields(self) -> StatsQuery:
201227
parsed_filters[key] = _parse_comma_separated(value)
202228

203229
# Individual dimension filter params
204-
for dim in ("browser", "os", "country", "city", "referrer", "short_code"):
230+
for dim in (
231+
"browser",
232+
"os",
233+
"device",
234+
"country",
235+
"city",
236+
"referrer",
237+
"short_code",
238+
"utm_source",
239+
"utm_medium",
240+
"utm_campaign",
241+
):
205242
raw = getattr(self, dim, None)
206243
if raw:
207244
# short_code filter is blocked when scope=anon (bypass prevention)

schemas/dto/responses/public_stats.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,6 @@ class PublicStatsResponse(ResponseBase):
4949
"The modern stats wire shape (same as GET /api/v1/stats): "
5050
"summary, metrics keyed '{metric}_by_{dimension}', time_range, "
5151
"time_bucket_info, computed_metrics. v1 links carry a "
52-
"'clicks_by_bots' dimension and no 'city'; v2 the reverse."
52+
"'clicks_by_bots' dimension and no 'city'/'device'; v2 the reverse."
5353
),
5454
)

schemas/enums/stats.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,14 @@ class StatsDimension(str, Enum):
2424
TIME = "time"
2525
BROWSER = "browser"
2626
OS = "os"
27+
DEVICE = "device"
2728
COUNTRY = "country"
2829
CITY = "city"
2930
REFERRER = "referrer"
3031
SHORT_CODE = "short_code"
32+
UTM_SOURCE = "utm_source"
33+
UTM_MEDIUM = "utm_medium"
34+
UTM_CAMPAIGN = "utm_campaign"
3135

3236

3337
class StatsMetric(str, Enum):
@@ -53,10 +57,14 @@ class ExportFormat(str, Enum):
5357
{
5458
StatsDimension.BROWSER,
5559
StatsDimension.OS,
60+
StatsDimension.DEVICE,
5661
StatsDimension.COUNTRY,
5762
StatsDimension.CITY,
5863
StatsDimension.REFERRER,
5964
StatsDimension.SHORT_CODE,
65+
StatsDimension.UTM_SOURCE,
66+
StatsDimension.UTM_MEDIUM,
67+
StatsDimension.UTM_CAMPAIGN,
6068
}
6169
)
6270
ALLOWED_EXPORT_FORMATS = frozenset(ExportFormat)

schemas/models/click.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,12 @@ class ClickDoc(MongoBaseModel):
5151
redirect_ms: int
5252
referrer: str | None = None # sanitised referrer domain, nullable
5353
bot_name: str | None = None # nullable
54+
# Nullable like meta.domain: clicks recorded before these fields existed
55+
# keep their original shape forever (time-series buckets can't be
56+
# backfilled). device is "mobile" | "tablet" | "desktop" | "unknown".
57+
device: str | None = None
58+
# UTM tags captured from the short link's own query string, sanitised
59+
# at event construction (services.click.events).
60+
utm_source: str | None = None
61+
utm_medium: str | None = None
62+
utm_campaign: str | None = None

services/click/consumers/stats.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ async def consume(self, payload: Any) -> None:
4646
user_agent=event.user_agent,
4747
referrer=event.referrer,
4848
cf_city=event.cf_city,
49+
utm_source=event.utm_source,
50+
utm_medium=event.utm_medium,
51+
utm_campaign=event.utm_campaign,
4952
)
5053
except ValidationError:
5154
log.info(

services/click/events.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from __future__ import annotations
2222

23+
import re
2324
from datetime import datetime, timezone
2425
from typing import Any
2526

@@ -37,6 +38,12 @@
3738
EVENT_TYPE_CLICK = "click.recorded"
3839
_WIRE_VERSION = "1"
3940

41+
# UTM values are visitor-controlled query-string input — bound their size so
42+
# a crafted link can't bloat stream payloads, and strip control characters
43+
# before they reach Mongo/logs.
44+
_UTM_MAX_LEN = 100
45+
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
46+
4047

4148
class ClickEvent(BaseModel):
4249
"""Immutable fact: a redirect was served and its click should be tracked."""
@@ -59,9 +66,24 @@ class ClickEvent(BaseModel):
5966
# Defaults keep pre-existing stream payloads decodable.
6067
resolved_country: str | None = None
6168
geo_matched: bool = False
69+
# Campaign tags from the short link's query string. Defaults keep
70+
# pre-existing stream payloads decodable.
71+
utm_source: str | None = None
72+
utm_medium: str | None = None
73+
utm_campaign: str | None = None
6274
redirect_ms: int
6375
enqueued_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
6476

77+
@field_validator("utm_source", "utm_medium", "utm_campaign")
78+
@classmethod
79+
def _sanitize_utm(cls, value: str | None) -> str | None:
80+
"""Enforced structurally (like the password-hash strip below) so
81+
every producer inherits the bound instead of remembering it."""
82+
if value is None:
83+
return None
84+
value = _CONTROL_CHARS_RE.sub("", value).strip()[:_UTM_MAX_LEN]
85+
return value or None
86+
6587
@field_validator("url")
6688
@classmethod
6789
def _strip_password_hash(cls, url: UrlCacheData) -> UrlCacheData:

services/click/handlers.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import tldextract
1414
from bson import ObjectId
15+
from ua_parser import Result
1516
from ua_parser import parse as ua_parse
1617

1718
from errors import ForbiddenError, ValidationError
@@ -31,6 +32,40 @@
3132

3233
_tld_extractor = tldextract.TLDExtract(cache_dir=None)
3334

35+
_DESKTOP_OS_FAMILIES = frozenset(
36+
{"Windows", "Mac OS X", "Linux", "Chrome OS", "Ubuntu", "Fedora"}
37+
)
38+
_MOBILE_OS_FAMILIES = frozenset({"Windows Phone", "KaiOS", "Firefox OS"})
39+
40+
41+
def classify_device(ua: Result, user_agent: str) -> str:
42+
"""Bucket a parsed UA into ``mobile`` / ``tablet`` / ``desktop`` / ``unknown``.
43+
44+
ua-parser reports device family/brand/model but deliberately no type;
45+
this is the token fallback chain Matomo's DeviceDetector itself uses
46+
when its model regexes don't decide. The signals survive Chrome's UA
47+
reduction (the "Mobile" token and OS family are preserved; the device
48+
model is frozen to "K"). Known, accepted limit: iPad Safari sends a
49+
Mac UA since iPadOS 13 and lands in ``desktop`` — indistinguishable
50+
server-side, counted the same way by GA4/Adobe/Matomo.
51+
"""
52+
os_family = ua.os.family if ua.os else ""
53+
device_family = ua.device.family if ua.device else ""
54+
if os_family == "iOS":
55+
return "tablet" if "iPad" in device_family else "mobile"
56+
if os_family == "Android":
57+
# Chrome on Android carries "Mobile" on phones only; tablets omit it
58+
return "mobile" if "Mobile" in user_agent else "tablet"
59+
if os_family in _DESKTOP_OS_FAMILIES:
60+
return "desktop"
61+
if os_family in _MOBILE_OS_FAMILIES:
62+
return "mobile"
63+
if device_family == "Generic Smartphone":
64+
return "mobile"
65+
if device_family == "Generic Tablet":
66+
return "tablet"
67+
return "unknown"
68+
3469

3570
class V2ClickHandler:
3671
"""Records a click for a v2 URL in the time-series clicks collection."""
@@ -85,6 +120,7 @@ async def handle(self, context: ClickContext) -> None:
85120

86121
os_name = ua.os.family
87122
browser = ua.user_agent.family
123+
device = classify_device(ua, user_agent)
88124

89125
# Referrer sanitization (v2 style)
90126
sanitized_referrer: str | None = None
@@ -139,6 +175,10 @@ async def handle(self, context: ClickContext) -> None:
139175
redirect_ms=redirect_ms,
140176
referrer=sanitized_referrer,
141177
bot_name=bot_name,
178+
device=device,
179+
utm_source=context.utm_source,
180+
utm_medium=context.utm_medium,
181+
utm_campaign=context.utm_campaign,
142182
)
143183

144184
await self._click_repo.insert(click_doc.to_mongo())
@@ -153,6 +193,7 @@ async def handle(self, context: ClickContext) -> None:
153193
city=city or "Unknown",
154194
browser=browser,
155195
os=os_name,
196+
device=device,
156197
is_bot=is_bot,
157198
bot_name=bot_name,
158199
referrer_domain=sanitized_referrer,

services/click/protocol.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ class ClickContext:
2121
referrer: str | None
2222
is_emoji: bool = False
2323
cf_city: str | None = None
24+
# Campaign tags from the short link's query string (already sanitised
25+
# by ClickEvent). Recorded by the v2 handler; legacy ignores them.
26+
utm_source: str | None = None
27+
utm_medium: str | None = None
28+
utm_campaign: str | None = None
2429

2530

2631
class ClickHandler(Protocol):

0 commit comments

Comments
 (0)