Skip to main content

Event Schema

The platform's central engineering claim is the asymmetry between the configurable execution layer (UI / content / recommender, all swappable) and the standardized measurement layer (every UI emits the same event contract into the same events table). This document is the single source of truth for that contract.

Researchers writing custom UIs do not define events. They mount the provided surface primitives (<FeedSurface>, <WatchSurface>, <VideoSurface>); each surface emits a subset of the contract on its behalf. The escape hatch useTracking() exists for genuinely new event types that future research may need.

Wire format​

A single event:

{
"event_type": "VIDEO_PLAY",
"video_id": "abc123", // optional, external id
"timestamp": "2026-04-30T12:34:56.789Z", // client clock
"watch_ratio": 0.42, // optional structured fields
"watch_duration": 12.7,
"position_in_feed": 3,
"payload": { ... } // event-specific JSON
}

The events table stores watch_ratio, watch_duration, position_in_feed, and the per-surface recommender keys (algorithm_feed and algorithm_watch) in promoted columns. Both keys are populated on every event so per-surface analysis can join either column without inferring the page from event_type. Everything else lives in the payload JSONB column. Promotion exists so the analytic-hot fields land on indexes. Freeform research data stays flexible in JSONB.

Batching​

BufferFlush conditionEvents
Normal5 s elapsed or buffer ≥ 20 or on beforeunload (sendBeacon)All non-high-frequency types
High-frequency2 s elapsed or buffer ≥ 50 or on beforeunloadMOUSE_MOVEMENT, SCROLL, VIEWPORT_VISIBILITY, VIDEO_PROGRESS
Immediateflushed the moment they enter the bufferVIDEO_ENDED, LIKE, DISLIKE, SESSION_END, PAGE_EXIT

Batching parameters are not user-configurable on purpose — comparability of historic and future studies depends on the buffer being identical. Treat them as part of the contract.

Session lifecycle​

Sessions are created on the client (crypto.randomUUID() in sessionStorage), registered via POST /sessions, and end either explicitly via POST /sessions/{id}/end or implicitly when no further events arrive. All events carry the same session_id for the lifetime of that session, which is the join key for behavioural analysis.

Event types​

There are 33 event types in the public schema, partitioned into 9 categories. The "Surface" column lists the surface primitive that emits the event; "Manual" means the event is only emitted via useTracking() or another bespoke caller and is not part of any surface's automatic set.

Session lifecycle (2)​

EventSurfaceRequired payload
SESSION_STARTEventProvider (mount)sessionId, startTime, referrer, initialUrl, initialPageType, environment (viewport, screen, userAgent, language, platform, timezone, connection?)
SESSION_ENDEventProvider (beforeunload)sessionId
EventSurfaceRequired payload
PAGE_LOADFeed/Watch surfaceurl, pageType
NAVIGATIONFeed/Watch surfacefrom, to, pageType, dwellTimeMs, dwellTimeSec
PAGE_EXITFeed/Watch surface (unmount)url, pageType, dwellTimeMs, dwellTimeSec

Video metadata (1)​

EventSurfaceRequired payload
VIDEO_META_CAPTUREDWatchSurface (per video change)videoId, title, channelName, category, viewCount, description, tags, duration, thumbnailUrl

Playback (8)​

All carry video_id on the row.

EventSurfaceRequired payload
VIDEO_PLAYVideoSurface (watch)currentTime, duration, playbackRate
VIDEO_PAUSEVideoSurface (watch)currentTime, duration, watchedDuration
VIDEO_SEEKVideoSurface (watch)from, to, seekDistance, duration
VIDEO_ENDEDVideoSurface (watch)duration, totalWatchedTime, completionRate (also promoted: watch_ratio, watch_duration)
VIDEO_PROGRESSVideoSurface (watch), every 5 scurrentTime, duration, progress
VIDEO_WATCHED_1SVideoSurface (watch), once per srccurrentTime, duration, progress (promoted: watch_duration, watch_ratio)
VIDEO_WATCHED_5SVideoSurface (watch), once per srcsame as above
VIDEO_BUFFERINGVideoSurface (watch)currentTime, readyState, networkState

Player controls (3)​

EventSurfaceRequired payload
PLAYBACK_RATE_CHANGEVideoSurface (watch)newRate, currentTime
VOLUME_CHANGEVideoSurface (watch)volume, previousVolume, muted, previousMuted
FULLSCREEN_CHANGEVideoSurface (watch)isFullscreen

Impressions (3)​

EventSurfaceRequired payload
HOME_FEEDFeedSurface (per new chunk)videoCount, videos[] (each with position, videoId, title, channelName?, duration?, viewCount?, thumbnailUrl?)
RECOMMENDATIONSWatchSurface (per related load)currentVideoId, recommendedCount, recommended[] (same shape as HOME_FEED items)
IMPRESSIONVideoSurface (card, ≥ 50 % visible, once per mount)(none beyond position_in_feed)

User interactions (6)​

EventSurfaceRequired payload
FEED_CLICKVideoSurface (context='feed')(none beyond position_in_feed)
LIKEuseLikes()videoId (and optional timestamp for video clock position)
DISLIKEuseLikes()videoId
VIDEO_CLICKVideoSurface (context='related')clickedVideoId, clickedTitle, position, fromVideoId, context
THUMBNAIL_HOVERVideoSurface (card)videoId, title, position, hoverDurationMs, followed_by_click, context
KEYBOARD_SHORTCUTVideoSurface (watch)key, action, currentTime, shiftKey, ctrlKey

High-frequency telemetry (3)​

EventSurfaceNotes
MOUSE_MOVEMENTFeed/Watch surfacepositions[] sampled at 100 ms, batched 2 s; payload contains samplingIntervalMs, context, duration
SCROLLFeed/Watch surfacescrollY, scrollPercent, maxScrollPercent, pageHeight; throttled to 500 ms
VIEWPORT_VISIBILITYFeed/Watch surfacevisibleItems[] (each: videoId, position, visibilityRatio, enterTime, exitTime?), context; reported every 3 s

Tab/window state (3)​

EventSurfaceRequired payload
VISIBILITY_CHANGEFeed/Watch surfacevisible, visibilityState
WINDOW_FOCUSFeed/Watch surface(empty)
WINDOW_BLURFeed/Watch surface(empty)

Layout (1)​

EventSurfaceRequired payload
LAYOUT_CHANGEManualfrom, to, context

LAYOUT_CHANGE is intentionally unsurfaced — it's a UI-author signal for when the column count or any other UI-side variable shifts mid-session, and the surfaces don't know about UI-author state.

Backend consumers​

The events table is read by several services. The relevant queries are documented here so any rename or schema change can audit consumers in one pass.

ReaderFileEvent types it filters on
Watched-video exclusion (Feed / Watch endpoints)backend/app/api/v1/feed.pyVIDEO_WATCHED_1S, VIDEO_ENDED
Per-experiment view counterbackend/app/api/v1/events.pyVIDEO_PLAY (popularity sort key — autoplay-included by design)
Recommendation evaluationbackend/app/api/v1/admin/stats.pyIMPRESSION, FEED_CLICK, VIDEO_PLAY, VIDEO_ENDED
RecBole training extractionbackend/app/services/recbole_trainer.pyVIDEO_WATCHED_1S (weight 1.0), LIKE (3.0), VIDEO_ENDED w/ ratio>0.5 (2.0)
Auto I2I (metadata + co-view)backend/app/services/item_similarity_computer.pyany (interaction filtered by video_id IS NOT NULL)
RecBole CF / I2I freshness checkbackend/app/services/training_scheduler.pyVIDEO_WATCHED_1S, LIKE, VIDEO_ENDED
Trajectory view (admin user status)backend/app/api/v1/admin/stats.pyexcludes MOUSE_MOVEMENT, SCROLL, VIEWPORT_VISIBILITY, VIDEO_PROGRESS, VISIBILITY_CHANGE, WINDOW_FOCUS, WINDOW_BLUR

Backend event-name reads (post-2026-05-06)​

The frontend emits the modern playback lifecycle (VIDEO_PLAY, VIDEO_PAUSE, VIDEO_ENDED, VIDEO_WATCHED_1S, VIDEO_WATCHED_5S) — the legacy VIDEO_START / VIDEO_END names were retired. Backend reads have been aligned and now use semantically appropriate modern names:

ReaderFileEvent types
Watched-video exclusionbackend/app/api/v1/feed.pyVIDEO_WATCHED_1S, VIDEO_ENDED
Per-experiment view counterbackend/app/api/v1/events.pyVIDEO_PLAY (popularity sort key — autoplay-included by design)
RecBole training extractionbackend/app/services/recbole_trainer.pyVIDEO_WATCHED_1S (weight 1.0), LIKE (3.0), VIDEO_ENDED w/ ratio>0.5 (2.0)
RecBole CF / I2I freshnessbackend/app/services/training_scheduler.pysame as training extraction
Recommendation evaluationbackend/app/api/v1/admin/stats.pyIMPRESSION, FEED_CLICK, VIDEO_PLAY, VIDEO_ENDED, (LIKE retained pre-drop)
Trajectory view (admin)backend/app/api/v1/admin/stats.pyexcludes high-frequency types (MOUSE_MOVEMENT, SCROLL, etc.)

Why VIDEO_WATCHED_1S for training rather than VIDEO_PLAY: the 1-second threshold filters autoplay-on-page-load false positives, leaving only "user actually watched" signal — matching the watched-history exclusion in feed.py for consistency. Use VIDEO_PLAY only when the desired signal includes raw playback starts (e.g., the popularity view counter, where autoplay-driven counts are intentional).

Embedded video sources (YouTube, Vimeo, ...)​

Platform contract is <video> element-based playback. The bundled <VideoPlayer> consumes PlayerHandlers from a watch-context <VideoSurface> and emits the 12 playback events natively, with frame-precise timestamps.

Embedded iframe players (YouTube, Vimeo, Twitch, ...) cannot be measured through the same path: cross-origin policy hides the iframe's playback state from the host page. The recommended pattern is a dedicated adapter component per provider that consumes the same PlayerHandlers and translates them into the provider's API:

<VideoSurface video={video} context="watch">
{(handlers) =>
video.resolved_url?.type === 'youtube'
? <YouTubeIFramePlayer src={video} {...handlers} />
: <VideoPlayer src={video.url} {...handlers} />
}
</VideoSurface>

The adapter (<YouTubeIFramePlayer> etc.) is not a generic UniversalPlayer. We deliberately avoid magic dispatch because the fidelity differences between native and adapter-derived events are load-bearing for analysis:

AspectNative (<VideoPlayer>)YouTube IFrame adapter
Timestamp precisionframe-precise (~16 ms)polling-based (~250–1000 ms)
VIDEO_SEEKnative seeked eventderived from getCurrentTime() jumps
VOLUME_CHANGEnative volumechangepolling, may miss intermediate values
KEYBOARD_SHORTCUTnative window keydownrequires disablekb=1 and may be lost while iframe holds focus
Ad insertionn/aYouTube ad time mixes with watch_duration unless filtered
API stabilitylocal/permanentYouTube can change embed policy at any time

For studies requiring fine-grained playback analysis (early-dropout curves, sub-second seek behavior), prefer mp4 sources. When mixing sources, filter by source type before any time-sensitive aggregation; the events table's payload.source (or video record metadata) is sufficient for that filter.

Adapters for providers other than YouTube are future work; the platform ships only the native <VideoPlayer> in Phase 1, with the YouTube adapter scheduled alongside the UI presets in a later phase.

Adding a new event type​

  1. Add the literal to frontend/src/types/event.ts EventType union.
  2. If the event needs to be high-frequency, also add the literal to HIGH_FREQ_EVENTS in frontend/src/contexts/EventContext.tsx. If it needs immediate flush, add it to IMMEDIATE_EVENTS.
  3. If the event belongs in a surface, add the emit there and update this doc's "Surface" column. If it's an escape-hatch event, document the emitter location instead.
  4. Update this file's per-category table with the payload contract.
  5. Add a backend consumer entry only if a service needs to filter on the event; many events are pure logging and don't need server-side reads.

Adding a new payload field​

Promoted columns (watch_ratio, watch_duration, position_in_feed, algorithm_feed, algorithm_watch) require an Alembic migration. Anything else goes inside payload and needs only a schema doc update. JSONB tolerates additions without migration.