Skip to content

38 — Analytics & Insights (envisioning)

▶ BACKEND BUILT 2026-06-27 (owner chose telemetry-first, before iOS). The analytics module ships the full backend: append-only Event store + TTL, POST /analytics/events (batched ingest with engagementBand classification), GET /analytics/track/:id/insights (waveform heatmap + plays/ listeners/listen-through/hook-rate), GET /analytics/workspace (opens/minutes/top tracks), and the doc-42 GET /management/insights tie-in. nest build + jest green. Remaining = clients emit: the iOS/web event emitters (play:heartbeat + sessions), the guest/link ingest path, and identified opt-in role-gating on reads. See CHANGELOG.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.duration is already persisted on new uploads — see .agent history — 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
  1. 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), and view/seen (doc-36) on meaningful dwell.
  2. Store (backend). A new analytics module with an append-only Event doc: { actor, eventType, nodeType, nodeId, sessionId, ts, props{} }. props carries the shape per type (playback: {fileId, posStart, posEnd, msPlayed}). Raw events get a TTL; durable truth lives in aggregates.
  3. 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).
  4. 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)

FamilyEvent(s)propsFeeds
Sessionapp:open · app:foreground · session:end{durationMs}opens, sessions, minutes-in-app
Playbackplay:start · play:heartbeat · play:pause · play:seek · play:complete{fileId, posStart, posEnd, msPlayed}most-listened-part heatmap, plays, listen-through %, unique listeners
View / seenview (doc-36){dwellMs}seen-by, "unseen since I last looked" lens
Action (derive, don't double-store)reuse existing comment:* / approval writesengagement: 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 engagementBand per play from msPlayed / % 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

PieceTodayWorkOwner
Events module + Event schema + POST /analytics/events (batch)✗ nonenew module, append-only, TTL indexbackend
Client event buffer + flush (open/session)✗ nonethin SDK-ish buffer; flush on bg/interval/logoutiOS (then web)
Playback heartbeat emitter✗ nonehook the player (PlayerViewModel) — position every N s + on pause/seek/stopiOS
Aggregation reads — GET /track/:id/insights (heatmap + counts)✗ noneMongo aggregation; materialize laterbackend
Track Insights surface (waveform heatmap)✗ noneoverlay intensity on existing waveiOS
Workspace Insights aggregate + view (role-gated)✗ noneGET /analytics/workspace + drawer destinationbackend + iOS
Privacy controls (opt-in identified) + retention TTL✗ noneconsent flag + role-gate; TTL on rawbackend
doc-36 view/seen✗ none (envisioned)same pipeline — a view event type, not a new systembackend + iOS
BI mirror (PostHog/Amplitude)✗ noneoptional sink off the events storebackend

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:

  1. Backend backboneanalytics module, Event schema, POST /analytics/events, TTL index.
  2. 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.)
  3. Track Insights readGET /track/:id/insights (position histogram + plays/listeners/listen-through).
  4. iOS Track Insights — waveform heatmap + the counts row.
  5. Workspace InsightsGET /analytics/workspace aggregate + the role-gated drawer view + Home pulse card.
  6. Privacy + retention — opt-in identified visibility, role-gating, raw-event TTL.
  7. (Optional) BI mirror + fold in doc-36 view/seen as a view event 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)

  1. 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).
  2. "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.
  3. 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.)
  4. 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.
  5. 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

Ctrl-Audio Platform Documentation