38 — Analytics & Insights (envisioning)
▶ BACKEND BUILT 2026-06-27 (owner chose telemetry-first, before iOS). The
analyticsmodule ships the full backend: append-onlyEventstore + TTL,POST /analytics/events(batched ingest withengagementBandclassification),GET /analytics/track/:id/insights(waveform heatmap + plays/ listeners/listen-through/hook-rate),GET /analytics/workspace(opens/minutes/top tracks), and the doc-42GET /management/insightstie-in.nest build+jestgreen. Remaining = clients emit: the iOS/web event emitters (play:heartbeat+ sessions), the guest/link ingest path, and identified opt-in role-gating on reads. SeeCHANGELOG.md.Envisioning doc — needs confirmation. Governed by
00-ROADMAP.md. Created 2026-06-26 (stakeholder-raised). The ask: "how many times the app has been opened, how many minutes, what part has been listened the most" — plus richer engagement metrics tied to our actors / use-cases. Decision locked at envisioning (2026-06-26): build both owner-BI and in-product actor metrics on a single unified events backbone (one append-only pipeline also feeds the doc-36 seen/view subsystem — do not build two telemetry paths). This is net-new capability, not a tweak. All five detail decisions are now resolved too — see §Decisions (locked).
What triggered this
The business stakeholder wants visibility into usage (opens, sessions, minutes) and engagement ("what part has been listened the most"), and expects this to deepen into actor-aware metrics. Today the app emits no telemetry — there is no way to answer any of those questions. This is the first time the platform needs to observe itself, and the "most-listened part" requirement quietly dictates the architecture.
Finding — nothing analytics exists today (verified 2026-06-26)
The only usage signal in the domain is Track.lastAccessedAt (track.schema.ts:138) — a single, overwritten timestamp powering "Recently opened" via POST /track/recent-access/:trackId + GET /track/recent-access. It is not an event log: no history, no counts, no durations, no per-position data. doc-36 envisions a view/seen record ({user, nodeId, lastSeenAt}) but that is also accumulated state, not analytics, and isn't built. There is no events collection, no ingestion endpoint, no aggregation anywhere.
The tell: opens and total-minutes could be faked with counters on a document. "Which part got replayed most" cannot — it needs playback position over time. That forces an append-only event stream + aggregation, not fields bolted onto
Track. Build that backbone once and opens / minutes / heatmaps / seen-state all fall out of it. (File.durationis already persisted on new uploads — see.agenthistory — so listen-through % = listened-seconds ÷ duration is computable on day one.)
The model — one events backbone, two read consumers (+ seen-state)
iOS / Web client Backend Reads
───────────────── ─────── ─────
buffer + flush ──POST──▶ analytics: Event[] ──aggregate──▶ in-product insights
(app-open, (append-only, · GET /track/:id/insights (heatmap, plays)
playback TTL on raw) ──aggregate──▶ owner / workspace BI
heartbeats, │ · GET /analytics/workspace (opens, minutes…)
view/seen) └──(optional mirror)──────▶ 3rd-party BI (PostHog/Amplitude) for
funnels/retention — no UI to rebuild- Capture (client). A thin event buffer flushes batches to
POST /analytics/events(on background, on interval, on logout). Producers:app:open/session, playback heartbeats (current position every N s while playing, + on pause/seek/stop), andview/seen (doc-36) on meaningful dwell. - Store (backend). A new
analyticsmodule with an append-onlyEventdoc:{ actor, eventType, nodeType, nodeId, sessionId, ts, props{} }.propscarries the shape per type (playback:{fileId, posStart, posEnd, msPlayed}). Raw events get a TTL; durable truth lives in aggregates. - Aggregate (read). MVP computes on read via Mongo aggregation (low volume); the scale lever is materialized rollup docs (per-file position histogram, per-user/-workspace daily counters) — deferred until volume warrants (doc 14).
- Two in-product consumers + an optional BI mirror. The events store is the source of truth; a BI tool, if wired, is a mirror sink for funnels/retention dashboards we don't want to build — not a replacement. (Owner chose "unified backbone," so: own the store, mirror later if useful.)
Event taxonomy (initial)
| Family | Event(s) | props | Feeds |
|---|---|---|---|
| Session | app:open · app:foreground · session:end | {durationMs} | opens, sessions, minutes-in-app |
| Playback | play:start · play:heartbeat · play:pause · play:seek · play:complete | {fileId, posStart, posEnd, msPlayed} | most-listened-part heatmap, plays, listen-through %, unique listeners |
| View / seen | view (doc-36) | {dwellMs} | seen-by, "unseen since I last looked" lens |
| Action (derive, don't double-store) | reuse existing comment:* / approval writes | — | engagement: comments-per-track, approvals/wk (north-star §9) |
Reuse, don't duplicate: approvals/comments are already persisted — derive those metrics from the domain, only new signal (sessions, playback position, views) goes through the events pipe.
Classify plays by depth — don't discard the short ones (decision 2, locked). A 2-second play is not noise, it's a first impression — keep it, but tag it so it never inflates "engaged listens." Derive an
engagementBandper play frommsPlayed/ % of duration:impression(grabbed → bailed, e.g. < ~3 s) ·sample(a skim) ·listen(passed a meaningful threshold, ~30 s or X %) ·complete(~95 %+). This yields the music-industry-native ratios for free — hook rate (listen/impression) and skip / completion rate — and the heatmap can be filtered to engaged plays so first-second curiosity doesn't drown the replayed sections. Bands are computed from the playback events (we already capture position +msPlayed), so no extra client work.
The "most-listened part" mechanic — heatmap on the waveform
The hero surface, and it needs no new metaphor: aggregate playback heartbeats into a per-file position histogram (seconds bucketed across the track length), then render that intensity directly on the waveform we already draw — wavesurfer on web, the player wave on iOS (doc-22). Replayed sections glow; skipped intros/outros stay dim. Pairs naturally with the approval loop (doc-29/32): "3 of 4 approvers opened the master; the bridge at 1:32 got the most replays."
Privacy & calm posture (not optional)
doc-31 ("professional calm") and doc-36 make seen-state calm and opt-in. Per-collaborator listening detail is even more sensitive — "who listened to what, how long" can read as surveillance in a creative-trust context. Stance baked in from the schema up:
- Aggregate by default. Counts, %, heatmap intensity — no names.
- Identified ("listened by / seen by {who}") is opt-in and role-gated — granularity rides Teams (decision 3, locked). Owner/Admin see engagement on their own catalog; a named collaborator's listening is surfaced only with consent (mirror doc-36's "seen-by is opt-in"). Reuse the Teams/Role model (doc-26) as the granularity + visibility primitive: slice engagement by team / role, and let team membership + per-member opt-in define who's shown identified vs folded into the aggregate — no separate consent system.
- Guest/link plays = an identifiable unit, not a forced identity (decision 5, locked). Attribute to the share link (doc-26), and enrich each guest play with timestamp + coarse (city/country) location + device hint so the owner gets "a listener in Berlin opened it 3× today" with zero friction on the guest. A guest may self-identify (optional name) → upgrades to a named guest (distinct from a platform user); never required. ⚠ Derive location server-side to city/country and store that, not the raw IP (IP is personal data under GDPR) — disclose it on the share surface.
- Retention. Raw events TTL out; aggregates persist. Don't hoard identifiable raw telemetry.
UI surfaces — where it lives (and where it doesn't)
- ▶ Track-level "Insights" (the hero). Waveform heatmap + plays · unique listeners · avg listen-through % · approvers-opened (N of M). On the track/player screen behind a tab or disclosure — depth lives here.
- ▶ Workspace / Owner "Insights" (role-gated). Opens, active days, total minutes, top tracks/projects, most-active collaborators. Fits one dashboard, role-driven content (doc-20) — a drawer destination under You / Workspace, gated to Owner/Admin. Not a per-user-type home (deferred templates layer).
- Home = a pulse card at most. A single glanceable line ("Your catalog got 42 plays this week →") that links into Workspace Insights. Home is "what needs my attention" (doc-37) — analytics depth is a destination, not the dashboard body.
- BI tool (optional, separate). Funnels/retention for the stakeholder live in PostHog/Amplitude fed by the mirror sink — not rebuilt in-app.
Backend vs iOS map
| Piece | Today | Work | Owner |
|---|---|---|---|
Events module + Event schema + POST /analytics/events (batch) | ✗ none | new module, append-only, TTL index | backend |
| Client event buffer + flush (open/session) | ✗ none | thin SDK-ish buffer; flush on bg/interval/logout | iOS (then web) |
| Playback heartbeat emitter | ✗ none | hook the player (PlayerViewModel) — position every N s + on pause/seek/stop | iOS |
Aggregation reads — GET /track/:id/insights (heatmap + counts) | ✗ none | Mongo aggregation; materialize later | backend |
| Track Insights surface (waveform heatmap) | ✗ none | overlay intensity on existing wave | iOS |
| Workspace Insights aggregate + view (role-gated) | ✗ none | GET /analytics/workspace + drawer destination | backend + iOS |
| Privacy controls (opt-in identified) + retention TTL | ✗ none | consent flag + role-gate; TTL on raw | backend |
doc-36 view/seen | ✗ none (envisioned) | same pipeline — a view event type, not a new system | backend + iOS |
| BI mirror (PostHog/Amplitude) | ✗ none | optional sink off the events store | backend |
How this sequences
Envision now; build as its own phase after the active doc-37 Home Dashboard work and the owner's standing first-byte perf priority. Capture-first, read-second — nothing accumulates until events flow, so the ingestion endpoint + the playback heartbeat are the earliest slices even though the visible payoff (the heatmap) comes a step later. Suggested order:
- Backend backbone —
analyticsmodule,Eventschema,POST /analytics/events, TTL index. - iOS capture — event buffer (
app:open/session) + playback heartbeats from the player. (Data starts accumulating here — ship this even before any read surface so there's history to aggregate.) - Track Insights read —
GET /track/:id/insights(position histogram + plays/listeners/listen-through). - iOS Track Insights — waveform heatmap + the counts row.
- Workspace Insights —
GET /analytics/workspaceaggregate + the role-gated drawer view + Home pulse card. - Privacy + retention — opt-in identified visibility, role-gating, raw-event TTL.
- (Optional) BI mirror + fold in doc-36
view/seen as aviewevent type on the same pipe.
Start narrow: session + playback events only (answers the stakeholder's exact three questions), prove the heatmap, then widen to views/seen and workspace rollups.
Decisions — locked (2026-06-26)
- Own vs mirror for BI → OWN. The events store is the source of truth; mirror to PostHog/Amplitude later for funnels/retention UI we don't want to rebuild. Not pure-3rd-party (that gives no in-product metrics, no heatmap).
- "What counts as a play" → classify, don't discard. Short plays count (a first impression is signal) but carry an
engagementBand(impression/sample/listen/complete) so they're tagged, never conflated with engaged listens — yields hook-rate + skip/completion-rate for free (see §taxonomy note). Open tuning knobs only: heartbeat interval (~10–15 s, battery vs resolution) + the exact band thresholds — set at build time, not architectural. - Identified vs aggregate → aggregate-default, identified opt-in, role-gated, granularity via Teams. Visibility + slicing reuse the doc-26 Team/Role model (membership + per-member opt-in decide identified-vs-aggregate; engagement sliceable by team/role). No separate consent system. (See §Privacy.)
- Retention + rollups → aggregate-on-read (my call, agreeing with you). Start with aggregate-on-read everywhere + a TTL on raw events — simplest, correct, fine at our volume; no batch rollup pipeline. One honest watch-point: the per-file position histogram (the heatmap) is the single read that scans many heartbeats. If/when it gets slow, the cheap escalation is not a rollup job but a write-time bucket
$inc(increment a per-file position-bucket counter on ingest → O(1) read). Flagged, not pre-built — decide when a real number says so. - Guest/link listening → identifiable unit, not forced identity (my call, agreeing with you). Attribute to the share link; enrich with timestamp + coarse city/country location for zero-friction "who/where" signal; guest may optionally self-identify (name) to upgrade to a named guest. ⚠ Store derived city/country, not raw IP (GDPR), and disclose. (See §Privacy.)
Until built, the app emits no telemetry; nothing here blocks other phases. Track.lastAccessedAt / recent-access keep powering "Recently opened" unchanged (and can become the first view-event producer).
Cross-references
- The seen/view subsystem that shares this backbone:
36-INTERACTION-PATTERNS.md§"View" events - The approval loop the heatmap pairs with:
29-VERSION-SCOPED-APPROVAL.md·32-APPROVAL-MODEL.md - One-dashboard / role-gated placement:
20-UI-SIMPLIFICATION.md·37-HOME-DASHBOARD.md - Waveform / player surface for the heatmap:
22-IOS-DESIGN-SYSTEM.md - Why calm + accountability (B2B):
31-PRODUCT-VISION-360.md - Live transport (broadcast, not the analytics store — a different path):
35-REALTIME-SYNC.md - Multi-instance / rollup scale levers:
14-SCALABILITY.md - Actors the metrics serve:
12-ACTORS-WORKFLOWS.md