Telemetry — Video QoS & Pose Data Pipeline
Layer 2 feature, post-foundation. Decisions in decisions.md → Why telemetry is PG + S3, not ClickHouse. Foundation primitives this stack relies on (per-purpose consent, signed-session-token from API, data classification) are already in place. Until Layer 2 begins, no Telemetry API exists; this doc describes the shape it will take when it does.
Scope
Telemetry exists for two concrete product needs:
- Video QoS analytics — load time, buffering, watch %, dropped frames. Observational signals about playback quality, never clinical.
- Pose-detection data — MediaPipe landmark frames captured during pose-tracked exercises, server-side computed informational signals (rep count, ROM), full-session replay for specialist review.
Not scope: patient progress milestones. Per-exercise lifecycle events (started, completed, skipped, paused_for_pain, resumed) are clinical record, not telemetry. They ride API's POST /v1/session-runs/{run_id}/exercise-event and live in session_exercise_events. The clinic's patient-detail page reads everything clinical (status, pain events, exercise milestones) from API alone — no cross-service join. Locked by the orchestrator's Design C decision.
Other concerns that earlier specs called "telemetry" are also out of scope because foundation primitives already cover them:
| Concern | Lives in |
|---|---|
| Compliance audit (who did what, GDPR/MDR forensic trail) | audit_log (P10, partitioned monthly per P41) — not telemetry |
| Patient progress milestones | session_exercise_events via API — not telemetry |
| Usage-based billing data | usage_records / usage_quotas / usage_summaries (1C.7) |
| AI provenance | audit_ai_provenance sibling to audit_log |
| Security signals | mostly audit_log; SIEM-shaped signals out of scope |
| App observability (latency, traces) | OTel → Datadog/Grafana, not bespoke |
| Cross-tenant analytics | none — readers are all clinic-scoped (specialist, patient, clinic admin) |
The platform's only telemetry-shaped workload is high-volume time-series ingest of pose landmarks and sparse video-QoS events, scoped to a single clinic per session, read by the same clinic's specialists or the patient themselves.
Architecture
Patient Portal ─── signed-session-token ───► Telemetry API ──┐
(browser) media events (JSON) │
pose batches (binary) ├─► Telemetry Postgres
│ (dedicated DB on shared Aurora —
│ media_session_metrics, media_buffering_events,
│ pose_session_metrics, pose_rep_metrics)
│
├─► S3 per-batch PUT (pose-half only;
│ in-flight buffer at
│ {org_id}/{run_id}/inflight/{batch_seq}.bin.gz)
│
└─► Server-side pose aggregation
(rep count, ROM, completion from
landmarks at run_end)
At run_end (pose half only):
─► S3: finalized replay blob
s3://restartix-telemetry/{org_id}/{run_id}.bin.gz
(concatenated from in-flight parts;
in-flight prefix deleted)
Specialist ─┐
Patient (own) ├─► API ──► Telemetry Postgres (read-through; dashboards post-launch)
Clinic admin ─┘ S3 replay blob fetch (replay viewer)
Console superadmin ─► API ──► Telemetry Postgres (anonymised cross-tenant counters only)Telemetry writes its own PG directly — no events.Bus event, no Cat F service-account callback, no API subscriber. Clinic UI's clinical surfaces (status, pain events, exercise milestones) read entirely from API; QoS analytics dashboards (post-launch) read telemetry PG through API endpoints.
Key design choices (rationale in decisions.md):
- Separate Go service (
services/telemetry/) — hard ingest isolation from API's transactional pool. No callback from Telemetry → API at MVP. - Postgres + S3, not ClickHouse — at the workload's actual shape (per-rep aggregates queryable by clinic-scoped readers + per-session replay blobs), PG carries to 50k+ peak concurrent users with monthly partitioning + materialized views + a read replica. ClickHouse is the Tier 3 escape hatch for cross-tenant analytical workloads we don't have today.
- Sparse-events + in-memory aggregation — media-events fires on state transitions and thresholds (no per-second heartbeat). The Telemetry service accumulates per-run state in memory and flushes one
media_session_metricsrow + Nmedia_buffering_eventsrows on receipt of the terminalsession_endevent. - Server-side pose aggregation — Telemetry computes rep count / ROM / completion from landmarks at run_end (informational only, and gated on the Class IIa step — see Aggregation engine). Client-side aggregation rejected because the patient controls the value.
- Signed session token on the hot path — issued by API at session-run start (
POST /v1/session-runs; HS256, claims:principal_id,org_id,run_id,consents.pose,exp). Verified by signature only; no Clerk JWT verify per batch. One token authorises ingest for one full run (multi-exercise); per-exercise scope is in themedia_idfield on each event. Engagement (playback-QoS) telemetry is GDPR Art. 6(1)(f) legitimate interest and carries no consent claim; only pose (Art. 9 biometric special-category) is consent-gated. - No pseudonymization — readers are all clinic-scoped, so principal_id + organization_id are stored plain. Pseudonymization existed to make cross-tenant aggregates safe; we don't have those readers.
Endpoints
Three typed ingest endpoints, narrow scope:
| Endpoint | Purpose | Auth |
|---|---|---|
POST /v1/media/events | Media QoS events within a session_run (load events, watch %, presence, buffering, quality, dropped frames, session_end). Audio sessions omit the video-only quality_change + dropped_frames. | Signed session token (audience: restartix-telemetry); no consent claim — legitimate interest |
POST /v1/library/views | Single-shot library-overlay watch (one row per overlay close; no run_id). Powers F9's library-curiosity tile. | Signed library token (audience: restartix-telemetry-library); no consent claim — legitimate interest |
POST /v1/pose/frames | Pose batch ingest (1-sec batches of MediaPipe landmarks). | Signed session token + consents.pose |
The session_end event in the media stream is the internal flush trigger — receipt finalizes the in-memory aggregator and writes one media_session_metrics row + N media_buffering_events rows. There is no separate POST /v1/sessions/{run_id}/end endpoint; the trigger rides the existing media-events surface.
Library views are deliberately a separate surface from media events (different aggregation shape, different table, no run_id). Same shared signing key, different JWT audience.
No generic /analytics/track or /errors/report. App-internal analytics (automation execution counts, etc.) are not telemetry — they're domain events or rows in domain tables. Errors → off-the-shelf (Sentry-style) when/if needed.
Full request/response shapes in api.md. Event schemas for media QoS in media-events.md. Library views wire shape in library-views.md.
Storage
Postgres (telemetry aggregates)
Lives in a dedicated database on the platform's shared Aurora cluster (logical isolation — Option B in project_aws_infrastructure). Telemetry's role (telemetry_app) has CREATE / SELECT / INSERT / UPDATE on the telemetry DB only, no access to the core DB. API gets SELECT on the two tables here for the post-launch analytics read path.
A separate Aurora cluster (full storage + compute isolation — Option A) was rejected at MVP scale: ~2× the DB cost line for a workload that fits comfortably in the existing cluster. The escape hatch is a DATABASE_URL flip when a real contention signal appears (Tier 2: 10k+ peak concurrent), no code change.
Telemetry writes its own DB directly — no API subscriber, no events.Bus, no Cat F callback. Process-level isolation between telemetry and API (different services, different pgxpools) is what prevents a telemetry write surge from starving API's transactional path; the shared Aurora cluster handles the storage-layer pressure via Serverless v2 autoscaling.
RLS isn't enforced at the DB layer because Telemetry has no per-tenant authenticated query path; tenant scoping rides on organization_id columns and is enforced by the API endpoints that read these tables (analytics dashboards, post-launch).
| Table | Cardinality | Partition |
|---|---|---|
pose_session_metrics | 1/session | None (state-shaped) |
pose_rep_metrics | ~100/session | Range-partitioned monthly (P41 — event-shaped) |
media_session_metrics | 1/(session, exercise) | None (state-shaped) |
media_buffering_events | ~5/session | Range-partitioned monthly (P41) |
media_library_views | 1/library overlay close | Range-partitioned monthly (P41) |
Per-(run, exercise) rollups (video_watch_percentage, pose_accuracy_score, actual_sets, actual_reps) live on the telemetry-owned tables themselves keyed by (run_id, media_id). The clinical-record per-exercise table — session_exercise_events in API — captures lifecycle milestones (started / completed / skipped / paused_for_pain / resumed), separately from QoS rollups. See sessions for that schema.
S3 (replay blobs)
Two S3 paths per session, one canonical:
In-flight (during the session): each 1-second batch lands as a small object under
s3://restartix-telemetry/{org_id}/{run_id}/inflight/{batch_seq}.bin.gzPer-batch PUT (not S3 multipart) — multipart's 5 MB minimum part size is incompatible with ~1-2 KB compressed batches. Each batch is durable as soon as Telemetry API confirms the PUT; this is what the "blast radius of 1 second of latest batch" claim in the resilience section rests on.
Finalized (at session_end): Telemetry API streams the in-flight parts into a single canonical blob and deletes the in-flight prefix:
s3://restartix-telemetry/{org_id}/{run_id}.bin.gzBinary format: [1-byte version][4-byte frame_count][4-byte fps][N × 33 × 4 × float32 landmarks][gzip]. ~3 MB per 30-min session at 10fps.
Lifecycle (canonical blob): standard → IA at 90 days → Glacier at 1 year → expire at retention horizon. In-flight prefix has a 24-hour expiry as a safety net for orphaned sessions where the finalize step never ran.
Replay = canonical-blob fetch via API (signed S3 URL). No queryable replay store.
Cost shape at launch: ~1800 batches/session × ~700 sessions/day at first-paying-clinic scale = ~1.3M PUTs/day. At S3 PUT pricing in eu-central-1 that's ~$5-10/month — a real number, but rounding error compared to the rest of the stack. The SessionBuffer swap-point interface preserves the option to move the streaming layer to Kinesis at Tier 2 (10k+ peak concurrent) when per-batch PUT volume crosses the per-prefix S3 rate-limit horizon.
Egress bytes
Stay in usage_records (1C.7). Do not duplicate in telemetry.
Pose payload encoding
MediaPipe runs entirely client-side (WebAssembly + WebGL/WebGPU) and outputs 33 landmarks (x, y, z, visibility) per frame. We transmit the landmarks, not the video — at 10fps a 30-min session is ~3 MB encoded, vs. tens of GB of video.
Wire format: binary float32 (33 × 4 × 4 = 528 bytes/frame), 1-second batches, gzipped per batch. The repo's exercise_recording_*.json test sample shows raw MediaPipe output at ~5910 bytes/frame as JSON — ~11× wasteful. JSON is forbidden on the wire; binary float32 is the canonical codec, behind the LandmarkCodec swap-point interface.
Precision: keypoints are normalized 0–1 floats; float32 gives 0.13-pixel resolution on 1280×720 video — well below clinical signal threshold.
Trust model
The patient device controls the data. Mitigations layered:
- Server computes form_score / ROM / rep_count from landmarks. Client cannot lie about the score, only about input quality.
pose_confidencechecks flag implausible inputs (camera at a wall, MediaPipe paused).- Cadence checks flag missing batches (gaps > expected fps).
- Session-flagged-unverified when checks fail; specialist sees the flag.
Server-side rerun from uploaded video would close the remaining gap but adds biometric-video archive (compliance-heavy) — out of scope today, can ship later for clinical-grade verification on flagged sessions.
Aggregation engine
The trust-model section above commits to server-side computation of session metrics from the landmark stream at session_end. The engine that does this is unbuilt, and the scope below is what it will do — informational rep count + range-of-motion (ROM) + session-completion signals, not clinical-grade form scoring used to drive treatment decisions.
This engine is outside the current registered intended purpose
The platform is registered Class I via Rule 13 (RestartiX MedCare v1.0, May 2026) and the CE label declares it has no measuring function. Rep count and ROM are measurements, so this engine ships at the Class IIa step, when the declared intended purpose covers measurement — not under the current declaration. The scope below therefore describes a design target, not something the platform may place in front of a patient today. Earlier revisions of this page called it "Class I scope"; that predates the registration and was wrong about which class permits it. See medical-device.md → Current Status and CLAUDE.md → Medical Device Readiness.
What the engine actually has to do at that scope:
| Signal | Implementation shape | Notes |
|---|---|---|
| Session completion | Boolean: pose detection ran, landmark stream is non-empty for the expected duration | Trivial — landmark presence + duration check |
| Rep count (estimated) | Per-exercise heuristic: peak detection on the relevant keypoint axis (e.g., hip y-coordinate for squats, wrist y-coordinate for arm raises) | Each exercise tunes which keypoint + axis is the rep signal. Static-hold exercises (planks) report duration-held instead of rep count. |
| Range of motion (informational) | Min/max joint angle measured during the session, computed from MediaPipe 3D keypoints | Pure geometry. Displayed as informational data, not a scored output. |
| Pose confidence | Surface MediaPipe's own confidence values + cadence checks for missing batches | Already in the Trust model section; this just exposes the signal to the specialist. |
What the engine deliberately does NOT do, even then:
- Form scoring — calling a number "form_score" implies a clinical judgment about quality of movement. We don't compute or display this. With clinical validation the swap-point interfaces support adding a scoring layer, but that is a further step beyond the measurement one.
- Treatment-decision support — the engine doesn't suggest "increase the resistance" or "reduce reps." Specialists make those calls from the informational data.
- Diagnosis-adjacent measurements — no "asymmetry index," "compensatory pattern detection," or other diagnostic signals.
Why this scope is tractable in-house:
A Go engineer with MediaPipe documentation, the per-exercise reference videos, and a few weekends gets to "rep count + ROM + session-completed" with heuristics. No biomechanics PhD required. The engineering shape is: small per-exercise tuning files (which keypoint, which axis, which threshold), shared geometry helpers (joint angles from 3D keypoints), and a stateless aggregator that runs at session_end. The architecture (signed-session-token, PG aggregates, S3 replay, swap-point interfaces) is designed for the eventual Class IIa upgrade without rewrite — the swap point is the engine itself, not the surrounding pipeline.
Resolved — pose-config storage shape: F9.1 Phase 1 parked this as "exercises table JSON column vs. dedicated exercise_pose_models table — the engineer building F9 picks this with real exercise data in front of them." F9.1 Phase 2 closed it as dedicated tables (D6 in exercise-taxonomy-pose-tracking.md; rationale in decisions.md → Why pose-tracking config lives in dedicated tables, not JSONB). The authoritative schema (pose_engines, pose_landmarks, exercise_pose_configs, exercise_pose_config_history, exercise_pose_landmarks, exercise_pose_metrics, exercise_pose_feedback_rules, pose_data_quality_overrides) lives in data-model.md Area 9. The engine reads these as described in Pose-config integration below.
What still gets decided at F9 implementation time (deferred, not committed today):
- Algorithm versioning policy in the data model (
algorithm_versioncolumn onpose_session_metricsis the single durable commitment — once recorded, scores never get retroactively rewritten; new algorithm versions produce new rows or new columns, never overwrite old ones). - Replay-blob retention durations (lifecycle policy on the S3 bucket; one Terraform change at provisioning time).
The AggregateStore swap-point interface guarantees the storage side is bounded; the engine itself is a small heuristic Go package owned by F9, not a scoping gap that blocks design.
Pose-config integration
When the pose-aggregation engine ships (separate F-tier work, currently unscheduled), it consumes the pose-config schema from API rather than carrying its own per-exercise heuristic files. This section is the integration contract API ships against so the schema (D6, B3, B7, B8 in exercise-taxonomy-pose-tracking.md) doesn't drift while the engine is unscoped. Engine implementation is deferred.
- Engine reads the active config at exercise start. When a
session_runbegins an exercise (signaled by the orchestrator viasession_exercise_events), the engine fetches the activeexercise_pose_configsrow byexercise_idfrom API, plus its associated landmark subset (exercise_pose_landmarks), metric definitions (exercise_pose_metrics), feedback rules (exercise_pose_feedback_rules), and rep-success rule fields (rep_success_rule_type+rep_success_rule_params) on the config row. Per-configmin_landmark_confidenceis part of this read. - Engine evaluates against incoming pose-frame batches. Each batch arriving on
POST /v1/pose/frames(gated byconsents.pose) is evaluated against the active config: metric values computed from the configured landmark subset, feedback-rule conditions checked, rep-cycle detection driven byrep_success_rule_params. - Engine emits per-frame scoring + rep counting + feedback-rule triggers. Per-batch outputs (rep count delta, in-target/out-of-target ticks, feedback rule fires, tracking-lost events) update the in-memory aggregator. Patient-facing feedback events ride the batch response.
- Engine writes session-level aggregates at session_end. Rep totals, ROM min/max, session-completion, and pose-confidence summaries flush to
pose_session_metrics+pose_rep_metricskeyed on(run_id, media_id). - Quality overrides exclude scored data. Aggregator-fed read queries (per-patient stats, cohort dashboards, promotion-threshold counts) consume
pose_data_quality_overrides(B3) viaWHERE NOT EXISTS— a specialist-flagged session or session_exercise_event drops out of scoring without losing the raw landmarks (audit + replay intact). - Asset-version invalidation is enforced at config-fetch time. A config row with
status='invalidated'(auto-flipped by an API trigger whenexercises.asset_versionbumps per D9) signals the engine to skip scoring for that exercise — pose ingest still works (data lands in S3 for replay), but no scoring runs until a clinician re-authors the config.
Engine-side deferrals
Two items from the F9.1 Phase 2 design depend on the engine's actual shape and stay deferred until the engine is scoped:
- Condition expression DSL (DF1 in exercise-taxonomy-pose-tracking.md) —
exercise_pose_feedback_rules.condition_expressionships asTEXTwith acondition_format ENUMdiscriminator (text_v1default). The grammar formalizes when the engine's parser exists; new rows tag a new format value, old rows keeptext_v1. No backfill. - Validation metrics shape (DF2 in exercise-taxonomy-pose-tracking.md) — the
exercise_pose_validation_runstable (per-exercise coverage %, confidence aggregate, validated-on-N-sessions counts) is shaped by what the aggregator actually computes per session. Schema deferred until aggregator output is known.
Both unlock on the same trigger: pose-aggregation engine scoped / built.
Resilience
- Client buffers events / pose batches; falls back to IndexedDB on disconnect, retries on reconnect.
- The terminal
session_endmedia event triggers the in-memory aggregator's PG flush (for the media half) and the pose aggregator + S3 finalize (for the pose half). There is no separate finalizer endpoint. - Server-side silence-sweep reaper (1-min tick, 10-min idle threshold) finalizes orphaned sessions (closed browser, OS kill, dead device) as
client_status = "auto_closed_unknown"— partial data preserved exactly as of the last received event. Wall-clock not event-clock to defend against skewed client timestamps. Runs in-process alongside the HTTP server; exits on shutdown context. - Blast radius of client-side failure: at most the unflushed in-memory state for one run between sweeps (≤ 10 min + 1-min tick) or ~1 second of the latest pose batch.
Known gap — server-process crash mid-run
If the Telemetry service process itself dies (OOM, deploy, container reschedule, host failure), the aggregator map is volatile — every active run loses its in-flight state. Clinical record is unaffected (separate process, separate DB on API).
This is acceptable degradation at MVP because:
- Aggregates are analytics, not clinical signals (the Design C lock moved clinical milestones off this surface).
- Single Telemetry instance at Tier 0 (≤ 1k concurrent) — crash frequency is dominated by deploys, not failures, and deploys can be timed away from peak.
- Lost rows show up as missing analytics, not corrupt clinical data.
Tier 1+ mitigation (deferred): checkpoint aggregator state to Redis every N seconds. Two implications:
- Crash recovery — on restart, repopulate the map from Redis. Worst-case loss drops from "all in-flight runs" to "≤ N seconds of each run."
- Horizontal scaling unlock — once Telemetry goes to 2+ instances at Tier 1 (1k–10k concurrent), aggregator state in Redis lets any instance handle any event for any run. Without it, going horizontal requires sticky
run_id → instancerouting via consistent-hash on the load balancer, which adds operational complexity. Redis checkpoint subsumes both crash-recovery and horizontal-scaling needs.
Trigger to build it: first credible signal of either a multi-instance deployment OR a non-deploy crash (OOM, host failure) costing real analytics. Until then, the in-memory aggregator + sweep reaper are sufficient. Keep the AggregateStore swap-point interface honest so the checkpoint layer can be added without rewriting handlers.
Consent
Telemetry has two ingest categories with distinct GDPR legal bases:
| Surface | Data class | GDPR legal basis | Gate |
|---|---|---|---|
/v1/media/events, /v1/library/views | Playback QoS / engagement | Art. 6(1)(f) legitimate interest | Audience + signature only — no consent claim |
/v1/pose/frames | Biometric pose landmarks | Art. 9 special category — explicit consent | consents.pose claim in the signed token |
Engagement telemetry is operational metadata necessary to deliver and improve the service (buffering, dropped frames, watch progress). It carries no separately consented purpose flag — there is no UI toggle for it and every active session generates QoS rows. Pose data is biometric special-category data and stays consent-gated through the foundation per-purpose consent flow (1B.9).
Pose consent state is embedded in the signed session token at mint time — not re-fetched per ingest batch. API reads the consent ledger when it issues the JWT at POST /v1/session-runs and stamps the result into the token's consents claim ({ pose: bool }). The Telemetry service reads the claim, no second network call. Per-batch re-fetch was rejected as pure overhead when the JWT already encodes the answer.
Trade-off — pose-consent withdrawal during a run. Tokens are minted for 90 minutes; a patient who withdraws pose consent mid-run keeps ingesting pose data until the token expires (max 90 min). This is acceptable degradation for MVP — the consent ledger remains authoritative for the next run, and 90 min is well under the per-session retention horizon. No mid-session refresh / revocation endpoint planned. Engagement is unaffected (no consent gate to withdraw).
Reads
All reads flow through API. No browser direct telemetry-DB or telemetry-S3 access; replay viewer in the Clinic app fetches a signed S3 URL from API. API reaches into telemetry's PG via a read-only connection scoped to the analytics endpoints below; tenant scoping is enforced at the API endpoint, not at the telemetry-PG layer (RLS isn't enforced there because telemetry has no per-tenant authenticated query path).
The API → telemetry contract is pinned at two layers so the no-RLS posture can't drift unnoticed. Boundary guard: telemetry.Client.GetMediaSummary rejects uuid.Nil orgID with ErrMissingOrgID (client.go:152-154), so a future caller that forgets to thread an orgID gets a hard error at the integration boundary instead of cross-tenant rows. Per-call-site test: TestService_TelemetryCallsCarryOrgID (service_telemetry_test.go:30) walks every stats.Service path that hits telemetry and asserts the right UUID lands in the ?org_id= query parameter at the wire level — catches argument swaps (e.g. patientID routed into the orgID slot). New call sites add a subtest in the same PR.
| Reader | Data | Access shape |
|---|---|---|
| Patient (Portal) | Own video QoS history (post-launch) | API → telemetry PG, filtered by principal_id |
| Specialist (Clinic app) | Their org's patients' QoS data + pose replays | API → telemetry PG/S3, filtered by organization_id + permission |
| Clinic admin (Clinic app) | Cohort QoS dashboards | API → telemetry PG materialized views, filtered by organization_id + permission |
| Console superadmin | Anonymised cross-tenant counters only (processor rule) | API admin pool, classification-filtered |
Clinical state (run status, pain events, exercise milestones) lives in API's own PG (session_runs, session_pain_events, session_exercise_events) and is read there — not from telemetry.
Swap-point interfaces
Mandatory from day one — these are the seams that make tier 1/2/3 scaling bounded:
| Interface | Today | Possible later swap |
|---|---|---|
AggregateStore.WriteRepMetric / WriteSessionMetric | PG INSERT | Dual-write to PG + CH at Tier 3 |
AggregateQuery.GetSessionMetrics / GetCohortAggregate | PG read | Per-dashboard CH read at Tier 3 |
SessionBuffer.AppendBatch / Finalize | Per-batch S3 PUT under {run_id}/inflight/ + concatenate-and-finalize at session_end | Kinesis if 10k+ peak concurrent or per-prefix S3 PUT rate forces it |
ReplayBlobStore.Get / Put | S3 | Pluggable; unlikely to swap |
LandmarkCodec.Encode / Decode | binary float32 + gzip | Protobuf or custom binary |
SignedSessionToken.Issue / Verify | HS256 with rotating secret | Ed25519 if needed |
Repository pattern enforces this — handlers and aggregator never touch PG/S3 directly. CI guard (cmd/check-telemetry-bounds) rejects direct imports if it ever drifts.
Scaling roadmap
| Tier | Peak concurrent | What's added | Trigger to advance |
|---|---|---|---|
| 0 — launch | up to ~1 000 | Single Telemetry API instance, PG primary, S3 | — |
| 1 | 1 000 – 10 000 | Telemetry API horizontal (2–4 instances), PG read replica, materialized views per dashboard | Dashboard p95 > 500ms after index tuning |
| 2 | 10 000 – 50 000 | Monthly partitioning on rep_metrics, Kinesis between Telemetry API and S3, Athena/Glue jobs for ad-hoc analytics over S3 blobs | Replica lag, S3 multipart rate limits, or batch jobs becoming a daily need |
| 3 | 50 000+ | ClickHouse for cross-tenant analytics surfaces only (clinical aggregates stay in PG) | Cross-tenant dashboard query consistently > 1s after materialized view tuning |
Tiers 1 and 2 are pure ops/config — no code rewrite if the swap-point interfaces are honored from day one.
For the legacy product's growth trajectory (20k+ users today, scaling at typical SaaS rates), Tier 3 is many years out — possibly never.
Daily.co and other media
Daily.co data stays in Daily.co. Media events here cover exercise video playback (Bunny Stream / S3 origin), not appointment video calls. 1:1 live video exercises are not in scope today.
Foundation status
Nothing to build for telemetry in foundation. The three primitives Layer 2 relies on are all in place:
- Per-purpose consent ledger (1B.9) — for
analytics+biometricflag values stamped into the JWT at mint time - Data classification framework (1A, P39) — extends to PG aggregate columns + S3 blob class
- Signed-session-token signer in API (services/api/internal/core/telemetrytoken) — landed in commit
e8916eaas part of sessions sub-PR 1.3b.1 - Signed-token pattern (HS256 helper, small new addition when 1C ships)
Nothing in services/telemetry/ exists today. When Layer 2 telemetry work begins, this doc + api.md + media-events.md are the locked design.