Coverage for baibu/metrics/rollups.py: 100%
116 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
1"""Compute the daily rollups from the platform's own tables.
3Days are calendar days in the platform time zone (``TIME_ZONE``). Computing
4a day again replaces what was stored for it, so every function here can be
5run as often as needed. Only counts are stored.
6"""
8from collections import Counter
9from datetime import date
10from datetime import datetime
11from datetime import time
12from datetime import timedelta
14from django.conf import settings
15from django.contrib.auth import get_user_model
16from django.db import transaction
17from django.db.models import Aggregate
18from django.db.models import Count
19from django.db.models import Exists
20from django.db.models import F
21from django.db.models import FloatField
22from django.db.models import Func
23from django.db.models import Max
24from django.db.models import OuterRef
25from django.db.models import QuerySet
26from django.utils import timezone
28from baibu.chat.models import Conversation
29from baibu.chat.models import Message
30from baibu.chat.models import Run
31from baibu.chat.models import ToolInvocation
32from baibu.submissions.models import Submission
33from baibu.users.models import ConsentRecord
35from .definitions import DAILY_METRICS
36from .models import DailyMetric
38User = get_user_model()
40Key = tuple[str, str] # (name, dimension)
43class PercentileCont(Aggregate):
44 """PostgreSQL's continuous percentile (interpolated), e.g. 0.5 for the median."""
46 function = "percentile_cont"
47 template = "%(function)s(%(fraction)s) WITHIN GROUP (ORDER BY %(expressions)s)"
48 output_field = FloatField()
50 def __init__(self, expression, fraction: float, **extra):
51 super().__init__(expression, fraction=float(fraction), **extra)
54class EpochSeconds(Func):
55 template = "EXTRACT(EPOCH FROM %(expressions)s)"
56 output_field = FloatField()
59def day_start(day: date) -> datetime:
60 """Midnight at the start of ``day`` in the platform time zone."""
61 return datetime.combine(day, time.min, tzinfo=timezone.get_default_timezone())
64def day_bounds(first: date, last: date | None = None) -> dict:
65 """Lookup values for ``created_at`` from the start of ``first`` to the end of ``last``."""
66 return {"gte": day_start(first), "lt": day_start((last or first) + timedelta(days=1))}
69def week_start(day: date) -> date:
70 """The Monday of the week that contains ``day``."""
71 return day - timedelta(days=day.weekday())
74def yesterday() -> date:
75 return timezone.localdate() - timedelta(days=1)
78def _between(field: str, first: date, last: date | None = None) -> dict:
79 bounds = day_bounds(first, last)
80 return {f"{field}__gte": bounds["gte"], f"{field}__lt": bounds["lt"]}
83def active_users(first: date, last: date | None = None, *, users: QuerySet | None = None) -> QuerySet:
84 """Users who sent a chat message or made a submission between ``first`` and ``last``."""
85 chatted = Message.objects.filter(
86 conversation__user=OuterRef("pk"), role=Message.Role.USER, **_between("created_at", first, last)
87 )
88 contributed = Submission.objects.filter(user=OuterRef("pk"), **_between("created_at", first, last))
89 users = User.objects.all() if users is None else users
90 return users.filter(Exists(chatted) | Exists(contributed))
93def _counts_by(queryset: QuerySet, field: str) -> dict[str, int]:
94 rows = queryset.order_by().values(field).annotate(n=Count("pk")).values_list(field, "n")
95 # A blank value (for example a conversation without a language) is "unknown",
96 # because an empty dimension means a plain total.
97 return {str(key) if key else "unknown": n for key, n in rows}
100def _consent_at(end: datetime) -> Counter:
101 """Each user's tier per scope at ``end``, counted as ``scope:tier``."""
102 latest = (
103 ConsentRecord.objects.filter(granted_at__lt=end)
104 .order_by("user_id", "scope", "-granted_at", "-created_at")
105 .distinct("user_id", "scope")
106 .values_list("scope", "tier", "revoked_at")
107 )
108 counts: Counter = Counter()
109 for scope, tier, revoked_at in latest:
110 current = ConsentRecord.Tier.NONE if revoked_at is not None and revoked_at < end else tier
111 counts[f"{scope}:{current}"] += 1
112 return counts
115def compute_day(day: date) -> dict[Key, int]:
116 """Every daily metric for ``day``. Breakdowns leave out zero values."""
117 created = _between("created_at", day)
118 end = day_bounds(day)["lt"]
119 values: dict[Key, int] = {
120 ("users_new", ""): User.objects.filter(**_between("date_joined", day)).count(),
121 ("users_total", ""): User.objects.filter(date_joined__lt=end).count(),
122 ("active_users", ""): active_users(day).count(),
123 ("active_users_7d", ""): active_users(day - timedelta(days=6), day).count(),
124 ("active_users_30d", ""): active_users(day - timedelta(days=29), day).count(),
125 ("chat_users", ""): Message.objects.filter(role=Message.Role.USER, **created)
126 .values("conversation__user_id")
127 .distinct()
128 .count(),
129 ("contributors", ""): Submission.objects.filter(**created).values("user_id").distinct().count(),
130 ("messages_user", ""): Message.objects.filter(role=Message.Role.USER, **created).count(),
131 ("messages_assistant", ""): Message.objects.filter(role=Message.Role.ASSISTANT, **created).count(),
132 }
134 conversations = Conversation.objects.filter(**created)
135 values["conversations", ""] = conversations.count()
136 breakdowns = {
137 "conversations_by_language": _counts_by(conversations, "language_code"),
138 "runs": _counts_by(Run.objects.filter(**created), "status"),
139 "tool_calls": _counts_by(ToolInvocation.objects.filter(**created), "status"),
140 "submissions_by_status": _counts_by(Submission.objects.filter(**created), "status"),
141 "submissions_by_language": _counts_by(Submission.objects.filter(**created), "language_code"),
142 "consent": _consent_at(end),
143 }
144 values["submissions", ""] = sum(breakdowns["submissions_by_status"].values())
145 for name, counts in breakdowns.items():
146 values.update({(name, dimension): n for dimension, n in counts.items() if n})
148 reply_seconds = EpochSeconds(F("completed_at") - F("created_at"))
149 times = Run.objects.filter(status=Run.Status.COMPLETED, completed_at__isnull=False, **created).aggregate(
150 median=PercentileCont(reply_seconds, 0.5), p90=PercentileCont(reply_seconds, 0.9)
151 )
152 if times["median"] is not None:
153 values["reply_time_median_ms", ""] = round(times["median"] * 1000)
154 values["reply_time_p90_ms", ""] = round(times["p90"] * 1000)
155 return values
158def compute_retention(day: date) -> dict[tuple[date, str, str], int | None]:
159 """Retention cells that activity on ``day`` can change.
161 A cohort is the users who joined in one week (Monday to Sunday). For the
162 week that contains ``day`` and each of the ``METRICS_RETENTION_WEEKS``
163 cohorts before it, this returns the cohort's size and how many of its
164 users were active in that week. ``None`` marks cells of an empty cohort.
165 """
166 activity_week = week_start(day)
167 activity_end = activity_week + timedelta(days=6)
168 cells: dict[tuple[date, str, str], int | None] = {}
169 for weeks_after in range(settings.METRICS_RETENTION_WEEKS + 1):
170 cohort_week = activity_week - timedelta(weeks=weeks_after)
171 cohort = User.objects.filter(**_between("date_joined", cohort_week, cohort_week + timedelta(days=6)))
172 size = cohort.count()
173 cells[cohort_week, "retention_cohort_size", ""] = size or None
174 if weeks_after:
175 active = active_users(activity_week, activity_end, users=cohort).count() if size else None
176 cells[cohort_week, "retention_active", str(weeks_after)] = active
177 return cells
180@transaction.atomic
181def store_day(day: date) -> int:
182 """Compute and store ``day``, replacing earlier values. Returns the rows stored."""
183 values = compute_day(day)
184 DailyMetric.objects.filter(date=day, name__in=DAILY_METRICS).delete()
185 DailyMetric.objects.bulk_create(
186 DailyMetric(date=day, name=name, dimension=dimension, value=value)
187 for (name, dimension), value in values.items()
188 )
189 stored = len(values)
190 for (cohort_week, name, dimension), value in compute_retention(day).items():
191 if value is None:
192 DailyMetric.objects.filter(date=cohort_week, name=name, dimension=dimension).delete()
193 else:
194 DailyMetric.objects.update_or_create(
195 date=cohort_week, name=name, dimension=dimension, defaults={"value": value}
196 )
197 stored += 1
198 return stored
201def store_range(first: date, last: date) -> int:
202 """Compute and store every day from ``first`` to ``last``. Returns the number of days."""
203 day = first
204 while day <= last:
205 store_day(day)
206 day += timedelta(days=1)
207 return (last - first).days + 1 if last >= first else 0
210def days_to_catch_up(today: date | None = None) -> tuple[date, date]:
211 """The days the nightly task computes: from the day after the last stored day to yesterday.
213 Always includes yesterday, and goes back at most
214 ``METRICS_CATCH_UP_DAYS`` days, so a stopped worker fills the gap.
215 """
216 last = (today or timezone.localdate()) - timedelta(days=1)
217 earliest = last - timedelta(days=max(settings.METRICS_CATCH_UP_DAYS, 1) - 1)
218 latest_stored = DailyMetric.objects.filter(name="active_users", date__lte=last).aggregate(Max("date"))["date__max"]
219 first = last if latest_stored is None else max(earliest, min(latest_stored + timedelta(days=1), last))
220 return first, last