SmileXing q275343119 commited on
Commit
19b1ae7
·
1 Parent(s): 42164c9

Optimization (#3)

Browse files

- feat - Optimization (a5cef14ae648e6d3c69fb58408a5ec70d070d773)
- feat - Optimization (47a187a765ee8ae1e0d2e7164e3f629f9b042d4d)


Co-authored-by: Y <q275343119@users.noreply.huggingface.co>

.github/workflows/ci.yml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Check out repository
12
+ uses: actions/checkout@v4
13
+
14
+ - name: Set up Python
15
+ uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.11"
18
+
19
+ - name: Install package
20
+ run: python -m pip install --upgrade pip && python -m pip install ".[dev]"
21
+
22
+ - name: Check formatting
23
+ run: ruff format --check .
24
+
25
+ - name: Lint
26
+ run: ruff check .
27
+
28
+ - name: Test
29
+ run: pytest
CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## Unreleased
6
+
7
+ ### Added
8
+
9
+ - Added full-range overview totals so UV and Sessions are distinct counts across the selected range.
10
+ - Added ordered funnel logic that counts each step only when it occurs after the previous required step.
11
+ - Added benchmark choices, raw data tables, and CSV export support to the dashboard.
12
+ - Added query validation, MongoDB ping checks, and dashboard-friendly error messages.
13
+ - Added pytest coverage for metric totals, query validation, and MongoDB aggregation pipeline shape.
14
+ - Added CI for formatting, linting, and tests.
15
+
16
+ ### Changed
17
+
18
+ - Updated new vs returning visitor logic to compute first-seen dates from the full available page-view history before applying the selected reporting range.
19
+ - Updated MongoDB aggregation pipelines to prefer an indexed `ts` Date field while retaining fallback support for legacy `timestamp` values.
20
+ - Documented recommended MongoDB indexes for production deployments.
README.md CHANGED
@@ -23,6 +23,7 @@ The primary purpose of this document is to define **what is measured**, **where
23
  All analytics are based on the `events` collection and the following stable fields:
24
 
25
  - Core dimensions: `event_name`, `timestamp`, `session_id`
 
26
  - Behavior context: `benchmark`, `filters`
27
  - Visitor identity (approximate): `properties.visitor_id`
28
  - Change context: `properties.old_value`, `properties.new_value`, `properties.filter_name`
@@ -51,7 +52,7 @@ Important event names:
51
  - **Definition**: Number of unique interaction sessions.
52
  - **Source fields**: `session_id`
53
  - **Calculation**:
54
- - Sessions = count of distinct `session_id` in the selected time range
55
 
56
  ### 3) UV (Unique Visitors, Approximate)
57
 
@@ -59,7 +60,7 @@ Important event names:
59
  - **Source fields**: `properties.visitor_id`
60
  - **Calculation**:
61
  - Remove null/empty `properties.visitor_id`
62
- - UV = count of distinct `properties.visitor_id`
63
 
64
  ### 4) Sessions Per Visitor
65
 
@@ -106,7 +107,7 @@ Important event names:
106
  - **Source fields**: `event_name`, `session_id`
107
  - **Calculation**:
108
  - For each `filter_change_`* event type:
109
- - collect distinct `session_id`
110
  - coverage = distinct session count
111
 
112
  ---
@@ -122,12 +123,12 @@ Recommended session-level funnel:
122
 
123
  ### 9) Step Session Count
124
 
125
- - **Definition**: Number of sessions that reached each funnel step.
126
- - **Source fields**: `session_id`, `event_name`
127
  - **Calculation**:
128
  - Group events by `session_id`
129
- - For each session, mark whether each step exists
130
- - Count sessions satisfying each cumulative step condition
131
 
132
  ### 10) Step Conversion Rate
133
 
@@ -144,10 +145,10 @@ Recommended session-level funnel:
144
  ### 11) New Visitors
145
 
146
  - **Definition**: Visitors whose current period contains their first observed visit date.
147
- - **Source fields**: `event_name`, `timestamp`, `properties.visitor_id`
148
  - **Calculation**:
149
  - Use `page_view` events only
150
- - For each `visitor_id`, find earliest timestamp (`first_seen`)
151
  - If event date equals `first_seen` date, classify as `new`
152
  - Count distinct `visitor_id` by period
153
 
@@ -170,10 +171,11 @@ All trend metrics support these granularities:
170
  - `week` -> `%G-W%V` (ISO week)
171
  - `month` -> `%Y-%m`
172
 
173
- Time filtering is applied on converted event time:
174
 
175
- - Convert `timestamp` to datetime (`ts`)
176
- - Keep records where `start_time <= ts <= end_time`
 
177
 
178
  Optional benchmark filtering:
179
 
@@ -186,6 +188,26 @@ Optional benchmark filtering:
186
  1. `visitor_id` is an approximate identifier, not a strict user identity.
187
  2. For `filter_change_`*, `properties.new_value` may not always represent the actual final filter value; prefer `filters` snapshot for behavioral context.
188
  3. If `table_download` is not instrumented, funnel step 4 will under-report by design.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
  ---
191
 
@@ -203,3 +225,10 @@ uv sync
203
  uv run leaderboard-analytics
204
  ```
205
 
 
 
 
 
 
 
 
 
23
  All analytics are based on the `events` collection and the following stable fields:
24
 
25
  - Core dimensions: `event_name`, `timestamp`, `session_id`
26
+ - Preferred event time: `ts` as a MongoDB Date
27
  - Behavior context: `benchmark`, `filters`
28
  - Visitor identity (approximate): `properties.visitor_id`
29
  - Change context: `properties.old_value`, `properties.new_value`, `properties.filter_name`
 
52
  - **Definition**: Number of unique interaction sessions.
53
  - **Source fields**: `session_id`
54
  - **Calculation**:
55
+ - Sessions = count of distinct non-empty `session_id` values in the selected time range
56
 
57
  ### 3) UV (Unique Visitors, Approximate)
58
 
 
60
  - **Source fields**: `properties.visitor_id`
61
  - **Calculation**:
62
  - Remove null/empty `properties.visitor_id`
63
+ - UV = count of distinct `properties.visitor_id` values in the selected time range
64
 
65
  ### 4) Sessions Per Visitor
66
 
 
107
  - **Source fields**: `event_name`, `session_id`
108
  - **Calculation**:
109
  - For each `filter_change_`* event type:
110
+ - collect distinct non-empty `session_id`
111
  - coverage = distinct session count
112
 
113
  ---
 
123
 
124
  ### 9) Step Session Count
125
 
126
+ - **Definition**: Number of sessions that reached each ordered funnel step.
127
+ - **Source fields**: `session_id`, `event_name`, `ts` or `timestamp`
128
  - **Calculation**:
129
  - Group events by `session_id`
130
+ - Sort events by event time
131
+ - Count each cumulative step only when it occurs after the previous required step
132
 
133
  ### 10) Step Conversion Rate
134
 
 
145
  ### 11) New Visitors
146
 
147
  - **Definition**: Visitors whose current period contains their first observed visit date.
148
+ - **Source fields**: `event_name`, `ts` or `timestamp`, `properties.visitor_id`
149
  - **Calculation**:
150
  - Use `page_view` events only
151
+ - For each `visitor_id`, find earliest timestamp (`first_seen`) from the full available dataset
152
  - If event date equals `first_seen` date, classify as `new`
153
  - Count distinct `visitor_id` by period
154
 
 
171
  - `week` -> `%G-W%V` (ISO week)
172
  - `month` -> `%Y-%m`
173
 
174
+ Time filtering rules:
175
 
176
+ - Prefer the indexed MongoDB Date field `ts`
177
+ - Fall back to converting legacy `timestamp` values when `ts` is not present
178
+ - Keep records where `start_time <= event time <= end_time`
179
 
180
  Optional benchmark filtering:
181
 
 
188
  1. `visitor_id` is an approximate identifier, not a strict user identity.
189
  2. For `filter_change_`*, `properties.new_value` may not always represent the actual final filter value; prefer `filters` snapshot for behavioral context.
190
  3. If `table_download` is not instrumented, funnel step 4 will under-report by design.
191
+ 4. Total UV and Sessions are distinct counts across the full selected time range. They are not calculated by summing per-period trend values.
192
+ 5. Funnel steps are ordered by event time. A session only reaches a later step when that step happens after the previous required step.
193
+
194
+ ---
195
+
196
+ ## MongoDB Performance Notes
197
+
198
+ For production deployments, store event time as a MongoDB Date field named `ts`. Keeping only string timestamps forces aggregation pipelines to convert time values at query time and can reduce index usage.
199
+
200
+ Recommended indexes:
201
+
202
+ ```javascript
203
+ db.events.createIndex({ ts: 1 })
204
+ db.events.createIndex({ ts: 1, benchmark: 1 })
205
+ db.events.createIndex({ event_name: 1, ts: 1 })
206
+ db.events.createIndex({ session_id: 1, ts: 1 })
207
+ db.events.createIndex({ "properties.visitor_id": 1, ts: 1 })
208
+ ```
209
+
210
+ Legacy events with only `timestamp` remain supported, but backfilling `ts` is recommended before running this dashboard against large collections.
211
 
212
  ---
213
 
 
225
  uv run leaderboard-analytics
226
  ```
227
 
228
+ Run quality checks:
229
+
230
+ ```bash
231
+ uv run ruff format --check .
232
+ uv run ruff check .
233
+ uv run pytest
234
+ ```
app.py CHANGED
@@ -1,5 +1,5 @@
1
- from pathlib import Path
2
  import sys
 
3
 
4
  # Ensure src-layout package is importable in Hugging Face Spaces runtime.
5
  ROOT_DIR = Path(__file__).resolve().parent
@@ -7,8 +7,7 @@ SRC_DIR = ROOT_DIR / "src"
7
  if str(SRC_DIR) not in sys.path:
8
  sys.path.insert(0, str(SRC_DIR))
9
 
10
- from leaderboard_analytics.main import run
11
-
12
 
13
  if __name__ == "__main__":
14
  run()
 
 
1
  import sys
2
+ from pathlib import Path
3
 
4
  # Ensure src-layout package is importable in Hugging Face Spaces runtime.
5
  ROOT_DIR = Path(__file__).resolve().parent
 
7
  if str(SRC_DIR) not in sys.path:
8
  sys.path.insert(0, str(SRC_DIR))
9
 
10
+ from leaderboard_analytics.main import run # noqa: E402
 
11
 
12
  if __name__ == "__main__":
13
  run()
pyproject.toml CHANGED
@@ -14,6 +14,12 @@ dependencies = [
14
  "plotly>=5.24.1",
15
  ]
16
 
 
 
 
 
 
 
17
  [tool.ruff]
18
  line-length = 100
19
  target-version = "py311"
@@ -34,3 +40,6 @@ build-backend = "hatchling.build"
34
 
35
  [tool.hatch.build.targets.wheel]
36
  packages = ["src/leaderboard_analytics"]
 
 
 
 
14
  "plotly>=5.24.1",
15
  ]
16
 
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "pytest>=8.3.0",
20
+ "ruff>=0.8.0",
21
+ ]
22
+
23
  [tool.ruff]
24
  line-length = 100
25
  target-version = "py311"
 
40
 
41
  [tool.hatch.build.targets.wheel]
42
  packages = ["src/leaderboard_analytics"]
43
+
44
+ [tool.pytest.ini_options]
45
+ pythonpath = ["src"]
src/leaderboard_analytics/__init__.py CHANGED
@@ -1,2 +1 @@
1
  """Leaderboard analytics package."""
2
-
 
1
  """Leaderboard analytics package."""
 
src/leaderboard_analytics/config.py CHANGED
@@ -17,4 +17,3 @@ class Settings(BaseSettings):
17
  @lru_cache(maxsize=1)
18
  def get_settings() -> Settings:
19
  return Settings()
20
-
 
17
  @lru_cache(maxsize=1)
18
  def get_settings() -> Settings:
19
  return Settings()
 
src/leaderboard_analytics/db.py CHANGED
@@ -9,7 +9,9 @@ def get_mongo_client() -> MongoClient:
9
  settings = get_settings()
10
  if not settings.mongo_uri:
11
  raise ValueError("MONGO_URI is not configured. Please set MONGO_URI in .env file.")
12
- return MongoClient(settings.mongo_uri)
 
 
13
 
14
 
15
  def get_database(client: MongoClient) -> Database:
@@ -20,4 +22,3 @@ def get_database(client: MongoClient) -> Database:
20
  def get_events_collection(db: Database) -> Collection:
21
  settings = get_settings()
22
  return db[settings.mongo_collection]
23
-
 
9
  settings = get_settings()
10
  if not settings.mongo_uri:
11
  raise ValueError("MONGO_URI is not configured. Please set MONGO_URI in .env file.")
12
+ client = MongoClient(settings.mongo_uri, serverSelectionTimeoutMS=5000)
13
+ client.admin.command("ping")
14
+ return client
15
 
16
 
17
  def get_database(client: MongoClient) -> Database:
 
22
  def get_events_collection(db: Database) -> Collection:
23
  settings = get_settings()
24
  return db[settings.mongo_collection]
 
src/leaderboard_analytics/main.py CHANGED
@@ -19,4 +19,3 @@ def run() -> None:
19
 
20
  if __name__ == "__main__":
21
  run()
22
-
 
19
 
20
  if __name__ == "__main__":
21
  run()
 
src/leaderboard_analytics/repositories.py CHANGED
@@ -11,12 +11,34 @@ def _period_expression(granularity: Granularity) -> dict:
11
  Granularity.WEEK: "%G-W%V",
12
  Granularity.MONTH: "%Y-%m",
13
  }
14
- return {"$dateToString": {"format": format_map[granularity], "date": "$ts"}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
 
17
  def _with_time_and_optional_benchmark(filters: QueryFilters) -> dict:
18
  matcher: dict = {
19
- "ts": {
20
  "$gte": filters.start_time,
21
  "$lte": filters.end_time,
22
  }
@@ -26,6 +48,23 @@ def _with_time_and_optional_benchmark(filters: QueryFilters) -> dict:
26
  return matcher
27
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  class AnalyticsRepository:
30
  def __init__(self, events_collection: Collection) -> None:
31
  self.events_collection = events_collection
@@ -33,7 +72,8 @@ class AnalyticsRepository:
33
  def overview_timeseries(self, filters: QueryFilters) -> list[dict]:
34
  period_expr = _period_expression(filters.granularity)
35
  pipeline: list[dict] = [
36
- {"$addFields": {"ts": {"$toDate": "$timestamp"}, "visitor_id": "$properties.visitor_id"}},
 
37
  {"$match": _with_time_and_optional_benchmark(filters)},
38
  {
39
  "$group": {
@@ -50,27 +90,52 @@ class AnalyticsRepository:
50
  "period": "$_id.period",
51
  "pv": 1,
52
  "event_count": 1,
53
- "session_count": {"$size": "$sessions"},
54
- "uv": {
55
- "$size": {
56
- "$filter": {
57
- "input": "$visitors",
58
- "as": "v",
59
- "cond": {"$and": [{"$ne": ["$$v", None]}, {"$ne": ["$$v", ""]}]},
60
- }
61
- }
62
- },
63
  }
64
  },
65
  {"$sort": {"period": 1}},
66
  ]
67
  return list(self.events_collection.aggregate(pipeline))
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  def benchmark_top(self, filters: QueryFilters, limit: int = 20) -> list[dict]:
70
  pipeline: list[dict] = [
71
- {"$addFields": {"ts": {"$toDate": "$timestamp"}}},
72
- {"$match": {**_with_time_and_optional_benchmark(filters), "event_name": "benchmark_change"}},
 
 
 
 
 
 
73
  {"$group": {"_id": "$properties.new_value", "count": {"$sum": 1}}},
 
74
  {"$project": {"_id": 0, "benchmark": "$_id", "count": 1}},
75
  {"$sort": {"count": -1}},
76
  {"$limit": limit},
@@ -79,20 +144,27 @@ class AnalyticsRepository:
79
 
80
  def filter_distribution(self, filters: QueryFilters) -> list[dict]:
81
  pipeline: list[dict] = [
82
- {"$addFields": {"ts": {"$toDate": "$timestamp"}}},
 
83
  {
84
  "$match": {
85
  **_with_time_and_optional_benchmark(filters),
86
  "event_name": {"$regex": "^filter_change_"},
87
  }
88
  },
89
- {"$group": {"_id": "$event_name", "count": {"$sum": 1}, "sessions": {"$addToSet": "$session_id"}}},
 
 
 
 
 
 
90
  {
91
  "$project": {
92
  "_id": 0,
93
  "event_name": "$_id",
94
  "count": 1,
95
- "session_coverage": {"$size": "$sessions"},
96
  }
97
  },
98
  {"$sort": {"count": -1}},
@@ -101,41 +173,169 @@ class AnalyticsRepository:
101
 
102
  def funnel(self, filters: QueryFilters) -> list[dict]:
103
  pipeline: list[dict] = [
104
- {"$addFields": {"ts": {"$toDate": "$timestamp"}}},
 
105
  {"$match": _with_time_and_optional_benchmark(filters)},
106
- {"$group": {"_id": "$session_id", "events": {"$addToSet": "$event_name"}}},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  {
108
  "$project": {
109
- "has_page_view": {"$in": ["page_view", "$events"]},
110
- "has_benchmark_change": {"$in": ["benchmark_change", "$events"]},
111
- "has_filter_change": {
112
- "$gt": [
 
113
  {
114
- "$size": {
115
- "$filter": {
116
- "input": "$events",
117
- "as": "e",
118
- "cond": {"$regexMatch": {"input": "$$e", "regex": "^filter_change_"}},
119
- }
 
 
 
 
 
 
 
 
 
120
  }
121
  },
122
  0,
123
  ]
124
  },
125
- "has_table_download": {"$in": ["table_download", "$events"]},
126
  }
127
  },
128
  {
129
  "$group": {
130
  "_id": None,
131
- "step1_page_view": {"$sum": {"$cond": ["$has_page_view", 1, 0]}},
 
 
132
  "step2_benchmark_change": {
133
- "$sum": {"$cond": [{"$and": ["$has_page_view", "$has_benchmark_change"]}, 1, 0]}
 
 
 
 
 
 
 
 
 
 
 
134
  },
135
  "step3_filter_change": {
136
  "$sum": {
137
  "$cond": [
138
- {"$and": ["$has_page_view", "$has_benchmark_change", "$has_filter_change"]},
 
 
 
 
 
 
139
  1,
140
  0,
141
  ]
@@ -146,10 +346,10 @@ class AnalyticsRepository:
146
  "$cond": [
147
  {
148
  "$and": [
149
- "$has_page_view",
150
- "$has_benchmark_change",
151
- "$has_filter_change",
152
- "$has_table_download",
153
  ]
154
  },
155
  1,
@@ -174,10 +374,9 @@ class AnalyticsRepository:
174
  def visitors_new_vs_returning(self, filters: QueryFilters) -> list[dict]:
175
  period_expr = _period_expression(filters.granularity)
176
  pipeline: list[dict] = [
177
- {"$addFields": {"ts": {"$toDate": "$timestamp"}, "visitor_id": "$properties.visitor_id"}},
178
  {
179
  "$match": {
180
- **_with_time_and_optional_benchmark(filters),
181
  "event_name": "page_view",
182
  "visitor_id": {"$nin": [None, ""]},
183
  }
@@ -185,31 +384,63 @@ class AnalyticsRepository:
185
  {
186
  "$setWindowFields": {
187
  "partitionBy": "$visitor_id",
188
- "sortBy": {"ts": 1},
189
- "output": {"first_seen": {"$first": "$ts"}},
190
  }
191
  },
 
192
  {
193
  "$project": {
194
  "period": period_expr,
195
- "is_new": {"$eq": [{"$dateToString": {"format": "%Y-%m-%d", "date": "$ts"}}, {"$dateToString": {"format": "%Y-%m-%d", "date": "$first_seen"}}]},
 
 
 
 
 
196
  "visitor_id": 1,
197
  }
198
  },
199
- {"$group": {"_id": {"period": "$period", "is_new": "$is_new"}, "visitors": {"$addToSet": "$visitor_id"}}},
 
 
 
 
 
200
  {
201
  "$project": {
202
  "_id": 0,
203
  "period": "$_id.period",
204
  "is_new": "$_id.is_new",
205
- "visitor_count": {"$size": "$visitors"},
206
  }
207
  },
208
  {"$sort": {"period": 1, "is_new": -1}},
209
  ]
210
  return list(self.events_collection.aggregate(pipeline))
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  @staticmethod
213
  def safe_first(items: Iterable[dict]) -> dict:
214
  return next(iter(items), {})
215
-
 
11
  Granularity.WEEK: "%G-W%V",
12
  Granularity.MONTH: "%Y-%m",
13
  }
14
+ return {"$dateToString": {"format": format_map[granularity], "date": "$event_ts"}}
15
+
16
+
17
+ def _with_normalized_time() -> dict:
18
+ return {
19
+ "$addFields": {
20
+ "event_ts": {"$ifNull": ["$ts", {"$toDate": "$timestamp"}]},
21
+ "visitor_id": "$properties.visitor_id",
22
+ }
23
+ }
24
+
25
+
26
+ def _indexed_time_prefilter(filters: QueryFilters) -> dict:
27
+ matcher: dict = {
28
+ "$or": [
29
+ {"ts": {"$gte": filters.start_time, "$lte": filters.end_time}},
30
+ {"ts": None},
31
+ {"ts": {"$exists": False}},
32
+ ]
33
+ }
34
+ if filters.benchmark:
35
+ matcher["benchmark"] = filters.benchmark
36
+ return matcher
37
 
38
 
39
  def _with_time_and_optional_benchmark(filters: QueryFilters) -> dict:
40
  matcher: dict = {
41
+ "event_ts": {
42
  "$gte": filters.start_time,
43
  "$lte": filters.end_time,
44
  }
 
48
  return matcher
49
 
50
 
51
+ def _non_empty_set_size(field_name: str, variable_name: str) -> dict:
52
+ return {
53
+ "$size": {
54
+ "$filter": {
55
+ "input": f"${field_name}",
56
+ "as": variable_name,
57
+ "cond": {
58
+ "$and": [
59
+ {"$ne": [f"$${variable_name}", None]},
60
+ {"$ne": [f"$${variable_name}", ""]},
61
+ ]
62
+ },
63
+ }
64
+ }
65
+ }
66
+
67
+
68
  class AnalyticsRepository:
69
  def __init__(self, events_collection: Collection) -> None:
70
  self.events_collection = events_collection
 
72
  def overview_timeseries(self, filters: QueryFilters) -> list[dict]:
73
  period_expr = _period_expression(filters.granularity)
74
  pipeline: list[dict] = [
75
+ {"$match": _indexed_time_prefilter(filters)},
76
+ _with_normalized_time(),
77
  {"$match": _with_time_and_optional_benchmark(filters)},
78
  {
79
  "$group": {
 
90
  "period": "$_id.period",
91
  "pv": 1,
92
  "event_count": 1,
93
+ "session_count": _non_empty_set_size("sessions", "s"),
94
+ "uv": _non_empty_set_size("visitors", "v"),
 
 
 
 
 
 
 
 
95
  }
96
  },
97
  {"$sort": {"period": 1}},
98
  ]
99
  return list(self.events_collection.aggregate(pipeline))
100
 
101
+ def overview_totals(self, filters: QueryFilters) -> dict:
102
+ pipeline: list[dict] = [
103
+ {"$match": _indexed_time_prefilter(filters)},
104
+ _with_normalized_time(),
105
+ {"$match": _with_time_and_optional_benchmark(filters)},
106
+ {
107
+ "$group": {
108
+ "_id": None,
109
+ "pv": {"$sum": {"$cond": [{"$eq": ["$event_name", "page_view"]}, 1, 0]}},
110
+ "events": {"$sum": 1},
111
+ "sessions": {"$addToSet": "$session_id"},
112
+ "visitors": {"$addToSet": "$visitor_id"},
113
+ }
114
+ },
115
+ {
116
+ "$project": {
117
+ "_id": 0,
118
+ "pv": 1,
119
+ "events": 1,
120
+ "sessions": _non_empty_set_size("sessions", "s"),
121
+ "uv": _non_empty_set_size("visitors", "v"),
122
+ }
123
+ },
124
+ ]
125
+ return self.safe_first(self.events_collection.aggregate(pipeline))
126
+
127
  def benchmark_top(self, filters: QueryFilters, limit: int = 20) -> list[dict]:
128
  pipeline: list[dict] = [
129
+ {"$match": _indexed_time_prefilter(filters)},
130
+ _with_normalized_time(),
131
+ {
132
+ "$match": {
133
+ **_with_time_and_optional_benchmark(filters),
134
+ "event_name": "benchmark_change",
135
+ }
136
+ },
137
  {"$group": {"_id": "$properties.new_value", "count": {"$sum": 1}}},
138
+ {"$match": {"_id": {"$nin": [None, ""]}}},
139
  {"$project": {"_id": 0, "benchmark": "$_id", "count": 1}},
140
  {"$sort": {"count": -1}},
141
  {"$limit": limit},
 
144
 
145
  def filter_distribution(self, filters: QueryFilters) -> list[dict]:
146
  pipeline: list[dict] = [
147
+ {"$match": _indexed_time_prefilter(filters)},
148
+ _with_normalized_time(),
149
  {
150
  "$match": {
151
  **_with_time_and_optional_benchmark(filters),
152
  "event_name": {"$regex": "^filter_change_"},
153
  }
154
  },
155
+ {
156
+ "$group": {
157
+ "_id": "$event_name",
158
+ "count": {"$sum": 1},
159
+ "sessions": {"$addToSet": "$session_id"},
160
+ }
161
+ },
162
  {
163
  "$project": {
164
  "_id": 0,
165
  "event_name": "$_id",
166
  "count": 1,
167
+ "session_coverage": _non_empty_set_size("sessions", "s"),
168
  }
169
  },
170
  {"$sort": {"count": -1}},
 
173
 
174
  def funnel(self, filters: QueryFilters) -> list[dict]:
175
  pipeline: list[dict] = [
176
+ {"$match": _indexed_time_prefilter(filters)},
177
+ _with_normalized_time(),
178
  {"$match": _with_time_and_optional_benchmark(filters)},
179
+ {"$sort": {"session_id": 1, "event_ts": 1}},
180
+ {
181
+ "$group": {
182
+ "_id": "$session_id",
183
+ "events": {"$push": {"name": "$event_name", "ts": "$event_ts"}},
184
+ }
185
+ },
186
+ {"$match": {"_id": {"$nin": [None, ""]}}},
187
+ {
188
+ "$project": {
189
+ "events": 1,
190
+ "page_view_at": {
191
+ "$arrayElemAt": [
192
+ {
193
+ "$map": {
194
+ "input": {
195
+ "$filter": {
196
+ "input": "$events",
197
+ "as": "event",
198
+ "cond": {"$eq": ["$$event.name", "page_view"]},
199
+ }
200
+ },
201
+ "as": "event",
202
+ "in": "$$event.ts",
203
+ }
204
+ },
205
+ 0,
206
+ ]
207
+ },
208
+ }
209
+ },
210
+ {
211
+ "$project": {
212
+ "events": 1,
213
+ "page_view_at": 1,
214
+ "benchmark_change_at": {
215
+ "$arrayElemAt": [
216
+ {
217
+ "$map": {
218
+ "input": {
219
+ "$filter": {
220
+ "input": "$events",
221
+ "as": "event",
222
+ "cond": {
223
+ "$and": [
224
+ {"$eq": ["$$event.name", "benchmark_change"]},
225
+ {"$gte": ["$$event.ts", "$page_view_at"]},
226
+ ]
227
+ },
228
+ }
229
+ },
230
+ "as": "event",
231
+ "in": "$$event.ts",
232
+ }
233
+ },
234
+ 0,
235
+ ]
236
+ },
237
+ }
238
+ },
239
+ {
240
+ "$project": {
241
+ "events": 1,
242
+ "page_view_at": 1,
243
+ "benchmark_change_at": 1,
244
+ "filter_change_at": {
245
+ "$arrayElemAt": [
246
+ {
247
+ "$map": {
248
+ "input": {
249
+ "$filter": {
250
+ "input": "$events",
251
+ "as": "event",
252
+ "cond": {
253
+ "$and": [
254
+ {
255
+ "$regexMatch": {
256
+ "input": "$$event.name",
257
+ "regex": "^filter_change_",
258
+ }
259
+ },
260
+ {
261
+ "$gte": [
262
+ "$$event.ts",
263
+ "$benchmark_change_at",
264
+ ]
265
+ },
266
+ ]
267
+ },
268
+ }
269
+ },
270
+ "as": "event",
271
+ "in": "$$event.ts",
272
+ }
273
+ },
274
+ 0,
275
+ ]
276
+ },
277
+ }
278
+ },
279
  {
280
  "$project": {
281
+ "page_view_at": 1,
282
+ "benchmark_change_at": 1,
283
+ "filter_change_at": 1,
284
+ "table_download_at": {
285
+ "$arrayElemAt": [
286
  {
287
+ "$map": {
288
+ "input": {
289
+ "$filter": {
290
+ "input": "$events",
291
+ "as": "event",
292
+ "cond": {
293
+ "$and": [
294
+ {"$eq": ["$$event.name", "table_download"]},
295
+ {"$gte": ["$$event.ts", "$filter_change_at"]},
296
+ ]
297
+ },
298
+ }
299
+ },
300
+ "as": "event",
301
+ "in": "$$event.ts",
302
  }
303
  },
304
  0,
305
  ]
306
  },
 
307
  }
308
  },
309
  {
310
  "$group": {
311
  "_id": None,
312
+ "step1_page_view": {
313
+ "$sum": {"$cond": [{"$ne": ["$page_view_at", None]}, 1, 0]}
314
+ },
315
  "step2_benchmark_change": {
316
+ "$sum": {
317
+ "$cond": [
318
+ {
319
+ "$and": [
320
+ {"$ne": ["$page_view_at", None]},
321
+ {"$gte": ["$benchmark_change_at", "$page_view_at"]},
322
+ ]
323
+ },
324
+ 1,
325
+ 0,
326
+ ]
327
+ }
328
  },
329
  "step3_filter_change": {
330
  "$sum": {
331
  "$cond": [
332
+ {
333
+ "$and": [
334
+ {"$ne": ["$page_view_at", None]},
335
+ {"$gte": ["$benchmark_change_at", "$page_view_at"]},
336
+ {"$gte": ["$filter_change_at", "$benchmark_change_at"]},
337
+ ]
338
+ },
339
  1,
340
  0,
341
  ]
 
346
  "$cond": [
347
  {
348
  "$and": [
349
+ {"$ne": ["$page_view_at", None]},
350
+ {"$gte": ["$benchmark_change_at", "$page_view_at"]},
351
+ {"$gte": ["$filter_change_at", "$benchmark_change_at"]},
352
+ {"$gte": ["$table_download_at", "$filter_change_at"]},
353
  ]
354
  },
355
  1,
 
374
  def visitors_new_vs_returning(self, filters: QueryFilters) -> list[dict]:
375
  period_expr = _period_expression(filters.granularity)
376
  pipeline: list[dict] = [
377
+ _with_normalized_time(),
378
  {
379
  "$match": {
 
380
  "event_name": "page_view",
381
  "visitor_id": {"$nin": [None, ""]},
382
  }
 
384
  {
385
  "$setWindowFields": {
386
  "partitionBy": "$visitor_id",
387
+ "sortBy": {"event_ts": 1},
388
+ "output": {"first_seen": {"$first": "$event_ts"}},
389
  }
390
  },
391
+ {"$match": _with_time_and_optional_benchmark(filters)},
392
  {
393
  "$project": {
394
  "period": period_expr,
395
+ "is_new": {
396
+ "$eq": [
397
+ {"$dateToString": {"format": "%Y-%m-%d", "date": "$event_ts"}},
398
+ {"$dateToString": {"format": "%Y-%m-%d", "date": "$first_seen"}},
399
+ ]
400
+ },
401
  "visitor_id": 1,
402
  }
403
  },
404
+ {
405
+ "$group": {
406
+ "_id": {"period": "$period", "is_new": "$is_new"},
407
+ "visitors": {"$addToSet": "$visitor_id"},
408
+ }
409
+ },
410
  {
411
  "$project": {
412
  "_id": 0,
413
  "period": "$_id.period",
414
  "is_new": "$_id.is_new",
415
+ "visitor_count": _non_empty_set_size("visitors", "v"),
416
  }
417
  },
418
  {"$sort": {"period": 1, "is_new": -1}},
419
  ]
420
  return list(self.events_collection.aggregate(pipeline))
421
 
422
+ def available_benchmarks(
423
+ self, filters: QueryFilters | None = None, limit: int = 100
424
+ ) -> list[str]:
425
+ pipeline: list[dict] = []
426
+ if filters is not None:
427
+ pipeline.extend(
428
+ [
429
+ {"$match": _indexed_time_prefilter(filters)},
430
+ _with_normalized_time(),
431
+ {"$match": _with_time_and_optional_benchmark(filters)},
432
+ ]
433
+ )
434
+ pipeline.extend(
435
+ [
436
+ {"$match": {"benchmark": {"$nin": [None, ""]}}},
437
+ {"$group": {"_id": "$benchmark"}},
438
+ {"$sort": {"_id": 1}},
439
+ {"$limit": limit},
440
+ ]
441
+ )
442
+ return [row["_id"] for row in self.events_collection.aggregate(pipeline)]
443
+
444
  @staticmethod
445
  def safe_first(items: Iterable[dict]) -> dict:
446
  return next(iter(items), {})
 
src/leaderboard_analytics/schemas.py CHANGED
@@ -1,7 +1,7 @@
1
- from datetime import datetime, timezone
2
  from enum import StrEnum
3
 
4
- from pydantic import BaseModel, Field
5
 
6
 
7
  class Granularity(StrEnum):
@@ -12,9 +12,16 @@ class Granularity(StrEnum):
12
 
13
  class QueryFilters(BaseModel):
14
  start_time: datetime = Field(
15
- default_factory=lambda: datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
 
 
16
  )
17
- end_time: datetime = Field(default_factory=lambda: datetime.now(tz=timezone.utc))
18
  benchmark: str | None = None
19
  granularity: Granularity = Granularity.DAY
20
 
 
 
 
 
 
 
1
+ from datetime import UTC, datetime
2
  from enum import StrEnum
3
 
4
+ from pydantic import BaseModel, Field, model_validator
5
 
6
 
7
  class Granularity(StrEnum):
 
12
 
13
  class QueryFilters(BaseModel):
14
  start_time: datetime = Field(
15
+ default_factory=lambda: datetime.now(tz=UTC).replace(
16
+ hour=0, minute=0, second=0, microsecond=0
17
+ )
18
  )
19
+ end_time: datetime = Field(default_factory=lambda: datetime.now(tz=UTC))
20
  benchmark: str | None = None
21
  granularity: Granularity = Granularity.DAY
22
 
23
+ @model_validator(mode="after")
24
+ def validate_time_range(self) -> "QueryFilters":
25
+ if self.start_time > self.end_time:
26
+ raise ValueError("start_time must be earlier than or equal to end_time")
27
+ return self
src/leaderboard_analytics/services.py CHANGED
@@ -11,25 +11,19 @@ class AnalyticsService:
11
  def get_overview(self, filters: QueryFilters) -> tuple[pd.DataFrame, dict]:
12
  rows = self.repository.overview_timeseries(filters)
13
  frame = pd.DataFrame(rows)
14
- if frame.empty:
15
- empty = {
16
- "pv": 0,
17
- "uv": 0,
18
- "sessions": 0,
19
- "events": 0,
20
- "events_per_session": 0.0,
21
- "sessions_per_visitor": 0.0,
22
- }
23
- return frame, empty
24
-
25
  totals = {
26
- "pv": int(frame["pv"].sum()),
27
- "uv": int(frame["uv"].sum()),
28
- "sessions": int(frame["session_count"].sum()),
29
- "events": int(frame["event_count"].sum()),
30
  }
31
- totals["events_per_session"] = round(totals["events"] / totals["sessions"], 2) if totals["sessions"] else 0.0
32
- totals["sessions_per_visitor"] = round(totals["sessions"] / totals["uv"], 2) if totals["uv"] else 0.0
 
 
 
 
33
  return frame, totals
34
 
35
  def get_benchmark_top(self, filters: QueryFilters) -> pd.DataFrame:
@@ -60,3 +54,5 @@ class AnalyticsService:
60
  frame["visitor_type"] = frame["is_new"].map({True: "new", False: "returning"})
61
  return frame
62
 
 
 
 
11
  def get_overview(self, filters: QueryFilters) -> tuple[pd.DataFrame, dict]:
12
  rows = self.repository.overview_timeseries(filters)
13
  frame = pd.DataFrame(rows)
14
+ raw_totals = self.repository.overview_totals(filters)
 
 
 
 
 
 
 
 
 
 
15
  totals = {
16
+ "pv": int(raw_totals.get("pv", 0)),
17
+ "uv": int(raw_totals.get("uv", 0)),
18
+ "sessions": int(raw_totals.get("sessions", 0)),
19
+ "events": int(raw_totals.get("events", 0)),
20
  }
21
+ totals["events_per_session"] = (
22
+ round(totals["events"] / totals["sessions"], 2) if totals["sessions"] else 0.0
23
+ )
24
+ totals["sessions_per_visitor"] = (
25
+ round(totals["sessions"] / totals["uv"], 2) if totals["uv"] else 0.0
26
+ )
27
  return frame, totals
28
 
29
  def get_benchmark_top(self, filters: QueryFilters) -> pd.DataFrame:
 
54
  frame["visitor_type"] = frame["is_new"].map({True: "new", False: "returning"})
55
  return frame
56
 
57
+ def get_available_benchmarks(self, filters: QueryFilters | None = None) -> list[str]:
58
+ return self.repository.available_benchmarks(filters)
src/leaderboard_analytics/ui.py CHANGED
@@ -1,8 +1,12 @@
1
- from datetime import datetime, timedelta, timezone
2
  import math
 
 
 
 
3
  from typing import Any
4
 
5
  import gradio as gr
 
6
  import plotly.express as px
7
 
8
  from leaderboard_analytics.schemas import Granularity, QueryFilters
@@ -19,7 +23,7 @@ def _to_utc_datetime(value: Any, fallback: datetime) -> datetime:
19
  if isinstance(value, float) and math.isnan(value):
20
  return fallback
21
  # Gradio DateTime may return Unix timestamps as numbers.
22
- dt = datetime.fromtimestamp(value, tz=timezone.utc)
23
  elif isinstance(value, str):
24
  dt = datetime.fromisoformat(value)
25
  else:
@@ -27,60 +31,165 @@ def _to_utc_datetime(value: Any, fallback: datetime) -> datetime:
27
 
28
  # Gradio DateTime may return naive datetime values in local time.
29
  if dt.tzinfo is None:
30
- dt = dt.replace(tzinfo=timezone.utc)
31
- return dt.astimezone(timezone.utc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
 
34
  def build_dashboard(service: AnalyticsService) -> gr.Blocks:
35
- default_end = datetime.now(tz=timezone.utc)
36
  default_start = (default_end - timedelta(days=7)).replace(microsecond=0)
37
 
 
 
 
 
 
 
 
38
  def query(
39
  start_time: datetime | str | None,
40
  end_time: datetime | str | None,
41
  benchmark: str,
42
  granularity: str,
43
- ) -> tuple[str, object, object, object, object, object]:
44
- filters = QueryFilters(
45
- start_time=_to_utc_datetime(start_time, default_start),
46
- end_time=_to_utc_datetime(end_time, default_end),
47
- benchmark=benchmark or None,
48
- granularity=Granularity(granularity),
49
- )
50
- overview_df, totals = service.get_overview(filters)
51
- benchmark_df = service.get_benchmark_top(filters)
52
- filter_df = service.get_filter_distribution(filters)
53
- funnel_df = service.get_funnel(filters)
54
- visitors_df = service.get_new_vs_returning(filters)
55
-
56
- metrics = (
57
- f"PV: {totals['pv']} | UV: {totals['uv']} | Sessions: {totals['sessions']} | "
58
- f"Events/Session: {totals['events_per_session']} | Sessions/Visitor: {totals['sessions_per_visitor']}"
59
- )
 
 
 
 
 
 
 
 
 
60
 
61
- overview_plot = (
62
- px.line(overview_df, x="period", y=["pv", "uv", "session_count"], title="Traffic overview")
63
- if not overview_df.empty
64
- else px.line(title="Traffic overview (no data)")
65
- )
66
- benchmark_plot = (
67
- px.bar(benchmark_df, x="benchmark", y="count", title="Benchmark Top")
68
- if not benchmark_df.empty
69
- else px.bar(title="Benchmark Top (no data)")
70
- )
71
- filter_plot = (
72
- px.bar(filter_df, x="event_name", y="count", title="Filter usage")
73
- if not filter_df.empty
74
- else px.bar(title="Filter usage (no data)")
75
- )
76
- funnel_plot = px.funnel(funnel_df, x="sessions", y="step", title="Session funnel")
77
- visitor_plot = (
78
- px.bar(visitors_df, x="period", y="visitor_count", color="visitor_type", barmode="group", title="New vs returning visitors")
79
- if not visitors_df.empty
80
- else px.bar(title="New vs returning visitors (no data)")
81
- )
82
 
83
- return metrics, overview_plot, benchmark_plot, filter_plot, funnel_plot, visitor_plot
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
  with gr.Blocks() as demo:
86
  gr.Markdown("# Leaderboard Analytics Dashboard")
@@ -100,7 +209,12 @@ def build_dashboard(service: AnalyticsService) -> gr.Blocks:
100
  value=default_end,
101
  timezone="UTC",
102
  )
103
- benchmark = gr.Textbox(label="Benchmark (optional)", placeholder="MTEB(eng)")
 
 
 
 
 
104
  granularity = gr.Dropdown(
105
  label="Granularity",
106
  choices=[Granularity.DAY.value, Granularity.WEEK.value, Granularity.MONTH.value],
@@ -108,7 +222,9 @@ def build_dashboard(service: AnalyticsService) -> gr.Blocks:
108
  )
109
  refresh = gr.Button("Refresh", variant="primary")
110
 
111
- metrics_text = gr.Markdown("PV: 0 | UV: 0 | Sessions: 0 | Events/Session: 0 | Sessions/Visitor: 0")
 
 
112
 
113
  with gr.Row():
114
  overview_plot = gr.Plot(label="Traffic Overview")
@@ -118,31 +234,41 @@ def build_dashboard(service: AnalyticsService) -> gr.Blocks:
118
  funnel_plot = gr.Plot(label="Funnel")
119
  visitor_plot = gr.Plot(label="Visitor Segmentation")
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  refresh.click(
122
  fn=query,
123
  inputs=[start_time, end_time, benchmark, granularity],
124
- outputs=[
125
- metrics_text,
126
- overview_plot,
127
- benchmark_plot,
128
- filter_plot,
129
- funnel_plot,
130
- visitor_plot,
131
- ],
132
  )
133
 
 
134
  demo.load(
135
  fn=query,
136
  inputs=[start_time, end_time, benchmark, granularity],
137
- outputs=[
138
- metrics_text,
139
- overview_plot,
140
- benchmark_plot,
141
- filter_plot,
142
- funnel_plot,
143
- visitor_plot,
144
- ],
145
  )
146
 
 
147
  return demo
148
-
 
 
1
  import math
2
+ import tempfile
3
+ import zipfile
4
+ from datetime import UTC, datetime, timedelta
5
+ from pathlib import Path
6
  from typing import Any
7
 
8
  import gradio as gr
9
+ import pandas as pd
10
  import plotly.express as px
11
 
12
  from leaderboard_analytics.schemas import Granularity, QueryFilters
 
23
  if isinstance(value, float) and math.isnan(value):
24
  return fallback
25
  # Gradio DateTime may return Unix timestamps as numbers.
26
+ dt = datetime.fromtimestamp(value, tz=UTC)
27
  elif isinstance(value, str):
28
  dt = datetime.fromisoformat(value)
29
  else:
 
31
 
32
  # Gradio DateTime may return naive datetime values in local time.
33
  if dt.tzinfo is None:
34
+ dt = dt.replace(tzinfo=UTC)
35
+ return dt.astimezone(UTC)
36
+
37
+
38
+ def _empty_plot(title: str):
39
+ return px.line(title=title)
40
+
41
+
42
+ def _query_range_text(filters: QueryFilters) -> str:
43
+ return f"{filters.start_time.isoformat()} to {filters.end_time.isoformat()}"
44
+
45
+
46
+ def _write_csv_archive(tables: dict[str, pd.DataFrame]) -> str | None:
47
+ if all(table.empty for table in tables.values()):
48
+ return None
49
+
50
+ archive = tempfile.NamedTemporaryFile(
51
+ prefix="leaderboard-analytics-", suffix=".zip", delete=False
52
+ )
53
+ archive.close()
54
+ with zipfile.ZipFile(archive.name, "w", compression=zipfile.ZIP_DEFLATED) as zip_file:
55
+ for name, table in tables.items():
56
+ zip_file.writestr(f"{name}.csv", table.to_csv(index=False))
57
+ return archive.name
58
 
59
 
60
  def build_dashboard(service: AnalyticsService) -> gr.Blocks:
61
+ default_end = datetime.now(tz=UTC)
62
  default_start = (default_end - timedelta(days=7)).replace(microsecond=0)
63
 
64
+ def load_benchmarks() -> object:
65
+ try:
66
+ benchmarks = service.get_available_benchmarks()
67
+ except Exception:
68
+ benchmarks = []
69
+ return gr.update(choices=[""] + benchmarks, value="")
70
+
71
  def query(
72
  start_time: datetime | str | None,
73
  end_time: datetime | str | None,
74
  benchmark: str,
75
  granularity: str,
76
+ ) -> tuple[
77
+ object,
78
+ object,
79
+ object,
80
+ object,
81
+ object,
82
+ object,
83
+ object,
84
+ object,
85
+ object,
86
+ object,
87
+ object,
88
+ object,
89
+ ]:
90
+ try:
91
+ filters = QueryFilters(
92
+ start_time=_to_utc_datetime(start_time, default_start),
93
+ end_time=_to_utc_datetime(end_time, default_end),
94
+ benchmark=benchmark or None,
95
+ granularity=Granularity(granularity),
96
+ )
97
+ overview_df, totals = service.get_overview(filters)
98
+ benchmark_df = service.get_benchmark_top(filters)
99
+ filter_df = service.get_filter_distribution(filters)
100
+ funnel_df = service.get_funnel(filters)
101
+ visitors_df = service.get_new_vs_returning(filters)
102
 
103
+ range_text = _query_range_text(filters)
104
+ if overview_df.empty and benchmark_df.empty and filter_df.empty and visitors_df.empty:
105
+ metrics = f"No data for {range_text}."
106
+ else:
107
+ metrics = (
108
+ f"Range: {range_text} \n"
109
+ f"PV: {totals['pv']} | UV: {totals['uv']} | Sessions: {totals['sessions']} | "
110
+ f"Events/Session: {totals['events_per_session']} | "
111
+ f"Sessions/Visitor: {totals['sessions_per_visitor']}"
112
+ )
 
 
 
 
 
 
 
 
 
 
 
113
 
114
+ overview_plot = (
115
+ px.line(
116
+ overview_df,
117
+ x="period",
118
+ y=["pv", "uv", "session_count"],
119
+ title="Traffic overview",
120
+ )
121
+ if not overview_df.empty
122
+ else _empty_plot(f"Traffic overview (no data for {range_text})")
123
+ )
124
+ benchmark_plot = (
125
+ px.bar(benchmark_df, x="benchmark", y="count", title="Benchmark Top")
126
+ if not benchmark_df.empty
127
+ else px.bar(title=f"Benchmark Top (no data for {range_text})")
128
+ )
129
+ filter_plot = (
130
+ px.bar(filter_df, x="event_name", y="count", title="Filter usage")
131
+ if not filter_df.empty
132
+ else px.bar(title=f"Filter usage (no data for {range_text})")
133
+ )
134
+ funnel_plot = px.funnel(funnel_df, x="sessions", y="step", title="Session funnel")
135
+ visitor_plot = (
136
+ px.bar(
137
+ visitors_df,
138
+ x="period",
139
+ y="visitor_count",
140
+ color="visitor_type",
141
+ barmode="group",
142
+ title="New vs returning visitors",
143
+ )
144
+ if not visitors_df.empty
145
+ else px.bar(title=f"New vs returning visitors (no data for {range_text})")
146
+ )
147
+ csv_archive = _write_csv_archive(
148
+ {
149
+ "overview": overview_df,
150
+ "benchmarks": benchmark_df,
151
+ "filters": filter_df,
152
+ "funnel": funnel_df,
153
+ "visitors": visitors_df,
154
+ }
155
+ )
156
+
157
+ return (
158
+ metrics,
159
+ overview_plot,
160
+ benchmark_plot,
161
+ filter_plot,
162
+ funnel_plot,
163
+ visitor_plot,
164
+ overview_df,
165
+ benchmark_df,
166
+ filter_df,
167
+ funnel_df,
168
+ visitors_df,
169
+ csv_archive,
170
+ )
171
+ except Exception as exc:
172
+ message = f"Query failed: {exc}"
173
+ empty = pd.DataFrame()
174
+ return (
175
+ message,
176
+ _empty_plot(message),
177
+ px.bar(title=message),
178
+ px.bar(title=message),
179
+ px.funnel(
180
+ pd.DataFrame({"step": [], "sessions": []}),
181
+ x="sessions",
182
+ y="step",
183
+ title=message,
184
+ ),
185
+ px.bar(title=message),
186
+ empty,
187
+ empty,
188
+ empty,
189
+ empty,
190
+ empty,
191
+ None,
192
+ )
193
 
194
  with gr.Blocks() as demo:
195
  gr.Markdown("# Leaderboard Analytics Dashboard")
 
209
  value=default_end,
210
  timezone="UTC",
211
  )
212
+ benchmark = gr.Dropdown(
213
+ label="Benchmark",
214
+ choices=[""],
215
+ value="",
216
+ allow_custom_value=True,
217
+ )
218
  granularity = gr.Dropdown(
219
  label="Granularity",
220
  choices=[Granularity.DAY.value, Granularity.WEEK.value, Granularity.MONTH.value],
 
222
  )
223
  refresh = gr.Button("Refresh", variant="primary")
224
 
225
+ metrics_text = gr.Markdown(
226
+ "PV: 0 | UV: 0 | Sessions: 0 | Events/Session: 0 | Sessions/Visitor: 0"
227
+ )
228
 
229
  with gr.Row():
230
  overview_plot = gr.Plot(label="Traffic Overview")
 
234
  funnel_plot = gr.Plot(label="Funnel")
235
  visitor_plot = gr.Plot(label="Visitor Segmentation")
236
 
237
+ with gr.Accordion("Raw data", open=False):
238
+ csv_file = gr.File(label="CSV export")
239
+ overview_table = gr.DataFrame(label="Traffic Overview")
240
+ benchmark_table = gr.DataFrame(label="Benchmark Analysis")
241
+ filter_table = gr.DataFrame(label="Filter Behavior")
242
+ funnel_table = gr.DataFrame(label="Funnel")
243
+ visitor_table = gr.DataFrame(label="Visitor Segmentation")
244
+
245
+ outputs = [
246
+ metrics_text,
247
+ overview_plot,
248
+ benchmark_plot,
249
+ filter_plot,
250
+ funnel_plot,
251
+ visitor_plot,
252
+ overview_table,
253
+ benchmark_table,
254
+ filter_table,
255
+ funnel_table,
256
+ visitor_table,
257
+ csv_file,
258
+ ]
259
+
260
  refresh.click(
261
  fn=query,
262
  inputs=[start_time, end_time, benchmark, granularity],
263
+ outputs=outputs,
 
 
 
 
 
 
 
264
  )
265
 
266
+ demo.load(fn=load_benchmarks, outputs=benchmark)
267
  demo.load(
268
  fn=query,
269
  inputs=[start_time, end_time, benchmark, granularity],
270
+ outputs=outputs,
 
 
 
 
 
 
 
271
  )
272
 
273
+ Path(tempfile.gettempdir()).mkdir(parents=True, exist_ok=True)
274
  return demo
 
tests/test_repositories.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import UTC, datetime
2
+
3
+ from leaderboard_analytics.repositories import AnalyticsRepository
4
+ from leaderboard_analytics.schemas import QueryFilters
5
+
6
+
7
+ class CapturingCollection:
8
+ def __init__(self, rows: list[dict] | None = None) -> None:
9
+ self.rows = rows or []
10
+ self.pipeline: list[dict] | None = None
11
+
12
+ def aggregate(self, pipeline: list[dict]):
13
+ self.pipeline = pipeline
14
+ return iter(self.rows)
15
+
16
+
17
+ def _filters() -> QueryFilters:
18
+ return QueryFilters(
19
+ start_time=datetime(2026, 1, 1, tzinfo=UTC),
20
+ end_time=datetime(2026, 1, 31, tzinfo=UTC),
21
+ )
22
+
23
+
24
+ def test_funnel_pipeline_preserves_ordered_step_logic() -> None:
25
+ collection = CapturingCollection()
26
+ repository = AnalyticsRepository(collection) # type: ignore[arg-type]
27
+
28
+ repository.funnel(_filters())
29
+
30
+ assert collection.pipeline is not None
31
+ assert {"$sort": {"session_id": 1, "event_ts": 1}} in collection.pipeline
32
+ assert any(
33
+ "$push" in stage.get("$group", {}).get("events", {}) for stage in collection.pipeline
34
+ )
35
+ assert not any(
36
+ "$addToSet" in str(stage) and "events" in str(stage) for stage in collection.pipeline
37
+ )
38
+ assert any(
39
+ "table_download_at" in str(stage) and "$filter_change_at" in str(stage)
40
+ for stage in collection.pipeline
41
+ )
42
+
43
+
44
+ def test_new_vs_returning_pipeline_computes_first_seen_before_range_match() -> None:
45
+ collection = CapturingCollection()
46
+ repository = AnalyticsRepository(collection) # type: ignore[arg-type]
47
+
48
+ repository.visitors_new_vs_returning(_filters())
49
+
50
+ assert collection.pipeline is not None
51
+ window_index = next(
52
+ i for i, stage in enumerate(collection.pipeline) if "$setWindowFields" in stage
53
+ )
54
+ range_match_index = next(
55
+ i
56
+ for i, stage in enumerate(collection.pipeline)
57
+ if stage.get("$match", {}).get("event_ts") is not None
58
+ )
59
+ assert window_index < range_match_index
60
+
61
+
62
+ def test_overview_totals_filters_empty_identifiers() -> None:
63
+ collection = CapturingCollection([{"pv": 1, "uv": 1, "sessions": 1, "events": 2}])
64
+ repository = AnalyticsRepository(collection) # type: ignore[arg-type]
65
+
66
+ totals = repository.overview_totals(_filters())
67
+
68
+ assert totals == {"pv": 1, "uv": 1, "sessions": 1, "events": 2}
69
+ assert collection.pipeline is not None
70
+ pipeline_text = str(collection.pipeline)
71
+ assert '"$sessions"' in pipeline_text or "'$sessions'" in pipeline_text
72
+ assert '"$visitors"' in pipeline_text or "'$visitors'" in pipeline_text
73
+ assert "$$s" in pipeline_text
74
+ assert "$$v" in pipeline_text
tests/test_schemas.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import UTC, datetime
2
+
3
+ import pytest
4
+ from pydantic import ValidationError
5
+
6
+ from leaderboard_analytics.schemas import QueryFilters
7
+
8
+
9
+ def test_query_filters_rejects_invalid_time_range() -> None:
10
+ with pytest.raises(
11
+ ValidationError, match="start_time must be earlier than or equal to end_time"
12
+ ):
13
+ QueryFilters(
14
+ start_time=datetime(2026, 1, 2, tzinfo=UTC),
15
+ end_time=datetime(2026, 1, 1, tzinfo=UTC),
16
+ )
tests/test_services.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import UTC, datetime
2
+
3
+ from leaderboard_analytics.schemas import QueryFilters
4
+ from leaderboard_analytics.services import AnalyticsService
5
+
6
+
7
+ class FakeRepository:
8
+ def overview_timeseries(self, filters: QueryFilters) -> list[dict]:
9
+ return [
10
+ {"period": "2026-01-01", "pv": 2, "uv": 1, "session_count": 1, "event_count": 3},
11
+ {"period": "2026-01-02", "pv": 1, "uv": 1, "session_count": 1, "event_count": 2},
12
+ ]
13
+
14
+ def overview_totals(self, filters: QueryFilters) -> dict:
15
+ return {"pv": 3, "uv": 1, "sessions": 1, "events": 5}
16
+
17
+
18
+ def test_overview_uses_full_range_distinct_totals() -> None:
19
+ service = AnalyticsService(FakeRepository()) # type: ignore[arg-type]
20
+ filters = QueryFilters(
21
+ start_time=datetime(2026, 1, 1, tzinfo=UTC),
22
+ end_time=datetime(2026, 1, 2, tzinfo=UTC),
23
+ )
24
+
25
+ frame, totals = service.get_overview(filters)
26
+
27
+ assert list(frame["period"]) == ["2026-01-01", "2026-01-02"]
28
+ assert totals == {
29
+ "pv": 3,
30
+ "uv": 1,
31
+ "sessions": 1,
32
+ "events": 5,
33
+ "events_per_session": 5.0,
34
+ "sessions_per_visitor": 1.0,
35
+ }