Skip to content

Commit 64df5eb

Browse files
agonza1Boanerges1996claudecursoragent
authored
Fix/participant count consistency (#29)
* feat: add duration/participants/issues summary endpoints (Phase 2 + 3) Five new endpoints for the remaining dashboard charts that fetch raw data: - GET /v1/conferences/duration-summary Returns conference counts bucketed by duration range (< 1m, 1-3m, etc.) - GET /v1/conferences/participant-count-summary Returns distribution of conferences by participant count - GET /v1/issues/summary Returns issue counts grouped by code with titles - GET /v1/issues/gum-summary Returns getusermedia_error issue counts grouped by error name Also adds three new filter params to /v1/conferences for click-to-detail modals on these charts: - duration_gte, duration_lt (for duration chart) - issue_code (for most-common-issues chart) All endpoints accept appId, created_at_gte, created_at_lte and handle both Python native ISO format and JavaScript's toISOString Z suffix. Phases 2 and 3 of #20 — eliminates the need for the dashboard to download all conferences (~38MB) and all issues (~73MB). * feat: add connections + sessions summary endpoints (Phase 4 + 5 of #20) Adds three new aggregation endpoints that let the dashboard stop downloading full /connections and /sessions payloads to build charts client-side: - GET /v1/connections/summary — relay vs direct connection counts (replaces the Relayed-connections pie chart's client-side reduce) - GET /v1/connections/setup-time-summary — connection setup-time buckets with per-bucket conference_ids for click-to-detail - GET /v1/sessions/summary — browsers, OS, country, and city/geo aggregates (powers Browsers, OS, and Map charts in one roundtrip) Also accepts `conference_ids=a,b,c` on /conferences so the setup-time chart can page through matched conferences on click. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: 60s Redis cache + pre-warm for dashboard summary endpoints (Phase C of #20) With Phases 0-5 merged, every dashboard chart reads from a server-side aggregation endpoint. The SQL is fast with indexes, but the same ~8 queries run on every page load, and the heavy ones (sessions.summary, connections.setup_time_summary) still cost 400-800ms on a live tenant. Adds a thin caching layer in front of each summary view: - `app/summary_cache.py` — `cached_json(endpoint, request, compute)` hashes (endpoint + filter params) into a short key, reads Redis, falls through to `compute()` on miss, and writes back with a 60s TTL. Redis failures are tolerated (settings already has IGNORE_EXCEPTIONS). - Each of the eight summary views moves its existing compute body into a local `compute()` closure and returns through the helper. No change to the JSON shape, query logic, or error handling. - `manage.py prewarm_summaries` — scheduled command that iterates apps with recent traffic (default: any conference in the last 2 days) and runs every summary view with the 30d-window filters the dashboard sends by default. Intended to run every ~30s as an ECS scheduled task so first visitors never see a cold miss. Measured locally against a 7-day Production clone (~18k conferences / 38k sessions / 38k connections): endpoint cold warm conferences/summary 391ms → 12ms (33x) sessions/summary 748ms → 11ms (68x) connections/setup_time_summary 373ms → 11ms (34x) conferences/participant_count_summary 216ms → 7ms (31x) issues/gum_summary 107ms → 6ms (18x) connections/summary 57ms → 6ms (9.5x) issues/summary 45ms → 86ms (noise; both <100ms) conferences/duration_summary 19ms → 8ms (2.3x) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: dedupe conferences for issue_code; harden gum-summary * test: summary_cache LocMem tests and prewarm smoke - Unit-test cache key rules, hit/miss, TTL override, and soft-fail on get/set errors. - Smoke-test prewarm_summaries for zero apps and one recent app (8 views). Made-with: Cursor * fix: bucket created_at_gte/created_at_lte to the minute in cache key The dashboard sends `new Date().toISOString()` minus 30 days as created_at_gte (web app.vue:189), which is millisecond-precise. With the unrounded value going straight into _make_key, every page load — even back-to-back reloads — produced a unique SHA1 digest and a fresh cache miss, so the warm path never served real users: flush redis -> 0 keys load dashboard -> 8 keys reload -> 16 keys (8 stale + 8 fresh) prewarm_summaries had the same problem on the write side: its own since_window = utcnow() - 30d advanced every run, so the entries it populated never matched what the dashboard requested. Truncate ISO timestamps to the minute (YYYY-MM-DDTHH:MM) before hashing, so two requests in the same wall-clock minute share an entry. Correctness still holds because the 60s TTL bounds staleness regardless of bucket size. After the fix, two same-minute dashboard loads both produce 8 keys (no growth), and the 2nd load's slow endpoints serve from cache: before after /v1/sessions/summary 2314ms -> 411ms /v1/connections/setup-time-summary 1117ms -> 229ms /v1/conferences/summary 994ms -> 48ms Two regression tests added covering the bucket and the minute boundary. * Fix conference participant counts for dashboard and list Count only active non-SFU participants with a session on the conference, excluding peer stubs without sessions and SFU endpoints. Shared annotation in conference_query.py used by conferences list and participant-count summary. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix participants_count when filtering conferences by participantId Use a correlated Subquery instead of Count on the outer participants join. Filtering by participantId narrowed that join to one person, so the aggregate incorrectly showed 1 for multi-participant calls. The subquery counts independently. Add regression test. Co-authored-by: Cursor <cursoragent@cursor.com> * Clarify participantId filter regression test docstring Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Samson Nkrumah <samsonnkrumah253@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b6180bb commit 64df5eb

4 files changed

Lines changed: 71 additions & 25 deletions

File tree

app/conference_query.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""
2+
Query helpers for Conference-related aggregates.
3+
4+
Peers created only for addConnection (P2P remote) are linked on conference.participants
5+
but often have no Session and no name; they are excluded. SFU endpoints (is_sfu) are
6+
excluded from participant totals — only human/clients with a session in the conference
7+
are counted.
8+
9+
participants_count must use a Subquery (not Count on the outer queryset's participants
10+
relation): when /v1/conferences is filtered by participantId, the outer query joins
11+
participants and would otherwise restrict the aggregate to that single participant.
12+
"""
13+
14+
from django.db.models import Count, IntegerField, OuterRef, Subquery
15+
16+
from .models.participant import Participant
17+
18+
PARTICIPANTS_COUNT_SUBQUERY = Subquery(
19+
Participant.objects.filter(
20+
conferences=OuterRef('pk'),
21+
is_active=True,
22+
is_sfu=False,
23+
sessions__conference_id=OuterRef('pk'),
24+
).order_by().values('conferences').annotate(
25+
cnt=Count('id', distinct=True),
26+
).values('cnt')[:1],
27+
output_field=IntegerField(),
28+
)

app/tests/test_pr26_regressions.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,41 @@ def test_gum_summary_skips_non_dict_issue_data(self):
141141
self.assertEqual(payload["total"], 1)
142142
self.assertEqual(payload["data"][0]["name"], "NotAllowedError")
143143
self.assertEqual(payload["data"][0]["count"], 1)
144+
145+
def test_conferences_participant_id_filter_preserves_full_participants_count(self):
146+
"""
147+
When the conference list is filtered to one person (participantId),
148+
participants_count must still reflect everyone in that conference, not 1.
149+
"""
150+
conference = Conference.objects.create(
151+
conference_id="conf-multi",
152+
app=self.app,
153+
)
154+
participants = []
155+
for i in range(3):
156+
p = Participant.objects.create(
157+
participant_id=f"user-{i}",
158+
app=self.app,
159+
)
160+
p.conferences.add(conference)
161+
Session.objects.create(
162+
conference=conference,
163+
participant=p,
164+
)
165+
participants.append(p)
166+
167+
response = self.client.get(
168+
"/v1/conferences",
169+
{
170+
"appId": str(self.app.id),
171+
"participantId": str(participants[0].id),
172+
"limit": "50",
173+
},
174+
)
175+
176+
self.assertEqual(response.status_code, 200)
177+
payload = json.loads(response.content)
178+
self.assertEqual(payload["count"], 1)
179+
self.assertEqual(len(payload["results"]), 1)
180+
self.assertEqual(payload["results"][0]["id"], str(conference.id))
181+
self.assertEqual(payload["results"][0]["participants_count"], 3)

app/views/conference_participant_count_summary_view.py

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,11 @@
22
from collections import Counter
33

44
from django.core.exceptions import ValidationError
5-
from django.db.models import Count, IntegerField, OuterRef, Subquery
6-
5+
from ..conference_query import PARTICIPANTS_COUNT_SUBQUERY
76
from ..errors import INVALID_PARAMETERS, MISSING_PARAMETERS, PMError
87
from ..summary_cache import cached_json
98
from ..utils import JSONHttpResponse
109
from ..models.conference import Conference
11-
from ..models.participant import Participant
1210
from .generic_view import GenericView
1311

1412

@@ -50,17 +48,7 @@ def compute():
5048
try:
5149
per_conf = (Conference.objects
5250
.filter(**filters)
53-
.annotate(
54-
participants_count=Subquery(
55-
Participant.objects.filter(
56-
conferences=OuterRef('pk'),
57-
is_active=True,
58-
).order_by().values('conferences').annotate(
59-
cnt=Count('id', distinct=True)
60-
).values('cnt')[:1],
61-
output_field=IntegerField(),
62-
),
63-
)
51+
.annotate(participants_count=PARTICIPANTS_COUNT_SUBQUERY)
6452
.values_list('participants_count', flat=True))
6553
except ValidationError:
6654
raise PMError(status=400, app_error=INVALID_PARAMETERS)

app/views/conference_view.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import datetime
22

33
from django.core.exceptions import ValidationError
4-
from django.db.models import Count, Exists, IntegerField, OuterRef, Subquery
4+
from django.db.models import Exists, OuterRef
55

6+
from ..conference_query import PARTICIPANTS_COUNT_SUBQUERY
67
from ..errors import (INVALID_PARAMETERS, CONFERENCE_NOT_FOUND,
78
MISSING_PARAMETERS, PMError)
89
from ..utils import JSONHttpResponse, serialize, paginate_and_serialize
910
from ..models.conference import Conference
1011
from ..models.issue import Issue
11-
from ..models.participant import Participant
1212
from .generic_view import GenericView
1313

1414
class ConferencesView(GenericView):
@@ -63,15 +63,7 @@ def filter(cls, request):
6363
has_warnings=Exists(
6464
Issue.objects.filter(conference=OuterRef('pk'), type='w', is_active=True)
6565
),
66-
participants_count=Subquery(
67-
Participant.objects.filter(
68-
conferences=OuterRef('pk'),
69-
is_active=True,
70-
).order_by().values('conferences').annotate(
71-
cnt=Count('id', distinct=True)
72-
).values('cnt')[:1],
73-
output_field=IntegerField(),
74-
),
66+
participants_count=PARTICIPANTS_COUNT_SUBQUERY,
7567
)
7668
if filter_by_issue_code:
7769
objs = objs.distinct()

0 commit comments

Comments
 (0)