openvidu-dashboard: the rooms list and Analytics say when a server reports no media statistics

media-stats-availability: the contract gains mediaStats on nodes, rooms and participants; the rooms list item and the analytics summary carry the mark and its coverage, the UI shows them as not reported, and the data generator and the demo can produce a node without media statistics.
pull/906/merge
juancarmore 2026-11-02 20:39:42 +01:00
parent e40f171278
commit 0ae24bb060
49 changed files with 1019 additions and 66 deletions

View File

@ -1,9 +1,12 @@
import type { Db, Document } from 'mongodb'; import type { Db, Document, FindCursor } from 'mongodb';
import { Collection, type NodeDocument } from '@openvidu-dashboard/typings';
import type { RollupRow } from './analytics-counters.js'; import type { RollupRow } from './analytics-counters.js';
import type { RollupCollection } from './analytics-planner.js'; import type { RollupCollection } from './analytics-planner.js';
export const ROLLUP_INDEX = 'scope_bucket'; export const ROLLUP_INDEX = 'scope_bucket';
const NODE_SCOPE_PREFIX = 'node:';
/** Rollup reads on `{ scope, bucket }`. */ /** Rollup reads on `{ scope, bucket }`. */
export class AnalyticsRepository { export class AnalyticsRepository {
constructor(private readonly db: Db) {} constructor(private readonly db: Db) {}
@ -27,4 +30,21 @@ export class AnalyticsRepository {
.find<RollupRow>({ scope, bucket: { $gte: from, $lt: to } }, { projection, hint: ROLLUP_INDEX }) .find<RollupRow>({ scope, bucket: { $gte: from, $lt: to } }, { projection, hint: ROLLUP_INDEX })
.sort({ bucket: 1 }); .sort({ bucket: 1 });
} }
/** The `mediaStats` of the nodes of `scope` whose heartbeats span overlaps `[from, to]`, on `lastSeenAt`. */
nodesInRange(scope: string, from: Date, to: Date): FindCursor<Pick<NodeDocument, 'mediaStats'>> {
const filter: Document = { lastSeenAt: { $gte: from }, firstSeenAt: { $lte: to } };
if (scope.startsWith(NODE_SCOPE_PREFIX)) {
filter._id = scope.slice(NODE_SCOPE_PREFIX.length);
}
return this.db
.collection<NodeDocument>(Collection.NODES)
.find<Pick<NodeDocument, 'mediaStats'>>(filter, { projection: { _id: 0, mediaStats: 1 } });
}
async nodesMediaStats(scope: string, from: Date, to: Date): Promise<(boolean | undefined)[]> {
return (await this.nodesInRange(scope, from, to).toArray()).map((node) => node.mediaStats);
}
} }

View File

@ -70,7 +70,11 @@ export const analyticsSummaryResponse = z
ingressSessions: count.meta({ description: 'Ingress sessions started in the range' }), ingressSessions: count.meta({ description: 'Ingress sessions started in the range' }),
ingressDurationMs: count.meta({ description: 'Duration of the ingress sessions ended in the range' }), ingressDurationMs: count.meta({ description: 'Duration of the ingress sessions ended in the range' }),
apiCalls: count, apiCalls: count,
apiErrorRate: z.number().optional() apiErrorRate: z.number().optional(),
mediaStats: z.enum(['partial', 'none']).optional().meta({
description:
'Present when some (partial) or all (none) of the nodes seen in the range do not report media statistics: bytes cover only the others'
})
}) })
.meta({ id: 'AnalyticsSummary', description: 'A value the stored documents cannot derive is absent' }); .meta({ id: 'AnalyticsSummary', description: 'A value the stored documents cannot derive is absent' });

View File

@ -3,6 +3,7 @@ import type { Db } from 'mongodb';
import { import {
type AnalyticsBreakdown, type AnalyticsBreakdown,
type AnalyticsBreakdownsResponse, type AnalyticsBreakdownsResponse,
type AnalyticsMediaStatsCoverage,
type AnalyticsSeries, type AnalyticsSeries,
type AnalyticsSeriesResponse, type AnalyticsSeriesResponse,
type AnalyticsSummary, type AnalyticsSummary,
@ -85,6 +86,17 @@ function roomAverage(counters: Counters, path: string, digits: 'one' | 'integer'
return digits === 'one' ? round1(average) : Math.round(average); return digits === 'one' ? round1(average) : Math.round(average);
} }
/** Coverage of the media statistics of the nodes seen in a range: absent when every one of them reports them. */
export function mediaStatsCoverage(marks: readonly (boolean | undefined)[]): AnalyticsMediaStatsCoverage | undefined {
const without = marks.filter((mark) => mark === false).length;
if (without === 0) {
return undefined;
}
return without === marks.length ? 'none' : 'partial';
}
/** Analytics over the rollup collections. */ /** Analytics over the rollup collections. */
export class AnalyticsService { export class AnalyticsService {
private readonly repository: AnalyticsRepository; private readonly repository: AnalyticsRepository;
@ -240,7 +252,7 @@ export class AnalyticsService {
const now = this.options.now(); const now = this.options.now();
const range = snapRange(query.from, query.to); const range = snapRange(query.from, query.to);
const days = utcDaysOf(range.from, range.to); const days = utcDaysOf(range.from, range.to);
const [counters, daily] = await Promise.all([ const [counters, daily, mediaStats] = await Promise.all([
this.read( this.read(
{ {
collection: range.collection, collection: range.collection,
@ -262,7 +274,8 @@ export class AnalyticsService {
fields: ['uniqueIdentities'] fields: ['uniqueIdentities']
}, },
now now
) ),
this.repository.nodesMediaStats(query.scope, range.from, range.to).then(mediaStatsCoverage)
]); ]);
const identities = [...daily.values()].flatMap((day) => { const identities = [...daily.values()].flatMap((day) => {
const count = day.get('uniqueIdentities'); const count = day.get('uniqueIdentities');
@ -298,7 +311,8 @@ export class AnalyticsService {
ingressSessions: value(counters, 'ingress.sessionsStarted'), ingressSessions: value(counters, 'ingress.sessionsStarted'),
ingressDurationMs: value(counters, 'ingress.durationMs'), ingressDurationMs: value(counters, 'ingress.durationMs'),
apiCalls: calls, apiCalls: calls,
apiErrorRate: share(value(counters, 'apiErrors.total'), calls) apiErrorRate: share(value(counters, 'apiErrors.total'), calls),
mediaStats
}); });
} }

View File

@ -8,6 +8,11 @@ export const staleField = z
.optional() .optional()
.meta({ description: 'Active entity whose last stats flush is older than the stale window' }); .meta({ description: 'Active entity whose last stats flush is older than the stale window' });
export const mediaStatsField = z.literal(false).optional().meta({
description:
"Present, as false, when the entity's server node does not report media statistics: its bytes and quality measure nothing"
});
/** `ListResponse<T>`: one page of a list with its cursors and, under the range limit, `total`. */ /** `ListResponse<T>`: one page of a list with its cursors and, under the range limit, `total`. */
export function listResponse(item: z.ZodType, id: string) { export function listResponse(item: z.ZodType, id: string) {
return z return z

View File

@ -68,7 +68,7 @@ export {
tzParam tzParam
} from './api/query.js'; } from './api/query.js';
export { ROLLUP_INDEX } from './analytics/analytics-repository.js'; export { ROLLUP_INDEX } from './analytics/analytics-repository.js';
export { isoDate, listResponse, staleField } from './api/responses.js'; export { isoDate, listResponse, mediaStatsField, staleField } from './api/responses.js';
export { prefixRegex } from './api/search.js'; export { prefixRegex } from './api/search.js';
export { bucketStart, bucketStarts, MAX_BUCKETS } from './api/time-series.js'; export { bucketStart, bucketStarts, MAX_BUCKETS } from './api/time-series.js';
export { ifNoneMatchMatches } from './http/conditional.js'; export { ifNoneMatchMatches } from './http/conditional.js';

View File

@ -28,6 +28,7 @@ export function toRoomListItem(doc: RoomDocument): RoomListItem {
agent: doc.features.agent, agent: doc.features.agent,
sip: doc.features.sip sip: doc.features.sip
}, },
lastStatsAt: iso(doc.lastStatsAt) lastStatsAt: iso(doc.lastStatsAt),
mediaStats: doc.mediaStats === false ? false : undefined
}); });
} }

View File

@ -2,7 +2,7 @@ import * as z from 'zod';
import { ClosedBy, RoomStatus } from '@openvidu-dashboard/typings'; import { ClosedBy, RoomStatus } from '@openvidu-dashboard/typings';
import { isoDate, listResponse, staleField } from '../api/responses.js'; import { isoDate, listResponse, mediaStatsField, staleField } from '../api/responses.js';
import { enumValues } from '../util/enum-values.js'; import { enumValues } from '../util/enum-values.js';
const count = z.number().int(); const count = z.number().int();
@ -24,7 +24,8 @@ export const roomListFields = {
bytes, bytes,
features: z.object({ egress: z.boolean(), ingress: z.boolean(), agent: z.boolean(), sip: z.boolean() }), features: z.object({ egress: z.boolean(), ingress: z.boolean(), agent: z.boolean(), sip: z.boolean() }),
lastStatsAt: isoDate.optional(), lastStatsAt: isoDate.optional(),
stale: staleField stale: staleField,
mediaStats: mediaStatsField
}; };
export const roomListItem = z.object(roomListFields).meta({ id: 'RoomListItem' }); export const roomListItem = z.object(roomListFields).meta({ id: 'RoomListItem' });

View File

@ -13,6 +13,7 @@ import {
analyticsSeriesResponse, analyticsSeriesResponse,
analyticsSummaryResponse analyticsSummaryResponse
} from '../../src/analytics/analytics-responses.js'; } from '../../src/analytics/analytics-responses.js';
import { AnalyticsRepository } from '../../src/analytics/analytics-repository.js';
import { openApiDocument } from '../../src/api/openapi.js'; import { openApiDocument } from '../../src/api/openapi.js';
import type { RouteRegistry } from '../../src/api/route-registry.js'; import type { RouteRegistry } from '../../src/api/route-registry.js';
import { TOKENS } from '../../src/di/tokens.js'; import { TOKENS } from '../../src/di/tokens.js';
@ -265,6 +266,53 @@ describe.skipIf(mongoUri === '')('analytics API', () => {
}); });
}); });
describe('media statistics coverage', () => {
const node = (id: string, mediaStats: boolean, lastSeenAt = new Date()): { _id: string } & Document => ({
_id: id,
firstSeenAt: new Date(hour - DAY),
lastSeenAt,
serverVersion: 'test',
batchIntervalMs: 10_000,
mediaStats
});
const coverage = async () => (await json<AnalyticsSummary>(ce, '/analytics/summary?from=now-1h')).mediaStats;
afterAll(async () => {
await ce.dashboard.db.collection(Collection.NODES).deleteMany({});
});
it('says whether the nodes seen in the range report media statistics', async () => {
const nodes = ce.dashboard.db.collection<{ _id: string } & Document>(Collection.NODES);
await seedRollups(ce, {});
await nodes.deleteMany({});
await nodes.insertMany([node('ND_a', false), node('ND_b', false)]);
const summary = await json<AnalyticsSummary>(ce, '/analytics/summary?from=now-1h');
expect(analyticsSummaryResponse.parse(summary)).toEqual(summary);
expect(summary).toMatchObject({ mediaStats: 'none', bytesUp: 0, bytesDown: 0 });
await nodes.updateOne({ _id: 'ND_b' }, { $set: { mediaStats: true } });
expect(await coverage()).toBe('partial');
// A node last seen before the range does not count.
await nodes.updateOne({ _id: 'ND_a' }, { $set: { lastSeenAt: new Date(hour - 2 * DAY) } });
expect(await coverage()).toBeUndefined();
});
it('reads the node of a node scope only', async () => {
const nodes = ce.dashboard.db.collection<{ _id: string } & Document>(Collection.NODES);
const repository = new AnalyticsRepository(ce.dashboard.db);
const from = new Date(Date.now() - HOUR);
await nodes.deleteMany({});
await nodes.insertMany([node('ND_a', false), node('ND_b', true)]);
expect(await repository.nodesMediaStats('node:ND_b', from, new Date())).toEqual([true]);
expect((await repository.nodesMediaStats('global', from, new Date())).sort()).toEqual([false, true]);
});
});
describe('editions', () => { describe('editions', () => {
it.each([ it.each([
['/analytics/series?metrics=roomsCreated,scoreAvg', 'analytics-quality'], ['/analytics/series?metrics=roomsCreated,scoreAvg', 'analytics-quality'],

View File

@ -138,6 +138,17 @@ describe.skipIf(mongoUri === '')(`analytics query plan at scenario ${scenario}`,
).toBeGreaterThan(0); ).toBeGreaterThan(0);
}); });
it('summary: the media statistics coverage reads the nodes on lastSeenAt', async () => {
const from = new Date(now.getTime() - DAY);
const nodes = await db.collection(Collection.NODES).countDocuments({});
const summary = await expectIndexBacked(db, repository.nodesInRange('global', from, now), {
maxDocsExamined: nodes
});
expect(nodes).toBeGreaterThan(0);
expectIndexes(summary, ['lastSeenAt_ttl']);
});
it('sums of the rollups equal the entity counts over whole hours', async () => { it('sums of the rollups equal the entity counts over whole hours', async () => {
const from = new Date(hour.getTime() - Math.min(days, 2) * DAY); const from = new Date(hour.getTime() - Math.min(days, 2) * DAY);
const summary = await service.summary({ from, to: hour, tz: 'UTC', scope: 'global' }); const summary = await service.summary({ from, to: hour, tz: 'UTC', scope: 'global' });

View File

@ -199,6 +199,48 @@ describe.skipIf(mongoUri === '')('data generator', () => {
await db.dropDatabase(); await db.dropDatabase();
} }
}); });
it('generates a node without media statistics on request', async () => {
const db = client.db('dashboard_test_generator_media_stats');
await db.dropDatabase();
try {
await generate(db, {
scenario: 'S',
days: 1,
statsMode: 'track',
seed: 7,
retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 },
timeseries: true,
rollups: { fiveMinute: true, nodes: true, roomSums: true },
nodesWithoutMediaStats: 1
});
const nodes = await db.collection<{ _id: string; mediaStats: boolean }>('nodes').find().toArray();
const unmarked = nodes.filter((node) => !node.mediaStats).map((node) => node._id);
expect(nodes.filter((node) => node.mediaStats)).toHaveLength(nodes.length - 1);
expect(unmarked).toHaveLength(1);
const rooms = db.collection<{ mediaStats?: boolean; quality: { scoreCount: number } }>('rooms');
const marked = await rooms.find({ nodeId: unmarked[0] }).toArray();
expect(marked.length).toBeGreaterThan(0);
expect(marked.every((room) => room.mediaStats === false && room.quality.scoreCount === 0)).toBe(true);
expect(await rooms.countDocuments({ nodeId: unmarked[0], 'bytes.up': { $ne: 0 } })).toBe(0);
expect(await rooms.countDocuments({ nodeId: { $ne: unmarked[0] }, mediaStats: { $exists: true } })).toBe(0);
expect(
await db.collection('participants').countDocuments({ nodeId: unmarked[0], mediaStats: { $ne: false } })
).toBe(0);
expect(await db.collection('track_stats').countDocuments({ 'meta.nodeId': unmarked[0] })).toBe(0);
expect(await db.collection('track_stats').countDocuments({})).toBeGreaterThan(0);
expect(
await db.collection('rooms').countDocuments({ $nor: [{ $jsonSchema: CONTRACT_SCHEMAS.rooms }] })
).toBe(0);
} finally {
await db.dropDatabase();
}
});
}); });
/** Every key of a payload at any depth; BSON values such as `Long` are leaves. */ /** Every key of a payload at any depth; BSON values such as `Long` are leaves. */

View File

@ -25,7 +25,7 @@ import {
analyticsSeriesQuery, analyticsSeriesQuery,
analyticsSummaryQuery analyticsSummaryQuery
} from '../../src/analytics/analytics-schemas.js'; } from '../../src/analytics/analytics-schemas.js';
import { AnalyticsService } from '../../src/analytics/analytics-service.js'; import { AnalyticsService, mediaStatsCoverage } from '../../src/analytics/analytics-service.js';
import { ApiError } from '../../src/http/api-error.js'; import { ApiError } from '../../src/http/api-error.js';
const MINUTE = 60_000; const MINUTE = 60_000;
@ -46,8 +46,11 @@ interface Read {
to: Date; to: Date;
} }
/** A database holding rollup documents per collection; `schema_meta` is empty, so the retention is the default. */ /**
function fakeDb(collections: Record<string, Doc[]>, reads: Read[] = []): Db { * A database holding rollup documents per collection and the `mediaStats` of the nodes; `schema_meta` is empty, so the
* retention is the default.
*/
function fakeDb(collections: Record<string, Doc[]>, reads: Read[] = [], nodes: (boolean | undefined)[] = []): Db {
return { return {
collection: (name: string) => ({ collection: (name: string) => ({
find: (filter: { scope: string; bucket: { $gte: Date; $lt: Date } }) => ({ find: (filter: { scope: string; bucket: { $gte: Date; $lt: Date } }) => ({
@ -66,7 +69,8 @@ function fakeDb(collections: Record<string, Doc[]>, reads: Read[] = []): Db {
// `for await` reads an array as well as the driver's cursor. // `for await` reads an array as well as the driver's cursor.
return rows; return rows;
} },
toArray: () => Promise.resolve(name === 'nodes' ? nodes.map((mediaStats) => ({ mediaStats })) : [])
}), }),
findOne: () => Promise.resolve(null) findOne: () => Promise.resolve(null)
}) })
@ -517,6 +521,22 @@ describe('analytics summary and breakdowns', () => {
}); });
}); });
it('reports the media statistics coverage of the nodes seen in the range', async () => {
const summary = await service(fakeDb({}, [], [false, false])).summary(summaryQuery.parse({}));
expect(analyticsSummaryResponse.parse(summary)).toEqual(summary);
expect(summary).toMatchObject({ mediaStats: 'none', bytesUp: 0, bytesDown: 0 });
expect(await service(fakeDb({}, [], [true])).summary(summaryQuery.parse({}))).not.toHaveProperty('mediaStats');
});
it('folds the media statistics marks of the nodes into a coverage', () => {
expect(mediaStatsCoverage([])).toBeUndefined();
expect(mediaStatsCoverage([true, undefined])).toBeUndefined();
expect(mediaStatsCoverage([false])).toBe('none');
expect(mediaStatsCoverage([false, true])).toBe('partial');
expect(mediaStatsCoverage([false, undefined])).toBe('partial');
});
it('omits the averages written without the room sums', async () => { it('omits the averages written without the room sums', async () => {
const db = fakeDb({ const db = fakeDb({
analytics_hourly: [{ scope: 'global', bucket: at('2026-10-13T09:00:00Z'), rooms: { created: 3, ended: 2 } }] analytics_hourly: [{ scope: 'global', bucket: at('2026-10-13T09:00:00Z'), rooms: { created: 3, ended: 2 } }]

View File

@ -43,6 +43,15 @@ describe('room mappers', () => {
expect(item).not.toHaveProperty('closedBy'); expect(item).not.toHaveProperty('closedBy');
}); });
it('carries the media statistics mark of a room only when its node reports none', () => {
const item = toRoomListItem({ ...active, mediaStats: false });
expect(roomListItem.strict().parse(item)).toEqual(item);
expect(item.mediaStats).toBe(false);
expect(item.bytes).toEqual({ up: active.bytes.up, down: active.bytes.down });
expect(toRoomListItem(active)).not.toHaveProperty('mediaStats');
});
it('maps egresses to their documented schema', () => { it('maps egresses to their documented schema', () => {
for (const doc of loadFixture<EgressDocument>('egresses')) { for (const doc of loadFixture<EgressDocument>('egresses')) {
const item = toEgressListItem(doc); const item = toEgressListItem(doc);

View File

@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import { test as base, type Page, type Route } from '@playwright/test'; import { test as base, type Page, type Route } from '@playwright/test';
import { import {
type AnalyticsSummary,
type EgressListItem, type EgressListItem,
type EgressSummary, type EgressSummary,
FeatureKey, FeatureKey,
@ -65,6 +66,8 @@ export interface ApiOptions {
analytics?: AnalyticsFixtures; analytics?: AnalyticsFixtures;
/** Analytics paths (`breakdowns`, `export.csv`…) that answer `500` with a request id. */ /** Analytics paths (`breakdowns`, `export.csv`…) that answer `500` with a request id. */
analyticsFail?: string[]; analyticsFail?: string[];
/** Fields merged into the body of `GET analytics/summary`, e.g. `mediaStats`. */
analyticsSummary?: Partial<AnalyticsSummary>;
/** /**
* Body of `GET overview`, built per request (an active deployment by default); its ETag is the body without * Body of `GET overview`, built per request (an active deployment by default); its ETag is the body without
* `generatedAt`, so an unchanged body answers `304` to `If-None-Match`. * `generatedAt`, so an unchanged body answers `304` to `If-None-Match`.
@ -316,7 +319,13 @@ export class MockApi {
await route.fulfill({ json: analyticsBreakdowns(params, this.options.analytics) }); await route.fulfill({ json: analyticsBreakdowns(params, this.options.analytics) });
break; break;
case 'summary': case 'summary':
await route.fulfill({ json: { ...ANALYTICS_SUMMARY, scope: params.get('scope') ?? 'global' } }); await route.fulfill({
json: {
...ANALYTICS_SUMMARY,
...this.options.analyticsSummary,
scope: params.get('scope') ?? 'global'
}
});
break; break;
case 'export.csv': { case 'export.csv': {
const csv = analyticsCsv(params); const csv = analyticsCsv(params);

View File

@ -171,3 +171,36 @@ test.describe('a failed request', () => {
await expect(card(page, 'Rooms').getByRole('button', { name: 'Export CSV' })).toBeEnabled(); await expect(card(page, 'Rooms').getByRole('button', { name: 'Export CSV' })).toBeEnabled();
}); });
}); });
test.describe('a range whose servers report no media statistics', () => {
test.use({ apiOptions: { analyticsSummary: { mediaStats: 'none' } } });
test('the Data up / down tile and the Bandwidth section say so', async ({ page }) => {
await page.goto('/analytics?from=now-7d&to=now&tz=UTC');
await expect(page.getByRole('region', { name: 'Summary' })).toContainText(
'Not reported by the servers of this range'
);
await expect(card(page, 'Data transferred')).toContainText(
'The servers of this range do not report media statistics'
);
await expect(card(page, 'Data transferred').locator('ovd-time-series-chart')).toHaveCount(0);
await expectNoViolations(page);
});
});
test.describe('a range where only some servers report media statistics', () => {
test.use({ apiOptions: { analyticsSummary: { mediaStats: 'partial' } } });
test('the Data up / down tile and the Bandwidth chart carry the note', async ({ page }) => {
await page.goto('/analytics?from=now-7d&to=now&tz=UTC');
await expect(page.getByRole('region', { name: 'Summary' })).toContainText(
'Only from the servers that report media statistics'
);
await expect(card(page, 'Data transferred')).toContainText(
'Only from the servers that report media statistics'
);
await expect(card(page, 'Data transferred').locator('ovd-time-series-chart')).toHaveCount(1);
});
});

View File

@ -153,3 +153,24 @@ test('the locked room detail fills its header from the list endpoint without PRO
await expect(page.getByRole('link', { name: 'Back to rooms' })).toBeVisible(); await expect(page.getByRole('link', { name: 'Back to rooms' })).toBeVisible();
expect(api.calls().filter((call) => call.startsWith('GET rooms/'))).toEqual([]); expect(api.calls().filter((call) => call.startsWith('GET rooms/'))).toEqual([]);
}); });
test.describe('a room whose server reports no media statistics', () => {
test.use({
apiOptions: { rooms: [roomItem({ mediaStats: false, bytes: { up: 0, down: 0 } })], roomSummary: ROOM_SUMMARY }
});
test('reads a dash with its explanation under Data up / down', async ({ page }) => {
await page.goto('/rooms');
const cell = table(page)
.getByRole('row', { name: /demo-room/ })
.getByLabel("Not reported: this room's server does not report media statistics");
await expect(cell).toHaveText('—');
await cell.hover();
await expect(page.locator('.mat-mdc-tooltip')).toHaveText(
"Not reported: this room's server does not report media statistics"
);
await expect(table(page)).not.toContainText('0 B');
});
});

View File

@ -58,6 +58,7 @@
label="Data up / down" label="Data up / down"
[info]="infoTexts.dataUpDown" [info]="infoTexts.dataUpDown"
[value]="bandwidthTile()" [value]="bandwidthTile()"
[hint]="bandwidthHint()"
[loading]="summary() === null" [loading]="summary() === null"
/> />
<ovd-kpi-tile <ovd-kpi-tile
@ -219,20 +220,27 @@
(widen)="widenRange()" (widen)="widenRange()"
> >
<ng-template ovdCardContent> <ng-template ovdCardContent>
<ovd-tile-row class="card-tiles"> @if (mediaStats() === 'none') {
<ovd-kpi-tile label="Total up" [value]="bytesTile(summary()?.bytesUp, '↑')" /> <ovd-notice class="media-stats-notice" variant="info">{{ mediaStatsTexts.notice }}</ovd-notice>
<ovd-kpi-tile label="Total down" [value]="bytesTile(summary()?.bytesDown, '↓')" /> } @else {
</ovd-tile-row> @if (mediaStats() === 'partial') {
<ovd-time-series-chart <ovd-notice class="media-stats-notice" variant="info">{{ mediaStatsTexts.partial }}</ovd-notice>
heading="Data up / down" }
kind="area" <ovd-tile-row class="card-tiles">
format="bytes" <ovd-kpi-tile label="Total up" [value]="bytesTile(summary()?.bytesUp, '↑')" />
[series]="charts().bytes" <ovd-kpi-tile label="Total down" [value]="bytesTile(summary()?.bytesDown, '↓')" />
[from]="chartRange('bytesUp').from" </ovd-tile-row>
[to]="chartRange('bytesUp').to" <ovd-time-series-chart
[tz]="tz()" heading="Data up / down"
[note]="note('bytesUp')" kind="area"
/> format="bytes"
[series]="charts().bytes"
[from]="chartRange('bytesUp').from"
[to]="chartRange('bytesUp').to"
[tz]="tz()"
[note]="note('bytesUp')"
/>
}
</ng-template> </ng-template>
</ovd-analytics-card> </ovd-analytics-card>

View File

@ -334,6 +334,38 @@ describe('AnalyticsPage', () => {
expect(size).toContain('Not provided by this server version'); expect(size).toContain('Not provided by this server version');
}); });
it('says the data is not reported when no server of the range reports media statistics', async () => {
const { element } = await open('/analytics?from=now-7d&to=now&tz=UTC', [], {
summary: { ...SUMMARY, mediaStats: 'none' }
});
const tile = [...element.querySelectorAll('.tiles ovd-kpi-tile')].map(text)[3];
const bandwidth = card(element, 'Data transferred');
expect(tile).toContain('—');
expect(tile).toContain('Not reported by the servers of this range');
expect(tile).not.toContain('TB');
expect(text(bandwidth?.querySelector('.media-stats-notice'))).toBe(
'The servers of this range do not report media statistics'
);
expect(bandwidth?.querySelector('ovd-time-series-chart')).toBeNull();
expect(bandwidth?.querySelectorAll('ovd-kpi-tile')).toHaveLength(0);
});
it('notes that the data covers only some servers when the others report no media statistics', async () => {
const { element } = await open('/analytics?from=now-7d&to=now&tz=UTC', [], {
summary: { ...SUMMARY, mediaStats: 'partial' }
});
const tile = [...element.querySelectorAll('.tiles ovd-kpi-tile')].map(text)[3];
const bandwidth = card(element, 'Data transferred');
expect(tile).toContain('↑ 1.2 TB');
expect(tile).toContain('Only from the servers that report media statistics');
expect(text(bandwidth?.querySelector('.media-stats-notice'))).toBe(
'Only from the servers that report media statistics'
);
expect(bandwidth?.querySelector('ovd-time-series-chart')).not.toBeNull();
});
it('hides unique identities below a day and shows them per UTC day at one day', async () => { it('hides unique identities below a day and shows them per UTC day at one day', async () => {
const weekly = await open('/analytics?from=now-7d&to=now&tz=UTC'); const weekly = await open('/analytics?from=now-7d&to=now&tz=UTC');

View File

@ -46,6 +46,7 @@ import {
INGRESS_INPUT_LABELS, INGRESS_INPUT_LABELS,
OS_LABELS, OS_LABELS,
PARTICIPANT_KIND_LABELS, PARTICIPANT_KIND_LABELS,
RANGE_MEDIA_STATS,
SDK_LABELS SDK_LABELS
} from '../../format/labels.js'; } from '../../format/labels.js';
import { bandwidthLines } from '../rooms/room-display.js'; import { bandwidthLines } from '../rooms/room-display.js';
@ -261,6 +262,8 @@ export class AnalyticsPage implements AnalyticsPageContext {
protected readonly result = this.loader.data; protected readonly result = this.loader.data;
protected readonly summary = computed(() => this.result()?.summary.value ?? null); protected readonly summary = computed(() => this.result()?.summary.value ?? null);
readonly mediaStats = computed(() => this.summary()?.mediaStats);
protected readonly mediaStatsTexts = RANGE_MEDIA_STATS;
protected readonly title = computed(() => protected readonly title = computed(() =>
this.scope() === 'global' ? 'Analytics' : `Analytics · ${this.scope()}` this.scope() === 'global' ? 'Analytics' : `Analytics · ${this.scope()}`
@ -358,10 +361,25 @@ export class AnalyticsPage implements AnalyticsPageContext {
return days === undefined ? 'Unique identities' : `Unique identities (sum of ${String(days)} UTC days)`; return days === undefined ? 'Unique identities' : `Unique identities (sum of ${String(days)} UTC days)`;
} }
protected bandwidthTile(): [string, string] | null { protected bandwidthTile(): [string, string] | string | null {
const summary = this.summary(); const summary = this.summary();
return summary === null ? null : bandwidthLines({ up: summary.bytesUp, down: summary.bytesDown }); if (summary === null) {
return null;
}
return summary.mediaStats === 'none' ? '—' : bandwidthLines({ up: summary.bytesUp, down: summary.bytesDown });
}
protected bandwidthHint(): string | undefined {
switch (this.mediaStats()) {
case 'none':
return RANGE_MEDIA_STATS.notReported;
case 'partial':
return RANGE_MEDIA_STATS.partial;
default:
return undefined;
}
} }
protected bytesTile(value: number | undefined, arrow: string): string | null { protected bytesTile(value: number | undefined, arrow: string): string | null {

View File

@ -2,6 +2,7 @@ import { InjectionToken, type Signal, type Type } from '@angular/core';
import type { import type {
AnalyticsBreakdown, AnalyticsBreakdown,
AnalyticsDimension, AnalyticsDimension,
AnalyticsMediaStatsCoverage,
AnalyticsMetric, AnalyticsMetric,
AnalyticsMetricInfo, AnalyticsMetricInfo,
AnalyticsScope, AnalyticsScope,
@ -85,6 +86,8 @@ export abstract class AnalyticsPageContext {
abstract readonly tz: Signal<string>; abstract readonly tz: Signal<string>;
/** Refreshes of the page, from 1: a card with requests of its own reloads when it changes. */ /** Refreshes of the page, from 1: a card with requests of its own reloads when it changes. */
abstract readonly refreshes: Signal<number>; abstract readonly refreshes: Signal<number>;
/** How much of the range's media statistics its servers report, from the summary; undefined when all of it. */
abstract readonly mediaStats: Signal<AnalyticsMediaStatsCoverage | undefined>;
/** The series of `metrics` from the combined series requests. */ /** The series of `metrics` from the combined series requests. */
abstract chart(metrics: readonly AnalyticsMetric[]): ChartSeries[]; abstract chart(metrics: readonly AnalyticsMetric[]): ChartSeries[];
/** Axis range of the series of `metric`. */ /** Axis range of the series of `metric`. */

View File

@ -144,7 +144,17 @@
<span class="ovd-tabular">{{ number(room.participants.active) }}</span> <span class="ovd-tabular">{{ number(room.participants.active) }}</span>
</ng-template> </ng-template>
<ng-template ovdCell="bytes" let-room> <ng-template ovdCell="bytes" let-room>
<span class="ovd-tabular">{{ bandwidth(room) }}</span> @if (room.mediaStats === false) {
<span
class="ovd-tabular"
tabindex="0"
[matTooltip]="mediaStatsNotReported"
[attr.aria-label]="mediaStatsNotReported"
>—</span
>
} @else {
<span class="ovd-tabular">{{ bandwidth(room) }}</span>
}
</ng-template> </ng-template>
<ng-template ovdCell="features" let-room> <ng-template ovdCell="features" let-room>
<span class="features"> <span class="features">

View File

@ -345,6 +345,22 @@ describe('RoomsPage', () => {
expect(stale?.querySelector('ovd-status-badge .badge')?.getAttribute('aria-label')).toContain('No stats since'); expect(stale?.querySelector('ovd-status-badge .badge')?.getAttribute('aria-label')).toContain('No stats since');
}); });
it('reads a dash with its tooltip in Data up / down for a room whose server reports no media statistics', async () => {
const { element } = await open(
'/rooms',
page([room({ sid: 'RM_ms', mediaStats: false, bytes: { up: 0, down: 0 } }), room({ sid: 'RM_ok' })])
);
const [unreported, reported] = [...element.querySelectorAll('tbody tr')].map(
(row) => row.querySelectorAll('td')[8]
);
expect(text(unreported)).toBe('—');
expect(unreported?.querySelector('span')?.getAttribute('aria-label')).toBe(
"Not reported: this room's server does not report media statistics"
);
expect(text(reported)).toBe('↑ 12 MB ↓ 48 MB');
});
it('keeps a fresh room unmarked under a long interval, judged at the last response', async () => { it('keeps a fresh room unmarked under a long interval, judged at the last response', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true }); vi.useFakeTimers({ shouldAdvanceTime: true });

View File

@ -36,7 +36,7 @@ import { injectRankedSort } from '../../core/state/ranked-sort.js';
import { rangeSpanMs } from '../../core/state/relative-time.js'; import { rangeSpanMs } from '../../core/state/relative-time.js';
import { formatNumber, formatTimestamp } from '../../format/formatters.js'; import { formatNumber, formatTimestamp } from '../../format/formatters.js';
import { INFO_TEXTS } from '../../format/info-texts.js'; import { INFO_TEXTS } from '../../format/info-texts.js';
import { RECONCILER_ESTIMATE } from '../../format/labels.js'; import { RECONCILER_ESTIMATE, ROOM_MEDIA_STATS_NOT_REPORTED } from '../../format/labels.js';
import { formatBandwidth, ROOM_FEATURES, roomPreview } from './room-display.js'; import { formatBandwidth, ROOM_FEATURES, roomPreview } from './room-display.js';
import { ROOMS_NODE_FILTER, RoomsDataService, type RoomsListRequest } from './rooms-data.js'; import { ROOMS_NODE_FILTER, RoomsDataService, type RoomsListRequest } from './rooms-data.js';
@ -98,6 +98,7 @@ export class RoomsPage {
protected readonly features = ROOM_FEATURES; protected readonly features = ROOM_FEATURES;
protected readonly estimate = RECONCILER_ESTIMATE; protected readonly estimate = RECONCILER_ESTIMATE;
protected readonly mediaStatsNotReported = ROOM_MEDIA_STATS_NOT_REPORTED;
protected readonly now = injectTicker(); protected readonly now = injectTicker();
protected readonly tz = this.query.tz; protected readonly tz = this.query.tz;
protected readonly limit = this.query.limit; protected readonly limit = this.query.limit;

View File

@ -104,3 +104,13 @@ export const CONNECTION_TYPE_LABELS: Readonly<Record<ConnectionType, string>> =
/** The one explanation of a value estimated by the reconciler. */ /** The one explanation of a value estimated by the reconciler. */
export const RECONCILER_ESTIMATE = 'Closed by the reconciler: the end time is estimated'; export const RECONCILER_ESTIMATE = 'Closed by the reconciler: the end time is estimated';
/** Bytes and quality of a room whose server does not report media statistics. */
export const ROOM_MEDIA_STATS_NOT_REPORTED = "Not reported: this room's server does not report media statistics";
/** Texts of a range whose servers report no media statistics (`none`) or only some do (`partial`). */
export const RANGE_MEDIA_STATS = {
notReported: 'Not reported by the servers of this range',
notice: 'The servers of this range do not report media statistics',
partial: 'Only from the servers that report media statistics'
} as const;

View File

@ -26,25 +26,26 @@ pnpm generate --uri "$MONGO_URI" --db ov_data_m --scenario M --days 7 --drop
pnpm generate --uri "$MONGO_URI" --db ov_data_l --scenario L --days 1 --drop --stats-mode track pnpm generate --uri "$MONGO_URI" --db ov_data_l --scenario L --days 1 --drop --stats-mode track
``` ```
| Option | Default | Meaning | | Option | Default | Meaning |
| ----------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------- | | ----------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `--uri`, `--db` | required | Connection string and target database | | `--uri`, `--db` | required | Connection string and target database |
| `--scenario S\|M\|L` | required | Rooms per day, sessions per day and average room size (table below) | | `--scenario S\|M\|L` | required | Rooms per day, sessions per day and average room size (table below) |
| `--days <n>` | required | Days of history ending now | | `--days <n>` | required | Days of history ending now |
| `--stats-mode participant\|track` | `participant` | One `track_stats` document per participant and direction, or per track | | `--stats-mode participant\|track` | `participant` | One `track_stats` document per participant and direction, or per track |
| `--seed <n>` | `1` | Seed of the pseudo-random generator (mulberry32); same seed, same data | | `--seed <n>` | `1` | Seed of the pseudo-random generator (mulberry32); same seed, same data |
| `--retention-events-days <n>` | `7` | Only raw events inside this window are written (TTL index on `ts`) | | `--retention-events-days <n>` | `7` | Only raw events inside this window are written (TTL index on `ts`) |
| `--retention-stats-days <n>` | `7` | Only stats inside this window are written (`expireAfterSeconds`) | | `--retention-stats-days <n>` | `7` | Only stats inside this window are written (`expireAfterSeconds`) |
| `--retention-tracks-days <n>` | `7` | `expiresAt` of tracks and subscriptions; expired ones are not written | | `--retention-tracks-days <n>` | `7` | `expiresAt` of tracks and subscriptions; expired ones are not written |
| `--retention-entities-days <n>` | `90` | `expiresAt = closedAt + retention` on closed entities | | `--retention-entities-days <n>` | `90` | `expiresAt = closedAt + retention` on closed entities |
| `--retention-rollups-5min-days <n>` | `7` | Only 5-minute rollups inside this window are written (TTL index on `bucket`) | | `--retention-rollups-5min-days <n>` | `7` | Only 5-minute rollups inside this window are written (TTL index on `bucket`) |
| `--drop` | off | Drop the database first | | `--drop` | off | Drop the database first |
| `--plain-stats` | off | Force the plain-collection fallback for `track_stats` | | `--plain-stats` | off | Force the plain-collection fallback for `track_stats` |
| `--five-minute-rollups` | off | Write `analytics_5min` inside the 5-minute rollup window | | `--five-minute-rollups` | off | Write `analytics_5min` inside the 5-minute rollup window |
| `--node-rollups` | off | Write the `node:<nodeId>` scope beside `global` in every rollup | | `--node-rollups` | off | Write the `node:<nodeId>` scope beside `global` in every rollup |
| `--room-sums` | off | Write `rooms.participantsSum` and `rooms.durationMsSum` at room end | | `--room-sums` | off | Write `rooms.participantsSum` and `rooms.durationMsSum` at room end |
| `--active-window <minutes>` | off | Rooms created in the last minutes stay active (two of three); without it, the most recent 10 % of the rooms are active | | `--active-window <minutes>` | off | Rooms created in the last minutes stay active (two of three); without it, the most recent 10 % of the rooms are active |
| `--min-active-rooms <n>` | 0 | With `--active-window`: rooms added to the window when the hour of day leaves fewer than `n` active rooms | | `--min-active-rooms <n>` | 0 | With `--active-window`: rooms added to the window when the hour of day leaves fewer than `n` active rooms |
| `--nodes-without-media-stats <n>` | 0 | The last `n` of the three nodes report no media statistics: `mediaStats: false`, no media `track_stats`, bytes or quality |
| Scenario | Rooms per day | Sessions per day | Average room size | | Scenario | Rooms per day | Sessions per day | Average room size |
| -------- | ------------- | ---------------- | ----------------- | | -------- | ------------- | ---------------- | ----------------- |

View File

@ -508,6 +508,10 @@ function ingressInfo(i: IngressDoc): Record<string, unknown> {
} }
export function deriveStats(rng: Rng, bundle: RoomBundle, windows: DeriveWindows, emit: (doc: StatsDoc) => void): void { export function deriveStats(rng: Rng, bundle: RoomBundle, windows: DeriveWindows, emit: (doc: StatsDoc) => void): void {
if (bundle.room.mediaStats === false) {
return;
}
const tracksById = new Map(bundle.tracks.map((t) => [t._id, t])); const tracksById = new Map(bundle.tracks.map((t) => [t._id, t]));
const tracksByParticipant = groupBy(bundle.tracks, (t) => t.participantSid); const tracksByParticipant = groupBy(bundle.tracks, (t) => t.participantSid);
const subsBySubscriber = groupBy( const subsBySubscriber = groupBy(
@ -920,7 +924,8 @@ export class Rollups {
const end = p.leftAt?.getTime() ?? windows.now; const end = p.leftAt?.getTime() ?? windows.now;
const total = Math.max(1, end - start); const total = Math.max(1, end - start);
const meanScore = bundle.meanScores.get(p._id) ?? 4; const meanScore = bundle.meanScores.get(p._id) ?? 4;
const directions = (p.tracks.published > 0 ? 1 : 0) + (p.tracks.subscribed > 0 ? 1 : 0); const directions =
p.mediaStats === false ? 0 : (p.tracks.published > 0 ? 1 : 0) + (p.tracks.subscribed > 0 ? 1 : 0);
const spread = (slot: number, size: number, apply: (share: number, flushes: number) => void) => { const spread = (slot: number, size: number, apply: (share: number, flushes: number) => void) => {
const overlap = Math.min(end, slot + size) - Math.max(start, slot); const overlap = Math.min(end, slot + size) - Math.max(start, slot);

View File

@ -43,7 +43,8 @@ async function main(): Promise<void> {
'node-rollups': { type: 'boolean' }, 'node-rollups': { type: 'boolean' },
'room-sums': { type: 'boolean' }, 'room-sums': { type: 'boolean' },
'active-window': { type: 'string' }, 'active-window': { type: 'string' },
'min-active-rooms': { type: 'string' } 'min-active-rooms': { type: 'string' },
'nodes-without-media-stats': { type: 'string' }
}); });
const uri = requireString(values, 'uri'); const uri = requireString(values, 'uri');
const dbName = requireString(values, 'db'); const dbName = requireString(values, 'db');
@ -82,6 +83,7 @@ async function main(): Promise<void> {
...(values['min-active-rooms'] === undefined ...(values['min-active-rooms'] === undefined
? {} ? {}
: { minActiveRooms: intOption(values, 'min-active-rooms', 0) }), : { minActiveRooms: intOption(values, 'min-active-rooms', 0) }),
nodesWithoutMediaStats: intOption(values, 'nodes-without-media-stats', 0),
log: (line) => { log: (line) => {
console.log(line); console.log(line);
} }

View File

@ -158,6 +158,8 @@ export interface GenerateOptions {
activeWindowMinutes?: number; activeWindowMinutes?: number;
/** Active rooms the active window holds at least, whatever the hour; extra rooms are added to it when needed. */ /** Active rooms the active window holds at least, whatever the hour; extra rooms are added to it when needed. */
minActiveRooms?: number; minActiveRooms?: number;
/** Generated nodes, the last ones, that report no media statistics; none by default. */
nodesWithoutMediaStats?: number;
log?: (line: string) => void; log?: (line: string) => void;
} }
@ -183,6 +185,12 @@ export async function generate(db: Db, options: GenerateOptions): Promise<Genera
throw new Error('minActiveRooms requires activeWindowMinutes'); throw new Error('minActiveRooms requires activeWindowMinutes');
} }
const nodesWithoutMediaStats = options.nodesWithoutMediaStats ?? 0;
if (nodesWithoutMediaStats > NODE_COUNT) {
throw new Error(`nodesWithoutMediaStats must be at most ${String(NODE_COUNT)}`);
}
const retention = { const retention = {
events: retentionDays.events * 86_400, events: retentionDays.events * 86_400,
stats: retentionDays.stats * 86_400, stats: retentionDays.stats * 86_400,
@ -215,7 +223,8 @@ export async function generate(db: Db, options: GenerateOptions): Promise<Genera
now, now,
nodeIds, nodeIds,
retentionEntitiesMs: retentionDays.entities * DAY, retentionEntitiesMs: retentionDays.entities * DAY,
retentionTracksMs: retentionDays.tracks * DAY retentionTracksMs: retentionDays.tracks * DAY,
withoutMediaStats: new Set(nodeIds.slice(NODE_COUNT - nodesWithoutMediaStats))
}; };
const windows: DeriveWindows = { const windows: DeriveWindows = {
now, now,
@ -302,7 +311,8 @@ export async function generate(db: Db, options: GenerateOptions): Promise<Genera
lastSeenAt: new Date(now), lastSeenAt: new Date(now),
serverVersion: '3.10.0', serverVersion: '3.10.0',
region: ['eu-west-1', 'eu-west-1', 'us-east-1'][i], region: ['eu-west-1', 'eu-west-1', 'us-east-1'][i],
batchIntervalMs: BATCH_INTERVAL_MS batchIntervalMs: BATCH_INTERVAL_MS,
mediaStats: !ctx.withoutMediaStats.has(nodeId)
}); });
} }

View File

@ -56,6 +56,8 @@ export interface RoomDoc {
features: { egress: boolean; ingress: boolean; agent: boolean; sip: boolean }; features: { egress: boolean; ingress: boolean; agent: boolean; sip: boolean };
lastEventAt: Date; lastEventAt: Date;
lastStatsAt: Date | null; lastStatsAt: Date | null;
/** Only `false`, on the rooms of a node without media statistics. */
mediaStats?: false;
schemaVersion: number; schemaVersion: number;
expiresAt: Date | null; expiresAt: Date | null;
} }
@ -102,6 +104,8 @@ export interface ParticipantDoc {
subscribed: number; subscribed: number;
subscribeFailures: number; subscribeFailures: number;
}; };
/** Only `false`, on the sessions of a node without media statistics. */
mediaStats?: false;
bytes: { up: Long; down: Long }; bytes: { up: Long; down: Long };
quality: { quality: {
scoreSum: number; scoreSum: number;
@ -284,6 +288,8 @@ export interface GeneratorContext {
retentionEntitiesMs: number; retentionEntitiesMs: number;
/** Retention of tracks and subscriptions (`retention.tracks`). */ /** Retention of tracks and subscriptions (`retention.tracks`). */
retentionTracksMs: number; retentionTracksMs: number;
/** Nodes that report no media statistics: their sessions carry no bytes, quality or `track_stats`. */
withoutMediaStats: ReadonlySet<string>;
} }
export interface RoomPlan { export interface RoomPlan {
@ -578,7 +584,10 @@ export function generateRoom(ctx: GeneratorContext, plan: RoomPlan): RoomBundle
} }
const lastEventAt = Math.max(endedAt ?? 0, ...sessions.map((s) => s.leftAt ?? s.doc.joinedAt.getTime())); const lastEventAt = Math.max(endedAt ?? 0, ...sessions.map((s) => s.leftAt ?? s.doc.joinedAt.getTime()));
const lastStatsAt = scoreCount > 0 ? Math.min(now, Math.max(...sessions.map((s) => s.leftAt ?? now))) : null; const withoutMediaStats = ctx.withoutMediaStats.has(nodeId);
// Signal statistics keep flowing on a node without media statistics.
const flushed = scoreCount > 0 || (withoutMediaStats && sessions.some((s) => s.doc.status !== 'aborted'));
const lastStatsAt = flushed ? Math.min(now, Math.max(...sessions.map((s) => s.leftAt ?? now))) : null;
const room: RoomDoc = { const room: RoomDoc = {
_id: roomSid, _id: roomSid,
name, name,
@ -613,6 +622,7 @@ export function generateRoom(ctx: GeneratorContext, plan: RoomPlan): RoomBundle
features: { egress: hasEgress, ingress: hasIngress, agent: hasAgent, sip: hasSip }, features: { egress: hasEgress, ingress: hasIngress, agent: hasAgent, sip: hasSip },
lastEventAt: new Date(lastEventAt), lastEventAt: new Date(lastEventAt),
lastStatsAt: lastStatsAt === null ? null : new Date(lastStatsAt), lastStatsAt: lastStatsAt === null ? null : new Date(lastStatsAt),
...(withoutMediaStats ? { mediaStats: false as const } : {}),
schemaVersion: SCHEMA_VERSION, schemaVersion: SCHEMA_VERSION,
expiresAt: expiresAt(ctx, endedAt) expiresAt: expiresAt(ctx, endedAt)
}; };
@ -935,7 +945,10 @@ function publishTracks(
codecs: [{ mimeType, layers }], codecs: [{ mimeType, layers }],
encryption: 'NONE', encryption: 'NONE',
subscribers: { current: 0, total: 0, failed: 0 }, subscribers: { current: 0, total: 0, failed: 0 },
publishStats: unpublishedAt === null ? null : rtpStats(rng, bitrate, durationMs, layers.length), publishStats:
unpublishedAt === null || ctx.withoutMediaStats.has(session.doc.nodeId)
? null
: rtpStats(rng, bitrate, durationMs, layers.length),
schemaVersion: SCHEMA_VERSION, schemaVersion: SCHEMA_VERSION,
expiresAt: tracksExpiresAt(ctx, unpublishedAt) expiresAt: tracksExpiresAt(ctx, unpublishedAt)
}; };
@ -1074,7 +1087,7 @@ function buildSubscriptions(ctx: GeneratorContext, roomSid: string, sessions: Se
durationMs: failed ? null : durationMs, durationMs: failed ? null : durationMs,
maxVideoQuality, maxVideoQuality,
subscribeStats: subscribeStats:
failed || durationMs === null failed || durationMs === null || ctx.withoutMediaStats.has(publisher.doc.nodeId)
? null ? null
: withoutLayers(rtpStats(rng, bitrate, durationMs, video ? 1 : 0)), : withoutLayers(rtpStats(rng, bitrate, durationMs, video ? 1 : 0)),
schemaVersion: SCHEMA_VERSION, schemaVersion: SCHEMA_VERSION,
@ -1101,6 +1114,23 @@ function finalizeSessions(ctx: GeneratorContext, sessions: Session[]): void {
const { rng, now } = ctx; const { rng, now } = ctx;
for (const s of sessions) { for (const s of sessions) {
if (ctx.withoutMediaStats.has(s.doc.nodeId)) {
s.upBytes = 0;
s.downBytes = 0;
s.doc.mediaStats = false;
s.doc.bytes = { up: int64(0), down: int64(0) };
s.doc.quality = {
scoreSum: 0,
scoreCount: 0,
minScore: null,
poor: 0,
lost: 0,
lastScore: null,
lastScoreAt: null
};
continue;
}
if (s.doc.status === 'aborted') { if (s.doc.status === 'aborted') {
continue; continue;
} }

View File

@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Shared parts of the local demos, sourced by dashboard-ce/tools/demo.sh and by the demos of workspaces that extend # Shared parts of the local demos, sourced by dashboard-ce/tools/demo.sh and by the demos of workspaces that extend
# these packages: a MongoDB replica set (a container, or DASHBOARD_DEMO_MONGO_URI), 30 days of generated data kept live, # these packages: a MongoDB replica set (a container, or DASHBOARD_DEMO_MONGO_URI), 30 days of generated data kept live,
# and the backend and frontend development servers of dev-lib.sh. The caller sets DATABASE and STATS_MODE and runs # and the backend and frontend development servers of dev-lib.sh. The caller sets DATABASE and STATS_MODE, and
# from the root of its workspace. # optionally the array GENERATOR_ARGS of extra data generator options, and runs from the root of its workspace.
# DASHBOARD_DEMO_MONGO_URI MongoDB to use instead of the container # DASHBOARD_DEMO_MONGO_URI MongoDB to use instead of the container
# DASHBOARD_DEMO_MONGO_PORT host port of the container (default 27018) # DASHBOARD_DEMO_MONGO_PORT host port of the container (default 27018)
# DASHBOARD_DEMO_KEEP_DATA true reuses the demo database instead of generating it again # DASHBOARD_DEMO_KEEP_DATA true reuses the demo database instead of generating it again
@ -54,7 +54,7 @@ prepare_data() {
echo "Generating $DATABASE (scenario S, 30 days, $STATS_MODE stats)" echo "Generating $DATABASE (scenario S, 30 days, $STATS_MODE stats)"
pnpm --silent --dir "$TOOLS_DIR/data-generator" run generate --uri "$MONGO_URI" --db "$DATABASE" \ pnpm --silent --dir "$TOOLS_DIR/data-generator" run generate --uri "$MONGO_URI" --db "$DATABASE" \
--scenario S --days 30 --stats-mode "$STATS_MODE" --drop --five-minute-rollups --node-rollups --room-sums \ --scenario S --days 30 --stats-mode "$STATS_MODE" --drop --five-minute-rollups --node-rollups --room-sums \
--active-window 60 --min-active-rooms 10 | tail -2 --active-window 60 --min-active-rooms 10 ${GENERATOR_ARGS[@]+"${GENERATOR_ARGS[@]}"} | tail -2
fi fi
pnpm --silent --dir "$TOOLS_DIR/data-generator" run keep-alive --uri "$MONGO_URI" --db "$DATABASE" & pnpm --silent --dir "$TOOLS_DIR/data-generator" run keep-alive --uri "$MONGO_URI" --db "$DATABASE" &

View File

@ -8,7 +8,8 @@ export const nodes: NodeDocument[] = [
lastSeenAt: at('10:35:10'), lastSeenAt: at('10:35:10'),
serverVersion: SERVER_VERSION, serverVersion: SERVER_VERSION,
region: REGION, region: REGION,
batchIntervalMs: 10_000 batchIntervalMs: 10_000,
mediaStats: true
}, },
{ {
_id: NODE_BETA, _id: NODE_BETA,
@ -16,7 +17,8 @@ export const nodes: NodeDocument[] = [
lastSeenAt: at('10:35:10'), lastSeenAt: at('10:35:10'),
serverVersion: SERVER_VERSION, serverVersion: SERVER_VERSION,
region: REGION, region: REGION,
batchIntervalMs: 10_000 batchIntervalMs: 10_000,
mediaStats: true
} }
]; ];

View File

@ -58,6 +58,9 @@ const entityBase = {
}; };
const internal = anyObject('Writer bookkeeping; never read by the dashboard'); const internal = anyObject('Writer bookkeeping; never read by the dashboard');
const mediaStatsMark = bool(
'Only false: the node that created the entity does not report media statistics; absent otherwise'
);
const closedBy = oneOfValues(ClosedBy); const closedBy = oneOfValues(ClosedBy);
@ -121,6 +124,7 @@ const rooms = obj({
features: req(obj({ egress: req(bool()), ingress: req(bool()), agent: req(bool()), sip: req(bool()) })), features: req(obj({ egress: req(bool()), ingress: req(bool()), agent: req(bool()), sip: req(bool()) })),
lastEventAt: req(date()), lastEventAt: req(date()),
lastStatsAt: date(), lastStatsAt: date(),
mediaStats: mediaStatsMark,
internal, internal,
...entityBase ...entityBase
}); });
@ -191,6 +195,7 @@ const participants = obj({
), ),
lastEventAt: req(date()), lastEventAt: req(date()),
lastStatsAt: date(), lastStatsAt: date(),
mediaStats: mediaStatsMark,
internal, internal,
...entityBase ...entityBase
}); });
@ -479,7 +484,10 @@ const nodes = obj({
lastSeenAt: req(date()), lastSeenAt: req(date()),
serverVersion: req(str()), serverVersion: req(str()),
region: str(), region: str(),
batchIntervalMs: { ...int('Configured batch interval of the node, in milliseconds'), minimum: 1000 } batchIntervalMs: { ...int('Configured batch interval of the node, in milliseconds'), minimum: 1000 },
mediaStats: bool(
'Whether the node reports media statistics; absent on documents written before it existed (read as true)'
)
}); });
const metaId = (id: string): BsonSchema => ({ bsonType: 'string', enum: [id] }); const metaId = (id: string): BsonSchema => ({ bsonType: 'string', enum: [id] });

View File

@ -5,7 +5,8 @@
"lastSeenAt": { "$date": "2026-10-12T10:35:10.000Z" }, "lastSeenAt": { "$date": "2026-10-12T10:35:10.000Z" },
"serverVersion": "3.10.0", "serverVersion": "3.10.0",
"region": "eu-west", "region": "eu-west",
"batchIntervalMs": { "$numberInt": "10000" } "batchIntervalMs": { "$numberInt": "10000" },
"mediaStats": true
}, },
{ {
"_id": "ND_beta3Vx9Pw", "_id": "ND_beta3Vx9Pw",
@ -13,6 +14,7 @@
"lastSeenAt": { "$date": "2026-10-12T10:35:10.000Z" }, "lastSeenAt": { "$date": "2026-10-12T10:35:10.000Z" },
"serverVersion": "3.10.0", "serverVersion": "3.10.0",
"region": "eu-west", "region": "eu-west",
"batchIntervalMs": { "$numberInt": "10000" } "batchIntervalMs": { "$numberInt": "10000" },
"mediaStats": true
} }
] ]

View File

@ -11,6 +11,10 @@
"bsonType": ["int", "long"], "bsonType": ["int", "long"],
"description": "Configured batch interval of the node, in milliseconds", "description": "Configured batch interval of the node, in milliseconds",
"minimum": 1000 "minimum": 1000
},
"mediaStats": {
"bsonType": "bool",
"description": "Whether the node reports media statistics; absent on documents written before it existed (read as true)"
} }
}, },
"additionalProperties": false "additionalProperties": false

View File

@ -182,6 +182,10 @@
}, },
"lastEventAt": { "bsonType": "date" }, "lastEventAt": { "bsonType": "date" },
"lastStatsAt": { "bsonType": "date" }, "lastStatsAt": { "bsonType": "date" },
"mediaStats": {
"bsonType": "bool",
"description": "Only false: the node that created the entity does not report media statistics; absent otherwise"
},
"internal": { "bsonType": "object", "description": "Writer bookkeeping; never read by the dashboard" }, "internal": { "bsonType": "object", "description": "Writer bookkeeping; never read by the dashboard" },
"schemaVersion": { "schemaVersion": {
"bsonType": ["int", "long"], "bsonType": ["int", "long"],

View File

@ -154,6 +154,10 @@
}, },
"lastEventAt": { "bsonType": "date" }, "lastEventAt": { "bsonType": "date" },
"lastStatsAt": { "bsonType": "date" }, "lastStatsAt": { "bsonType": "date" },
"mediaStats": {
"bsonType": "bool",
"description": "Only false: the node that created the entity does not report media statistics; absent otherwise"
},
"internal": { "bsonType": "object", "description": "Writer bookkeeping; never read by the dashboard" }, "internal": { "bsonType": "object", "description": "Writer bookkeeping; never read by the dashboard" },
"schemaVersion": { "schemaVersion": {
"bsonType": ["int", "long"], "bsonType": ["int", "long"],

View File

@ -236,8 +236,13 @@ export interface AnalyticsSummary {
apiCalls: number; apiCalls: number;
/** Percentage, one decimal. */ /** Percentage, one decimal. */
apiErrorRate?: number; apiErrorRate?: number;
/** Present when some (`partial`) or all (`none`) of the nodes seen in the range do not report media statistics. */
mediaStats?: AnalyticsMediaStatsCoverage;
} }
/** How much of a range's media statistics (bytes, quality) its nodes report; absent means all of it. */
export type AnalyticsMediaStatsCoverage = 'partial' | 'none';
export type AnalyticsExportKind = 'series' | 'breakdowns'; export type AnalyticsExportKind = 'series' | 'breakdowns';
export type AnalyticsExportQuery = export type AnalyticsExportQuery =

View File

@ -49,6 +49,8 @@ export interface RoomListItem {
lastStatsAt?: string; lastStatsAt?: string;
/** `true` for an active entity whose last stats flush is older than the stale window; absent otherwise. */ /** `true` for an active entity whose last stats flush is older than the stale window; absent otherwise. */
stale?: true; stale?: true;
/** `false` when the room's node does not report media statistics: `bytes` then measures nothing. */
mediaStats?: false;
} }
/** Numbers above the rooms table. */ /** Numbers above the rooms table. */

View File

@ -95,6 +95,8 @@ export interface RoomDocument extends EntityBase {
features: RoomFeatures; features: RoomFeatures;
lastEventAt: Date; lastEventAt: Date;
lastStatsAt?: Date; lastStatsAt?: Date;
/** `false` when the node that created it does not report media statistics; absent otherwise. */
mediaStats?: false;
internal?: WriterInternal; internal?: WriterInternal;
} }
@ -167,6 +169,8 @@ export interface ParticipantDocument extends EntityBase {
quality: ParticipantQuality; quality: ParticipantQuality;
lastEventAt: Date; lastEventAt: Date;
lastStatsAt?: Date; lastStatsAt?: Date;
/** `false` when the node that created it does not report media statistics; absent otherwise. */
mediaStats?: false;
internal?: WriterInternal; internal?: WriterInternal;
} }

View File

@ -9,6 +9,8 @@ export interface NodeDocument {
region?: string; region?: string;
/** Configured batch interval of the node; absent on documents written before it existed (read as 10 s). */ /** Configured batch interval of the node; absent on documents written before it existed (read as 10 s). */
batchIntervalMs?: number; batchIntervalMs?: number;
/** Whether the node reports media statistics; absent on documents written before it existed (read as true). */
mediaStats?: boolean;
} }
/** `schema_meta` `_id: "schema"`: contract version implemented by the writer. */ /** `schema_meta` `_id: "schema"`: contract version implemented by the writer. */

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-02

View File

@ -0,0 +1,123 @@
# Design
## Context
- Telemetry stats reach the writer from two sources: per-track media stats, built from the RTP buffers of the server's
media path, and the signal (`TR_SG…`) and data (`TR_DT…`) pseudo-tracks, built from WebSocket and data channel byte
counters. A media engine that does not move RTP through those buffers produces only the second kind.
- `projection/stats.go` adds every stat to the participant (bytes, `lastStatsAt`) and skips pseudo-tracks before the
room delta, so the room gets `lastStatsAt`, bytes and quality only from media stats.
- `nodes` documents are written by `store.Heartbeat` on every batch tick and expire 30 days after `lastSeenAt`. Node ids
are generated per process, so a restarted node writes a new document and the old one expires on its own.
- Rooms take `nodeId` from `ROOM_CREATED` (and from the first event of any kind while it is unknown); participant
sessions take it from their first event. Entities are kept for `retention.entities` (90 days by default).
- `analytics.Node{ID, Region}` is built in `openvidu/openvidu.go`; the projection options in `analytics.go`. A server
edition adjusts analytics defaults through `openviduconfig` package variables before the analytics start
(`DefaultStatsDetail`).
## Goals / Non-Goals
**Goals:**
- Every view that shows bytes or quality can tell "not reported" apart from "zero", for the whole entity retention.
- The CE code and specs stay engine-agnostic: the server states a capability, not an engine name.
**Non-Goals:**
- Hiding views or features: only the values and charts that depend on media statistics change.
- Marking track or subscription documents: views reach them through their participant.
## Decisions
### D1. A capability flag set by the server
`openviduconfig.MediaStats` (default `true`) says whether this server's media engine reports media statistics. A server
whose engine does not sets it to `false` before the analytics start, like `DefaultStatsDetail`. `openvidu.Start` passes
it as `analytics.Node.MediaStats`. There is no configuration key: the value is a fact of the build and the engine, not
an operator choice.
Rejected: the engine name in the data (`rtcEngine: "…"`). It ties the CE contract to an edition's engines and must be
reinterpreted when an engine gains statistics; the capability flips by itself.
### D2. Mark both the node and the entities
The heartbeat `$set`s `mediaStats` (true or false) on every tick. Rooms and participant sessions created by a node with
`MediaStats: false` get `mediaStats: false`; others get no field. Views of rooms and sessions read the entity, so the
mark lives as long as the entity; analytics read the nodes, since rollups have no per-engine dimension.
Rejected: nodes only, with the dashboard joining `nodeId`. Node documents expire 30 days after their last heartbeat and
every restart creates a new node id, so rooms between 30 and 90 days old would fall back to "0 B".
Rejected: entities only. A range or scope of the Analytics page has no entity to read the mark from.
### D3. Only `false` is stored on entities
Absent means "reports media statistics", which is also what every document written before the change says. No
backfill, no extra bytes on the common case, and the API omits the property unless it is `false`.
### D4. The mark follows `nodeId`
The mark is computed where `nodeId` is: `ROOM_CREATED` sets `nodeId` and sets `mediaStats: false` or removes it
(`$$REMOVE`); the touch of other events sets `mediaStats` only when the document has no `nodeId` yet, in the same stage,
so the first writer of the room decides until `ROOM_CREATED` arrives. Participants follow their own `nodeId` rule. The
legacy migration replays with `MediaStats: true`: the engine of legacy nodes is unknown.
### D5. Pseudo-track stats advance the room's `lastStatsAt`
A pseudo-track stat creates the room delta with `statsAt` and zero bytes and quality, exactly as a media stat of zero
bytes would. Every connected participant flushes signal stats every 30 s, so the room's `lastStatsAt` becomes a
liveness signal under any engine: the room stale flag, the stale hints of the overview and the reconciler's
empty-listing guard (`Untrusted live listing`) cover these rooms. With media stats nothing changes, since the room
already gets the same flush time from them.
### D6. Analytics coverage from heartbeat spans, on the summary only
The summary reads `nodes` documents whose span overlaps the range (`firstSeenAt <= to`, `lastSeenAt >= from`, plus
`_id` for a node scope) with a `mediaStats` projection and folds them into `none`, `partial` or nothing. The page
applies the summary's coverage to the Data up / down tile and the Bandwidth section, and exposes it to the cards of the
page's extension slots as `AnalyticsPageContext.mediaStats` (a signal of `'partial' | 'none' | undefined`), so the
series and breakdown responses do not change.
Rejected: a coverage field on every series response (the page already holds the summary of the same range and scope).
Rejected: inferring coverage from rollup bytes at 0 (a quiet range reads the same).
### D7. Values stay as stored
The rooms list keeps `bytes` next to `mediaStats: false`, and the summary keeps `bytesUp` and `bytesDown`; only the UI
replaces them. API clients see the same shapes plus one optional property.
### D8. A generator option for nodes without media statistics
`--nodes-without-media-stats <n>` (default 0) marks the last `n` generated nodes: their node documents carry
`mediaStats: false`, their rooms and sessions are stamped and get no media `track_stats`, room bytes or quality, and their
rollup contributions lose bytes and quality. The other nodes get `mediaStats: true`. The keep-alive preserves the flag.
## Data model
- `nodes.mediaStats`: bool, written on every heartbeat. `rooms.mediaStats` and `participants.mediaStats`: bool, only
`false`, set by the projections. Contract schemas of the three collections gain the optional property; the dashboard
mirror and its typings follow.
- No index, retention or schema version change (the v2 schema has not shipped). No mapping from legacy events (D4).
## API
- `RoomListItem.mediaStats?: false` (`GET /api/v1/rooms`).
- `AnalyticsSummary.mediaStats?: "partial" | "none"` (`GET /api/v1/analytics/summary`).
- The coverage query runs on `nodes`, backed by the `lastSeenAt` TTL index; the collection holds one document per node
process of the last 30 days.
## Performance budget
One extra query per summary on a collection of tens of documents: under 5 ms, verified by an integration test with an
`explain()` plan (`IXSCAN` on `lastSeenAt`) next to the existing analytics plan tests. The summary p95 budget (150 ms at
scenario L) is unchanged. The pseudo-track room delta adds at most one room update per room and batch, and only for
rooms whose flush carries no media stat; the writer benchmark of the batch path checks it stays within its budget.
## Risks / Trade-offs
- [A room touched first by an egress or ingress event of another node] → it takes that node's mark until its
`ROOM_CREATED` is applied, which then sets the host's mark (D4).
- [Coverage misses nodes whose documents expired] → ranges older than 30 days may read as fully reported; the entity
marks keep rooms and sessions right.
- [A pseudo-track-only room delta on a room not written yet] → the same upsert as a media stat delta today.
## Migration Plan
Ships with the v2 writer and dashboard. Rollback: dashboards ignore the property and writers without it never write it.

View File

@ -0,0 +1,60 @@
# Proposal
## Why
A server node whose media engine does not hand RTP statistics to the telemetry sends only the signal and data
pseudo-track stats: no `track_stats`, room and rollup bytes at 0, no quality samples and no final publish or subscribe
statistics. The dashboard cannot tell that apart from a room where nothing was sent, so it shows "↑ 0 B ↓ 0 B",
empty charts and "No stats received" as if they were measurements. The same gap leaves `rooms.lastStatsAt` unset, so
the stale cue of a room never fires and the reconciler's empty-listing guard does not see those rooms.
## What Changes
- The server tells the writer whether the node reports media statistics (true unless the server says otherwise).
- The writer publishes it on the node heartbeat (`nodes.mediaStats`) and stamps `mediaStats: false` on the rooms and
participant sessions created on a node that does not report them, so the mark outlives the node document.
- Signal and data pseudo-track stats also advance the room's `lastStatsAt`, as the stats delta requirement already
states for every flush.
- The rooms list returns `mediaStats: false` for those rooms, and the Rooms table shows a dash with a tooltip instead
of "↑ 0 B ↓ 0 B".
- The analytics summary returns `mediaStats: "partial" | "none"` when some or all nodes seen in the range do not
report media statistics. The Data up / down tile and the Bandwidth section say the data is not reported (`none`) or
covers only some servers (`partial`).
Scope excludes the following:
- Producing media statistics on such a node (a server concern).
- Views outside the rooms list and the Analytics page: they read the same marks, through the seam the Analytics page
exposes to its extension cards (`AnalyticsPageContext.mediaStats`) and the entity documents.
- Backfilling the mark on documents written before the change.
Edition: CE and PRO (writer and the CE views every edition shows). Repositories: openvidu-livekit
(`openvidu/analytics`, `openvidu/openviduconfig`, `openvidu/openvidu.go`) and the dashboard (typings, backend, frontend,
data generator, specs).
CHANGELOG lines: none. The v2 data model, its writer and these views have not shipped in a release, and a CE server
always reports media statistics.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `server-analytics-writer`: the node heartbeat carries `mediaStats`, rooms and sessions of such a node are stamped,
and pseudo-track stats advance the room's `lastStatsAt`.
- `data-model`: `mediaStats` on `nodes`, `rooms` and `participants`.
- `rooms-api`: the room list item carries `mediaStats: false`.
- `rooms-ui`: the Data up / down cell of such a room reads as not reported.
- `analytics-api`: the summary carries the media statistics coverage of its range and scope.
- `analytics-ui`: the Data up / down tile and the Bandwidth section follow that coverage.
## Impact
- Writer: a `MediaStats` setting on `openviduconfig`, passed through `analytics.Node` to the heartbeat and to the
projection options; room and participant projections; the stats projection; the contract schemas of `nodes`,
`rooms` and `participants`. Unit and integration tests in `openvidu/analytics`.
- Dashboard: typings and their schema mirror, rooms mapper, analytics summary (one `nodes` query per summary), Rooms
table and Analytics page, the data generator (an option for nodes without media statistics), unit and e2e tests.
- No schema version bump: the v2 schema has not shipped.

View File

@ -0,0 +1,33 @@
## ADDED Requirements
### Requirement: Media statistics coverage
Edition: CE. The summary SHALL carry `mediaStats: "none"` when every `nodes` document of its scope whose heartbeat span
(`firstSeenAt` to `lastSeenAt`) overlaps the range has `mediaStats: false`, `mediaStats: "partial"` when only some do,
and no `mediaStats` property when none does or no node document overlaps the range. The `global` scope covers every
node, a `node:<id>` scope that node only.
#### Scenario: No server reports media statistics
- **GIVEN** two nodes seen during the range, both with `mediaStats: false`
- **WHEN** the summary is requested
- **THEN** the body has `mediaStats: "none"` and still carries `bytesUp` and `bytesDown`
#### Scenario: Some servers report media statistics
- **GIVEN** a node with `mediaStats: false` and a node with `mediaStats: true` seen during the range
- **WHEN** the summary is requested with the `global` scope
- **THEN** the body has `mediaStats: "partial"`
#### Scenario: Node scope
- **GIVEN** the same two nodes and `scope=node:<id>` of the node with `mediaStats: true`
- **WHEN** the summary is requested
- **THEN** the body has no `mediaStats` property
#### Scenario: Node seen outside the range
- **GIVEN** a node with `mediaStats: false` whose `lastSeenAt` is earlier than `from`, and a node with
`mediaStats: true` seen during the range
- **WHEN** the summary is requested
- **THEN** the body has no `mediaStats` property

View File

@ -0,0 +1,27 @@
## ADDED Requirements
### Requirement: Media statistics coverage notes
Edition: CE. With `mediaStats: "none"` in the summary, the Data up / down tile SHALL read "—" with the hint "Not
reported by the servers of this range" and the Bandwidth section SHALL show the notice "The servers of this range do
not report media statistics" instead of its chart and tiles. With `"partial"`, the tile and the section SHALL keep their
values and add the note "Only from the servers that report media statistics".
#### Scenario: No server reports media statistics
- **GIVEN** a summary with `mediaStats: "none"`
- **WHEN** the page renders
- **THEN** the Data up / down tile reads "—" with its hint, and the Bandwidth section shows the notice and no chart
#### Scenario: Some servers report media statistics
- **GIVEN** a summary with `mediaStats: "partial"`, `bytesUp: 1200000000` and `bytesDown: 3400000000`
- **WHEN** the page renders
- **THEN** the tile reads "↑ 1.2 GB" over "↓ 3.4 GB" with the hint "Only from the servers that report media
statistics", and the Bandwidth chart renders with the same note under its heading
#### Scenario: Every server reports media statistics
- **GIVEN** a summary without `mediaStats`
- **WHEN** the page renders
- **THEN** the tile and the Bandwidth section render as before, without the note

View File

@ -0,0 +1,78 @@
## MODIFIED Requirements
### Requirement: Room document
Edition: CE and PRO. A `rooms` document SHALL describe one room session: identity and lifecycle, node,
configuration, participant/track/egress/ingress counters, traffic, quality, feature flags and retention
fields, with the field list of the shared contract (`dashboard-ce/typings/src/data-model`).
`participants.sessions`, `unique`, `active`, `peakActive` and `aborted` SHALL count STANDARD sessions only;
`participants.byKind` SHALL count the sessions of every kind.
#### Scenario: Fields of a room document
- **WHEN** a room document is read
- **THEN** it has `name`, `nameLower`, `status` (`active` | `closed`), `createdAt`, `endedAt`, `durationMs`, `endReason`, `closedBy`, `nodeId`, `config` (max participants, empty and departure timeouts, metadata when allowed), `participants { sessions, unique, active, peakActive, aborted, byKind }`, `tracks`, `egress` (by type), `ingress`, `bytes { up, down }`, `quality { scoreSum, scoreCount, minScore, poor, lost }`, `features { egress, ingress, agent, sip }`, `lastEventAt`, `lastStatsAt`, `mediaStats` (only `false`, when its node does not report media statistics), `schemaVersion`, `expiresAt`
#### Scenario: Room lifecycle
- **GIVEN** `ROOM_CREATED`, three `PARTICIPANT_JOINED`/`ACTIVE`, one `PARTICIPANT_LEFT`, `ROOM_ENDED (API_DELETE)`
- **WHEN** all are processed
- **THEN** the room has `status: "closed"`, `endReason: "API_DELETE"`, `participants.sessions: 3`, `participants.active: 0`, `participants.peakActive: 3`, `durationMs = endedAt - createdAt`, `closedBy: "event"`
#### 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
identity, room, kind, lifecycle status, connection facts, client facts, track and subscription counters,
traffic, quality and retention fields, with the field list of the shared contract.
#### Scenario: Fields of a participant document
- **WHEN** a participant document is read
- **THEN** it has `identity`, `identityLower`, `name`, `roomSid`, `roomName`, `kind`, `kindDetails`, `status` (`joining` | `active` | `left` | `aborted`), `joinedAt`, `activeAt`, `leftAt`, `durationMs`, `disconnectReason`, `closedBy`, `connectTimeMs`, `connectionType`, `region`, `nodeId`, `resumes`, `client { sdk, version, os, osVersion, browser, browserVersion, deviceModel, network, address }`, `permission`, `isPublisher`, `metadata` and `attributes` (when allowed), `tracks { published, bySource, subscribed, subscribeFailures }`, `bytes`, `quality`, `lastEventAt`, `lastStatsAt`, `mediaStats` (only `false`, when its node does not report media statistics), `schemaVersion`, `expiresAt`
#### Scenario: Connection aborted
- **GIVEN** `PARTICIPANT_JOINED` followed by `PARTICIPANT_CONNECTION_ABORTED`
- **WHEN** both are processed
- **THEN** the document has `status: "aborted"`, `activeAt` absent, `leftAt` set and `disconnectReason` from the event
#### Scenario: Reconnect creates a new session
- **GIVEN** a participant that reconnects with the same identity and receives a new SID
- **WHEN** `PARTICIPANT_LEFT (DUPLICATE_IDENTITY)` and the new `PARTICIPANT_JOINED` are processed
- **THEN** two documents exist with the same `identity` and `roomSid`, the first `left` and the second `joining` or `active`
#### Scenario: Resume keeps the session
- **GIVEN** an active participant that resumes its signal connection
- **WHEN** `PARTICIPANT_RESUMED` is processed
- **THEN** the same document stays `active` and `resumes` increases by one
### Requirement: Nodes
Edition: CE and PRO. The `nodes` collection SHALL hold one document per server node with `firstSeenAt`,
`lastSeenAt`, `serverVersion`, `region`, `batchIntervalMs` (int32 milliseconds, at least 1 000: the node's
configured batch interval) and `mediaStats`, updated by every writer batch, and SHALL expire documents 30 days after
their last heartbeat so decommissioned nodes disappear. A document without `batchIntervalMs` SHALL be read as a 10 s
interval, and one without `mediaStats` as `true`.
#### Scenario: Node heartbeat
- **GIVEN** a node writing batches every 10 s
- **WHEN** `nodes` is read
- **THEN** its document has `lastSeenAt` within the last batch interval and `batchIntervalMs: 10000`
#### Scenario: Node document without the interval
- **GIVEN** a `nodes` document without `batchIntervalMs`
- **WHEN** the dashboard derives that node's freshness windows
- **THEN** it uses a 10 000 ms interval
#### Scenario: Interval below the minimum
- **GIVEN** the test configuration
- **WHEN** a `nodes` document with `batchIntervalMs: 500` is written
- **THEN** the write is rejected by the validator
#### Scenario: Node document without media statistics
- **GIVEN** a `nodes` document without `mediaStats`
- **WHEN** the dashboard reads it
- **THEN** it treats the node as reporting media statistics

View File

@ -0,0 +1,44 @@
## MODIFIED Requirements
### Requirement: Room list item
Edition: CE. Each rooms list item SHALL be the room document's public projection: identity, lifecycle, node,
participant, egress and ingress counters, bandwidth, feature flags, the end cause behind the "estimated" cue, the last
stats flush, the media statistics mark and the `stale` flag of `api-conventions`, never internal id arrays,
`schemaVersion` or `expiresAt`.
Participant counters count STANDARD sessions only; `participants.unique` counts distinct STANDARD identities.
#### Scenario: Fields of an item
- **WHEN** an item is read
- **THEN** it has `sid`, `name`, `status` (`active` | `closed`), `createdAt`, `endedAt?`, `durationMs?`, `closedBy?`
(`event` | `reconciler`), `nodeId?`, `participants { sessions, unique, active, peakActive }`,
`egress { total, active }`, `ingress { sessions, active }`, `bytes { up, down }`,
`features { egress, ingress, agent, sip }`, `lastStatsAt?`, `stale?`, `mediaStats?` and no `participantSids`,
`identities` or similar array
#### Scenario: Active room
- **GIVEN** a room still open with three active STANDARD participants and one active EGRESS participant
- **WHEN** it is returned
- **THEN** `status` is `active`, `endedAt`, `durationMs` and `closedBy` are absent and `participants.active` is 3
#### Scenario: Unique identities
- **GIVEN** a closed room where STANDARD identity `alice` had three sessions, `bob` one, and an AGENT session joined
- **WHEN** it is returned
- **THEN** `participants.sessions` is 4 and `participants.unique` is 2
#### Scenario: Stale active room
- **GIVEN** the default writer batch interval of 10 s and an active room whose `lastStatsAt` is 3 minutes old at the
server's clock
- **WHEN** it is returned
- **THEN** it has `stale: true`, and an active room whose `lastStatsAt` is 20 s old has no `stale` property
#### Scenario: Room of a node without media statistics
- **GIVEN** a room document with `mediaStats: false` and `bytes { up: 0, down: 0 }`
- **WHEN** it is returned
- **THEN** it has `mediaStats: false` and `bytes { up: 0, down: 0 }`, and an item of a room without the field has no
`mediaStats` property

View File

@ -0,0 +1,46 @@
## MODIFIED Requirements
### Requirement: Rooms table
Edition: CE. The Rooms table SHALL render, in order, the columns SID (detail link with copy button), Name (copy
button), Status (estimated and stale cues), Started, Ended, Duration (live), Unique identities (`participants.unique`),
Active, Data up / down, Features (badges E, I, A) and Node (hidden by default); the sortable headers are Started
(`createdAt`, the default), Ended (`endedAt`), Duration (`duration`), Unique identities (`uniqueIdentities`) and Active
(`active`).
#### Scenario: Row of an active room
- **GIVEN** an active room `demo-room` / `RM_ab12cd` with 6 unique identities, 7 sessions, 5 active, 12 MB up, 48 MB
down, an egress and an agent
- **WHEN** the row renders
- **THEN** it shows, in order, `RM_ab12cd` once, as a link to `/rooms/RM_ab12cd` with its copy button, `demo-room` with
its copy button, the active badge without a LIVE chip, the start time, a dash for Ended, a live duration, 6, 5,
"↑ 12.0 MB ↓ 48.0 MB" and the E and A badges, under headers that include "Unique identities" and "Data up / down"
#### Scenario: Sort by Ended
- **GIVEN** `status=all` and the default 24 h range
- **WHEN** the user activates the Ended header
- **THEN** the URL gets `sort=endedAt&order=desc` without `cursor`, the list holds the rooms started in the range,
and the active rooms, which have no end yet, come first
#### Scenario: Sort by unique identities
- **GIVEN** the default 24 h range
- **WHEN** the user activates the Unique identities header
- **THEN** the URL gets `sort=uniqueIdentities&order=desc` without `cursor` and the rooms with the most distinct
STANDARD identities come first
#### Scenario: Reconciler-closed room
- **GIVEN** a room with `closedBy: "reconciler"`
- **WHEN** the row renders
- **THEN** the status badge has the dotted outline and the Ended cell carries the tooltip "Closed by the reconciler:
the end time is estimated"
#### Scenario: Room of a node without media statistics
- **GIVEN** a room item with `mediaStats: false`
- **WHEN** the row renders
- **THEN** the Data up / down cell reads "—" with the tooltip "Not reported: this room's server does not report media
statistics" instead of "↑ 0 B ↓ 0 B"

View File

@ -0,0 +1,59 @@
## MODIFIED Requirements
### Requirement: Signal and data pseudo-tracks
Edition: CE and PRO. Stats whose track id starts with `TR_SG` or `TR_DT` SHALL NOT produce `track_stats`
documents; their bytes SHALL be added to the participant's `bytes` for their direction and `lastStatsAt`
SHALL be updated on the participant and on its room, without adding bytes or quality samples to the room.
#### Scenario: Signal-only participant
- **GIVEN** a participant whose flush contains only `TR_SG…` upstream and downstream stats
- **WHEN** it is stored
- **THEN** no `track_stats` document is written, the participant's `bytes.up` and `bytes.down` grow by the sample bytes, and the participant's and the room's `lastStatsAt` equal the flush time while the room's `bytes` and `quality` are unchanged
### Requirement: Node heartbeat
Edition: CE and PRO. On every batch tick, including empty batches, the writer SHALL upsert the node's
`nodes` document with `lastSeenAt`, `serverVersion`, `region`, `batchIntervalMs` (the configured
`openvidu.analytics.interval` in milliseconds) and `mediaStats`, setting `firstSeenAt` on insert.
#### Scenario: Idle node
- **GIVEN** a node with no traffic for ten minutes
- **WHEN** `nodes` is read
- **THEN** its document has `lastSeenAt` within the last batch interval and `firstSeenAt` unchanged
#### Scenario: Configured interval published
- **GIVEN** `openvidu.analytics.interval: 30s`
- **WHEN** a batch tick runs
- **THEN** the node's document has `batchIntervalMs: 30000`
#### Scenario: Interval changed on restart
- **GIVEN** a node document with `batchIntervalMs: 10000`
- **WHEN** the node restarts with `openvidu.analytics.interval: 60s` and runs a batch tick
- **THEN** its document has `batchIntervalMs: 60000` and `firstSeenAt` unchanged
#### Scenario: Node reporting media statistics
- **GIVEN** a server that reports media statistics
- **WHEN** a batch tick runs
- **THEN** the node's document has `mediaStats: true`
## ADDED Requirements
### Requirement: Nodes without media statistics
Edition: CE and PRO. A server whose media engine does not report media statistics SHALL tell the writer so, and the
writer SHALL then publish `mediaStats: false` on its node document and stamp `mediaStats: false` on the rooms and
participant sessions it creates, following their `nodeId`; the rooms and sessions of a node that reports them carry no
`mediaStats` field.
#### Scenario: Room created on such a node
- **GIVEN** a node whose server does not report media statistics
- **WHEN** it writes `ROOM_CREATED` and `PARTICIPANT_JOINED` of a new room
- **THEN** its `nodes` document has `mediaStats: false`, and the room and the participant session both have `nodeId` of that node and `mediaStats: false`
#### Scenario: Room created on a node reporting media statistics
- **GIVEN** a node whose server reports media statistics
- **WHEN** it writes `ROOM_CREATED` and `PARTICIPANT_JOINED`
- **THEN** neither the room nor the participant session has a `mediaStats` field
#### Scenario: Room touched first by another node
- **GIVEN** a room document created by an egress event written by a node reporting media statistics, without `mediaStats`
- **WHEN** a node that does not report them writes its `ROOM_CREATED`
- **THEN** the room has that node's `nodeId` and `mediaStats: false`

View File

@ -0,0 +1,60 @@
# Tasks
## 1. Contract and typings
- [x] 1.1 Add the optional `mediaStats` bool to the `nodes`, `rooms` and `participants` schemas of
`dashboard-ce/typings/contract/`, the document types of `src/data-model/` and the fixtures, then regenerate
`schema/` and `fixtures/`. Verify with the typings contract tests and the typecheck.
- [x] 1.2 Add `mediaStats?: false` to `RoomListItem` and `mediaStats?: 'partial' | 'none'` to `AnalyticsSummary` in
`src/api/`. Verify with the typecheck of every package.
## 2. Writer (openvidu-livekit)
- [x] 2.1 Vendor the contract into `openvidu/analytics/schema/contract/` with `contract/vendor.sh`. Verify with the
writer's contract tests and `./dashboard.sh contract-check`.
- [x] 2.2 Add `openviduconfig.MediaStats` (default true) and `analytics.Node.MediaStats`, set from it in
`openvidu/openvidu.go`, and write `mediaStats` on every heartbeat. Verify with a store integration test of the
heartbeat for both values.
- [x] 2.3 Stamp `mediaStats: false` on rooms and participant sessions created by a node without media statistics,
following `nodeId` (`ROOM_CREATED` sets or removes it; other events only while `nodeId` is unknown), and keep the
legacy migration at `MediaStats: true`. Verify with projection unit tests and writer integration tests of the three
scenarios of "Nodes without media statistics".
- [x] 2.4 Make signal and data pseudo-track stats advance the room's `lastStatsAt` without bytes or quality. Verify with
a projection unit test of the signal-only flush, the writer integration tests and the batch benchmark.
- [x] 2.5 Lint the fork with the Dockerfile Go toolchain and run its unit and integration tests. Verify that both pass.
## 3. Backend
- [x] 3.1 Return `mediaStats: false` on rooms list items of stamped rooms. Verify with a mapper unit test and a rooms
route integration test.
- [x] 3.2 Compute the summary's `mediaStats` coverage from the `nodes` documents whose span overlaps the range, for the
`global` and `node:<id>` scopes, and declare it in the OpenAPI document. Verify with unit tests of the fold, an
integration test of the four "Media statistics coverage" scenarios with an `explain()` assertion on the `nodes`
query, and the OpenAPI test.
## 4. Frontend
- [x] 4.1 Render "—" with the tooltip in the Data up / down cell of stamped rooms in the Rooms table. Verify with a
component test and an e2e test against the mocked API.
- [x] 4.2 Apply the summary coverage to the Data up / down tile and the Bandwidth section (notice for `none`, note for
`partial`), and expose it to the extension cards as `AnalyticsPageContext.mediaStats`. Verify with component tests,
a test of the context signal and an e2e test of the three "Media statistics coverage notes" scenarios.
## 5. Data generator
- [x] 5.1 Add `--nodes-without-media-stats <n>` (default 0) to the data generator and keep the flag in the keep-alive,
let a calling demo pass extra generator options (`GENERATOR_ARGS` of `demo-lib.sh`), and update the README. Verify with a generator test that the marked node's rooms and sessions are stamped and have no
media `track_stats`, room bytes or quality, and that the other nodes report `mediaStats: true`.
## 6. Docs and CHANGELOG
- [x] 6.1 No CHANGELOG line: unreleased, and a CE server always reports media statistics. Update the `AGENTS.md` notes
that list the node heartbeat fields or the generator options, if any. Verify by reading the change against the
CHANGELOG and the `AGENTS.md` files.
## 7. Integration check
- [x] 7.1 Run `./dashboard.sh check` and `./dashboard.sh contract-check ../openvidu-livekit`. Verify that both pass.
- [ ] 7.2 Run the analytics writer suite of `openvidu-test-integration` against a local deployment with the new
server image. Verify that the node document has `mediaStats: true` and that rooms of signal-only participants get
`lastStatsAt`.