System logic

Engagement tracking — system logic

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

SignalSourceSurfaceCarries
LAUNCHLauncher "Open" button (launchApp)LAUNCHERpupil, app, time
PINGHeartbeat from a page EduSpaze owns: the launcher (app = none) and the in-platform app frameLAUNCHER / APP_FRAMEpupil, app, time
ENDpagehide beacon when the tab closes or navigates awayLAUNCHER / APP_FRAMEpupil, app, time
PING / ENDVendor telemetry API POST /api/vendor/v1/heartbeatVENDORtenant, pseudonymous subject, time
Completed sessionVendor telemetry API POST /api/vendor/v1/sessionstenant, 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:

  1. Session key = tenant + pupil + app. Sign-ins are not sessions. Logging in is not an engagement event; opening an app is.
  2. Continuation. A signal that arrives within engagementInactivityMinutes of the open session's last activity extends that session. A repeat launch inside the window increments launchCount on 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.
  3. 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 of closedAt.
  4. Launch-only. A launch with no further signal is worth launchOnlyMinutes (endedAt = startedAt + launchOnlyMinutes), labelled LAUNCH_ONLY. The first ping upgrades the row to HEARTBEAT; a vendor ping upgrades it to VENDOR_HEARTBEAT.
  5. Presumed end. A ping sets endedAt = now + heartbeatIntervalSeconds (the pupil is presumed present for one interval). An END pins endedAt to now and sets closedAt — 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.
  6. 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 (EngagementSession with HEARTBEAT / 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

TierMeaningParent timelineSchool engagement view
Reported by the appThe vendor posted the session or heartbeatsfilled blockcounted, badge "Reported by the app"
Estimated from activityOur heartbeat on the launcher / app framefilled block with a dashed outline; at day zoom a day is dashed when any of its minutes are estimates and the tooltip says how manycounted, badge "Estimated from activity"
Launch onlyA launch with no further signalnot 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)

SettingDefaultRangeEffect
engagementInactivityMinutes101–120gap that closes a session and the continuation window for repeat sign-ins
launchOnlyMinutes20–30assumed time for a launch with no further signal; 0 counts nothing
heartbeatIntervalSeconds6015–300client 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

  • StudentActivityPing is kept 30 days (trimActivityPings); EngagementSession and VendorSessionReport are 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 END signal for SSO tenants.
  • A nightly job that closes sessions older than the cutoff explicitly (cosmetic; reads already treat them as closed).

Source: docs/engagement-tracking.md in the repository. Last updated with the code it describes.

Engagement tracking — EduSpaze