Engagement tracking — system logic
How EduSpaze measures the time a pupil spends in a connected app when there is no logout to observe. This is the reference for the code in lib/engagement/, the launcher, the vendor telemetry API and the school/parent views. Last updated 2026-09-02.
1. The problem
A pupil signs in (locally, through the launcher, or via the school's SSO), opens an app, and then simply closes the tab, walks away, or opens the app again ten minutes later. We see the start. We never see an end, and we may see several starts for what a teacher would call one sitting. So the design stops looking for a logout and reconstructs sessions from activity.
2. Signals
| Signal | Source | Surface | Carries |
|---|---|---|---|
LAUNCH | Launcher "Open" button (launchApp) | LAUNCHER | pupil, app, time |
PING | Heartbeat from a page EduSpaze owns: the launcher (app = none) and the in-platform app frame | LAUNCHER / APP_FRAME | pupil, app, time |
END | pagehide beacon when the tab closes or navigates away | LAUNCHER / APP_FRAME | pupil, app, time |
PING / END | Vendor telemetry API POST /api/vendor/v1/heartbeat | VENDOR | tenant, pseudonymous subject, time |
| Completed session | Vendor telemetry API POST /api/vendor/v1/sessions | — | tenant, subject, start, end |
Every signal is time-only. Nothing about what the pupil did, wrote, scored or saw is ever sent or stored, which keeps the measurement inside the parent module's "when and how much, never what" rule.
The heartbeat client (components/engagement/heartbeat.tsx) pings on mount, then every heartbeatIntervalSeconds while the tab is visible (Page Visibility API). A hidden tab stops pinging; it does not send an end. The end beacon is best effort, so the server never relies on it alone.
3. Sessionisation
Raw signals land in StudentActivityPing; the sessioniser (recordActivity) folds each one into EngagementSession in the same transaction. Rules, each covered by tests/engagement.test.ts:
- Session key = tenant + pupil + app. Sign-ins are not sessions. Logging in is not an engagement event; opening an app is.
- Continuation. A signal that arrives within
engagementInactivityMinutesof the open session's last activity extends that session. A repeat launch inside the window incrementslaunchCounton the same row instead of opening a second session. This is the double-counting guard: three sign-ins and two "Open" clicks in ten minutes are one session. - Gap closes. A gap longer than the cutoff starts a new session. Nothing is written to close the old one; "open" is a property of recency (
lastActivityAt ≥ now − cutoff), not ofclosedAt. - Launch-only. A launch with no further signal is worth
launchOnlyMinutes(endedAt = startedAt + launchOnlyMinutes), labelledLAUNCH_ONLY. The first ping upgrades the row toHEARTBEAT; a vendor ping upgrades it toVENDOR_HEARTBEAT. - Presumed end. A ping sets
endedAt = now + heartbeatIntervalSeconds(the pupil is presumed present for one interval). AnENDpinsendedAtto now and setsclosedAt— but a signal inside the cutoff resumes that same session and clears the pin. Closing a tab and reopening the app a minute later is one sitting, not two. - Ping de-duplication. Two pings from the same surface for the same pupil + app closer than 15 seconds are one ping (retries, duplicate tabs); the second is dropped before it touches a session. A vendor ping and our own ping are different observers and both count — the interval union (§4) makes sure they still describe one minute, not two.
4. Counting minutes without double counting
Minutes are never read off session rows directly. For any pupil + app + window, engagementIntervals collects:
- vendor-reported sessions (
VendorSessionReport, source vendor), - heartbeat sessions (
EngagementSessionwithHEARTBEAT/VENDOR_HEARTBEAT, source heartbeat), - optionally launch-only sessions (source launch-only),
and passes them through mergeIntervals: sort, union anything that overlaps or touches, keep the most reliable source on the merged block (vendor > heartbeat > launch-only). Minutes are the length of the union inside the window. A minute described by a vendor report, by our heartbeat and by a repeat launch is still one minute.
Vendor batch posts are idempotent as well: VendorSessionReport is unique on tenant + vendor + subject + startedAt, so a re-sent batch is reported as duplicates, not counted twice.
5. Confidence tiers
| Tier | Meaning | Parent timeline | School engagement view |
|---|---|---|---|
| Reported by the app | The vendor posted the session or heartbeats | filled block | counted, badge "Reported by the app" |
| Estimated from activity | Our heartbeat on the launcher / app frame | filled block with a dashed outline; at day zoom a day is dashed when any of its minutes are estimates and the tooltip says how many | counted, badge "Estimated from activity" |
| Launch only | A launch with no further signal | not drawn as time — a single "opened" mark (the parent module rule: a launch proves one click) | counted at the assumed minimum, badge "Launch only" |
6. Configuration (per school, School Settings → Engagement measurement)
| Setting | Default | Range | Effect |
|---|---|---|---|
engagementInactivityMinutes | 10 | 1–120 | gap that closes a session and the continuation window for repeat sign-ins |
launchOnlyMinutes | 2 | 0–30 | assumed time for a launch with no further signal; 0 counts nothing |
heartbeatIntervalSeconds | 60 | 15–300 | client ping cadence and the presumed-presence horizon after the last ping |
These are measurement parameters, so they carry stated defaults that the school can change. The usage bands on the parent timeline are a judgement about a child and still never default (see parent-access/discovery.md).
7. Where each audience sees it
- Pupil: the launcher and app frame explain in plain words that time is counted, never content, alongside the existing Children's Code notice.
- Parent: the timeline heat map, merged intervals, dashed outline for estimates, launch marks for launch-only. Bands computed from the day's merged minutes.
- School (teacher and above): School → Engagement — minutes, sessions, opens, confidence and last-active per pupil per app over 7 or 30 days, filterable by class.
- Vendor: Startup → Integration & API — telemetry health (last seen, sessions reported, heartbeat sessions) and the endpoint reference.
8. Retention and audit
StudentActivityPingis kept 30 days (trimActivityPings);EngagementSessionandVendorSessionReportare the record and follow the tenant's timeline retention.- Both telemetry tables are excluded from the audit capture trigger (they are machine-generated, high volume and content-free); the exception is recorded in
guardrails.md. Everything a person changes about the measurement — settings, API keys, launch URLs, secrets — is a domain event on the audit trail.
9. Open items
- Rate limiting on the vendor API (per key) before general availability.
- Identity-provider back-channel logout as an additional
ENDsignal for SSO tenants. - A nightly job that closes sessions older than the cutoff explicitly (cosmetic; reads already treat them as closed).
