mirror of https://github.com/OpenVidu/openvidu.git
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
parent
e40f171278
commit
0ae24bb060
|
|
@ -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 { RollupCollection } from './analytics-planner.js';
|
||||
|
||||
export const ROLLUP_INDEX = 'scope_bucket';
|
||||
const NODE_SCOPE_PREFIX = 'node:';
|
||||
/** Rollup reads on `{ scope, bucket }`. */
|
||||
export class AnalyticsRepository {
|
||||
constructor(private readonly db: Db) {}
|
||||
|
|
@ -27,4 +30,21 @@ export class AnalyticsRepository {
|
|||
.find<RollupRow>({ scope, bucket: { $gte: from, $lt: to } }, { projection, hint: ROLLUP_INDEX })
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,11 @@ export const analyticsSummaryResponse = z
|
|||
ingressSessions: count.meta({ description: 'Ingress sessions started in the range' }),
|
||||
ingressDurationMs: count.meta({ description: 'Duration of the ingress sessions ended in the range' }),
|
||||
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' });
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Db } from 'mongodb';
|
|||
import {
|
||||
type AnalyticsBreakdown,
|
||||
type AnalyticsBreakdownsResponse,
|
||||
type AnalyticsMediaStatsCoverage,
|
||||
type AnalyticsSeries,
|
||||
type AnalyticsSeriesResponse,
|
||||
type AnalyticsSummary,
|
||||
|
|
@ -85,6 +86,17 @@ function roomAverage(counters: Counters, path: string, digits: 'one' | 'integer'
|
|||
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. */
|
||||
export class AnalyticsService {
|
||||
private readonly repository: AnalyticsRepository;
|
||||
|
|
@ -240,7 +252,7 @@ export class AnalyticsService {
|
|||
const now = this.options.now();
|
||||
const range = snapRange(query.from, query.to);
|
||||
const days = utcDaysOf(range.from, range.to);
|
||||
const [counters, daily] = await Promise.all([
|
||||
const [counters, daily, mediaStats] = await Promise.all([
|
||||
this.read(
|
||||
{
|
||||
collection: range.collection,
|
||||
|
|
@ -262,7 +274,8 @@ export class AnalyticsService {
|
|||
fields: ['uniqueIdentities']
|
||||
},
|
||||
now
|
||||
)
|
||||
),
|
||||
this.repository.nodesMediaStats(query.scope, range.from, range.to).then(mediaStatsCoverage)
|
||||
]);
|
||||
const identities = [...daily.values()].flatMap((day) => {
|
||||
const count = day.get('uniqueIdentities');
|
||||
|
|
@ -298,7 +311,8 @@ export class AnalyticsService {
|
|||
ingressSessions: value(counters, 'ingress.sessionsStarted'),
|
||||
ingressDurationMs: value(counters, 'ingress.durationMs'),
|
||||
apiCalls: calls,
|
||||
apiErrorRate: share(value(counters, 'apiErrors.total'), calls)
|
||||
apiErrorRate: share(value(counters, 'apiErrors.total'), calls),
|
||||
mediaStats
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ export const staleField = z
|
|||
.optional()
|
||||
.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`. */
|
||||
export function listResponse(item: z.ZodType, id: string) {
|
||||
return z
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export {
|
|||
tzParam
|
||||
} from './api/query.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 { bucketStart, bucketStarts, MAX_BUCKETS } from './api/time-series.js';
|
||||
export { ifNoneMatchMatches } from './http/conditional.js';
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export function toRoomListItem(doc: RoomDocument): RoomListItem {
|
|||
agent: doc.features.agent,
|
||||
sip: doc.features.sip
|
||||
},
|
||||
lastStatsAt: iso(doc.lastStatsAt)
|
||||
lastStatsAt: iso(doc.lastStatsAt),
|
||||
mediaStats: doc.mediaStats === false ? false : undefined
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import * as z from 'zod';
|
|||
|
||||
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';
|
||||
|
||||
const count = z.number().int();
|
||||
|
|
@ -24,7 +24,8 @@ export const roomListFields = {
|
|||
bytes,
|
||||
features: z.object({ egress: z.boolean(), ingress: z.boolean(), agent: z.boolean(), sip: z.boolean() }),
|
||||
lastStatsAt: isoDate.optional(),
|
||||
stale: staleField
|
||||
stale: staleField,
|
||||
mediaStats: mediaStatsField
|
||||
};
|
||||
|
||||
export const roomListItem = z.object(roomListFields).meta({ id: 'RoomListItem' });
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
analyticsSeriesResponse,
|
||||
analyticsSummaryResponse
|
||||
} from '../../src/analytics/analytics-responses.js';
|
||||
import { AnalyticsRepository } from '../../src/analytics/analytics-repository.js';
|
||||
import { openApiDocument } from '../../src/api/openapi.js';
|
||||
import type { RouteRegistry } from '../../src/api/route-registry.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', () => {
|
||||
it.each([
|
||||
['/analytics/series?metrics=roomsCreated,scoreAvg', 'analytics-quality'],
|
||||
|
|
|
|||
|
|
@ -138,6 +138,17 @@ describe.skipIf(mongoUri === '')(`analytics query plan at scenario ${scenario}`,
|
|||
).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 () => {
|
||||
const from = new Date(hour.getTime() - Math.min(days, 2) * DAY);
|
||||
const summary = await service.summary({ from, to: hour, tz: 'UTC', scope: 'global' });
|
||||
|
|
|
|||
|
|
@ -199,6 +199,48 @@ describe.skipIf(mongoUri === '')('data generator', () => {
|
|||
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. */
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import {
|
|||
analyticsSeriesQuery,
|
||||
analyticsSummaryQuery
|
||||
} 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';
|
||||
|
||||
const MINUTE = 60_000;
|
||||
|
|
@ -46,8 +46,11 @@ interface Read {
|
|||
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 {
|
||||
collection: (name: string) => ({
|
||||
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.
|
||||
return rows;
|
||||
}
|
||||
},
|
||||
toArray: () => Promise.resolve(name === 'nodes' ? nodes.map((mediaStats) => ({ mediaStats })) : [])
|
||||
}),
|
||||
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 () => {
|
||||
const db = fakeDb({
|
||||
analytics_hourly: [{ scope: 'global', bucket: at('2026-10-13T09:00:00Z'), rooms: { created: 3, ended: 2 } }]
|
||||
|
|
|
|||
|
|
@ -43,6 +43,15 @@ describe('room mappers', () => {
|
|||
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', () => {
|
||||
for (const doc of loadFixture<EgressDocument>('egresses')) {
|
||||
const item = toEgressListItem(doc);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|||
|
||||
import { test as base, type Page, type Route } from '@playwright/test';
|
||||
import {
|
||||
type AnalyticsSummary,
|
||||
type EgressListItem,
|
||||
type EgressSummary,
|
||||
FeatureKey,
|
||||
|
|
@ -65,6 +66,8 @@ export interface ApiOptions {
|
|||
analytics?: AnalyticsFixtures;
|
||||
/** Analytics paths (`breakdowns`, `export.csv`…) that answer `500` with a request id. */
|
||||
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
|
||||
* `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) });
|
||||
break;
|
||||
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;
|
||||
case 'export.csv': {
|
||||
const csv = analyticsCsv(params);
|
||||
|
|
|
|||
|
|
@ -171,3 +171,36 @@ test.describe('a failed request', () => {
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
label="Data up / down"
|
||||
[info]="infoTexts.dataUpDown"
|
||||
[value]="bandwidthTile()"
|
||||
[hint]="bandwidthHint()"
|
||||
[loading]="summary() === null"
|
||||
/>
|
||||
<ovd-kpi-tile
|
||||
|
|
@ -219,20 +220,27 @@
|
|||
(widen)="widenRange()"
|
||||
>
|
||||
<ng-template ovdCardContent>
|
||||
<ovd-tile-row class="card-tiles">
|
||||
<ovd-kpi-tile label="Total up" [value]="bytesTile(summary()?.bytesUp, '↑')" />
|
||||
<ovd-kpi-tile label="Total down" [value]="bytesTile(summary()?.bytesDown, '↓')" />
|
||||
</ovd-tile-row>
|
||||
<ovd-time-series-chart
|
||||
heading="Data up / down"
|
||||
kind="area"
|
||||
format="bytes"
|
||||
[series]="charts().bytes"
|
||||
[from]="chartRange('bytesUp').from"
|
||||
[to]="chartRange('bytesUp').to"
|
||||
[tz]="tz()"
|
||||
[note]="note('bytesUp')"
|
||||
/>
|
||||
@if (mediaStats() === 'none') {
|
||||
<ovd-notice class="media-stats-notice" variant="info">{{ mediaStatsTexts.notice }}</ovd-notice>
|
||||
} @else {
|
||||
@if (mediaStats() === 'partial') {
|
||||
<ovd-notice class="media-stats-notice" variant="info">{{ mediaStatsTexts.partial }}</ovd-notice>
|
||||
}
|
||||
<ovd-tile-row class="card-tiles">
|
||||
<ovd-kpi-tile label="Total up" [value]="bytesTile(summary()?.bytesUp, '↑')" />
|
||||
<ovd-kpi-tile label="Total down" [value]="bytesTile(summary()?.bytesDown, '↓')" />
|
||||
</ovd-tile-row>
|
||||
<ovd-time-series-chart
|
||||
heading="Data up / down"
|
||||
kind="area"
|
||||
format="bytes"
|
||||
[series]="charts().bytes"
|
||||
[from]="chartRange('bytesUp').from"
|
||||
[to]="chartRange('bytesUp').to"
|
||||
[tz]="tz()"
|
||||
[note]="note('bytesUp')"
|
||||
/>
|
||||
}
|
||||
</ng-template>
|
||||
</ovd-analytics-card>
|
||||
|
||||
|
|
|
|||
|
|
@ -334,6 +334,38 @@ describe('AnalyticsPage', () => {
|
|||
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 () => {
|
||||
const weekly = await open('/analytics?from=now-7d&to=now&tz=UTC');
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
INGRESS_INPUT_LABELS,
|
||||
OS_LABELS,
|
||||
PARTICIPANT_KIND_LABELS,
|
||||
RANGE_MEDIA_STATS,
|
||||
SDK_LABELS
|
||||
} from '../../format/labels.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 summary = computed(() => this.result()?.summary.value ?? null);
|
||||
readonly mediaStats = computed(() => this.summary()?.mediaStats);
|
||||
protected readonly mediaStatsTexts = RANGE_MEDIA_STATS;
|
||||
|
||||
protected readonly title = computed(() =>
|
||||
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)`;
|
||||
}
|
||||
|
||||
protected bandwidthTile(): [string, string] | null {
|
||||
protected bandwidthTile(): [string, string] | string | null {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { InjectionToken, type Signal, type Type } from '@angular/core';
|
|||
import type {
|
||||
AnalyticsBreakdown,
|
||||
AnalyticsDimension,
|
||||
AnalyticsMediaStatsCoverage,
|
||||
AnalyticsMetric,
|
||||
AnalyticsMetricInfo,
|
||||
AnalyticsScope,
|
||||
|
|
@ -85,6 +86,8 @@ export abstract class AnalyticsPageContext {
|
|||
abstract readonly tz: Signal<string>;
|
||||
/** Refreshes of the page, from 1: a card with requests of its own reloads when it changes. */
|
||||
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. */
|
||||
abstract chart(metrics: readonly AnalyticsMetric[]): ChartSeries[];
|
||||
/** Axis range of the series of `metric`. */
|
||||
|
|
|
|||
|
|
@ -144,7 +144,17 @@
|
|||
<span class="ovd-tabular">{{ number(room.participants.active) }}</span>
|
||||
</ng-template>
|
||||
<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 ovdCell="features" let-room>
|
||||
<span class="features">
|
||||
|
|
|
|||
|
|
@ -345,6 +345,22 @@ describe('RoomsPage', () => {
|
|||
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 () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import { injectRankedSort } from '../../core/state/ranked-sort.js';
|
|||
import { rangeSpanMs } from '../../core/state/relative-time.js';
|
||||
import { formatNumber, formatTimestamp } from '../../format/formatters.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 { ROOMS_NODE_FILTER, RoomsDataService, type RoomsListRequest } from './rooms-data.js';
|
||||
|
||||
|
|
@ -98,6 +98,7 @@ export class RoomsPage {
|
|||
|
||||
protected readonly features = ROOM_FEATURES;
|
||||
protected readonly estimate = RECONCILER_ESTIMATE;
|
||||
protected readonly mediaStatsNotReported = ROOM_MEDIA_STATS_NOT_REPORTED;
|
||||
protected readonly now = injectTicker();
|
||||
protected readonly tz = this.query.tz;
|
||||
protected readonly limit = this.query.limit;
|
||||
|
|
|
|||
|
|
@ -104,3 +104,13 @@ export const CONNECTION_TYPE_LABELS: Readonly<Record<ConnectionType, string>> =
|
|||
|
||||
/** The one explanation of a value estimated by the reconciler. */
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
| Option | Default | Meaning |
|
||||
| ----------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--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) |
|
||||
| `--days <n>` | required | Days of history ending now |
|
||||
| `--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 |
|
||||
| `--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-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-rollups-5min-days <n>` | `7` | Only 5-minute rollups inside this window are written (TTL index on `bucket`) |
|
||||
| `--drop` | off | Drop the database first |
|
||||
| `--plain-stats` | off | Force the plain-collection fallback for `track_stats` |
|
||||
| `--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 |
|
||||
| `--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 |
|
||||
| `--min-active-rooms <n>` | 0 | With `--active-window`: rooms added to the window when the hour of day leaves fewer than `n` active rooms |
|
||||
| Option | Default | Meaning |
|
||||
| ----------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--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) |
|
||||
| `--days <n>` | required | Days of history ending now |
|
||||
| `--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 |
|
||||
| `--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-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-rollups-5min-days <n>` | `7` | Only 5-minute rollups inside this window are written (TTL index on `bucket`) |
|
||||
| `--drop` | off | Drop the database first |
|
||||
| `--plain-stats` | off | Force the plain-collection fallback for `track_stats` |
|
||||
| `--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 |
|
||||
| `--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 |
|
||||
| `--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 |
|
||||
| -------- | ------------- | ---------------- | ----------------- |
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
if (bundle.room.mediaStats === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tracksById = new Map(bundle.tracks.map((t) => [t._id, t]));
|
||||
const tracksByParticipant = groupBy(bundle.tracks, (t) => t.participantSid);
|
||||
const subsBySubscriber = groupBy(
|
||||
|
|
@ -920,7 +924,8 @@ export class Rollups {
|
|||
const end = p.leftAt?.getTime() ?? windows.now;
|
||||
const total = Math.max(1, end - start);
|
||||
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 overlap = Math.min(end, slot + size) - Math.max(start, slot);
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ async function main(): Promise<void> {
|
|||
'node-rollups': { type: 'boolean' },
|
||||
'room-sums': { type: 'boolean' },
|
||||
'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 dbName = requireString(values, 'db');
|
||||
|
|
@ -82,6 +83,7 @@ async function main(): Promise<void> {
|
|||
...(values['min-active-rooms'] === undefined
|
||||
? {}
|
||||
: { minActiveRooms: intOption(values, 'min-active-rooms', 0) }),
|
||||
nodesWithoutMediaStats: intOption(values, 'nodes-without-media-stats', 0),
|
||||
log: (line) => {
|
||||
console.log(line);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,6 +158,8 @@ export interface GenerateOptions {
|
|||
activeWindowMinutes?: number;
|
||||
/** Active rooms the active window holds at least, whatever the hour; extra rooms are added to it when needed. */
|
||||
minActiveRooms?: number;
|
||||
/** Generated nodes, the last ones, that report no media statistics; none by default. */
|
||||
nodesWithoutMediaStats?: number;
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +185,12 @@ export async function generate(db: Db, options: GenerateOptions): Promise<Genera
|
|||
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 = {
|
||||
events: retentionDays.events * 86_400,
|
||||
stats: retentionDays.stats * 86_400,
|
||||
|
|
@ -215,7 +223,8 @@ export async function generate(db: Db, options: GenerateOptions): Promise<Genera
|
|||
now,
|
||||
nodeIds,
|
||||
retentionEntitiesMs: retentionDays.entities * DAY,
|
||||
retentionTracksMs: retentionDays.tracks * DAY
|
||||
retentionTracksMs: retentionDays.tracks * DAY,
|
||||
withoutMediaStats: new Set(nodeIds.slice(NODE_COUNT - nodesWithoutMediaStats))
|
||||
};
|
||||
const windows: DeriveWindows = {
|
||||
now,
|
||||
|
|
@ -302,7 +311,8 @@ export async function generate(db: Db, options: GenerateOptions): Promise<Genera
|
|||
lastSeenAt: new Date(now),
|
||||
serverVersion: '3.10.0',
|
||||
region: ['eu-west-1', 'eu-west-1', 'us-east-1'][i],
|
||||
batchIntervalMs: BATCH_INTERVAL_MS
|
||||
batchIntervalMs: BATCH_INTERVAL_MS,
|
||||
mediaStats: !ctx.withoutMediaStats.has(nodeId)
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ export interface RoomDoc {
|
|||
features: { egress: boolean; ingress: boolean; agent: boolean; sip: boolean };
|
||||
lastEventAt: Date;
|
||||
lastStatsAt: Date | null;
|
||||
/** Only `false`, on the rooms of a node without media statistics. */
|
||||
mediaStats?: false;
|
||||
schemaVersion: number;
|
||||
expiresAt: Date | null;
|
||||
}
|
||||
|
|
@ -102,6 +104,8 @@ export interface ParticipantDoc {
|
|||
subscribed: number;
|
||||
subscribeFailures: number;
|
||||
};
|
||||
/** Only `false`, on the sessions of a node without media statistics. */
|
||||
mediaStats?: false;
|
||||
bytes: { up: Long; down: Long };
|
||||
quality: {
|
||||
scoreSum: number;
|
||||
|
|
@ -284,6 +288,8 @@ export interface GeneratorContext {
|
|||
retentionEntitiesMs: number;
|
||||
/** Retention of tracks and subscriptions (`retention.tracks`). */
|
||||
retentionTracksMs: number;
|
||||
/** Nodes that report no media statistics: their sessions carry no bytes, quality or `track_stats`. */
|
||||
withoutMediaStats: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
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 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 = {
|
||||
_id: roomSid,
|
||||
name,
|
||||
|
|
@ -613,6 +622,7 @@ export function generateRoom(ctx: GeneratorContext, plan: RoomPlan): RoomBundle
|
|||
features: { egress: hasEgress, ingress: hasIngress, agent: hasAgent, sip: hasSip },
|
||||
lastEventAt: new Date(lastEventAt),
|
||||
lastStatsAt: lastStatsAt === null ? null : new Date(lastStatsAt),
|
||||
...(withoutMediaStats ? { mediaStats: false as const } : {}),
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
expiresAt: expiresAt(ctx, endedAt)
|
||||
};
|
||||
|
|
@ -935,7 +945,10 @@ function publishTracks(
|
|||
codecs: [{ mimeType, layers }],
|
||||
encryption: 'NONE',
|
||||
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,
|
||||
expiresAt: tracksExpiresAt(ctx, unpublishedAt)
|
||||
};
|
||||
|
|
@ -1074,7 +1087,7 @@ function buildSubscriptions(ctx: GeneratorContext, roomSid: string, sessions: Se
|
|||
durationMs: failed ? null : durationMs,
|
||||
maxVideoQuality,
|
||||
subscribeStats:
|
||||
failed || durationMs === null
|
||||
failed || durationMs === null || ctx.withoutMediaStats.has(publisher.doc.nodeId)
|
||||
? null
|
||||
: withoutLayers(rtpStats(rng, bitrate, durationMs, video ? 1 : 0)),
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
|
|
@ -1101,6 +1114,23 @@ function finalizeSessions(ctx: GeneratorContext, sessions: Session[]): void {
|
|||
const { rng, now } = ctx;
|
||||
|
||||
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') {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
#!/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
|
||||
# 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
|
||||
# from the root of its workspace.
|
||||
# and the backend and frontend development servers of dev-lib.sh. The caller sets DATABASE and STATS_MODE, and
|
||||
# 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_PORT host port of the container (default 27018)
|
||||
# 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)"
|
||||
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 \
|
||||
--active-window 60 --min-active-rooms 10 | tail -2
|
||||
--active-window 60 --min-active-rooms 10 ${GENERATOR_ARGS[@]+"${GENERATOR_ARGS[@]}"} | tail -2
|
||||
fi
|
||||
|
||||
pnpm --silent --dir "$TOOLS_DIR/data-generator" run keep-alive --uri "$MONGO_URI" --db "$DATABASE" &
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ export const nodes: NodeDocument[] = [
|
|||
lastSeenAt: at('10:35:10'),
|
||||
serverVersion: SERVER_VERSION,
|
||||
region: REGION,
|
||||
batchIntervalMs: 10_000
|
||||
batchIntervalMs: 10_000,
|
||||
mediaStats: true
|
||||
},
|
||||
{
|
||||
_id: NODE_BETA,
|
||||
|
|
@ -16,7 +17,8 @@ export const nodes: NodeDocument[] = [
|
|||
lastSeenAt: at('10:35:10'),
|
||||
serverVersion: SERVER_VERSION,
|
||||
region: REGION,
|
||||
batchIntervalMs: 10_000
|
||||
batchIntervalMs: 10_000,
|
||||
mediaStats: true
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ const entityBase = {
|
|||
};
|
||||
|
||||
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);
|
||||
|
||||
|
|
@ -121,6 +124,7 @@ const rooms = obj({
|
|||
features: req(obj({ egress: req(bool()), ingress: req(bool()), agent: req(bool()), sip: req(bool()) })),
|
||||
lastEventAt: req(date()),
|
||||
lastStatsAt: date(),
|
||||
mediaStats: mediaStatsMark,
|
||||
internal,
|
||||
...entityBase
|
||||
});
|
||||
|
|
@ -191,6 +195,7 @@ const participants = obj({
|
|||
),
|
||||
lastEventAt: req(date()),
|
||||
lastStatsAt: date(),
|
||||
mediaStats: mediaStatsMark,
|
||||
internal,
|
||||
...entityBase
|
||||
});
|
||||
|
|
@ -479,7 +484,10 @@ const nodes = obj({
|
|||
lastSeenAt: req(date()),
|
||||
serverVersion: req(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] });
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
"lastSeenAt": { "$date": "2026-10-12T10:35:10.000Z" },
|
||||
"serverVersion": "3.10.0",
|
||||
"region": "eu-west",
|
||||
"batchIntervalMs": { "$numberInt": "10000" }
|
||||
"batchIntervalMs": { "$numberInt": "10000" },
|
||||
"mediaStats": true
|
||||
},
|
||||
{
|
||||
"_id": "ND_beta3Vx9Pw",
|
||||
|
|
@ -13,6 +14,7 @@
|
|||
"lastSeenAt": { "$date": "2026-10-12T10:35:10.000Z" },
|
||||
"serverVersion": "3.10.0",
|
||||
"region": "eu-west",
|
||||
"batchIntervalMs": { "$numberInt": "10000" }
|
||||
"batchIntervalMs": { "$numberInt": "10000" },
|
||||
"mediaStats": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@
|
|||
"bsonType": ["int", "long"],
|
||||
"description": "Configured batch interval of the node, in milliseconds",
|
||||
"minimum": 1000
|
||||
},
|
||||
"mediaStats": {
|
||||
"bsonType": "bool",
|
||||
"description": "Whether the node reports media statistics; absent on documents written before it existed (read as true)"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
|
|
|||
|
|
@ -182,6 +182,10 @@
|
|||
},
|
||||
"lastEventAt": { "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" },
|
||||
"schemaVersion": {
|
||||
"bsonType": ["int", "long"],
|
||||
|
|
|
|||
|
|
@ -154,6 +154,10 @@
|
|||
},
|
||||
"lastEventAt": { "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" },
|
||||
"schemaVersion": {
|
||||
"bsonType": ["int", "long"],
|
||||
|
|
|
|||
|
|
@ -236,8 +236,13 @@ export interface AnalyticsSummary {
|
|||
apiCalls: number;
|
||||
/** Percentage, one decimal. */
|
||||
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 AnalyticsExportQuery =
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ export interface RoomListItem {
|
|||
lastStatsAt?: string;
|
||||
/** `true` for an active entity whose last stats flush is older than the stale window; absent otherwise. */
|
||||
stale?: true;
|
||||
/** `false` when the room's node does not report media statistics: `bytes` then measures nothing. */
|
||||
mediaStats?: false;
|
||||
}
|
||||
|
||||
/** Numbers above the rooms table. */
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ export interface RoomDocument extends EntityBase {
|
|||
features: RoomFeatures;
|
||||
lastEventAt: Date;
|
||||
lastStatsAt?: Date;
|
||||
/** `false` when the node that created it does not report media statistics; absent otherwise. */
|
||||
mediaStats?: false;
|
||||
internal?: WriterInternal;
|
||||
}
|
||||
|
||||
|
|
@ -167,6 +169,8 @@ export interface ParticipantDocument extends EntityBase {
|
|||
quality: ParticipantQuality;
|
||||
lastEventAt: Date;
|
||||
lastStatsAt?: Date;
|
||||
/** `false` when the node that created it does not report media statistics; absent otherwise. */
|
||||
mediaStats?: false;
|
||||
internal?: WriterInternal;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export interface NodeDocument {
|
|||
region?: string;
|
||||
/** Configured batch interval of the node; absent on documents written before it existed (read as 10 s). */
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-11-02
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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`
|
||||
|
|
@ -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`.
|
||||
Loading…
Reference in New Issue