mirror of https://github.com/OpenVidu/openvidu.git
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
import type { Document } from 'mongodb';
|
|
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest';
|
|
|
|
import {
|
|
type AnalyticsBreakdownsResponse,
|
|
type AnalyticsSeriesResponse,
|
|
type AnalyticsSummary,
|
|
Collection
|
|
} from '@openvidu-dashboard/typings';
|
|
|
|
import {
|
|
analyticsBreakdownsResponse,
|
|
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';
|
|
import { bootClient, type Client, get, issue, json } from '../support/api-client.js';
|
|
|
|
const mongoUri = inject('mongoUri');
|
|
const MINUTE = 60_000;
|
|
const HOUR = 60 * MINUTE;
|
|
const DAY = 24 * HOUR;
|
|
|
|
type Rollups = Partial<Record<'analytics_5min' | 'analytics_hourly' | 'analytics_daily', Document[]>>;
|
|
|
|
function rollup(bucket: number | Date, fields: Document, scope = 'global'): Document {
|
|
const date = new Date(bucket);
|
|
|
|
return { _id: `${scope}:${date.toISOString().replace('.000Z', 'Z')}`, scope, bucket: date, ...fields };
|
|
}
|
|
|
|
async function seedRollups(client: Client, rollups: Rollups): Promise<void> {
|
|
for (const collection of [Collection.ANALYTICS_5MIN, Collection.ANALYTICS_HOURLY, Collection.ANALYTICS_DAILY]) {
|
|
await client.dashboard.db.collection(collection).deleteMany({});
|
|
|
|
const documents = rollups[collection];
|
|
|
|
if (documents !== undefined && documents.length > 0) {
|
|
await client.dashboard.db.collection(collection).insertMany(documents);
|
|
}
|
|
}
|
|
}
|
|
|
|
const iso = (time: number) => new Date(time).toISOString();
|
|
const values = (response: AnalyticsSeriesResponse, name: string) =>
|
|
response.series.find((series) => series.name === name)?.points.map(([, value]) => value) ?? [];
|
|
|
|
describe.skipIf(mongoUri === '')('analytics API', () => {
|
|
let ce: Client;
|
|
/** Start of the current UTC hour. */
|
|
let hour: number;
|
|
|
|
beforeAll(async () => {
|
|
ce = await bootClient(mongoUri, { DASHBOARD_ANALYTICS_CACHE_TTL: '0' });
|
|
hour = Math.floor(Date.now() / HOUR) * HOUR;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await ce.dashboard.stop();
|
|
});
|
|
|
|
describe('series', () => {
|
|
it('answers dense five-minute series over a day', async () => {
|
|
await seedRollups(ce, {
|
|
analytics_5min: [
|
|
rollup(hour - 2 * HOUR, { rooms: { created: 2 }, bytes: { up: 1000 } }),
|
|
rollup(hour - 5 * MINUTE, { rooms: { created: 1 } })
|
|
]
|
|
});
|
|
|
|
const response = await json<AnalyticsSeriesResponse>(
|
|
ce,
|
|
`/analytics/series?metrics=roomsCreated,bytesUp&from=${iso(hour - DAY)}&to=${iso(hour)}`
|
|
);
|
|
|
|
expect(analyticsSeriesResponse.parse(response)).toEqual(response);
|
|
expect(response).toMatchObject({
|
|
scope: 'global',
|
|
granularity: '5m',
|
|
from: iso(hour - DAY),
|
|
to: iso(hour)
|
|
});
|
|
expect(response.series.map((series) => [series.name, series.points.length])).toEqual([
|
|
['roomsCreated', 288],
|
|
['bytesUp', 288]
|
|
]);
|
|
expect(values(response, 'roomsCreated').reduce((sum, value) => (sum ?? 0) + (value ?? 0), 0)).toBe(3);
|
|
expect(values(response, 'roomsCreated').at(-1)).toBe(1);
|
|
});
|
|
|
|
it('reads the collection of the resolved granularity', async () => {
|
|
await seedRollups(ce, {
|
|
analytics_5min: [rollup(hour - HOUR, { rooms: { created: 5 } })],
|
|
analytics_hourly: [rollup(hour - 2 * HOUR, { rooms: { created: 7 } })],
|
|
analytics_daily: [rollup(Math.floor((hour - 3 * DAY) / DAY) * DAY, { rooms: { created: 9 } })]
|
|
});
|
|
|
|
const read = async (span: number) => {
|
|
const response = await json<AnalyticsSeriesResponse>(
|
|
ce,
|
|
`/analytics/series?metrics=roomsCreated&from=${iso(hour - span)}&to=${iso(hour)}`
|
|
);
|
|
|
|
return { granularity: response.granularity, seen: values(response, 'roomsCreated').filter(Boolean) };
|
|
};
|
|
|
|
expect(await read(DAY)).toEqual({ granularity: '5m', seen: [5] });
|
|
expect(await read(7 * DAY)).toEqual({ granularity: '1h', seen: [7] });
|
|
expect(await read(30 * DAY)).toEqual({ granularity: '6h', seen: [7] });
|
|
expect(await read(100 * DAY)).toEqual({ granularity: '1d', seen: [9] });
|
|
});
|
|
|
|
it('expands the egress end statuses present in the range', async () => {
|
|
await seedRollups(ce, {
|
|
analytics_hourly: [
|
|
rollup(hour - 3 * HOUR, { egress: { endedByStatus: { EGRESS_COMPLETE: 4 } } }),
|
|
rollup(hour - 2 * HOUR, { egress: { endedByStatus: { EGRESS_FAILED: 1, EGRESS_COMPLETE: 1 } } })
|
|
]
|
|
});
|
|
|
|
const response = await json<AnalyticsSeriesResponse>(
|
|
ce,
|
|
`/analytics/series?metrics=egressEnded&granularity=1h&from=${iso(hour - DAY)}&to=${iso(hour)}`
|
|
);
|
|
|
|
expect(response.series.map((series) => series.name)).toEqual([
|
|
'egressEnded.EGRESS_COMPLETE',
|
|
'egressEnded.EGRESS_FAILED'
|
|
]);
|
|
expect(values(response, 'egressEnded.EGRESS_COMPLETE').slice(-3)).toEqual([4, 1, 0]);
|
|
});
|
|
|
|
it.each([
|
|
['metrics=roomsCreated,roomsDeleted', ['metrics'], 'roomsDeleted is not one of'],
|
|
['metrics=bytesUp&granularity=1m', ['granularity'], 'the finest analytics granularity is 5m'],
|
|
['metrics=bytesUp&granularity=5m&from=now-48h', ['granularity'], 'at most 24 hours'],
|
|
['metrics=bytesUp&granularity=1h&from=now-60d', ['granularity'], 'limit of 1000 buckets'],
|
|
['metrics=uniqueIdentities', ['metrics'], 'uniqueIdentities requires granularity=1d'],
|
|
['metrics=egressStarted,egressEnded', ['metrics'], 'egressEnded requires a granularity of at least 1h'],
|
|
['metrics=bytesUp&scope=node:', ['scope'], 'must be global or node:<id>']
|
|
])('rejects %s', async (query, path, message) => {
|
|
expect(await issue(ce, `/analytics/series?${query}`)).toMatchObject({
|
|
status: 400,
|
|
code: 'VALIDATION_ERROR',
|
|
first: { path, message: expect.stringContaining(message) as string }
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('breakdowns and summary', () => {
|
|
it('snaps a one-hour preset to whole hours and sorts the keys by count', async () => {
|
|
await seedRollups(ce, {
|
|
analytics_hourly: [
|
|
rollup(hour - 2 * HOUR, { participants: { bySdk: { JS: 1000 } } }),
|
|
rollup(hour - HOUR, { participants: { bySdk: { JS: 600, SWIFT: 300 } } }),
|
|
rollup(hour, { participants: { bySdk: { ANDROID: 100 } } })
|
|
]
|
|
});
|
|
|
|
const response = await json<AnalyticsBreakdownsResponse>(
|
|
ce,
|
|
'/analytics/breakdowns?dimensions=sdk,egressType&from=now-1h&to=now'
|
|
);
|
|
|
|
expect(analyticsBreakdownsResponse.parse(response)).toEqual(response);
|
|
expect(response).toMatchObject({ from: iso(hour - HOUR), to: iso(hour + HOUR), scope: 'global' });
|
|
expect(response.breakdowns).toEqual([
|
|
{
|
|
dimension: 'sdk',
|
|
total: 1000,
|
|
items: [
|
|
{ key: 'JS', count: 600, percent: 60 },
|
|
{ key: 'SWIFT', count: 300, percent: 30 },
|
|
{ key: 'ANDROID', count: 100, percent: 10 }
|
|
]
|
|
},
|
|
{ dimension: 'egressType', total: 0, items: [] }
|
|
]);
|
|
});
|
|
|
|
it('derives the summary from hourly documents and the daily identities', async () => {
|
|
const today = Math.floor(hour / DAY) * DAY;
|
|
|
|
await seedRollups(ce, {
|
|
analytics_hourly: [
|
|
rollup(hour - 2 * HOUR, {
|
|
rooms: { created: 120, ended: 110, participantsSum: 440, durationMsSum: 39_600_000 },
|
|
participants: { joined: 900, active: 880 },
|
|
ingress: {
|
|
sessionsStarted: 12,
|
|
durationMs: 21_600_000,
|
|
byInputType: { rtmp: 8, whip: 3, url: 1 }
|
|
},
|
|
apiCalls: { total: 5000 },
|
|
apiErrors: { total: 50 }
|
|
})
|
|
],
|
|
analytics_daily: [
|
|
rollup(today - 2 * DAY, { uniqueIdentities: 100 }),
|
|
rollup(today - DAY, { uniqueIdentities: 120 }),
|
|
rollup(today, { uniqueIdentities: 90 })
|
|
]
|
|
});
|
|
|
|
const summary = await json<AnalyticsSummary>(ce, `/analytics/summary?from=${iso(today - 2 * DAY + HOUR)}`);
|
|
|
|
expect(analyticsSummaryResponse.parse(summary)).toEqual(summary);
|
|
expect(summary).toMatchObject({
|
|
roomsCreated: 120,
|
|
roomsEnded: 110,
|
|
participantSessions: 900,
|
|
connectionSuccessRate: 97.8,
|
|
apiCalls: 5000,
|
|
apiErrorRate: 1,
|
|
avgRoomSize: 4,
|
|
avgRoomDurationMs: 360_000,
|
|
uniqueIdentities: { value: 310, basis: 'utcDays', days: 3 },
|
|
ingressSessions: 12,
|
|
ingressDurationMs: 21_600_000
|
|
});
|
|
|
|
const breakdowns = await json<AnalyticsBreakdownsResponse>(
|
|
ce,
|
|
`/analytics/breakdowns?dimensions=ingressInputType&from=${iso(today - 2 * DAY + HOUR)}`
|
|
);
|
|
|
|
expect(breakdowns.breakdowns).toEqual([
|
|
{
|
|
dimension: 'ingressInputType',
|
|
total: 12,
|
|
items: [
|
|
{ key: 'rtmp', count: 8, percent: 66.7 },
|
|
{ key: 'whip', count: 3, percent: 25 },
|
|
{ key: 'url', count: 1, percent: 8.3 }
|
|
]
|
|
}
|
|
]);
|
|
// Ingress analytics are CE.
|
|
expect(
|
|
(
|
|
await get(
|
|
ce,
|
|
`/analytics/series?metrics=ingressSessions,ingressDurationMs&granularity=1h&from=${iso(hour - DAY)}&to=${iso(hour)}`
|
|
)
|
|
).status
|
|
).toBe(200);
|
|
});
|
|
|
|
it('reads daily documents for long ranges and omits the averages without the room sums', async () => {
|
|
const today = Math.floor(hour / DAY) * DAY;
|
|
|
|
await seedRollups(ce, {
|
|
analytics_hourly: [rollup(hour - 2 * HOUR, { rooms: { created: 1000, ended: 1000 } })],
|
|
analytics_daily: [rollup(today - 10 * DAY, { rooms: { created: 3, ended: 2 } })]
|
|
});
|
|
|
|
const summary = await json<AnalyticsSummary>(ce, '/analytics/summary?from=now-120d');
|
|
|
|
expect(summary).toMatchObject({ roomsCreated: 3, roomsEnded: 2, to: iso(today + DAY) });
|
|
expect(summary.from).toMatch(/T00:00:00\.000Z$/);
|
|
expect(summary).not.toHaveProperty('avgRoomSize');
|
|
expect(summary).not.toHaveProperty('avgRoomDurationMs');
|
|
});
|
|
});
|
|
|
|
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'],
|
|
['/analytics/breakdowns?dimensions=sdk,disconnectReason', 'analytics-diagnostics'],
|
|
['/analytics/summary?scope=node:n1', 'per-node-breakdown'],
|
|
['/analytics/top?metric=uniqueIdentities', 'analytics-top-rooms'],
|
|
['/analytics/scopes', 'per-node-breakdown'],
|
|
['/analytics/export.csv?kind=series&metrics=scoreAvg', 'analytics-quality']
|
|
])('answers PRO_FEATURE on CE for %s', async (path, feature) => {
|
|
expect(await issue(ce, path)).toMatchObject({ status: 403, code: 'PRO_FEATURE', feature });
|
|
});
|
|
|
|
it('documents the CE analytics routes, and none of the locked ones', () => {
|
|
const registry = ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry);
|
|
const paths = Object.keys(openApiDocument(registry, { version: 'test' }).paths ?? {});
|
|
|
|
expect(paths).toEqual(
|
|
expect.arrayContaining([
|
|
'/api/v1/analytics/series',
|
|
'/api/v1/analytics/breakdowns',
|
|
'/api/v1/analytics/summary',
|
|
'/api/v1/analytics/export.csv'
|
|
])
|
|
);
|
|
expect(paths).not.toContain('/api/v1/analytics/top');
|
|
expect(paths).not.toContain('/api/v1/analytics/scopes');
|
|
});
|
|
});
|
|
|
|
describe('CSV export', () => {
|
|
it('exports the series with the cells of the JSON response', async () => {
|
|
await seedRollups(ce, {
|
|
analytics_hourly: [
|
|
rollup(hour - 3 * HOUR, { bytes: { up: 1200 }, rooms: { created: 2 } }),
|
|
rollup(hour - 2 * HOUR, { bytes: { up: 300 } })
|
|
]
|
|
});
|
|
|
|
const query = `metrics=bytesUp,roomsCreated&granularity=1h&from=${iso(hour - DAY)}&to=${iso(hour)}`;
|
|
const response = await get(ce, `/analytics/export.csv?kind=series&${query}`);
|
|
const body = await response.text();
|
|
const lines = body.split('\r\n');
|
|
const expected = await json<AnalyticsSeriesResponse>(ce, `/analytics/series?${query}`);
|
|
|
|
expect(response.headers.get('content-type')).toBe('text/csv; charset=utf-8');
|
|
expect(response.headers.get('content-disposition')).toBe(
|
|
`attachment; filename=${`analytics-series-${iso(hour - DAY)}-${iso(hour)}.csv`.replaceAll(':', '')}`
|
|
);
|
|
expect(lines[0]).toBe('bucket,bytesUp,roomsCreated');
|
|
expect(lines).toHaveLength(26);
|
|
expect(lines.at(-1)).toBe('');
|
|
expect(lines.slice(1, -1)).toEqual(
|
|
expected.series[0]?.points.map(
|
|
([bucket, bytes], index) =>
|
|
`${bucket},${String(bytes)},${String(expected.series[1]?.points[index]?.[1] ?? '')}`
|
|
)
|
|
);
|
|
expect(lines.at(-4)).toBe(`${iso(hour - 3 * HOUR)},1200,2`);
|
|
expect(lines.at(-3)).toBe(`${iso(hour - 2 * HOUR)},300,0`);
|
|
});
|
|
|
|
it('exports the breakdowns dimension after dimension', async () => {
|
|
await seedRollups(ce, {
|
|
analytics_hourly: [
|
|
rollup(hour - 2 * HOUR, { participants: { bySdk: { JS: 2, SWIFT: 5 }, byOs: { linux: 1 } } })
|
|
]
|
|
});
|
|
|
|
const response = await get(ce, '/analytics/export.csv?kind=breakdowns&dimensions=sdk,os');
|
|
|
|
expect(await response.text()).toBe(
|
|
'dimension,key,count,percent\r\nsdk,SWIFT,5,71.4\r\nsdk,JS,2,28.6\r\nos,linux,1,100\r\n'
|
|
);
|
|
});
|
|
});
|
|
});
|