Skip to content

Telemetry API Endpoints

Layer 2 feature, not yet implemented. The Telemetry API is a separate Go service (services/telemetry/). All endpoints live there, not in API. This doc describes the locked design — see index.md for the full architecture and rationale.

The Telemetry API serves two concerns:

  1. Patient-facing ingest — media events + pose batches. Authenticated via short-lived signed session token issued by API at session-run start (POST /v1/session-runs). The terminal session_end media event triggers the in-memory aggregator's PG flush; there is no separate finalizer endpoint and no callback to API.
  2. Reads — none directly from clients. Telemetry's PG + S3 are read by API (through a read-only connection scoped to analytics endpoints), never by browsers.

Authentication

Signed session token (hot path)

Pose-frame ingest hits 10k+ requests/sec at peak. Verifying a Clerk JWT per batch is wasteful. Instead:

  1. Patient taps "Începe sesiunea" in Portal.
  2. Portal calls API: POST /v1/session-runs (Clerk JWT auth as usual).
  3. API creates the session_runs row and returns a short-lived signed token:
    json
    {
      "run_id": "550e8400-e29b-41d4-a716-446655440000",
      "telemetry_token": "v1.eyJwcmluY2lwYWxfaWQiOiIuLi4iLCJvcmdfaWQiOiIuLi4iLCJydW5faWQiOiIuLi4iLCJleHAiOjE3NTQ5OTk5OTl9.signature",
      "telemetry_token_expires_at": "2026-05-07T14:00:00Z"
    }
  4. Portal sends every pose/media batch to Telemetry API with Authorization: Bearer <telemetry_token>.
  5. Telemetry API verifies the JWT (signature + iss + aud + exp) and reads consent state directly from the consents claim. No second network call back to API per batch.

The token authorises ingest for one run — a full multi-exercise playthrough. The same token covers every /v1/media/events POST across every exercise within the run; per-exercise scope is carried in the media_id event field, not in the token.

JWT shape:

header:  { alg: "HS256", typ: "JWT", kid: "<key version>" }
claims:  {
  iss: "restartix-api",
  aud: "restartix-telemetry",
  sub: <patient_id>,
  iat, exp,                       // exp = iat + 90min
  run_id, org_id, patient_id,
  consents: { pose: bool }
}
ClaimTypePurpose
issstringAlways "restartix-api". Telemetry rejects on mismatch.
audstringAlways "restartix-telemetry". Telemetry rejects on mismatch.
subUUIDPatient principal id (patient_id).
iatint (unix)Issue time.
expint (unix)Expiry; iat + 90min. No refresh endpoint — see "Token expiry mid-session" below.
run_idUUIDThe session_runs.id the token authorises ingest for.
org_idUUIDTenant scope.
patient_idUUIDSame as sub; surfaced explicitly for handler convenience.
consents.poseboolGates /v1/pose/frames. Mirrors biometric per-purpose consent at mint time. Engagement (playback-QoS) telemetry is GDPR Art. 6(1)(f) legitimate interest — no consent claim is carried for it.

Signing: HS256. Shared HMAC secret in AWS Secrets Manager at restartix/{env}/telemetry-jwt-signing-key. API reads it for minting; Telemetry reads it for verifying. Both services need IAM grants in the env's Terraform.

Key rotation: the kid header claim is reserved for future multi-key support. For 1.3b a single active key is fine — the verifier accepts a map[kid]secret so rotation becomes a config-only change (publish next key, re-deploy with both, retire previous). Ed25519 swap-point preserved per index.md → Swap-point interfaces.

Token expiry mid-session: out of scope for MVP. 90 min covers the longest planned session + slop. If the token expires mid-session, Telemetry returns 401 and ingest stops for that run. No refresh endpoint; no mid-session reissue. Acceptable degradation — partial telemetry is still useful, and clinical-record events flow through API on Clerk auth which is independently refreshed.

Validation: Telemetry rejects with 401 if signature fails, any of iss/aud/exp mismatch, or token is malformed. Rejects with 403 only on /v1/pose/frames when consents.pose is false. Engagement routes (/v1/media/events, /v1/library/views) have no consent gate — audience + signature are sufficient. Run identity comes from the run_id claim — no path-param cross-check (the token IS the authorization).

Endpoints

POST /v1/pose/frames

Batched pose ingest. Client buffers ~1 second of frames and posts.

Request body (binary, Content-Type: application/octet-stream):

[1-byte version] [4-byte frame_count] [4-byte fps_hint]
[N × 33 × 4 × float32 landmarks]   ← x, y, z, visibility per landmark per frame
[N × 4 byte timestamp_ms]          ← elapsed ms since session_start, per frame
[gzip wrapper around the whole binary blob]

Plus optional JSON sidecar with batch metadata in ?meta=... query param or X-Batch-Meta header (pose_confidence summary, camera_resolution, processing_time_ms — small, not per-frame).

Response: 202 Accepted

json
{
  "frames_accepted": 10,
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "buffer_position_bytes": 5280
}

Failure modes:

  • 400 — malformed binary, version unsupported
  • 401 — token signature invalid / expired
  • 403 — biometric consent not active
  • 413 — batch too large (cap at ~64 KB after gzip)
  • 429 — backpressure: drop policy says shed this batch (pose frames are dropable; aggregates are not)

Backpressure behavior: at high load, Telemetry API may drop pose-frame batches. Frames are fungible — losing 5% still gives 95% of an exercise's signal. The terminal session_end event on the media stream MUST NOT drop, as it carries the flush trigger; it has priority quota over pose batches.

POST /v1/media/events

Video lifecycle ingest. Volume is low (~110 events/sec at 1000 concurrent), so JSON is fine here.

Request body (Content-Type: application/json):

json
{
  "event": "session_start",
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "media_id": "exercise-uuid",
  "media_type": "video",
  "timestamp": "2026-05-07T10:00:00.000Z",
  "data": {
    "total_duration_seconds": 120.5,
    "ttfb_ms": 340,
    "video_load_time_ms": 1200,
    "cdn_response_time_ms": 280,
    "connection_type": "wifi",
    "effective_bandwidth": 12.5,
    "rtt_ms": 45,
    "initial_bitrate": 2500000,
    "initial_resolution": "720p"
  }
}

Full event taxonomy (video_played, video_paused, buffering_start, buffering_end, quality_change, watch_pct_25, watch_pct_50, watch_pct_75, watch_pct_95, page_visible, page_hidden, dropped_frames, session_end) and per-event field schemas live in media-events.md. The terminal session_end event triggers the in-memory aggregator's PG flush.

Response: 202 Accepted

json
{
  "status": "accepted",
  "session_id": "550e8400-e29b-41d4-a716-446655440000"
}

Failure modes:

  • 400 — unknown event type, schema violation
  • 401 — token invalid / expired
  • 403analytics consent not active
  • 429 — rate limit (per-session)

Run finalization

There is no separate finalizer endpoint. The terminal session_end event in the media-events stream is the flush trigger:

  • Media half: receipt of session_end finalizes the in-memory aggregator and writes one media_session_metrics row + N media_buffering_events rows.
  • Pose half: receipt of session_end finalizes pose aggregation (rep count, ROM, completion), writes pose_session_metrics + per-rep rows, and concatenates the S3 in-flight prefix into the canonical replay blob.

Idempotency: the aggregator records "flushed" per run; a duplicate session_end is dropped server-side.

The s3://restartix-telemetry/{org_id}/{run_id}.bin.gz replay blob URL is internal — clients fetch replay via API (GET /v1/session-runs/{run_id}/replay) which mints a short-lived signed S3 URL.

GET /v1/healthz

Internal health check for ALB. Returns 200 OK with {"status": "ok", "version": "..."}. Not authenticated; not exposed publicly.

Reads (none on Telemetry API)

There are no read endpoints on Telemetry API for clients. All reads flow through API:

Reader → endpointSource data
Patient: GET /v1/me/session-runspose_session_metrics + media_session_metrics (PG, RLS by principal)
Specialist: GET /v1/patients/{id}/session-runsSame tables, RLS by org + permission
Specialist: GET /v1/session-runs/{run_id}/replaySigned S3 URL to replay blob
Clinic admin: GET /v1/analytics/cohort/exercise-adherenceMaterialized view over pose_session_metrics
Console: GET /v1/admin/platform/exercise-aggregate-countsAnonymised aggregates only (no principal_id in response)

This is by design: API is the single source of authentication, RLS, audit, classification, and per-org permission enforcement. Telemetry API is ingest-only.

Run lifecycle (end-to-end)

Portal                    API                   Telemetry API        Telemetry PG          S3
  │                          │                       │                    │                  │
  ├── POST /v1/session-runs ─►                       │                    │                  │
  │                          │ create session_runs   │                    │                  │
  │                          │ row, mint JWT         │                    │                  │
  ◄── 200 (run_id, telemetry_token) ──               │                    │                  │
  │                          │                       │                    │                  │
  ├── POST /v1/media/events (token) ────────────────► │ verify JWT, read  │                  │
  │   { event: "video_played" / "buffering_*" / ... } │ consents claim,   │                  │
  │                          │                       │ accumulate in mem │                  │
  │                          │                  ◄── 202                  │                  │
  │                          │                  ... many events ...      │                  │
  │                          │                       │                    │                  │
  ├── POST /v1/pose/frames (token) ────────────────► │ verify, append    │                  │
  │   (binary, ~1s batch)    │                       │ to in-flight ─────────────────────────►
  │                          │                  ◄── 202                  │                  │
  │                          │                  ... many batches ...     │                  │
  │                          │                       │                    │                  │
  ├── POST /v1/media/events (token) ────────────────► │ session_end:     │                  │
  │   { event: "session_end" } │                     │  ├ flush media to │                  │
  │                          │                       │  │  PG ───────────►                  │
  │                          │                       │  └ flush pose to  │                  │
  │                          │                       │     PG + finalize │                  │
  │                          │                       │     S3 blob ────────────────────────► │
  │                          │                  ◄── 202                  │                  │
  │                          │                       │                    │                  │
  │ ... post-launch: specialist views ...           │                    │                  │
  │                          ◄── GET /v1/session-runs/{run_id}/analytics (Clinic app)       │
  │                          │ read telemetry PG ──────────────────────► │                  │
  │                          ── 200 ──►              │                    │                  │

The API endpoint that reads telemetry PG is post-launch (analytics dashboards). At MVP, telemetry writes its tables but no client-facing read surface consumes them yet.

Idempotency

  • Pose batches are not idempotent — duplicate batches are appended (small cost). Client should not retry pose batches; on failure, the next batch overlaps anyway.
  • Media events are idempotent by (session_id, event_type, timestamp) — duplicates dropped at ingest.
  • The terminal session_end is idempotent — the aggregator records "flushed" per run; a duplicate session_end is dropped server-side without re-writing PG rows.

Rate limiting

Per-session quota: ~1 batch/sec per endpoint (matches client batch cadence). Burst allowance ~5 batches/sec for reconnect-and-flush cases. Per-org global quota: based on entitlements + 1C.7 metering — exceeding the org's quota returns 429 across all sessions.

Observability

Telemetry API itself emits OTel traces / metrics / logs to the platform's observability stack (Datadog at scale, CloudWatch at staging). These are operational signals about the service — separate concern from the product telemetry it ingests.

Migration / older spec

Earlier versions of this doc described POST /v1/analytics/track, POST /v1/errors/report, POST /v1/audit/ingest, and an admin/dashboard surface. All four are out — see index.md → Scope for what each was replaced by (or rejected). When Layer 2 builds the service, this doc is the canonical reference; older references in features specs will be cleaned up at that time.