diff --git a/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-repository.ts b/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-repository.ts index 7cf8fe37b..6ef3e5283 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-repository.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-repository.ts @@ -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({ 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> { + 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(Collection.NODES) + .find>(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); + } } diff --git a/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-responses.ts b/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-responses.ts index 8532d10d3..c0ca5ce8f 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-responses.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-responses.ts @@ -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' }); diff --git a/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-service.ts b/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-service.ts index 92aa478ac..d559ccb32 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-service.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-service.ts @@ -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 }); } diff --git a/openvidu-dashboard/dashboard-ce/backend/src/api/responses.ts b/openvidu-dashboard/dashboard-ce/backend/src/api/responses.ts index 077112b5c..c92985766 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/api/responses.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/api/responses.ts @@ -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`: one page of a list with its cursors and, under the range limit, `total`. */ export function listResponse(item: z.ZodType, id: string) { return z diff --git a/openvidu-dashboard/dashboard-ce/backend/src/index.ts b/openvidu-dashboard/dashboard-ce/backend/src/index.ts index 1664d6b78..a34dc39c2 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/index.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/index.ts @@ -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'; diff --git a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-mappers.ts b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-mappers.ts index 858bf9f6f..1b5567675 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-mappers.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-mappers.ts @@ -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 }); } diff --git a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-responses.ts b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-responses.ts index d53323a30..f7cc858fd 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-responses.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-responses.ts @@ -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' }); diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-api.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-api.test.ts index 25a11e3f4..ebedf5039 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-api.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-api.test.ts @@ -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(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(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'], diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-query-plan.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-query-plan.test.ts index aa84d57e9..1518da06b 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-query-plan.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-query-plan.test.ts @@ -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' }); diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/data-generator.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/data-generator.test.ts index 5089c10b4..8a1c16b79 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/data-generator.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/data-generator.test.ts @@ -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. */ diff --git a/openvidu-dashboard/dashboard-ce/backend/test/unit/analytics.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/unit/analytics.test.ts index 2c527cff9..4553c51fc 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/unit/analytics.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/unit/analytics.test.ts @@ -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, 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, 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, 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 } }] diff --git a/openvidu-dashboard/dashboard-ce/backend/test/unit/rooms-mappers.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/unit/rooms-mappers.test.ts index 206b5cd07..cfe1cd62a 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/unit/rooms-mappers.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/unit/rooms-mappers.test.ts @@ -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('egresses')) { const item = toEgressListItem(doc); diff --git a/openvidu-dashboard/dashboard-ce/e2e/fixtures/api.ts b/openvidu-dashboard/dashboard-ce/e2e/fixtures/api.ts index a4b7ecfaa..c2d7fc5c5 100644 --- a/openvidu-dashboard/dashboard-ce/e2e/fixtures/api.ts +++ b/openvidu-dashboard/dashboard-ce/e2e/fixtures/api.ts @@ -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; /** * 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); diff --git a/openvidu-dashboard/dashboard-ce/e2e/tests/analytics.spec.ts b/openvidu-dashboard/dashboard-ce/e2e/tests/analytics.spec.ts index cfcc47dcf..74dccf2d8 100644 --- a/openvidu-dashboard/dashboard-ce/e2e/tests/analytics.spec.ts +++ b/openvidu-dashboard/dashboard-ce/e2e/tests/analytics.spec.ts @@ -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); + }); +}); diff --git a/openvidu-dashboard/dashboard-ce/e2e/tests/rooms.spec.ts b/openvidu-dashboard/dashboard-ce/e2e/tests/rooms.spec.ts index 07370bfa2..28764ec5b 100644 --- a/openvidu-dashboard/dashboard-ce/e2e/tests/rooms.spec.ts +++ b/openvidu-dashboard/dashboard-ce/e2e/tests/rooms.spec.ts @@ -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'); + }); +}); diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.html b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.html index b0bd1402b..747db0bfa 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.html +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.html @@ -58,6 +58,7 @@ label="Data up / down" [info]="infoTexts.dataUpDown" [value]="bandwidthTile()" + [hint]="bandwidthHint()" [loading]="summary() === null" /> - - - - - + @if (mediaStats() === 'none') { + {{ mediaStatsTexts.notice }} + } @else { + @if (mediaStats() === 'partial') { + {{ mediaStatsTexts.partial }} + } + + + + + + } diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.spec.ts index 453cb161f..f816c6db9 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.spec.ts @@ -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'); diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.ts index d453a3c92..6e80e430e 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.ts @@ -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 { diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-seams.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-seams.ts index 732597336..7b99baf59 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-seams.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-seams.ts @@ -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; /** Refreshes of the page, from 1: a card with requests of its own reloads when it changes. */ abstract readonly refreshes: Signal; + /** How much of the range's media statistics its servers report, from the summary; undefined when all of it. */ + abstract readonly mediaStats: Signal; /** The series of `metrics` from the combined series requests. */ abstract chart(metrics: readonly AnalyticsMetric[]): ChartSeries[]; /** Axis range of the series of `metric`. */ diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.html b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.html index e95a3e946..7ae47cbe4 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.html +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.html @@ -144,7 +144,17 @@ {{ number(room.participants.active) }} - {{ bandwidth(room) }} + @if (room.mediaStats === false) { + — + } @else { + {{ bandwidth(room) }} + } diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.spec.ts index b5b6bbbbb..fa97665db 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.spec.ts @@ -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 }); diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.ts index 958f31cf7..e62f5dbd3 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.ts @@ -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; diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/labels.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/labels.ts index b5208d00f..7b601024d 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/labels.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/labels.ts @@ -104,3 +104,13 @@ export const CONNECTION_TYPE_LABELS: Readonly> = /** 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; diff --git a/openvidu-dashboard/dashboard-ce/tools/data-generator/README.md b/openvidu-dashboard/dashboard-ce/tools/data-generator/README.md index a09d0a2f3..4d1f3d758 100644 --- a/openvidu-dashboard/dashboard-ce/tools/data-generator/README.md +++ b/openvidu-dashboard/dashboard-ce/tools/data-generator/README.md @@ -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 ` | required | Days of history ending now | -| `--stats-mode participant\|track` | `participant` | One `track_stats` document per participant and direction, or per track | -| `--seed ` | `1` | Seed of the pseudo-random generator (mulberry32); same seed, same data | -| `--retention-events-days ` | `7` | Only raw events inside this window are written (TTL index on `ts`) | -| `--retention-stats-days ` | `7` | Only stats inside this window are written (`expireAfterSeconds`) | -| `--retention-tracks-days ` | `7` | `expiresAt` of tracks and subscriptions; expired ones are not written | -| `--retention-entities-days ` | `90` | `expiresAt = closedAt + retention` on closed entities | -| `--retention-rollups-5min-days ` | `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:` scope beside `global` in every rollup | -| `--room-sums` | off | Write `rooms.participantsSum` and `rooms.durationMsSum` at room end | -| `--active-window ` | 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 ` | 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 ` | required | Days of history ending now | +| `--stats-mode participant\|track` | `participant` | One `track_stats` document per participant and direction, or per track | +| `--seed ` | `1` | Seed of the pseudo-random generator (mulberry32); same seed, same data | +| `--retention-events-days ` | `7` | Only raw events inside this window are written (TTL index on `ts`) | +| `--retention-stats-days ` | `7` | Only stats inside this window are written (`expireAfterSeconds`) | +| `--retention-tracks-days ` | `7` | `expiresAt` of tracks and subscriptions; expired ones are not written | +| `--retention-entities-days ` | `90` | `expiresAt = closedAt + retention` on closed entities | +| `--retention-rollups-5min-days ` | `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:` scope beside `global` in every rollup | +| `--room-sums` | off | Write `rooms.participantsSum` and `rooms.durationMsSum` at room end | +| `--active-window ` | 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 ` | 0 | With `--active-window`: rooms added to the window when the hour of day leaves fewer than `n` active rooms | +| `--nodes-without-media-stats ` | 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 | | -------- | ------------- | ---------------- | ----------------- | diff --git a/openvidu-dashboard/dashboard-ce/tools/data-generator/src/derive.ts b/openvidu-dashboard/dashboard-ce/tools/data-generator/src/derive.ts index 6472521aa..68f4946d2 100644 --- a/openvidu-dashboard/dashboard-ce/tools/data-generator/src/derive.ts +++ b/openvidu-dashboard/dashboard-ce/tools/data-generator/src/derive.ts @@ -508,6 +508,10 @@ function ingressInfo(i: IngressDoc): Record { } 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); diff --git a/openvidu-dashboard/dashboard-ce/tools/data-generator/src/generate.ts b/openvidu-dashboard/dashboard-ce/tools/data-generator/src/generate.ts index 4962146b2..24f9ad2bf 100644 --- a/openvidu-dashboard/dashboard-ce/tools/data-generator/src/generate.ts +++ b/openvidu-dashboard/dashboard-ce/tools/data-generator/src/generate.ts @@ -43,7 +43,8 @@ async function main(): Promise { '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 { ...(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); } diff --git a/openvidu-dashboard/dashboard-ce/tools/data-generator/src/generator.ts b/openvidu-dashboard/dashboard-ce/tools/data-generator/src/generator.ts index 013cee9b0..d96af8ee7 100644 --- a/openvidu-dashboard/dashboard-ce/tools/data-generator/src/generator.ts +++ b/openvidu-dashboard/dashboard-ce/tools/data-generator/src/generator.ts @@ -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 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; } 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; } diff --git a/openvidu-dashboard/dashboard-ce/tools/demo-lib.sh b/openvidu-dashboard/dashboard-ce/tools/demo-lib.sh index ff0a64bc7..0998ea536 100644 --- a/openvidu-dashboard/dashboard-ce/tools/demo-lib.sh +++ b/openvidu-dashboard/dashboard-ce/tools/demo-lib.sh @@ -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" & diff --git a/openvidu-dashboard/dashboard-ce/typings/contract/fixtures/system.ts b/openvidu-dashboard/dashboard-ce/typings/contract/fixtures/system.ts index a0c6f9f65..fa8b02a77 100644 --- a/openvidu-dashboard/dashboard-ce/typings/contract/fixtures/system.ts +++ b/openvidu-dashboard/dashboard-ce/typings/contract/fixtures/system.ts @@ -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 } ]; diff --git a/openvidu-dashboard/dashboard-ce/typings/contract/schemas.ts b/openvidu-dashboard/dashboard-ce/typings/contract/schemas.ts index 6d25b592a..992f5bfc6 100644 --- a/openvidu-dashboard/dashboard-ce/typings/contract/schemas.ts +++ b/openvidu-dashboard/dashboard-ce/typings/contract/schemas.ts @@ -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] }); diff --git a/openvidu-dashboard/dashboard-ce/typings/fixtures/nodes.json b/openvidu-dashboard/dashboard-ce/typings/fixtures/nodes.json index 45b7d6de7..88011e39b 100644 --- a/openvidu-dashboard/dashboard-ce/typings/fixtures/nodes.json +++ b/openvidu-dashboard/dashboard-ce/typings/fixtures/nodes.json @@ -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 } ] diff --git a/openvidu-dashboard/dashboard-ce/typings/schema/nodes.schema.json b/openvidu-dashboard/dashboard-ce/typings/schema/nodes.schema.json index fca7d9b09..dd0fc40fc 100644 --- a/openvidu-dashboard/dashboard-ce/typings/schema/nodes.schema.json +++ b/openvidu-dashboard/dashboard-ce/typings/schema/nodes.schema.json @@ -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 diff --git a/openvidu-dashboard/dashboard-ce/typings/schema/participants.schema.json b/openvidu-dashboard/dashboard-ce/typings/schema/participants.schema.json index 44357c749..e1d136372 100644 --- a/openvidu-dashboard/dashboard-ce/typings/schema/participants.schema.json +++ b/openvidu-dashboard/dashboard-ce/typings/schema/participants.schema.json @@ -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"], diff --git a/openvidu-dashboard/dashboard-ce/typings/schema/rooms.schema.json b/openvidu-dashboard/dashboard-ce/typings/schema/rooms.schema.json index 54424078e..a71b74403 100644 --- a/openvidu-dashboard/dashboard-ce/typings/schema/rooms.schema.json +++ b/openvidu-dashboard/dashboard-ce/typings/schema/rooms.schema.json @@ -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"], diff --git a/openvidu-dashboard/dashboard-ce/typings/src/api/analytics.ts b/openvidu-dashboard/dashboard-ce/typings/src/api/analytics.ts index 01710545a..543d1869d 100644 --- a/openvidu-dashboard/dashboard-ce/typings/src/api/analytics.ts +++ b/openvidu-dashboard/dashboard-ce/typings/src/api/analytics.ts @@ -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 = diff --git a/openvidu-dashboard/dashboard-ce/typings/src/api/rooms.ts b/openvidu-dashboard/dashboard-ce/typings/src/api/rooms.ts index e4626e813..64b26c510 100644 --- a/openvidu-dashboard/dashboard-ce/typings/src/api/rooms.ts +++ b/openvidu-dashboard/dashboard-ce/typings/src/api/rooms.ts @@ -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. */ diff --git a/openvidu-dashboard/dashboard-ce/typings/src/data-model/entities.ts b/openvidu-dashboard/dashboard-ce/typings/src/data-model/entities.ts index bfd0e5a68..4b10b95bc 100644 --- a/openvidu-dashboard/dashboard-ce/typings/src/data-model/entities.ts +++ b/openvidu-dashboard/dashboard-ce/typings/src/data-model/entities.ts @@ -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; } diff --git a/openvidu-dashboard/dashboard-ce/typings/src/data-model/system.ts b/openvidu-dashboard/dashboard-ce/typings/src/data-model/system.ts index 0a49b930f..e2b4ab168 100644 --- a/openvidu-dashboard/dashboard-ce/typings/src/data-model/system.ts +++ b/openvidu-dashboard/dashboard-ce/typings/src/data-model/system.ts @@ -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. */ diff --git a/openvidu-dashboard/openspec/changes/media-stats-availability/.openspec.yaml b/openvidu-dashboard/openspec/changes/media-stats-availability/.openspec.yaml new file mode 100644 index 000000000..a7587c5f0 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/media-stats-availability/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-02 diff --git a/openvidu-dashboard/openspec/changes/media-stats-availability/design.md b/openvidu-dashboard/openspec/changes/media-stats-availability/design.md new file mode 100644 index 000000000..c5574bd4a --- /dev/null +++ b/openvidu-dashboard/openspec/changes/media-stats-availability/design.md @@ -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 ` (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. diff --git a/openvidu-dashboard/openspec/changes/media-stats-availability/proposal.md b/openvidu-dashboard/openspec/changes/media-stats-availability/proposal.md new file mode 100644 index 000000000..1b4e901dc --- /dev/null +++ b/openvidu-dashboard/openspec/changes/media-stats-availability/proposal.md @@ -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. diff --git a/openvidu-dashboard/openspec/changes/media-stats-availability/specs/analytics-api/spec.md b/openvidu-dashboard/openspec/changes/media-stats-availability/specs/analytics-api/spec.md new file mode 100644 index 000000000..e7055332b --- /dev/null +++ b/openvidu-dashboard/openspec/changes/media-stats-availability/specs/analytics-api/spec.md @@ -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:` 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:` 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 diff --git a/openvidu-dashboard/openspec/changes/media-stats-availability/specs/analytics-ui/spec.md b/openvidu-dashboard/openspec/changes/media-stats-availability/specs/analytics-ui/spec.md new file mode 100644 index 000000000..94f0d636f --- /dev/null +++ b/openvidu-dashboard/openspec/changes/media-stats-availability/specs/analytics-ui/spec.md @@ -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 diff --git a/openvidu-dashboard/openspec/changes/media-stats-availability/specs/data-model/spec.md b/openvidu-dashboard/openspec/changes/media-stats-availability/specs/data-model/spec.md new file mode 100644 index 000000000..fb819f119 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/media-stats-availability/specs/data-model/spec.md @@ -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 diff --git a/openvidu-dashboard/openspec/changes/media-stats-availability/specs/rooms-api/spec.md b/openvidu-dashboard/openspec/changes/media-stats-availability/specs/rooms-api/spec.md new file mode 100644 index 000000000..53e79a8df --- /dev/null +++ b/openvidu-dashboard/openspec/changes/media-stats-availability/specs/rooms-api/spec.md @@ -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 diff --git a/openvidu-dashboard/openspec/changes/media-stats-availability/specs/rooms-ui/spec.md b/openvidu-dashboard/openspec/changes/media-stats-availability/specs/rooms-ui/spec.md new file mode 100644 index 000000000..2729e9f0f --- /dev/null +++ b/openvidu-dashboard/openspec/changes/media-stats-availability/specs/rooms-ui/spec.md @@ -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" diff --git a/openvidu-dashboard/openspec/changes/media-stats-availability/specs/server-analytics-writer/spec.md b/openvidu-dashboard/openspec/changes/media-stats-availability/specs/server-analytics-writer/spec.md new file mode 100644 index 000000000..1567b157e --- /dev/null +++ b/openvidu-dashboard/openspec/changes/media-stats-availability/specs/server-analytics-writer/spec.md @@ -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` diff --git a/openvidu-dashboard/openspec/changes/media-stats-availability/tasks.md b/openvidu-dashboard/openspec/changes/media-stats-availability/tasks.md new file mode 100644 index 000000000..8cfe3c200 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/media-stats-availability/tasks.md @@ -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:` 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 ` (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`.