The analytics read model stored in MongoDB: the collections, fields, indexes, retention and versioning
contract shared by the OpenVidu server (writer) and OpenVidu Dashboard (reader), and the write rules that
keep the model consistent when events are delayed, replayed or duplicated.
## Requirements
### Requirement: Schema version contract
Edition: CE and PRO. The writer SHALL record the schema version it implements in `schema_meta` (`_id:
"schema"`, integer `version`, `writerVersion`, `updatedAt`) at startup. The dashboard SHALL compare that
version with the one it supports and refuse to serve data when the writer is older than its minimum.
#### Scenario: Writer writes its version
- **GIVEN** a server with analytics enabled starting against an empty database
- **WHEN** it finishes initialization
- **THEN** `schema_meta` contains `{ _id: "schema", version: <writer schema version> }` and the collections and indexes of this specification exist
#### Scenario: Dashboard rejects an older writer
- **GIVEN** `schema_meta.version` lower than the dashboard's minimum supported version
- **WHEN** the dashboard starts
- **THEN** it exits with a message naming both versions and the server version to install
#### Scenario: Dashboard tolerates a newer writer
- **GIVEN** `schema_meta.version` higher than the dashboard's version
- **WHEN** the dashboard starts
- **THEN** it serves data and `GET /api/v1/info` reports the mismatch as a warning
### Requirement: Writer metadata documents
Edition: CE and PRO. Besides `schema`, `schema_meta` SHALL hold `retention`, `capabilities` and `config`, which the
writer replaces at every initialization, `migration` (`server-legacy-migration`) and `reconciler`
(`server-reconciler`); `_id: "dashboard"` is reserved for non-secret facts written by the dashboard.
#### Scenario: Retention document
- **GIVEN** a writer initialized with the default retention
- **WHEN** `schema_meta``_id: "retention"` is read
- **THEN** it has `eventsMs`, `statsMs`, `tracksMs` and `rollups5MinMs` 604 800 000, `entitiesMs` 7 776 000 000, `rollupsHourlyMs` 34 560 000 000, `rollupsDailyMs: null` (daily rollups kept forever) and `updatedAt`
#### Scenario: Daily rollup retention recorded
- **GIVEN** a writer initialized with `retention.rollups_daily: 1825d`
- **WHEN** `schema_meta``_id: "retention"` is read
- **THEN** `rollupsDailyMs` is 157 680 000 000
#### Scenario: Capabilities and config documents
- **WHEN** `schema_meta``_id: "capabilities"` and `_id: "config"` are read
- **THEN** `capabilities` has `timeseries`, `transactions`, `serverVersion` and `detectedAt`, and `config` has the writer's `statsDetail` (`participant` | `track`) and `recordedAt`
### Requirement: Entity documents keyed by LiveKit identifiers
Edition: CE and PRO. Each room session, participant session, published track, subscription, egress,
ingress and ingress session SHALL be represented by exactly one document whose `_id` is its LiveKit
#### Scenario: Dependent participants do not count as room size
- **GIVEN** a room with two STANDARD participants and one EGRESS participant
- **WHEN** counters are read
- **THEN** `participants.byKind.EGRESS` is 1 and `participants.active`/`peakActive` count only the two STANDARD participants
#### Scenario: Only standard participants count as participants
- **GIVEN** an active room with two active STANDARD participants of different identities, one EGRESS, one INGRESS and one AGENT participant, and one aborted SIP session
- **WHEN** counters are read
- **THEN** `participants.sessions` is 2, `unique` is 2, `active` is 2, `peakActive` is 2, `aborted` is 0 and `participants.byKind` is `{ STANDARD: 2, EGRESS: 1, INGRESS: 1, AGENT: 1, SIP: 1 }`
### Requirement: Participant session document
Edition: CE and PRO. A `participants` document SHALL represent one participant session (one SID) with its
- **GIVEN** `EGRESS_STARTED`, two `EGRESS_UPDATED` and `EGRESS_ENDED` with status COMPLETE and one file result
- **WHEN** all are processed
- **THEN** the document has `status: "complete"`, one output of kind `file` with its filename, location, size and duration, and four `statusHistory` entries
#### Scenario: Credentials are never stored
- **GIVEN** an egress request with S3 credentials and a stream URL containing a key
Edition: CE and PRO. `rawRequest` SHALL hold the request as the raw events of the egress store `egress.request`: the
member of the request oneof that is set, under its protocol name, through the secrets allow-list.
#### Scenario: Raw request
- **GIVEN** a room composite egress of room `Demo-Room` with layout `grid`, preset `H264_720P_30`, an MP4 file output with path `recordings/demo.mp4` uploaded to S3 with credentials, and a stream output to `rtmps://live.example.com/app/live_123?token=abc`
- **WHEN** its `EGRESS_STARTED` event is processed
- **THEN** `rawRequest` is `{ room_composite: { room_name: "Demo-Room", layout: "grid", options: { preset: "H264_720P_30" }, file_outputs: [{ file_type: "MP4", filepath: "recordings/demo.mp4" }], stream_outputs: [{ urls: ["rtmps://live.example.com/app/***"] }] } }`, the value of `payload.egress.request` in the stored event
- **GIVEN** `INGRESS_CREATED`, then `INGRESS_STARTED`/`INGRESS_ENDED` twice with different `resource_id`
- **WHEN** all are processed
- **THEN** one `ingresses` document has `sessions: 2` and `totalDurationMs` equal to the sum of both sessions, and two `ingress_sessions` documents exist
#### Scenario: Stream key is never stored
- **GIVEN** an RTMP ingress with a stream key
- **WHEN** it is projected
- **THEN** no document contains the stream key
### Requirement: Raw events
Edition: CE and PRO. The `events` collection SHALL store one document per stored event, keyed by the LiveKit
event id, with the type, timestamp, lifted entity references, node, a synthetic flag and a payload without
secrets. The set of stored types SHALL be configurable with a default that excludes chatty and sensitive
types.
#### Scenario: Fields of a raw event
- **WHEN** a raw event is read
- **THEN** it has `_id` (LiveKit event id), `type`, `ts`, `roomSid`, `roomName`, `participantSid`, `identity`, `trackSid`, `egressId`, `ingressId`, `ingressSessionId` (those that apply), `nodeId`, `synthetic` and `payload`
#### Scenario: Default stored types
- **GIVEN** default configuration
- **WHEN** `API_CALL`, `TRACK_PUBLISHED_UPDATE`, `TRACK_MAX_SUBSCRIBED_VIDEO_QUALITY`, `TRACK_SUBSCRIBE_REQUESTED` and two identical consecutive `INGRESS_UPDATED` events are received
- **THEN** no `API_CALL`, `TRACK_PUBLISHED_UPDATE`, `TRACK_MAX_SUBSCRIBED_VIDEO_QUALITY` or `TRACK_SUBSCRIBE_REQUESTED` document is stored and only the first `INGRESS_UPDATED` is stored
#### Scenario: Room timeline
- **GIVEN** events of a room within the events retention window
- **WHEN** `events` is queried by `roomSid` and `ts`
- **THEN** every stored event of the room is returned in time order with its lifted references, and none of them is an `API_CALL`
#### Scenario: Closure by the reconciler
- **GIVEN** an active participant on a dead node
- **WHEN** the reconciler closes it
- **THEN** the document has `status: "left"` (or `"aborted"` when it never became active), `closedBy: "reconciler"`, `disconnectReason: "RECONCILED"` and `leftAt` equal to the latest of `lastStatsAt` and `lastEventAt`
#### Scenario: Synthetic close event
- **GIVEN** the reconciler closes a stale participant
- **WHEN** it records the closure
- **THEN** an `events` document with `type: "PARTICIPANT_LEFT"`, `synthetic: true` and an `_id` prefixed `AE_SYN_` exists
### Requirement: Track statistics time series
Edition: CE and PRO. `track_stats` SHALL hold one document per 30-second flush per participant and
direction (`participant` mode) or per track and direction (`track` mode), with normalized units, and
- **THEN** they have counters for rooms (created, ended, by end reason, `durationMsSum`, `participantsSum`), participants (joined, active, aborted, left, by kind, SDK, OS, browser, connection type, disconnect reason, connect time, minutes), tracks, egress (started, by type, by end status, duration), ingress sessions (started, ended, started by input type, duration), bytes, quality (score sum/count, poor, lost), API calls and API errors (total and by method), and daily documents also `uniqueIdentities` (set by the server's reconciler once the UTC day has closed); 5-minute documents have bytes, quality, `participants.connected` (distinct participants with stats), the `PARTICIPANT_ACTIVE` count and the lifecycle counters (rooms created/ended, participants joined/left/aborted, egress started/ended)
- **GIVEN** stats flushed between 10:00 and 10:05 UTC
- **WHEN** `analytics_5min` is read for `global:…T10:00:00Z`
- **THEN** `bytes.up` and `bytes.down` equal the sum of the flushed upstream and downstream bytes and `quality.scoreCount` equals the number of samples
#### Scenario: Hourly counters
- **GIVEN** five rooms created and two ended between 10:00 and 11:00 UTC on one node
- **WHEN** the events are processed
- **THEN** `analytics_hourly` has `global:2026-…T10:00:00Z` and `node:<id>:2026-…T10:00:00Z` with `rooms.created: 5` and `rooms.ended: 2`
#### Scenario: Replayed batch does not double count
- **GIVEN** a batch whose raw events were already inserted
- **WHEN** the batch is processed again
- **THEN** rollup counters are unchanged
#### Scenario: Services in rollups
- **GIVEN** between 10:00 and 11:00 UTC three STANDARD, one EGRESS and one AGENT participant joined, and the AGENT left after 10 minutes
- **WHEN** the hourly rollup is read
- **THEN** `participants.joined` is 3, `participants.left` is 0, `participants.minutes` counts only the STANDARD sessions and `participants.byKind` is `{ STANDARD: 3, EGRESS: 1, AGENT: 1 }`
#### Scenario: Ingress sessions by input type
- **GIVEN** two RTMP and one WHIP ingress sessions started between 10:00 and 11:00 UTC
- **WHEN** the hourly rollup is read
- **THEN** it has `ingress.sessionsStarted: 3` and `ingress.byInputType: { rtmp: 2, whip: 1 }`
### Requirement: Nodes
Edition: CE and PRO. The `nodes` collection SHALL hold one document per server node with `firstSeenAt`,
hourly rollups 400 d, daily rollups unlimited unless configured. A closed entity document SHALL carry `expiresAt` = its
end time plus its class retention, computed when written, and an open one `expiresAt: null`; `events`, `track_stats`
and the rollups expire by TTL indexes.
#### Scenario: TTL indexes
- **GIVEN** a writer that finished initialization with the default retention
- **WHEN** the TTL indexes are listed
- **THEN** `events` expires by `ts` after `retention.events`, `track_stats` by `ts` after `retention.stats`, `analytics_5min` by `bucket` after `retention.rollups_5min`, `analytics_hourly` by `bucket` after `retention.rollups_hourly`, every entity collection by `expiresAt` with `expireAfterSeconds: 0`, `nodes` by `lastSeenAt` after 30 days, and `analytics_daily` has no TTL index
#### Scenario: Daily rollups with a retention
- **GIVEN** a writer that finished initialization with `retention.rollups_daily: 1825d`
- **WHEN** the TTL indexes of `analytics_daily` are listed
- **THEN** it expires by `bucket` after 1 825 days
#### Scenario: Expiry of closed entities
- **WHEN** closed entity documents are read
- **THEN** `expiresAt` is `endedAt` + `retention.entities` for rooms, `leftAt` + `retention.entities` for participants, `endedAt` (else `updatedAt`) + `retention.entities` for egresses in a terminal status, `endedAt` (else `startedAt`) + `retention.entities` for ended ingress sessions, `unpublishedAt` + `retention.tracks` for tracks, `unsubscribedAt` + `retention.tracks` for subscriptions and the failure time + `retention.tracks` for failed subscriptions
#### Scenario: Ingress definition
- **GIVEN** a reusable ingress
- **WHEN** its document is read before and after its `INGRESS_DELETED`
- **THEN** `expiresAt` is `null` before, whatever its sessions, and `deletedAt` + `retention.entities` after
#### Scenario: Entities retention change
- **GIVEN** a room closed while `retention.entities` was `90d`
- **WHEN** the writer restarts with `retention.entities: 30d`
- **THEN** that room keeps its `expiresAt` and rooms closed afterwards get `endedAt` + 30 days
#### Scenario: Active entity never expires
- **GIVEN** a room active for longer than the entities retention
- **WHEN** the egress list is searched by the room name `demo`
- **THEN** that egress is not returned, and egresses of the same room that carry `roomNameLower` are
### Requirement: Ingress last-session index
Edition: CE and PRO. `ingresses` SHALL have the index `lastSessionAt_id``{ lastSessionAt: -1, _id: -1 }`, partial on
`lastSessionAt` existing, which backs the selection of the ingresses whose last session started in a time range.
#### Scenario: Index of an initialized database
- **GIVEN** a writer that finished initialization
- **WHEN** the indexes of `ingresses` are listed
- **THEN** `lastSessionAt_id` exists with the key `{ lastSessionAt: -1, _id: -1 }` and the partial filter `{ lastSessionAt: { $exists: true } }`
### Requirement: Generated data follows the writer's shape
Edition: CE and PRO. The data generator SHALL write documents in the writer's shape: event payloads as the writer's
protojson (snake_case names nested under `room`, `participant`, `track`, `egress` and `ingress`, 64-bit integers as
64-bit numbers, as in the contract event fixtures), ingress `nameLower` and egress `roomNameLower` as the lower-cased
`name` and `roomName`, and node heartbeats with `batchIntervalMs` 10 000.
#### Scenario: Generated event payload
- **GIVEN** a database generated by the data generator
- **WHEN** a generated `PARTICIPANT_JOINED` event is read
- **THEN** its payload has `room.sid`, `participant.sid`, `participant.identity` and `client_info.sdk`, has no camelCase key such as `clientInfo`, and satisfies the payload interfaces of the typings
#### Scenario: Lower-cased search fields
- **GIVEN** a generated ingress named `Studio-A` and a generated egress of room `Demo-Room`
- **WHEN** their documents are read
- **THEN** the ingress has `nameLower: "studio-a"` and the egress has `roomNameLower: "demo-room"`
#### Scenario: Generated node heartbeats
- **GIVEN** a database generated by the data generator, kept live by its keep-alive
- **WHEN** `nodes` is read
- **THEN** every document has `batchIntervalMs: 10000`