Tracking
Analytics in the PanelWave Player — consent-aware TrackingService, event whitelisting, batching, the endpoint payload, and player events.
The player's TrackingService is a privacy-conscious analytics pipeline: nothing is recorded without consent (when required), events can be whitelisted by the work's manifest, and delivery is batched to a configurable endpoint. Integrators can also bypass it entirely and forward the shell's Angular outputs to their own analytics.
How the manifest configures tracking
When a manifest with a tracking section loads, the shell configures the service automatically:
{
"tracking": {
"enabled": true,
"consent": { "required": true, "defaultOptIn": false },
"eventWhitelist": ["panelChange", "decision", "videoPlay", "videoEnded"],
"endpoint": "https://analytics.example.com/pw/events"
}
}
Mapping (verified in the shell's configureTracking):
| Manifest field | Effect |
|---|---|
consent.required | Sets consentRequired (defaults to true when absent) |
consent.defaultOptIn | If true, consent is granted immediately (until an explicit consent UI overrides it) |
eventWhitelist | Only listed event types are recorded; all others are dropped |
endpoint | Where batches are POSTed; without it, events stay in the local queue/observable only |
See Tracking (schema) for the format definition.
Consent
import { inject } from '@angular/core';
import { TrackingService } from '@panelwave/player';
const tracking = inject(TrackingService);
// Wire this to your cookie/consent banner:
tracking.setConsent(true);
tracking.getConsent(); // → boolean
While consentRequired is true and consent has not been given, track() is a no-op. Revoking consent (setConsent(false)) also clears any queued, unsent events.
Recording events
tracking.track('decision', {
panelId: 'p-12',
choiceId: 'hs-door-left',
});
track(type, data?) applies, in order: consent check → whitelist check → debouncing for high-frequency types (scroll, mousemove, resize, progress; 300 ms default) → queue.
Each recorded event has this shape (TrackingEvent):
interface TrackingEvent {
type: string;
timestamp: number; // Date.now()
data?: Record<string, any>;
sessionId?: string; // anonymized, random per session
}
The session id is a crypto.randomUUID() generated when the service is created — it identifies a session, not a user.
Batching and delivery
Events are sent when the queue reaches 10 events (batchSize) or every 5 seconds (batchInterval), whichever comes first. Delivery is a JSON POST:
{
"sessionId": "6f0d…",
"events": [
{ "type": "panelChange", "timestamp": 1751712000000, "data": { "panelId": "p-2" }, "sessionId": "6f0d…" }
],
"timestamp": 1751712001234
}
A non-2xx response is logged; failed batches are not re-queued (deliberately, to avoid memory buildup). Useful controls:
| Method | Purpose |
|---|---|
configure(partialConfig) | Override endpoint, consentRequired, eventWhitelist, batchSize, batchInterval, debounceMs |
flush() | Send the current queue immediately (e.g. on beforeunload) |
clear() | Drop queued events and pending debounce timers |
getSessionId() / getQueueSize() | Introspection |
Events the player emits
The PlayerEvent enum defines the event vocabulary used by the player internals:
| Event | Value | Meaning |
|---|---|---|
READY | ready | Player initialized |
PANEL_CHANGE | panelChange | Navigation to a panel |
DECISION | decision | A branch choice was made |
PAYWALL_SHOWN | paywallShown | A gate was displayed |
ERROR | error | An error occurred |
STATE_CHANGE / PREFERENCE_CHANGE / LOCALE_CHANGE | stateChange / preferenceChange / localeChange | State, preference, locale updates |
VIDEO_PLAY | videoPlay | A video layer started |
VIDEO_PAUSE | videoPause | A video layer paused |
VIDEO_ENDED | videoEnded | A video finished — or was force-skipped after stalling |
VIDEO_LOOP | videoLoop | A loop/pingpong cycle completed |
Video events carry a VideoTrackingPayload:
interface VideoTrackingPayload {
panelId?: string;
assetId?: string;
trigger: 'view' | 'hover' | 'click' | 'sequencer';
reason?: 'ended' | 'stall-skip'; // qualifies VIDEO_ENDED
}
reason: 'stall-skip' marks videos the autoplay watchdog or the page-view sequencer skipped because the source never finished — valuable for spotting broken encodes in the wild.
Reading-session and hotspot events
Beyond video events, the shell instruments the reading session automatically — every event below flows through the same consent/whitelist pipeline:
| Event | When | Payload highlights |
|---|---|---|
session_start | Once, when tracking is configured | deviceType, locale, totalPanels (reading-order count) |
panel_view | Each panel the reader lands on (deduplicated) | panelId, chapterId, panelOrder |
work_complete | Once, when the reader reaches an end panel of the last chapter (in a chapter without edges: its last panel in reading order) | panelId, chapterId |
hotspot_click | Every click/tap on panel content (panel, page and canvas view) | see below |
branch_choice | The reader picked a path in the branch chooser | chapterId, from, to, index (position in the edge list) |
like | Like toggled | workId, liked |
bookmark | Bookmark set or cleared | workId, chapterId, panelId, bookmarked |
speech_toggle / audio_toggle / sfx_toggle | A sound toggle changed (toolbar or Settings) | enabled |
session_end | On page hide / destroy, followed by a synchronous flush (sendBeacon) | — |
hotspot_click
Activating a hotspot — and, deliberately, also clicking panel artwork that hits no hotspot — records a hotspot_click:
{
"type": "hotspot_click",
"data": {
"panelId": "p-12",
"chapterId": "ch-1",
"hotspotId": "hs-door",
"x": 0.3142,
"y": 0.6108,
"hit": true
}
}
x/yare normalized (0–1), panel-relative coordinates of the click.hit: truecarries thehotspotId;hit: falsemarks a dead click — a tap that missed every hotspot — and omitshotspotId.- Keyboard activations report the shape's centroid.
Dead clicks are recorded on purpose: they show where readers try to interact, which powers the click heatmap in the CMS — see Analytics (CMS). Like every event, hotspot_click respects consent and must be present in the manifest's eventWhitelist to be recorded.
Forwarding player activity yourself
If you already have an analytics stack, subscribe to the shell's outputs and forward what you need — no TrackingService required:
import { Component } from '@angular/core';
import { PlayerShellComponent } from '@panelwave/player';
import type { PlayerPanelChangeEvent } from '@panelwave/player';
@Component({
selector: 'app-reader',
standalone: true,
imports: [PlayerShellComponent],
template: `
<pw-player-shell
[manifest]="manifest"
(ready)="analytics.event('pw_ready')"
(panelChange)="onPanel($event)"
(navigationAttempt)="analytics.event('pw_nav', $event)"
(localeChange)="analytics.event('pw_locale', { locale: $event })"
(likeChange)="analytics.event('pw_like', $event)"
(bookmarkChange)="analytics.event('pw_bookmark', $event)"
(error)="analytics.event('pw_error', { message: $event.message })">
</pw-player-shell>
`,
})
export class ReaderComponent {
// panelChange fires exactly once per navigation — no de-duplication needed.
// panelId / previousPanelId are part of the payload from player 1.2.0.
onPanel(e: PlayerPanelChangeEvent): void {
this.analytics.event('pw_panel_view', {
chapterId: e.chapter.id,
panelId: e.panelId,
from: e.previousPanelId,
});
}
}
Creators publishing through the PanelWave platform get aggregated dashboards in the CMS — see Analytics (CMS).