Skip to content

Commit 62cc6f7

Browse files
committed
feat: Add peak-load report using Google Analytics data for DREAM Portal
1 parent 61ea409 commit 62cc6f7

5 files changed

Lines changed: 239 additions & 5 deletions

File tree

.gitignore

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
data/
2-
debug/
3-
suspects.*
4-
*.bak
1+
captcha/*.bak
2+
captcha/debug/
3+
captcha/suspects.*
4+
google-analytics/*.json
5+
onedrive-downloader/data/

.pre-commit-config.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ repos:
1111
rev: 0.7.19
1212
hooks:
1313
- id: pip-compile
14-
name: pip-compile requirements.in
14+
name: pip-compile captcha/requirements.in
1515
args: [captcha/requirements.in, -o, captcha/requirements.txt]
1616
files: ^captcha/requirements\.(in|txt)$
17+
- id: pip-compile
18+
name: pip-compile google-analytics/requirements.in
19+
args: [google-analytics/requirements.in, -o, google-analytics/requirements.txt]
20+
files: ^google-analytics/requirements\.(in|txt)$
1721
- repo: https://github.com/biomejs/pre-commit
1822
rev: v2.5.2
1923
hooks:

google-analytics/manage.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
#!/usr/bin/env python
2+
"""
3+
GA4 peak-load reporter.
4+
5+
Pulls minute-level traffic (page views, active users, events) from the GA4 Data
6+
API and renders the busiest minutes as a table. Minute granularity (dateHourMinute)
7+
is exposed by the API even though the GA4 web UI hides it.
8+
9+
Auth: relies on Application Default Credentials. Point GOOGLE_APPLICATION_CREDENTIALS
10+
at your service-account JSON key, e.g.
11+
export GOOGLE_APPLICATION_CREDENTIALS=~/.config/ga-credentials.json
12+
13+
Property: pass --property-id or set GA4_PROPERTY_ID in the environment.
14+
15+
Note: dateHourMinute values are in the property's configured timezone, not UTC.
16+
"""
17+
18+
import calendar
19+
import os
20+
from datetime import UTC, date, datetime
21+
22+
import click
23+
from google.analytics.data_v1beta import BetaAnalyticsDataClient
24+
from google.analytics.data_v1beta.types import (
25+
DateRange,
26+
Dimension,
27+
Metric,
28+
MetricAggregation,
29+
OrderBy,
30+
RunReportRequest,
31+
)
32+
from rich.console import Console
33+
from rich.table import Table
34+
35+
# Map the friendly CLI sort keys to (api_field, is_dimension).
36+
SORT_FIELDS = {
37+
"minute": ("dateHourMinute", True),
38+
"views": ("screenPageViews", False),
39+
"users": ("activeUsers", False),
40+
"events": ("eventCount", False),
41+
}
42+
43+
METRICS = ["screenPageViews", "activeUsers", "eventCount"]
44+
45+
# Length of a dateHourMinute value: YYYYMMDDHHMM.
46+
MINUTE_STAMP_LENGTH = 12
47+
48+
49+
def months_ago(n: int) -> str:
50+
"""Return the ISO date n whole months before today, day-clamped for short months."""
51+
today = datetime.now(tz=UTC).date()
52+
total = today.year * 12 + (today.month - 1) - n
53+
year, month0 = divmod(total, 12)
54+
month = month0 + 1
55+
day = min(today.day, calendar.monthrange(year, month)[1])
56+
return date(year, month, day).isoformat()
57+
58+
59+
def fmt_minute(raw: str) -> str:
60+
"""202606141530 -> 2026-06-14 15:30 (falls back to raw if unexpected)."""
61+
if len(raw) == MINUTE_STAMP_LENGTH and raw.isdigit():
62+
return f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]} {raw[8:10]}:{raw[10:12]}"
63+
return raw
64+
65+
66+
def build_order_by(sort_by: str, *, descending: bool) -> OrderBy:
67+
field, is_dimension = SORT_FIELDS[sort_by]
68+
if is_dimension:
69+
return OrderBy(
70+
dimension=OrderBy.DimensionOrderBy(
71+
dimension_name=field,
72+
order_type=OrderBy.DimensionOrderBy.OrderType.ALPHANUMERIC,
73+
),
74+
desc=descending,
75+
)
76+
return OrderBy(
77+
metric=OrderBy.MetricOrderBy(metric_name=field),
78+
desc=descending,
79+
)
80+
81+
82+
@click.command()
83+
@click.option(
84+
"--property-id",
85+
default=lambda: os.environ.get("GA4_PROPERTY_ID", ""),
86+
help="Numeric GA4 property ID (or set GA4_PROPERTY_ID).",
87+
)
88+
@click.option(
89+
"--sort-by",
90+
type=click.Choice(list(SORT_FIELDS), case_sensitive=False),
91+
default="views",
92+
show_default=True,
93+
help="Column to sort by. Sorting is pushed to the API so --limit returns the true top-N.",
94+
)
95+
@click.option(
96+
"--order",
97+
type=click.Choice(["desc", "asc"], case_sensitive=False),
98+
default="desc",
99+
show_default=True,
100+
help="Sort direction.",
101+
)
102+
@click.option("--limit", default=20, show_default=True, help="Rows to return.")
103+
@click.option(
104+
"--months",
105+
default=14,
106+
show_default=True,
107+
help="Look-back window in months. Defaults to the 14-month retention max; "
108+
"the range auto-covers whatever data exists within it.",
109+
)
110+
def main(property_id: str, sort_by: str, order: str, limit: int, months: int) -> None:
111+
"""Report the busiest minutes for a GA4 property."""
112+
if not property_id:
113+
raise click.UsageError("Provide --property-id or set GA4_PROPERTY_ID.")
114+
115+
descending = order.lower() == "desc"
116+
start_date = months_ago(months)
117+
118+
client = BetaAnalyticsDataClient() # reads GOOGLE_APPLICATION_CREDENTIALS
119+
request = RunReportRequest(
120+
property=f"properties/{property_id}",
121+
dimensions=[Dimension(name="dateHourMinute")],
122+
metrics=[Metric(name=m) for m in METRICS],
123+
date_ranges=[DateRange(start_date=start_date, end_date="yesterday")],
124+
order_bys=[build_order_by(sort_by, descending=descending)],
125+
metric_aggregations=[MetricAggregation.MAXIMUM],
126+
limit=limit,
127+
)
128+
response = client.run_report(request)
129+
130+
console = Console()
131+
table = Table(
132+
title=f"GA4 busiest minutes · {start_date} → yesterday · sorted by {sort_by} {order.lower()}",
133+
caption=f"{len(response.rows)} of {response.row_count} matching minutes shown",
134+
)
135+
table.add_column("Timestamp", style="cyan", no_wrap=True)
136+
table.add_column("Views", justify="right")
137+
table.add_column("Active users", justify="right")
138+
table.add_column("Events", justify="right")
139+
140+
for row in response.rows:
141+
ts = fmt_minute(row.dimension_values[0].value)
142+
views, users, events = (mv.value for mv in row.metric_values)
143+
table.add_row(ts, views, users, events)
144+
145+
console.print(table)
146+
147+
# metric_aggregations=MAXIMUM computes the peak of each metric across the full
148+
# result set (not just the returned rows), so it stays accurate under --limit.
149+
if response.maximums:
150+
peak = response.maximums[0].metric_values
151+
console.print(
152+
f"[bold]Peak values across range[/bold] — "
153+
f"views: {peak[0].value}, active users: {peak[1].value}, events: {peak[2].value}"
154+
)
155+
156+
if response.property_quota and response.property_quota.tokens_per_day.consumed:
157+
q = response.property_quota.tokens_per_day
158+
console.print(f"[dim]API tokens today: {q.consumed}/{q.consumed + q.remaining}[/dim]")
159+
160+
161+
if __name__ == "__main__":
162+
main()

google-analytics/requirements.in

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
click
2+
google-analytics-data
3+
rich

google-analytics/requirements.txt

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# This file was autogenerated by uv via the following command:
2+
# uv pip compile google-analytics/requirements.in -o google-analytics/requirements.txt
3+
certifi==2026.6.17
4+
# via requests
5+
cffi==2.1.0
6+
# via cryptography
7+
charset-normalizer==3.4.9
8+
# via requests
9+
click==8.4.2
10+
# via -r google-analytics/requirements.in
11+
cryptography==49.0.0
12+
# via google-auth
13+
google-analytics-data==0.23.0
14+
# via -r google-analytics/requirements.in
15+
google-api-core==2.31.0
16+
# via google-analytics-data
17+
google-auth==2.56.0
18+
# via
19+
# google-analytics-data
20+
# google-api-core
21+
googleapis-common-protos==1.75.0
22+
# via
23+
# google-api-core
24+
# grpcio-status
25+
grpcio==1.82.1
26+
# via
27+
# google-analytics-data
28+
# google-api-core
29+
# grpcio-status
30+
grpcio-status==1.82.1
31+
# via google-api-core
32+
idna==3.18
33+
# via requests
34+
markdown-it-py==4.2.0
35+
# via rich
36+
mdurl==0.1.2
37+
# via markdown-it-py
38+
proto-plus==1.28.1
39+
# via
40+
# google-analytics-data
41+
# google-api-core
42+
protobuf==7.35.1
43+
# via
44+
# google-analytics-data
45+
# google-api-core
46+
# googleapis-common-protos
47+
# grpcio-status
48+
# proto-plus
49+
pyasn1==0.6.4
50+
# via pyasn1-modules
51+
pyasn1-modules==0.4.2
52+
# via google-auth
53+
pycparser==3.0
54+
# via cffi
55+
pygments==2.20.0
56+
# via rich
57+
requests==2.34.2
58+
# via google-api-core
59+
rich==15.0.0
60+
# via -r google-analytics/requirements.in
61+
typing-extensions==4.16.0
62+
# via grpcio
63+
urllib3==2.7.0
64+
# via requests

0 commit comments

Comments
 (0)