mirror of https://github.com/OpenVidu/openvidu.git
635 lines
22 KiB
TypeScript
635 lines
22 KiB
TypeScript
import type { Db } from 'mongodb';
|
|
import type * as z from 'zod';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
ANALYTICS_CE_DIMENSIONS,
|
|
ANALYTICS_CE_METRICS,
|
|
ANALYTICS_CSV_CONTENT_TYPE,
|
|
type AnalyticsMetric
|
|
} from '@openvidu-dashboard/typings';
|
|
|
|
import { AnalyticsCache } from '../../src/analytics/analytics-cache.js';
|
|
import { CE_ANALYTICS_CATALOGUE } from '../../src/analytics/analytics-catalogue.js';
|
|
import { breakdownsCsv, seriesCsv, toCsv } from '../../src/analytics/analytics-csv.js';
|
|
import { AnalyticsFeatureGate } from '../../src/analytics/analytics-gate.js';
|
|
import { planSeries, type SeriesPlanInput, snapRange } from '../../src/analytics/analytics-planner.js';
|
|
import {
|
|
analyticsBreakdownsResponse,
|
|
analyticsSeriesResponse,
|
|
analyticsSummaryResponse
|
|
} from '../../src/analytics/analytics-responses.js';
|
|
import {
|
|
analyticsBreakdownsQuery,
|
|
analyticsExportQuery,
|
|
analyticsSeriesQuery,
|
|
analyticsSummaryQuery
|
|
} from '../../src/analytics/analytics-schemas.js';
|
|
import { AnalyticsService, mediaStatsCoverage } from '../../src/analytics/analytics-service.js';
|
|
import { ApiError } from '../../src/http/api-error.js';
|
|
|
|
const MINUTE = 60_000;
|
|
const HOUR = 60 * MINUTE;
|
|
const DAY = 24 * HOUR;
|
|
const NOW = new Date('2026-10-13T12:00:00Z');
|
|
const RETENTION = { statsMs: 7 * DAY, entitiesMs: 90 * DAY, rollups5MinMs: 7 * DAY, rollupsHourlyMs: 400 * DAY };
|
|
|
|
interface Doc {
|
|
scope: string;
|
|
bucket: Date;
|
|
[field: string]: unknown;
|
|
}
|
|
|
|
interface Read {
|
|
collection: string;
|
|
from: Date;
|
|
to: Date;
|
|
}
|
|
|
|
/**
|
|
* A database holding rollup documents per collection and the `mediaStats` of the nodes; `schema_meta` is empty, so the
|
|
* retention is the default.
|
|
*/
|
|
function fakeDb(collections: Record<string, Doc[]>, reads: Read[] = [], nodes: (boolean | undefined)[] = []): Db {
|
|
return {
|
|
collection: (name: string) => ({
|
|
find: (filter: { scope: string; bucket: { $gte: Date; $lt: Date } }) => ({
|
|
sort: () => {
|
|
reads.push({ collection: name, from: filter.bucket.$gte, to: filter.bucket.$lt });
|
|
|
|
const rows = (collections[name] ?? [])
|
|
.filter(
|
|
(doc) =>
|
|
doc.scope === filter.scope &&
|
|
doc.bucket >= filter.bucket.$gte &&
|
|
doc.bucket < filter.bucket.$lt
|
|
)
|
|
.sort((a, b) => a.bucket.getTime() - b.bucket.getTime())
|
|
.map(({ scope: _scope, ...row }) => structuredClone(row));
|
|
|
|
// `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)
|
|
})
|
|
} as unknown as Db;
|
|
}
|
|
|
|
function service(db: Db, options: { now?: () => Date; ttlMs?: number } = {}): AnalyticsService {
|
|
const now = options.now ?? (() => NOW);
|
|
|
|
return new AnalyticsService(db, {
|
|
now,
|
|
cache: new AnalyticsCache(options.ttlMs ?? 0, now),
|
|
gate: new AnalyticsFeatureGate(() => []),
|
|
catalogue: CE_ANALYTICS_CATALOGUE
|
|
});
|
|
}
|
|
|
|
const seriesQuery = analyticsSeriesQuery(() => NOW);
|
|
|
|
function series(query: Record<string, string>) {
|
|
return seriesQuery.parse(query);
|
|
}
|
|
|
|
function plan(input: Partial<SeriesPlanInput> & { from: Date }): ReturnType<typeof planSeries> {
|
|
return planSeries({
|
|
to: NOW,
|
|
tz: 'UTC',
|
|
granularity: 'auto',
|
|
metrics: ['roomsCreated'],
|
|
now: NOW,
|
|
retention: RETENTION,
|
|
info: CE_ANALYTICS_CATALOGUE.info,
|
|
...input
|
|
});
|
|
}
|
|
|
|
function issueOf(run: () => unknown): { path: unknown; message: string } | undefined {
|
|
try {
|
|
run();
|
|
} catch (error) {
|
|
if (error instanceof ApiError) {
|
|
const issues = error.details?.issues as { path: unknown; message: string }[] | undefined;
|
|
|
|
return issues?.[0];
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function firstIssue(schema: z.ZodType, value: unknown) {
|
|
const result = schema.safeParse(value);
|
|
|
|
return result.success
|
|
? undefined
|
|
: { path: result.error.issues[0]?.path, message: result.error.issues[0]?.message };
|
|
}
|
|
|
|
const at = (iso: string) => new Date(iso);
|
|
|
|
describe('analytics planner', () => {
|
|
it.each([
|
|
[DAY, '5m', 'analytics_5min', 288],
|
|
[7 * DAY, '1h', 'analytics_hourly', 168],
|
|
[30 * DAY, '6h', 'analytics_hourly', 120],
|
|
[100 * DAY, '1d', 'analytics_daily', 101]
|
|
])('resolves auto for a range of %d ms to %s on %s', (span, granularity, collection, buckets) => {
|
|
const result = plan({ from: new Date(NOW.getTime() - span) });
|
|
|
|
expect(result.granularity).toBe(granularity);
|
|
expect(result.collection).toBe(collection);
|
|
expect(result.starts).toHaveLength(buckets);
|
|
});
|
|
|
|
it('rejects sub-hour granularities on long or expired ranges and serves old days hourly', () => {
|
|
expect(issueOf(() => plan({ from: new Date(NOW.getTime() - 2 * DAY), granularity: '5m' }))).toEqual({
|
|
path: ['granularity'],
|
|
message: '5m and 15m require a range of at most 24 hours'
|
|
});
|
|
|
|
const old = { from: new Date(NOW.getTime() - 31 * DAY), to: new Date(NOW.getTime() - 30 * DAY) };
|
|
|
|
expect(plan(old)).toMatchObject({ granularity: '1h', collection: 'analytics_hourly' });
|
|
expect(issueOf(() => plan({ ...old, granularity: '5m' }))?.message).toBe(
|
|
'5m and 15m require a range inside the 5-minute rollup retention (7 days)'
|
|
);
|
|
});
|
|
|
|
it('gates sub-hour granularities on the 5-minute rollup retention, not the stats one', () => {
|
|
const shortRollups = { ...RETENTION, statsMs: 7 * DAY, rollups5MinMs: 2 * DAY };
|
|
const threeDaysAgo = { from: new Date(NOW.getTime() - 4 * DAY), to: new Date(NOW.getTime() - 3 * DAY) };
|
|
|
|
expect(plan({ ...threeDaysAgo, retention: shortRollups })).toMatchObject({ granularity: '1h' });
|
|
expect(plan({ from: new Date(NOW.getTime() - DAY), retention: shortRollups })).toMatchObject({
|
|
granularity: '5m',
|
|
collection: 'analytics_5min'
|
|
});
|
|
|
|
const twoDaysAgo = { from: new Date(NOW.getTime() - 3 * DAY), to: new Date(NOW.getTime() - 2 * DAY) };
|
|
|
|
expect(
|
|
issueOf(() =>
|
|
plan({ ...twoDaysAgo, granularity: '5m', retention: { ...RETENTION, rollups5MinMs: 36 * HOUR } })
|
|
)?.message
|
|
).toBe('5m and 15m require a range inside the 5-minute rollup retention (36 hours)');
|
|
});
|
|
|
|
it('states the bucket limit and the daily and hourly metric rules', () => {
|
|
expect(issueOf(() => plan({ from: new Date(NOW.getTime() - 60 * DAY), granularity: '1h' }))?.message).toContain(
|
|
'limit of 1000 buckets'
|
|
);
|
|
expect(issueOf(() => plan({ from: new Date(NOW.getTime() - DAY), metrics: ['uniqueIdentities'] }))).toEqual({
|
|
path: ['metrics'],
|
|
message: 'uniqueIdentities requires granularity=1d'
|
|
});
|
|
expect(
|
|
issueOf(() => plan({ from: new Date(NOW.getTime() - DAY), metrics: ['egressStarted', 'egressEnded'] }))
|
|
?.message
|
|
).toBe('egressEnded requires a granularity of at least 1h, not 5m');
|
|
expect(
|
|
plan({ from: new Date(NOW.getTime() - DAY), metrics: ['egressStarted', 'egressEnded'], granularity: '1h' })
|
|
.starts
|
|
).toHaveLength(24);
|
|
});
|
|
|
|
it('cuts local days of another zone from hourly documents while they are retained, UTC days beyond', () => {
|
|
const week = plan({
|
|
from: at('2026-10-29T04:00:00Z'),
|
|
to: at('2026-11-05T05:00:00Z'),
|
|
tz: 'America/New_York',
|
|
granularity: '1d'
|
|
});
|
|
|
|
expect(week.collection).toBe('analytics_hourly');
|
|
expect(week.starts.map((start) => start.toISOString().slice(10))).toEqual([
|
|
'T04:00:00.000Z',
|
|
'T04:00:00.000Z',
|
|
'T04:00:00.000Z',
|
|
'T04:00:00.000Z',
|
|
'T05:00:00.000Z',
|
|
'T05:00:00.000Z',
|
|
'T05:00:00.000Z'
|
|
]);
|
|
|
|
const old = plan({
|
|
from: new Date(NOW.getTime() - 500 * DAY),
|
|
to: new Date(NOW.getTime() - 480 * DAY),
|
|
tz: 'America/New_York',
|
|
granularity: '1d'
|
|
});
|
|
|
|
expect(old.collection).toBe('analytics_daily');
|
|
expect(old.starts[0]?.toISOString()).toMatch(/T00:00:00\.000Z$/);
|
|
});
|
|
|
|
it('snaps breakdown ranges to hours up to 90 days and to UTC days beyond', () => {
|
|
expect(snapRange(at('2026-10-13T11:40:00Z'), at('2026-10-13T12:40:00Z'))).toEqual({
|
|
collection: 'analytics_hourly',
|
|
from: at('2026-10-13T11:00:00Z'),
|
|
to: at('2026-10-13T13:00:00Z')
|
|
});
|
|
expect(snapRange(new Date(NOW.getTime() - 120 * DAY), NOW)).toMatchObject({
|
|
collection: 'analytics_daily',
|
|
from: at('2026-06-15T00:00:00Z'),
|
|
to: at('2026-10-14T00:00:00Z')
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('analytics schemas', () => {
|
|
it.each([
|
|
[{ metrics: 'roomsCreated,roomsDeleted' }, ['metrics'], 'roomsDeleted is not one of'],
|
|
[{ metrics: 'bytesUp,bytesUp' }, ['metrics'], 'lists bytesUp more than once'],
|
|
[{ metrics: '' }, ['metrics'], 'must list at least one value'],
|
|
[{ metrics: 'bytesUp', granularity: '1m' }, ['granularity'], 'the finest analytics granularity is 5m'],
|
|
[{ metrics: 'bytesUp', scope: 'node:' }, ['scope'], 'must be global or node:<id>']
|
|
])('rejects %j', (query, path, message) => {
|
|
expect(firstIssue(seriesQuery, query)).toEqual({ path, message: expect.stringContaining(message) as string });
|
|
});
|
|
|
|
it('defaults the series query and validates the export query', () => {
|
|
expect(series({ metrics: 'roomsCreated,bytesUp' })).toMatchObject({
|
|
metrics: ['roomsCreated', 'bytesUp'],
|
|
granularity: 'auto',
|
|
scope: 'global',
|
|
from: at('2026-10-12T12:00:00Z'),
|
|
to: NOW
|
|
});
|
|
expect(
|
|
firstIssue(
|
|
analyticsExportQuery(() => NOW),
|
|
{ kind: 'series', metrics: 'nope' }
|
|
)?.path
|
|
).toEqual(['metrics']);
|
|
expect(
|
|
firstIssue(
|
|
analyticsExportQuery(() => NOW),
|
|
{ metrics: 'bytesUp' }
|
|
)?.path
|
|
).toEqual(['kind']);
|
|
expect(analyticsExportQuery(() => NOW).parse({ kind: 'breakdowns', dimensions: 'sdk' })).toMatchObject({
|
|
kind: 'breakdowns',
|
|
dimensions: ['sdk']
|
|
});
|
|
expect(analyticsSummaryQuery(() => NOW).parse({ scope: 'node:n1' }).scope).toBe('node:n1');
|
|
});
|
|
});
|
|
|
|
describe('analytics catalogue', () => {
|
|
it('defines every CE metric and dimension', () => {
|
|
expect(Object.keys(CE_ANALYTICS_CATALOGUE.metrics).sort()).toEqual(
|
|
ANALYTICS_CE_METRICS.filter((metric) => metric !== 'egressEnded').sort()
|
|
);
|
|
expect(Object.keys(CE_ANALYTICS_CATALOGUE.dimensions).sort()).toEqual([...ANALYTICS_CE_DIMENSIONS].sort());
|
|
});
|
|
});
|
|
|
|
describe('analytics feature gate', () => {
|
|
const ce = new AnalyticsFeatureGate(() => []);
|
|
|
|
it('rejects a request with any locked item, naming the first one', () => {
|
|
expect(() => {
|
|
ce.check({ metrics: ['roomsCreated', 'scoreAvg'] });
|
|
}).toThrow(
|
|
expect.objectContaining({ code: 'PRO_FEATURE', details: { feature: 'analytics-quality' } }) as Error
|
|
);
|
|
expect(() => {
|
|
ce.check({ dimensions: ['sdk', 'apiMethod'] });
|
|
}).toThrow(expect.objectContaining({ details: { feature: 'analytics-diagnostics' } }) as Error);
|
|
expect(() => {
|
|
ce.check({ scope: 'node:n1' });
|
|
}).toThrow(expect.objectContaining({ details: { feature: 'per-node-breakdown' } }) as Error);
|
|
expect(() => {
|
|
ce.check({ metrics: ['roomsCreated'], dimensions: ['sdk'], scope: 'global' });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('allows the items of the enabled features', () => {
|
|
const pro = new AnalyticsFeatureGate(() => ['analytics-quality', 'per-node-breakdown']);
|
|
|
|
expect(() => {
|
|
pro.check({ metrics: ['scoreAvg', 'ingressSessions'], scope: 'node:n1' });
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('analytics series', () => {
|
|
it('fills a day of five-minute buckets from three documents', async () => {
|
|
const docs = ['2026-10-13T08:00:00Z', '2026-10-13T08:05:00Z', '2026-10-13T11:55:00Z'].map((bucket) => ({
|
|
scope: 'global',
|
|
bucket: at(bucket),
|
|
rooms: { created: 2 }
|
|
}));
|
|
const response = await service(fakeDb({ analytics_5min: docs })).series(
|
|
series({ metrics: 'roomsCreated,connectionSuccessRate' })
|
|
);
|
|
const [rooms, rate] = response.series;
|
|
|
|
expect(analyticsSeriesResponse.parse(response)).toEqual(response);
|
|
expect(response).toMatchObject({
|
|
granularity: '5m',
|
|
scope: 'global',
|
|
from: '2026-10-12T12:00:00.000Z',
|
|
to: '2026-10-13T12:00:00.000Z'
|
|
});
|
|
expect(rooms?.points).toHaveLength(288);
|
|
expect(rooms?.points.filter(([, value]) => value === 2)).toHaveLength(3);
|
|
expect(rooms?.points.filter(([, value]) => value === 0)).toHaveLength(285);
|
|
expect(rate?.points.every(([, value]) => value === null)).toBe(true);
|
|
});
|
|
|
|
it('aggregates fifteen minutes from summed numerators and denominators', async () => {
|
|
const docs = [
|
|
{ joined: 2, active: 1 },
|
|
{ joined: 4, active: 4 },
|
|
{ joined: 4, active: 3 }
|
|
].map((participants, index) => ({
|
|
scope: 'global',
|
|
bucket: new Date(at('2026-10-13T10:00:00Z').getTime() + index * 5 * MINUTE),
|
|
participants
|
|
}));
|
|
const response = await service(fakeDb({ analytics_5min: docs })).series(
|
|
series({ metrics: 'connectionSuccessRate,participantsJoined', granularity: '15m' })
|
|
);
|
|
const bucket = (name: string) =>
|
|
response.series
|
|
.find((item) => item.name === name)
|
|
?.points.find(([ts]) => ts === '2026-10-13T10:00:00.000Z');
|
|
|
|
expect(bucket('connectionSuccessRate')).toEqual(['2026-10-13T10:00:00.000Z', 80]);
|
|
expect(bucket('participantsJoined')).toEqual(['2026-10-13T10:00:00.000Z', 10]);
|
|
});
|
|
|
|
it('computes the connection success rate and the participant minutes', async () => {
|
|
const db = fakeDb({
|
|
analytics_5min: [
|
|
{ scope: 'global', bucket: at('2026-10-13T10:00:00Z'), participants: { joined: 40, active: 38 } }
|
|
],
|
|
analytics_hourly: [{ scope: 'global', bucket: at('2026-10-13T10:00:00Z'), participants: { minutes: 1234 } }]
|
|
});
|
|
const rate = await service(db).series(series({ metrics: 'connectionSuccessRate' }));
|
|
const minutes = await service(db).series(series({ metrics: 'participantMinutes', granularity: '1h' }));
|
|
|
|
expect(rate.series[0]?.points.find(([ts]) => ts === '2026-10-13T10:00:00.000Z')?.[1]).toBe(95);
|
|
expect(rate.series[0]?.points[0]?.[1]).toBeNull();
|
|
expect(minutes.series[0]?.points.find(([ts]) => ts === '2026-10-13T10:00:00.000Z')?.[1]).toBe(1234);
|
|
expect(minutes.series[0]?.points[0]?.[1]).toBe(0);
|
|
});
|
|
|
|
it('expands egressEnded into the statuses present in the range', async () => {
|
|
const db = fakeDb({
|
|
analytics_hourly: [
|
|
{
|
|
scope: 'global',
|
|
bucket: at('2026-10-13T09:00:00Z'),
|
|
egress: { endedByStatus: { EGRESS_COMPLETE: 3 } }
|
|
},
|
|
{ scope: 'global', bucket: at('2026-10-13T10:00:00Z'), egress: { endedByStatus: { EGRESS_FAILED: 1 } } }
|
|
]
|
|
});
|
|
const response = await service(db).series(series({ metrics: 'egressEnded', granularity: '1h' }));
|
|
|
|
expect(response.series.map((item) => item.name)).toEqual([
|
|
'egressEnded.EGRESS_COMPLETE',
|
|
'egressEnded.EGRESS_FAILED'
|
|
]);
|
|
expect(response.series[1]?.points.at(-2)).toEqual(['2026-10-13T10:00:00.000Z', 1]);
|
|
});
|
|
|
|
it('sums hourly documents per local day and keeps unique identities on UTC days', async () => {
|
|
const from = at('2026-10-28T04:00:00Z');
|
|
const to = at('2026-11-04T05:00:00Z');
|
|
const hourly: Doc[] = [];
|
|
|
|
for (let time = from.getTime(); time < to.getTime(); time += HOUR) {
|
|
hourly.push({ scope: 'global', bucket: new Date(time), rooms: { created: 1 } });
|
|
}
|
|
|
|
const daily: Doc[] = [0, 1, 2, 3, 4, 5, 6, 7].map((day) => ({
|
|
scope: 'global',
|
|
bucket: new Date(at('2026-10-28T00:00:00Z').getTime() + day * DAY),
|
|
uniqueIdentities: 10 + day
|
|
}));
|
|
const now = () => at('2026-11-10T00:00:00Z');
|
|
const query = analyticsSeriesQuery(now).parse({
|
|
metrics: 'uniqueIdentities,roomsCreated',
|
|
from: from.toISOString(),
|
|
to: to.toISOString(),
|
|
tz: 'America/New_York',
|
|
granularity: '1d'
|
|
});
|
|
const response = await service(fakeDb({ analytics_hourly: hourly, analytics_daily: daily }), { now }).series(
|
|
query
|
|
);
|
|
const [identities, rooms] = response.series;
|
|
|
|
expect(identities).toMatchObject({ name: 'uniqueIdentities', basis: 'utcDay' });
|
|
expect(identities?.points.every(([ts]) => ts.endsWith('T00:00:00.000Z'))).toBe(true);
|
|
expect(rooms?.basis).toBeUndefined();
|
|
expect(rooms?.points.map(([, value]) => value)).toEqual([24, 24, 24, 24, 25, 24, 24]);
|
|
expect(rooms?.points[5]?.[0]).toBe('2026-11-02T05:00:00.000Z');
|
|
});
|
|
});
|
|
|
|
describe('analytics cache', () => {
|
|
it('reads only the open bucket again within the TTL, and everything with a TTL of zero', async () => {
|
|
const docs = [0, 1, 2].map((index) => ({
|
|
scope: 'global',
|
|
bucket: new Date(at('2026-10-13T11:45:00Z').getTime() + index * 5 * MINUTE),
|
|
bytes: { up: 100 }
|
|
}));
|
|
const reads: Read[] = [];
|
|
const now = () => at('2026-10-13T11:57:00Z');
|
|
const query = analyticsSeriesQuery(now).parse({ metrics: 'bytesUp' });
|
|
const cached = service(fakeDb({ analytics_5min: docs }, reads), { now, ttlMs: 60_000 });
|
|
|
|
await cached.series(query);
|
|
reads.length = 0;
|
|
|
|
const second = await cached.series(query);
|
|
|
|
expect(reads).toEqual([
|
|
{ collection: 'analytics_5min', from: at('2026-10-13T11:55:00Z'), to: at('2026-10-13T11:57:00Z') }
|
|
]);
|
|
expect(second.series[0]?.points.slice(-3).map(([, value]) => value)).toEqual([100, 100, 100]);
|
|
|
|
reads.length = 0;
|
|
await service(fakeDb({ analytics_5min: docs }, reads), { now }).series(query);
|
|
await service(fakeDb({ analytics_5min: docs }, reads), { now }).series(query);
|
|
expect(reads.map((read) => read.from.toISOString())).toEqual([
|
|
'2026-10-12T11:55:00.000Z',
|
|
'2026-10-12T11:55:00.000Z'
|
|
]);
|
|
});
|
|
|
|
it('expires entries after the TTL and evicts the least recently used', () => {
|
|
let time = 0;
|
|
const cache = new AnalyticsCache(1000, () => new Date(time), 2);
|
|
|
|
cache.set('a', new Map());
|
|
cache.set('b', new Map());
|
|
cache.get('a');
|
|
cache.set('c', new Map());
|
|
expect(cache.get('b')).toBeUndefined();
|
|
expect(cache.get('a')).toBeDefined();
|
|
time = 1000;
|
|
expect(cache.get('a')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('analytics summary and breakdowns', () => {
|
|
const summaryQuery = analyticsSummaryQuery(() => NOW);
|
|
|
|
it('derives the summary values from the hourly documents and the daily identities', async () => {
|
|
const db = fakeDb({
|
|
analytics_hourly: [
|
|
{
|
|
scope: 'global',
|
|
bucket: at('2026-10-13T09:00:00Z'),
|
|
rooms: { created: 120, ended: 110, participantsSum: 440, durationMsSum: 39_600_000 },
|
|
participants: { joined: 900, active: 880 },
|
|
apiCalls: { total: 5000 },
|
|
apiErrors: { total: 50 }
|
|
}
|
|
],
|
|
analytics_daily: [
|
|
{ scope: 'global', bucket: at('2026-10-10T00:00:00Z'), uniqueIdentities: 100 },
|
|
{ scope: 'global', bucket: at('2026-10-11T00:00:00Z'), uniqueIdentities: 120 },
|
|
{ scope: 'global', bucket: at('2026-10-12T00:00:00Z'), uniqueIdentities: 90 },
|
|
{ scope: 'global', bucket: at('2026-10-13T00:00:00Z') }
|
|
]
|
|
});
|
|
const summary = await service(db).summary(summaryQuery.parse({ from: '2026-10-10T06:00:00Z' }));
|
|
|
|
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 }
|
|
});
|
|
});
|
|
|
|
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 } }]
|
|
});
|
|
const summary = await service(db).summary(summaryQuery.parse({}));
|
|
|
|
expect(summary).not.toHaveProperty('avgRoomSize');
|
|
expect(summary).not.toHaveProperty('avgRoomDurationMs');
|
|
expect(summary).not.toHaveProperty('uniqueIdentities');
|
|
expect(summary).toMatchObject({ roomsCreated: 3, roomsEnded: 2, bytesUp: 0 });
|
|
});
|
|
|
|
it('sorts breakdown keys by count with percentages of the dimension total', async () => {
|
|
const db = fakeDb({
|
|
analytics_hourly: [
|
|
{
|
|
scope: 'global',
|
|
bucket: at('2026-10-13T09:00:00Z'),
|
|
participants: { bySdk: { JS: 400, SWIFT: 300 } }
|
|
},
|
|
{
|
|
scope: 'global',
|
|
bucket: at('2026-10-13T10:00:00Z'),
|
|
participants: { bySdk: { JS: 200, ANDROID: 100 } }
|
|
}
|
|
]
|
|
});
|
|
const response = await service(db).breakdowns(
|
|
analyticsBreakdownsQuery(() => NOW).parse({ dimensions: 'sdk,egressType', from: 'now-3h' })
|
|
);
|
|
|
|
expect(analyticsBreakdownsResponse.parse(response)).toEqual(response);
|
|
expect(response.from).toBe('2026-10-13T09:00:00.000Z');
|
|
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: [] }
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('analytics CSV', () => {
|
|
it('quotes cells with separators, quotes or line breaks and ends every line with CRLF', () => {
|
|
expect(
|
|
toCsv(
|
|
['a', 'b'],
|
|
[
|
|
['x,y', 'say "hi"'],
|
|
[null, 3]
|
|
]
|
|
)
|
|
).toBe('a,b\r\n"x,y","say ""hi"""\r\n,3\r\n');
|
|
});
|
|
|
|
it('writes one row per bucket and one per breakdown key', () => {
|
|
const metrics: AnalyticsMetric[] = ['bytesUp', 'connectionSuccessRate'];
|
|
|
|
expect(
|
|
seriesCsv({
|
|
granularity: '1h',
|
|
from: '2026-10-13T10:00:00.000Z',
|
|
to: '2026-10-13T12:00:00.000Z',
|
|
scope: 'global',
|
|
series: metrics.map((name, index) => ({
|
|
name,
|
|
points: [
|
|
['2026-10-13T10:00:00.000Z', index === 0 ? 1200 : null],
|
|
['2026-10-13T11:00:00.000Z', index === 0 ? 0 : 4.1]
|
|
]
|
|
}))
|
|
})
|
|
).toBe(
|
|
'bucket,bytesUp,connectionSuccessRate\r\n2026-10-13T10:00:00.000Z,1200,\r\n2026-10-13T11:00:00.000Z,0,4.1\r\n'
|
|
);
|
|
expect(
|
|
breakdownsCsv({
|
|
from: '2026-10-13T10:00:00.000Z',
|
|
to: '2026-10-13T12:00:00.000Z',
|
|
scope: 'global',
|
|
breakdowns: [
|
|
{ dimension: 'sdk', total: 3, items: [{ key: 'JS', count: 3, percent: 100 }] },
|
|
{ dimension: 'os', total: 1, items: [{ key: 'linux', count: 1, percent: 100 }] }
|
|
]
|
|
})
|
|
).toBe('dimension,key,count,percent\r\nsdk,JS,3,100\r\nos,linux,1,100\r\n');
|
|
expect(ANALYTICS_CSV_CONTENT_TYPE).toBe('text/csv; charset=utf-8');
|
|
});
|
|
});
|