2026-10-25 16:35:47 +01:00
|
|
|
import { type Long } from 'mongodb';
|
|
|
|
|
import {
|
|
|
|
|
int64,
|
|
|
|
|
subscriptionBitrate,
|
|
|
|
|
trackBitrate,
|
|
|
|
|
type EgressDoc,
|
|
|
|
|
type IngressDoc,
|
|
|
|
|
type IngressSessionDoc,
|
|
|
|
|
type ParticipantDoc,
|
|
|
|
|
type RoomBundle,
|
|
|
|
|
type SubscriptionDoc,
|
|
|
|
|
type TrackDoc
|
|
|
|
|
} from './model.js';
|
|
|
|
|
import type { Rng } from './random.js';
|
|
|
|
|
|
|
|
|
|
export type StatsMode = 'participant' | 'track';
|
|
|
|
|
|
|
|
|
|
export interface DeriveWindows {
|
|
|
|
|
now: number;
|
|
|
|
|
/** Raw events dated before this instant are not stored (events retention). */
|
|
|
|
|
eventsFrom: number;
|
|
|
|
|
/** Stats dated before this instant are not stored (stats retention). */
|
|
|
|
|
statsFrom: number;
|
|
|
|
|
/** 5-minute rollups dated before this instant are not stored (5-minute rollup retention). */
|
|
|
|
|
fiveMinuteFrom: number;
|
|
|
|
|
statsMode: StatsMode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface EventDoc {
|
|
|
|
|
_id: string;
|
|
|
|
|
type: string;
|
|
|
|
|
ts: Date;
|
|
|
|
|
roomSid: string;
|
|
|
|
|
roomName: string;
|
|
|
|
|
participantSid?: string;
|
|
|
|
|
identity?: string;
|
|
|
|
|
trackSid?: string;
|
|
|
|
|
egressId?: string;
|
|
|
|
|
ingressId?: string;
|
|
|
|
|
ingressSessionId?: string;
|
|
|
|
|
nodeId: string;
|
|
|
|
|
synthetic: boolean;
|
|
|
|
|
payload: Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface StatsDoc {
|
|
|
|
|
ts: Date;
|
|
|
|
|
meta: { roomSid: string; participantSid: string; trackSid?: string; kind: 'up' | 'down'; nodeId: string };
|
|
|
|
|
score: number;
|
|
|
|
|
minScore: number;
|
|
|
|
|
medianScore: number;
|
|
|
|
|
bytes: Long;
|
|
|
|
|
packets: number;
|
|
|
|
|
packetsLost: number;
|
|
|
|
|
rttMs: number;
|
|
|
|
|
jitterMs: number;
|
|
|
|
|
nacks: number;
|
|
|
|
|
plis: number;
|
|
|
|
|
firs: number;
|
|
|
|
|
frames: number;
|
|
|
|
|
layers: { layer: number; bytes: Long; packets: number; frames: number }[];
|
|
|
|
|
mimeType?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const HOUR = 3_600_000;
|
|
|
|
|
const DAY = 86_400_000;
|
|
|
|
|
const FLUSH_MS = 30_000;
|
|
|
|
|
const POOR_SCORE = 3.1;
|
|
|
|
|
const LOST_SCORE = 2.1;
|
|
|
|
|
|
|
|
|
|
export function deriveEvents(
|
|
|
|
|
rng: Rng,
|
|
|
|
|
bundle: RoomBundle,
|
|
|
|
|
windows: DeriveWindows,
|
|
|
|
|
emit: (doc: EventDoc) => void
|
|
|
|
|
): void {
|
|
|
|
|
const { room } = bundle;
|
|
|
|
|
const base = { roomSid: room._id, roomName: room.name, nodeId: room.nodeId };
|
|
|
|
|
const push = (
|
|
|
|
|
type: string,
|
|
|
|
|
ts: Date | null,
|
|
|
|
|
extra: Partial<EventDoc>,
|
|
|
|
|
payload: Record<string, unknown>,
|
|
|
|
|
synthetic = false
|
|
|
|
|
): void => {
|
|
|
|
|
if (ts === null || ts.getTime() < windows.eventsFrom) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
emit({ _id: rng.id(synthetic ? 'AE_SYN_' : 'AE_'), type, ts, ...base, ...extra, synthetic, payload });
|
|
|
|
|
};
|
|
|
|
|
const roomMin = { sid: room._id, name: room.name };
|
|
|
|
|
const roomFull = {
|
|
|
|
|
...roomMin,
|
|
|
|
|
empty_timeout: Math.round(room.config.emptyTimeoutMs / 1000),
|
|
|
|
|
departure_timeout: Math.round(room.config.departureTimeoutMs / 1000),
|
|
|
|
|
max_participants: room.config.maxParticipants,
|
|
|
|
|
creation_time: unixSeconds(room.createdAt),
|
|
|
|
|
creation_time_ms: unixMs(room.createdAt)
|
|
|
|
|
};
|
|
|
|
|
const tracksById = new Map(bundle.tracks.map((t) => [t._id, t]));
|
|
|
|
|
|
|
|
|
|
push('ROOM_CREATED', room.createdAt, {}, { room: roomFull });
|
|
|
|
|
|
|
|
|
|
if (room.closedBy === 'reconciler') {
|
|
|
|
|
push(
|
|
|
|
|
'ROOM_ENDED',
|
|
|
|
|
room.endedAt,
|
|
|
|
|
{},
|
|
|
|
|
{ room: roomMin, room_end_reason: RECONCILED, reconciler: { reason: 'node-dead' } },
|
|
|
|
|
true
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
push('ROOM_ENDED', room.endedAt, {}, { room: roomFull, ...enumField('room_end_reason', roomEndReason(room)) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const p of bundle.participants) {
|
|
|
|
|
const ref = { participantSid: p._id, identity: p.identity };
|
|
|
|
|
const info = participantInfo(p);
|
|
|
|
|
push('PARTICIPANT_JOINED', p.joinedAt, ref, {
|
|
|
|
|
room: roomMin,
|
|
|
|
|
participant: info,
|
|
|
|
|
client_info: clientInfo(p),
|
|
|
|
|
client_meta: { region: p.region, node: p.nodeId }
|
|
|
|
|
});
|
|
|
|
|
push('PARTICIPANT_ACTIVE', p.activeAt, ref, {
|
|
|
|
|
room: roomMin,
|
|
|
|
|
participant: { ...info, state: 'ACTIVE' },
|
|
|
|
|
client_meta: {
|
|
|
|
|
...(p.connectTimeMs === null ? {} : { client_connect_time: p.connectTimeMs }),
|
|
|
|
|
...(p.connectionType === null ? {} : { connection_type: p.connectionType })
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (p.closedBy === 'reconciler') {
|
|
|
|
|
push(
|
|
|
|
|
p.status === 'aborted' ? 'PARTICIPANT_CONNECTION_ABORTED' : 'PARTICIPANT_LEFT',
|
|
|
|
|
p.leftAt,
|
|
|
|
|
ref,
|
|
|
|
|
{
|
|
|
|
|
room: roomMin,
|
|
|
|
|
participant: { sid: p._id, identity: p.identity, disconnect_reason: RECONCILED },
|
|
|
|
|
reconciler: { reason: 'node-dead' }
|
|
|
|
|
},
|
|
|
|
|
true
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
push(p.status === 'aborted' ? 'PARTICIPANT_CONNECTION_ABORTED' : 'PARTICIPANT_LEFT', p.leftAt, ref, {
|
|
|
|
|
room: roomMin,
|
|
|
|
|
participant: {
|
|
|
|
|
...info,
|
|
|
|
|
state: 'DISCONNECTED',
|
|
|
|
|
...(p.isPublisher ? { is_publisher: true } : {}),
|
|
|
|
|
...enumField('disconnect_reason', p.disconnectReason ?? 'UNKNOWN_REASON')
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const t of bundle.tracks) {
|
|
|
|
|
const ref = { participantSid: t.participantSid, identity: t.identity, trackSid: t._id };
|
|
|
|
|
const participant = { sid: t.participantSid, identity: t.identity };
|
|
|
|
|
push('TRACK_PUBLISHED', t.publishedAt, ref, { room: roomMin, participant, track: trackInfo(t) });
|
|
|
|
|
push('TRACK_UNPUBLISHED', t.unpublishedAt, ref, {
|
|
|
|
|
room: roomMin,
|
|
|
|
|
participant: { sid: t.participantSid },
|
|
|
|
|
track: trackInfo(t)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const s of bundle.subscriptions) {
|
|
|
|
|
const ref = { participantSid: s.subscriberSid, identity: s.subscriberIdentity, trackSid: s.trackSid };
|
|
|
|
|
const track = tracksById.get(s.trackSid);
|
|
|
|
|
const participant = { sid: s.subscriberSid };
|
|
|
|
|
|
|
|
|
|
if (s.status === 'failed') {
|
|
|
|
|
push('TRACK_SUBSCRIBE_FAILED', s.subscribedAt, ref, {
|
|
|
|
|
room: roomMin,
|
|
|
|
|
participant,
|
|
|
|
|
track: { sid: s.trackSid },
|
|
|
|
|
error: s.error ?? 'could not find published track'
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
push('TRACK_SUBSCRIBED', s.subscribedAt, ref, {
|
|
|
|
|
room: roomMin,
|
|
|
|
|
participant,
|
|
|
|
|
track: track === undefined ? { sid: s.trackSid } : trackInfo(track),
|
|
|
|
|
publisher: { sid: s.publisherSid, identity: s.publisherIdentity }
|
|
|
|
|
});
|
|
|
|
|
push('TRACK_UNSUBSCRIBED', s.unsubscribedAt, ref, {
|
|
|
|
|
room: roomMin,
|
|
|
|
|
participant,
|
|
|
|
|
track: { sid: s.trackSid, ...(track === undefined ? {} : trackType(track)) }
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const e of bundle.egresses) {
|
|
|
|
|
const ref = { egressId: e._id };
|
|
|
|
|
const egress = egressInfo(e);
|
|
|
|
|
|
|
|
|
|
push('EGRESS_STARTED', e.startedAt, ref, { egress: { ...egress, updated_at: unixNs(e.startedAt) } });
|
|
|
|
|
|
|
|
|
|
for (const h of e.endedAt === null ? e.statusHistory.slice(1) : e.statusHistory.slice(1, -1)) {
|
|
|
|
|
push('EGRESS_UPDATED', h.at, ref, {
|
|
|
|
|
egress: { ...egress, ...egressStatus(h.status), updated_at: unixNs(h.at) }
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (e.endedAt !== null) {
|
|
|
|
|
push(
|
|
|
|
|
'EGRESS_ENDED',
|
|
|
|
|
e.endedAt,
|
|
|
|
|
ref,
|
|
|
|
|
{
|
|
|
|
|
egress: {
|
|
|
|
|
...egress,
|
|
|
|
|
...egressStatus(e.status),
|
|
|
|
|
ended_at: unixNs(e.endedAt),
|
|
|
|
|
updated_at: unixNs(e.endedAt),
|
|
|
|
|
...(e.error === null ? {} : { error: e.error }),
|
|
|
|
|
...(e.errorCode === null ? {} : { error_code: e.errorCode }),
|
|
|
|
|
...egressResults(e)
|
|
|
|
|
},
|
|
|
|
|
...(e.closedBy === 'reconciler' ? { reconciler: { reason: 'node-dead' } } : {})
|
|
|
|
|
},
|
|
|
|
|
e.closedBy === 'reconciler'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const i of bundle.ingresses) {
|
|
|
|
|
push('INGRESS_CREATED', i.createdAt, { ingressId: i._id }, { ingress: { ...ingressInfo(i), state: {} } });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ingressesById = new Map(bundle.ingresses.map((i) => [i._id, i]));
|
|
|
|
|
|
|
|
|
|
for (const s of bundle.ingressSessions) {
|
|
|
|
|
const ref = { ingressId: s.ingressId, ingressSessionId: s._id, identity: s.participantIdentity };
|
|
|
|
|
const ingress = ingressesById.get(s.ingressId);
|
|
|
|
|
const definition = ingress === undefined ? { ingress_id: s.ingressId } : ingressInfo(ingress);
|
|
|
|
|
const state = {
|
|
|
|
|
room_id: s.roomSid,
|
|
|
|
|
started_at: unixNs(s.startedAt),
|
|
|
|
|
resource_id: s._id
|
|
|
|
|
};
|
|
|
|
|
push('INGRESS_STARTED', s.startedAt, ref, {
|
|
|
|
|
ingress: {
|
|
|
|
|
...definition,
|
|
|
|
|
state: {
|
|
|
|
|
status: 'ENDPOINT_PUBLISHING',
|
|
|
|
|
video: {
|
|
|
|
|
mime_type: s.video.mimeType,
|
|
|
|
|
average_bitrate: s.video.averageBitrate,
|
|
|
|
|
width: s.video.width,
|
|
|
|
|
height: s.video.height,
|
|
|
|
|
framerate: s.video.framerate
|
|
|
|
|
},
|
|
|
|
|
audio: {
|
|
|
|
|
mime_type: s.audio.mimeType,
|
|
|
|
|
average_bitrate: s.audio.averageBitrate,
|
|
|
|
|
channels: s.audio.channels,
|
|
|
|
|
sample_rate: s.audio.sampleRate
|
|
|
|
|
},
|
|
|
|
|
...state,
|
|
|
|
|
updated_at: unixNs(s.startedAt)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (s.endedAt !== null) {
|
|
|
|
|
push('INGRESS_ENDED', s.endedAt, ref, {
|
|
|
|
|
ingress: {
|
|
|
|
|
...definition,
|
|
|
|
|
state: {
|
|
|
|
|
...enumField('status', INGRESS_STATES[s.status]),
|
|
|
|
|
...(s.error === null ? {} : { error: s.error }),
|
|
|
|
|
...state,
|
|
|
|
|
ended_at: unixNs(s.endedAt),
|
|
|
|
|
updated_at: unixNs(s.endedAt)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Reason and disconnect reason of the reconciler's synthetic close events. */
|
|
|
|
|
const RECONCILED = 'RECONCILED';
|
|
|
|
|
|
|
|
|
|
/** Protojson names of the ingress session states; the zero value `ENDPOINT_INACTIVE` is never written. */
|
|
|
|
|
const INGRESS_STATES: Record<IngressSessionDoc['status'], string> = {
|
|
|
|
|
buffering: 'ENDPOINT_BUFFERING',
|
|
|
|
|
publishing: 'ENDPOINT_PUBLISHING',
|
|
|
|
|
error: 'ENDPOINT_ERROR',
|
|
|
|
|
complete: 'ENDPOINT_COMPLETE'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Protojson zero values, which the writer's codec omits. */
|
|
|
|
|
const ZERO_ENUMS = new Set(['STANDARD', 'AUDIO', 'LOW', 'ROOM_END_UNKNOWN', 'UNKNOWN_REASON', 'EGRESS_STARTING']);
|
|
|
|
|
|
|
|
|
|
/** `{ [name]: value }`, or nothing when the value is absent: the driver would store `undefined` as `null`. */
|
|
|
|
|
function defined(name: string, value: string | undefined): Record<string, string> {
|
|
|
|
|
return value === undefined ? {} : { [name]: value };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function enumField(name: string, value: string): Record<string, string> {
|
|
|
|
|
return ZERO_ENUMS.has(value) ? {} : { [name]: value };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function roomEndReason(room: RoomBundle['room']): string {
|
|
|
|
|
return `ROOM_END_${room.endReason ?? 'UNKNOWN'}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function unixSeconds(date: Date): Long {
|
|
|
|
|
return int64(Math.floor(date.getTime() / 1000));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function unixMs(date: Date): Long {
|
|
|
|
|
return int64(date.getTime());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function unixNs(date: Date): Long {
|
|
|
|
|
return int64(date.getTime()).multiply(1_000_000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function participantInfo(p: ParticipantDoc): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
sid: p._id,
|
|
|
|
|
identity: p.identity,
|
|
|
|
|
...(p.name === '' ? {} : { name: p.name }),
|
|
|
|
|
...enumField('kind', p.kind),
|
|
|
|
|
...(p.kindDetails.length === 0 ? {} : { kind_details: p.kindDetails }),
|
|
|
|
|
joined_at: unixSeconds(p.joinedAt),
|
|
|
|
|
joined_at_ms: unixMs(p.joinedAt),
|
|
|
|
|
permission: {
|
|
|
|
|
...(p.permission.canSubscribe ? { can_subscribe: true } : {}),
|
|
|
|
|
...(p.permission.canPublish ? { can_publish: true } : {}),
|
|
|
|
|
...(p.permission.canPublishData ? { can_publish_data: true } : {}),
|
|
|
|
|
...(p.permission.hidden ? { hidden: true } : {})
|
|
|
|
|
},
|
|
|
|
|
region: p.region
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clientInfo(p: ParticipantDoc): Record<string, unknown> {
|
|
|
|
|
const c = p.client;
|
|
|
|
|
const fields: [string, string][] = [
|
|
|
|
|
['sdk', c.sdk],
|
|
|
|
|
['version', c.version],
|
|
|
|
|
['os', c.os],
|
|
|
|
|
['os_version', c.osVersion],
|
|
|
|
|
['browser', c.browser],
|
|
|
|
|
['browser_version', c.browserVersion],
|
|
|
|
|
['device_model', c.deviceModel],
|
|
|
|
|
['network', c.network]
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return Object.fromEntries(fields.filter(([, value]) => value !== ''));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trackType(t: TrackDoc): Record<string, string> {
|
|
|
|
|
return enumField('type', t.type);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trackLayers(layers: TrackDoc['codecs'][number]['layers']): Record<string, unknown>[] {
|
|
|
|
|
return layers.map((l) => ({
|
|
|
|
|
...enumField('quality', l.quality),
|
|
|
|
|
width: l.width,
|
|
|
|
|
height: l.height,
|
|
|
|
|
bitrate: l.bitrate
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trackInfo(t: TrackDoc): Record<string, unknown> {
|
|
|
|
|
const layers = t.codecs[0]?.layers ?? [];
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
sid: t._id,
|
|
|
|
|
...trackType(t),
|
|
|
|
|
name: t.name,
|
|
|
|
|
...(t.muted ? { muted: true } : {}),
|
|
|
|
|
...(t.width > 0 ? { width: t.width, height: t.height } : {}),
|
|
|
|
|
...(t.simulcast ? { simulcast: true } : {}),
|
|
|
|
|
source: t.source,
|
|
|
|
|
mime_type: t.mimeType,
|
|
|
|
|
...(layers.length === 0 ? {} : { layers: trackLayers(layers) }),
|
|
|
|
|
codecs: t.codecs.map((c) => ({
|
|
|
|
|
mime_type: c.mimeType,
|
|
|
|
|
...(c.layers.length === 0 ? {} : { layers: trackLayers(c.layers) })
|
|
|
|
|
})),
|
|
|
|
|
...(t.encryption === 'GCM' ? { encryption: 'GCM' } : {})
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function egressStatus(status: EgressDoc['status']): Record<string, string> {
|
|
|
|
|
return enumField('status', `EGRESS_${status.toUpperCase()}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 17:05:00 +01:00
|
|
|
/**
|
|
|
|
|
* The request of an egress as the writer stores it in `rawRequest` and in its events: the set member of the oneof under
|
|
|
|
|
* its name.
|
|
|
|
|
*/
|
|
|
|
|
export function egressRawRequest(e: EgressDoc): Record<string, unknown> {
|
2026-10-25 16:35:47 +01:00
|
|
|
const r = e.request as Partial<
|
|
|
|
|
Record<'participantIdentity' | 'trackId' | 'videoTrackId' | 'audioTrackId', string>
|
|
|
|
|
> & {
|
|
|
|
|
preset?: string;
|
|
|
|
|
layout?: string;
|
|
|
|
|
};
|
|
|
|
|
const options = { options: { preset: r.preset ?? 'H264_720P_30' } };
|
|
|
|
|
const output = e.outputs[0];
|
|
|
|
|
const outputs =
|
|
|
|
|
output === undefined
|
|
|
|
|
? {}
|
|
|
|
|
: output.kind === 'file'
|
|
|
|
|
? { file_outputs: [{ file_type: 'MP4', ...defined('filepath', output.filename) }] }
|
|
|
|
|
: output.kind === 'stream'
|
|
|
|
|
? { stream_outputs: [{ protocol: 'RTMP', urls: ['rtmps://stream.example.com/live/***'] }] }
|
|
|
|
|
: { segment_outputs: [defined('playlist_name', output.playlistName)] };
|
|
|
|
|
|
|
|
|
|
switch (e.type) {
|
|
|
|
|
case 'room_composite':
|
|
|
|
|
return { room_composite: { room_name: e.roomName, layout: r.layout ?? 'grid', ...options, ...outputs } };
|
|
|
|
|
case 'web':
|
|
|
|
|
return { web: { url: 'https://app.example.com/***', ...options, ...outputs } };
|
|
|
|
|
case 'participant':
|
|
|
|
|
return {
|
|
|
|
|
participant: {
|
|
|
|
|
room_name: e.roomName,
|
|
|
|
|
...defined('identity', r.participantIdentity),
|
|
|
|
|
...options,
|
|
|
|
|
...outputs
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
case 'track_composite':
|
|
|
|
|
return {
|
|
|
|
|
track_composite: {
|
|
|
|
|
room_name: e.roomName,
|
|
|
|
|
...defined('audio_track_id', r.audioTrackId),
|
|
|
|
|
...defined('video_track_id', r.videoTrackId),
|
|
|
|
|
...options,
|
|
|
|
|
...outputs
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
case 'track':
|
|
|
|
|
return { track: { room_name: e.roomName, ...defined('track_id', r.trackId) } };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function egressInfo(e: EgressDoc): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
egress_id: e._id,
|
|
|
|
|
room_id: e.roomSid,
|
|
|
|
|
room_name: e.roomName,
|
|
|
|
|
...(e.sourceType === 'sdk' ? { source_type: 'EGRESS_SOURCE_TYPE_SDK' } : {}),
|
|
|
|
|
started_at: unixNs(e.startedAt),
|
2026-10-27 17:05:00 +01:00
|
|
|
request: egressRawRequest(e)
|
2026-10-25 16:35:47 +01:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function egressResults(e: EgressDoc): Record<string, unknown> {
|
|
|
|
|
const files: Record<string, unknown>[] = [];
|
|
|
|
|
const streams: Record<string, unknown>[] = [];
|
|
|
|
|
const segments: Record<string, unknown>[] = [];
|
|
|
|
|
|
|
|
|
|
for (const o of e.outputs) {
|
|
|
|
|
const times = {
|
|
|
|
|
started_at: unixNs(o.startedAt),
|
|
|
|
|
...(o.endedAt === null ? {} : { ended_at: unixNs(o.endedAt) }),
|
|
|
|
|
duration: int64(o.durationMs).multiply(1_000_000)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (o.kind === 'file') {
|
|
|
|
|
files.push({ ...defined('filename', o.filename), ...times, size: o.size, location: o.location });
|
|
|
|
|
} else if (o.kind === 'stream') {
|
|
|
|
|
streams.push({ url: 'rtmps://stream.example.com/live/***', status: o.status, ...times });
|
|
|
|
|
} else {
|
|
|
|
|
segments.push({
|
|
|
|
|
...defined('playlist_name', o.playlistName),
|
|
|
|
|
playlist_location: o.location,
|
|
|
|
|
...times,
|
|
|
|
|
size: o.size
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...(files.length === 0 ? {} : { file_results: files }),
|
|
|
|
|
...(streams.length === 0 ? {} : { stream_results: streams }),
|
|
|
|
|
...(segments.length === 0 ? {} : { segment_results: segments })
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ingressInfo(i: IngressDoc): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
ingress_id: i._id,
|
|
|
|
|
name: i.name,
|
|
|
|
|
url: i.url,
|
|
|
|
|
...(i.inputType === 'rtmp' ? {} : { input_type: `${i.inputType.toUpperCase()}_INPUT` }),
|
|
|
|
|
room_name: i.roomName,
|
|
|
|
|
participant_identity: i.participantIdentity,
|
|
|
|
|
participant_name: i.participantName,
|
|
|
|
|
...(i.reusable ? { reusable: true } : {}),
|
|
|
|
|
...(i.enabled ? { enabled: true } : {}),
|
|
|
|
|
...(i.enableTranscoding ? { enable_transcoding: true } : {})
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function deriveStats(rng: Rng, bundle: RoomBundle, windows: DeriveWindows, emit: (doc: StatsDoc) => void): void {
|
2026-11-02 20:39:42 +01:00
|
|
|
if (bundle.room.mediaStats === false) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-25 16:35:47 +01:00
|
|
|
const tracksById = new Map(bundle.tracks.map((t) => [t._id, t]));
|
|
|
|
|
const tracksByParticipant = groupBy(bundle.tracks, (t) => t.participantSid);
|
|
|
|
|
const subsBySubscriber = groupBy(
|
|
|
|
|
bundle.subscriptions.filter((s) => s.status !== 'failed'),
|
|
|
|
|
(s) => s.subscriberSid
|
|
|
|
|
);
|
|
|
|
|
const nodeId = bundle.room.nodeId;
|
|
|
|
|
|
|
|
|
|
for (const p of bundle.participants) {
|
|
|
|
|
if (p.status === 'aborted' || p.activeAt === null) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const meanScore = bundle.meanScores.get(p._id) ?? 4;
|
|
|
|
|
const start = p.activeAt.getTime();
|
|
|
|
|
const end = p.leftAt?.getTime() ?? windows.now;
|
|
|
|
|
const tracks = tracksByParticipant.get(p._id) ?? [];
|
|
|
|
|
const subs = subsBySubscriber.get(p._id) ?? [];
|
|
|
|
|
const meta = { roomSid: p.roomSid, participantSid: p._id, nodeId };
|
|
|
|
|
|
|
|
|
|
for (let ts = start + FLUSH_MS; ts <= end; ts += FLUSH_MS) {
|
|
|
|
|
if (ts < windows.statsFrom) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const published = tracks.filter(
|
|
|
|
|
(t) => t.publishedAt.getTime() <= ts && (t.unpublishedAt === null || t.unpublishedAt.getTime() >= ts)
|
|
|
|
|
);
|
|
|
|
|
const open = subs.filter(
|
|
|
|
|
(s) => s.subscribedAt.getTime() <= ts && (s.unsubscribedAt === null || s.unsubscribedAt.getTime() >= ts)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (windows.statsMode === 'track') {
|
|
|
|
|
for (const t of published) {
|
|
|
|
|
emit(
|
|
|
|
|
sample(rng, new Date(ts), { ...meta, trackSid: t._id, kind: 'up' }, [t], meanScore, t.mimeType)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const s of open) {
|
|
|
|
|
const t = tracksById.get(s.trackSid);
|
|
|
|
|
|
|
|
|
|
if (t !== undefined) {
|
|
|
|
|
emit(
|
|
|
|
|
sample(
|
|
|
|
|
rng,
|
|
|
|
|
new Date(ts),
|
|
|
|
|
{ ...meta, trackSid: t._id, kind: 'down' },
|
|
|
|
|
[[s, t]],
|
|
|
|
|
meanScore,
|
|
|
|
|
t.mimeType
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if (published.length > 0) {
|
|
|
|
|
emit(sample(rng, new Date(ts), { ...meta, kind: 'up' }, published, meanScore));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (open.length > 0) {
|
|
|
|
|
const pairs: [SubscriptionDoc, TrackDoc][] = [];
|
|
|
|
|
|
|
|
|
|
for (const s of open) {
|
|
|
|
|
const t = tracksById.get(s.trackSid);
|
|
|
|
|
|
|
|
|
|
if (t !== undefined) {
|
|
|
|
|
pairs.push([s, t]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
emit(sample(rng, new Date(ts), { ...meta, kind: 'down' }, pairs, meanScore));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sample(
|
|
|
|
|
rng: Rng,
|
|
|
|
|
ts: Date,
|
|
|
|
|
meta: StatsDoc['meta'],
|
|
|
|
|
sources: readonly (TrackDoc | [SubscriptionDoc, TrackDoc])[],
|
|
|
|
|
meanScore: number,
|
|
|
|
|
mimeType?: string
|
|
|
|
|
): StatsDoc {
|
|
|
|
|
let bytes = 0;
|
|
|
|
|
let frames = 0;
|
|
|
|
|
const layers = new Map<number, { bytes: number; packets: number; frames: number }>();
|
|
|
|
|
|
|
|
|
|
for (const source of sources) {
|
|
|
|
|
const track = Array.isArray(source) ? source[1] : source;
|
|
|
|
|
const bitrate = Array.isArray(source)
|
|
|
|
|
? subscriptionBitrate(source[0].source, source[0].maxVideoQuality, trackBitrate(track))
|
|
|
|
|
: trackBitrate(track);
|
|
|
|
|
const flushBytes = (bitrate / 8) * (FLUSH_MS / 1000) * rng.float(0.85, 1.15);
|
|
|
|
|
bytes += flushBytes;
|
|
|
|
|
|
|
|
|
|
if (track.type === 'VIDEO') {
|
|
|
|
|
const videoFrames = 30 * (FLUSH_MS / 1000);
|
|
|
|
|
frames += videoFrames;
|
|
|
|
|
const layerCount = Array.isArray(source) ? 1 : (track.codecs[0]?.layers.length ?? 1);
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < layerCount; i += 1) {
|
|
|
|
|
const layer = layers.get(i) ?? { bytes: 0, packets: 0, frames: 0 };
|
|
|
|
|
layer.bytes += flushBytes / layerCount;
|
|
|
|
|
layer.packets += Math.round(flushBytes / layerCount / 1_100);
|
|
|
|
|
layer.frames += videoFrames / layerCount;
|
|
|
|
|
layers.set(i, layer);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const packets = Math.round(bytes / 1_100);
|
|
|
|
|
const score = round2(Math.min(4.5, Math.max(1, meanScore + rng.gaussian(0, 0.15))));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
ts,
|
|
|
|
|
meta,
|
|
|
|
|
score,
|
|
|
|
|
minScore: round2(Math.max(1, score - rng.float(0, 0.4))),
|
|
|
|
|
medianScore: score,
|
|
|
|
|
bytes: int64(bytes),
|
|
|
|
|
packets,
|
|
|
|
|
packetsLost: Math.round(packets * rng.clampedGaussian(0.005, 0.006, 0, 0.08)),
|
|
|
|
|
rttMs: Math.round(rng.clampedGaussian(90, 50, 5, 800)),
|
|
|
|
|
jitterMs: Math.round(rng.clampedGaussian(12, 8, 1, 120)),
|
|
|
|
|
nacks: rng.int(0, 3),
|
|
|
|
|
plis: rng.chance(0.05) ? 1 : 0,
|
|
|
|
|
firs: 0,
|
|
|
|
|
frames: Math.round(frames),
|
|
|
|
|
layers: [...layers.entries()].map(([layer, l]) => ({
|
|
|
|
|
layer,
|
|
|
|
|
bytes: int64(l.bytes),
|
|
|
|
|
packets: l.packets,
|
|
|
|
|
frames: Math.round(l.frames)
|
|
|
|
|
})),
|
|
|
|
|
...(mimeType === undefined ? {} : { mimeType })
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Counter = Record<string, number>;
|
|
|
|
|
|
|
|
|
|
interface Bucket {
|
|
|
|
|
rooms: {
|
|
|
|
|
created: number;
|
|
|
|
|
ended: number;
|
|
|
|
|
endedByReason: Counter;
|
|
|
|
|
participantsSum?: number;
|
|
|
|
|
durationMsSum?: number;
|
|
|
|
|
};
|
|
|
|
|
participants: {
|
|
|
|
|
joined: number;
|
|
|
|
|
active: number;
|
|
|
|
|
aborted: number;
|
|
|
|
|
left: number;
|
|
|
|
|
byKind: Counter;
|
|
|
|
|
bySdk: Counter;
|
|
|
|
|
byConnectionType: Counter;
|
|
|
|
|
byOs: Counter;
|
|
|
|
|
byBrowser: Counter;
|
|
|
|
|
disconnectReasons: Counter;
|
|
|
|
|
connectTimeSumMs: number;
|
|
|
|
|
connectTimeCount: number;
|
|
|
|
|
minutes: number;
|
|
|
|
|
};
|
|
|
|
|
tracks: { published: number; subscribed: number; subscribeFailed: number };
|
|
|
|
|
egress: { started: number; endedByStatus: Counter; byType: Counter; durationMs: number };
|
|
|
|
|
ingress: { sessionsStarted: number; sessionsEnded: number; byInputType: Counter; durationMs: number };
|
|
|
|
|
bytes: { up: number; down: number };
|
|
|
|
|
quality: { scoreSum: number; scoreCount: number; poor: number; lost: number };
|
|
|
|
|
identities?: Set<string>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FiveMinuteBucket {
|
|
|
|
|
rooms: { created: number; ended: number };
|
|
|
|
|
participants: { connected: number; joined: number; active: number; left: number; aborted: number };
|
|
|
|
|
egress: { started: number; ended: number };
|
|
|
|
|
bytes: { up: number; down: number };
|
|
|
|
|
quality: { scoreSum: number; scoreCount: number; poor: number; lost: number };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RollupDoc {
|
|
|
|
|
_id: string;
|
|
|
|
|
scope: string;
|
|
|
|
|
bucket: Date;
|
|
|
|
|
rooms: Bucket['rooms'];
|
|
|
|
|
participants: Bucket['participants'];
|
|
|
|
|
tracks: Bucket['tracks'];
|
|
|
|
|
egress: Bucket['egress'];
|
|
|
|
|
ingress: Bucket['ingress'];
|
|
|
|
|
bytes: { up: Long; down: Long };
|
|
|
|
|
quality: Bucket['quality'];
|
|
|
|
|
uniqueIdentities?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface FiveMinuteRollupDoc extends Omit<FiveMinuteBucket, 'bytes'> {
|
|
|
|
|
_id: string;
|
|
|
|
|
scope: string;
|
|
|
|
|
bucket: Date;
|
|
|
|
|
bytes: { up: Long; down: Long };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Rollups beyond the global hourly and daily documents. */
|
|
|
|
|
export interface RollupOptions {
|
|
|
|
|
/** `analytics_5min` documents inside the 5-minute rollup window. */
|
|
|
|
|
fiveMinute: boolean;
|
|
|
|
|
/** `node:<nodeId>` documents beside `global` in every rollup collection. */
|
|
|
|
|
nodes: boolean;
|
|
|
|
|
/** `rooms.participantsSum` and `rooms.durationMsSum` at room end. */
|
|
|
|
|
roomSums: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const BASE_ROLLUPS: RollupOptions = { fiveMinute: false, nodes: false, roomSums: false };
|
|
|
|
|
|
|
|
|
|
const FIVE_MINUTES = 300_000;
|
|
|
|
|
|
|
|
|
|
type Update = (bucket: Bucket, daily: boolean) => void;
|
|
|
|
|
type FiveMinuteUpdate = (bucket: FiveMinuteBucket) => void;
|
|
|
|
|
|
|
|
|
|
/** Accumulates the hourly, daily and (optionally) five-minute rollups of every bundle it receives. */
|
|
|
|
|
export class Rollups {
|
|
|
|
|
private readonly hourly = new Map<string, Bucket>();
|
|
|
|
|
private readonly daily = new Map<string, Bucket>();
|
|
|
|
|
private readonly fiveMinute = new Map<string, FiveMinuteBucket>();
|
|
|
|
|
private scopes: string[] = ['global'];
|
|
|
|
|
private fiveMinuteFrom = Number.POSITIVE_INFINITY;
|
|
|
|
|
|
|
|
|
|
constructor(private readonly options: RollupOptions = BASE_ROLLUPS) {}
|
|
|
|
|
|
|
|
|
|
add(bundle: RoomBundle, windows: DeriveWindows): void {
|
|
|
|
|
const { room } = bundle;
|
|
|
|
|
|
|
|
|
|
this.scopes = this.options.nodes ? ['global', `node:${room.nodeId}`] : ['global'];
|
|
|
|
|
this.fiveMinuteFrom = windows.fiveMinuteFrom;
|
|
|
|
|
this.at(
|
|
|
|
|
room.createdAt,
|
|
|
|
|
(b) => {
|
|
|
|
|
b.rooms.created += 1;
|
|
|
|
|
},
|
|
|
|
|
(f) => {
|
|
|
|
|
f.rooms.created += 1;
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
this.at(
|
|
|
|
|
room.endedAt,
|
|
|
|
|
(b) => {
|
|
|
|
|
b.rooms.ended += 1;
|
|
|
|
|
inc(b.rooms.endedByReason, room.endReason ?? 'UNKNOWN');
|
|
|
|
|
|
|
|
|
|
if (this.options.roomSums) {
|
|
|
|
|
b.rooms.participantsSum = (b.rooms.participantsSum ?? 0) + bundle.room.participants.sessions;
|
|
|
|
|
b.rooms.durationMsSum = (b.rooms.durationMsSum ?? 0) + (room.durationMs ?? 0);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
(f) => {
|
|
|
|
|
f.rooms.ended += 1;
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for (const p of bundle.participants) {
|
|
|
|
|
this.addParticipant(p, bundle, windows);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const t of bundle.tracks) {
|
|
|
|
|
this.at(t.publishedAt, (b) => {
|
|
|
|
|
b.tracks.published += 1;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const s of bundle.subscriptions) {
|
|
|
|
|
this.at(s.subscribedAt, (b) => {
|
|
|
|
|
if (s.status === 'failed') {
|
|
|
|
|
b.tracks.subscribeFailed += 1;
|
|
|
|
|
} else {
|
|
|
|
|
b.tracks.subscribed += 1;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const e of bundle.egresses) {
|
|
|
|
|
this.at(
|
|
|
|
|
e.startedAt,
|
|
|
|
|
(b) => {
|
|
|
|
|
b.egress.started += 1;
|
|
|
|
|
inc(b.egress.byType, e.type);
|
|
|
|
|
},
|
|
|
|
|
(f) => {
|
|
|
|
|
f.egress.started += 1;
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
this.at(
|
|
|
|
|
e.endedAt,
|
|
|
|
|
(b) => {
|
|
|
|
|
inc(b.egress.endedByStatus, e.status);
|
|
|
|
|
b.egress.durationMs += e.durationMs ?? 0;
|
|
|
|
|
},
|
|
|
|
|
(f) => {
|
|
|
|
|
f.egress.ended += 1;
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const s of bundle.ingressSessions) {
|
|
|
|
|
this.at(s.startedAt, (b) => {
|
|
|
|
|
b.ingress.sessionsStarted += 1;
|
|
|
|
|
inc(b.ingress.byInputType, s.inputType);
|
|
|
|
|
});
|
|
|
|
|
this.at(s.endedAt, (b) => {
|
|
|
|
|
b.ingress.sessionsEnded += 1;
|
|
|
|
|
b.ingress.durationMs += s.durationMs ?? 0;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hourlyDocs(): RollupDoc[] {
|
|
|
|
|
return sortedEntries(this.hourly).map(([scope, bucket, b]) => toDoc(scope, bucket, b, false));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dailyDocs(): RollupDoc[] {
|
|
|
|
|
return sortedEntries(this.daily).map(([scope, bucket, b]) => toDoc(scope, bucket, b, true));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fiveMinuteDocs(): FiveMinuteRollupDoc[] {
|
|
|
|
|
return sortedEntries(this.fiveMinute).map(([scope, bucket, b]) => {
|
|
|
|
|
const date = new Date(bucket);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
_id: `${scope}:${date.toISOString().replace('.000Z', 'Z')}`,
|
|
|
|
|
scope,
|
|
|
|
|
bucket: date,
|
|
|
|
|
...b,
|
|
|
|
|
bytes: { up: int64(b.bytes.up), down: int64(b.bytes.down) },
|
|
|
|
|
quality: { ...b.quality, scoreSum: round2(b.quality.scoreSum) }
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private addParticipant(p: ParticipantDoc, bundle: RoomBundle, windows: DeriveWindows): void {
|
|
|
|
|
if (p.kind !== 'STANDARD') {
|
|
|
|
|
this.at(p.joinedAt, (b) => {
|
|
|
|
|
inc(b.participants.byKind, p.kind);
|
|
|
|
|
});
|
|
|
|
|
this.addTraffic(p, bundle, windows);
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.at(
|
|
|
|
|
p.joinedAt,
|
|
|
|
|
(b, daily) => {
|
|
|
|
|
b.participants.joined += 1;
|
|
|
|
|
inc(b.participants.byKind, p.kind);
|
|
|
|
|
inc(b.participants.bySdk, p.client.sdk || 'UNKNOWN');
|
|
|
|
|
inc(b.participants.byOs, p.client.os || 'UNKNOWN');
|
|
|
|
|
inc(b.participants.byBrowser, browserKey(p.client.browser));
|
|
|
|
|
|
|
|
|
|
if (daily) {
|
|
|
|
|
(b.identities ??= new Set()).add(p.identity);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
(f) => {
|
|
|
|
|
f.participants.joined += 1;
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
this.at(
|
|
|
|
|
p.activeAt,
|
|
|
|
|
(b) => {
|
|
|
|
|
b.participants.active += 1;
|
|
|
|
|
inc(b.participants.byConnectionType, p.connectionType ?? 'UNKNOWN');
|
|
|
|
|
b.participants.connectTimeSumMs += p.connectTimeMs ?? 0;
|
|
|
|
|
b.participants.connectTimeCount += 1;
|
|
|
|
|
},
|
|
|
|
|
(f) => {
|
|
|
|
|
f.participants.active += 1;
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
this.at(
|
|
|
|
|
p.leftAt,
|
|
|
|
|
(b) => {
|
|
|
|
|
if (p.status === 'aborted') {
|
|
|
|
|
b.participants.aborted += 1;
|
|
|
|
|
} else {
|
|
|
|
|
b.participants.left += 1;
|
|
|
|
|
b.participants.minutes += (p.durationMs ?? 0) / 60_000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inc(b.participants.disconnectReasons, p.disconnectReason ?? 'UNKNOWN');
|
|
|
|
|
},
|
|
|
|
|
(f) => {
|
|
|
|
|
if (p.status === 'aborted') {
|
|
|
|
|
f.participants.aborted += 1;
|
|
|
|
|
} else {
|
|
|
|
|
f.participants.left += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.addTraffic(p, bundle, windows);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Bytes and quality of every kind, as `track_stats` carries no participant kind. */
|
|
|
|
|
private addTraffic(p: ParticipantDoc, bundle: RoomBundle, windows: DeriveWindows): void {
|
|
|
|
|
if (p.status === 'aborted' || p.activeAt === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bytes and quality are spread over the time the session was connected, as stats flushes would do.
|
|
|
|
|
const start = p.activeAt.getTime();
|
|
|
|
|
const end = p.leftAt?.getTime() ?? windows.now;
|
|
|
|
|
const total = Math.max(1, end - start);
|
|
|
|
|
const meanScore = bundle.meanScores.get(p._id) ?? 4;
|
2026-11-02 20:39:42 +01:00
|
|
|
const directions =
|
|
|
|
|
p.mediaStats === false ? 0 : (p.tracks.published > 0 ? 1 : 0) + (p.tracks.subscribed > 0 ? 1 : 0);
|
2026-10-25 16:35:47 +01:00
|
|
|
const spread = (slot: number, size: number, apply: (share: number, flushes: number) => void) => {
|
|
|
|
|
const overlap = Math.min(end, slot + size) - Math.max(start, slot);
|
|
|
|
|
|
|
|
|
|
apply(overlap / total, Math.floor(overlap / FLUSH_MS) * directions);
|
|
|
|
|
};
|
|
|
|
|
const addQuality = (quality: Bucket['quality'], flushes: number) => {
|
|
|
|
|
quality.scoreSum += meanScore * flushes;
|
|
|
|
|
quality.scoreCount += flushes;
|
|
|
|
|
quality.poor += meanScore < POOR_SCORE && meanScore >= LOST_SCORE ? flushes : 0;
|
|
|
|
|
quality.lost += meanScore < LOST_SCORE ? flushes : 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (let hour = Math.floor(start / HOUR) * HOUR; hour < end; hour += HOUR) {
|
|
|
|
|
spread(hour, HOUR, (share, flushes) => {
|
|
|
|
|
this.at(new Date(hour), (b) => {
|
|
|
|
|
b.bytes.up += p.bytes.up.toNumber() * share;
|
|
|
|
|
b.bytes.down += p.bytes.down.toNumber() * share;
|
|
|
|
|
addQuality(b.quality, flushes);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.options.fiveMinute) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let slot = Math.floor(start / FIVE_MINUTES) * FIVE_MINUTES; slot < end; slot += FIVE_MINUTES) {
|
|
|
|
|
spread(slot, FIVE_MINUTES, (share, flushes) => {
|
|
|
|
|
this.atFiveMinutes(slot, (f) => {
|
|
|
|
|
f.participants.connected += 1;
|
|
|
|
|
f.bytes.up += p.bytes.up.toNumber() * share;
|
|
|
|
|
f.bytes.down += p.bytes.down.toNumber() * share;
|
|
|
|
|
addQuality(f.quality, flushes);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private at(ts: Date | null, update: Update, fiveMinute?: FiveMinuteUpdate): void {
|
|
|
|
|
if (ts === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ms = ts.getTime();
|
|
|
|
|
|
|
|
|
|
for (const scope of this.scopes) {
|
|
|
|
|
update(getOrCreate(this.hourly, scope, Math.floor(ms / HOUR) * HOUR, this.options.roomSums), false);
|
|
|
|
|
update(getOrCreate(this.daily, scope, Math.floor(ms / DAY) * DAY, this.options.roomSums), true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (fiveMinute !== undefined) {
|
|
|
|
|
this.atFiveMinutes(ms, fiveMinute);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private atFiveMinutes(ms: number, update: FiveMinuteUpdate): void {
|
|
|
|
|
if (!this.options.fiveMinute || ms < this.fiveMinuteFrom) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bucket = Math.floor(ms / FIVE_MINUTES) * FIVE_MINUTES;
|
|
|
|
|
|
|
|
|
|
for (const scope of this.scopes) {
|
|
|
|
|
const key = `${scope}|${String(bucket)}`;
|
|
|
|
|
let five = this.fiveMinute.get(key);
|
|
|
|
|
|
|
|
|
|
if (five === undefined) {
|
|
|
|
|
five = {
|
|
|
|
|
rooms: { created: 0, ended: 0 },
|
|
|
|
|
participants: { connected: 0, joined: 0, active: 0, left: 0, aborted: 0 },
|
|
|
|
|
egress: { started: 0, ended: 0 },
|
|
|
|
|
bytes: { up: 0, down: 0 },
|
|
|
|
|
quality: { scoreSum: 0, scoreCount: 0, poor: 0, lost: 0 }
|
|
|
|
|
};
|
|
|
|
|
this.fiveMinute.set(key, five);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
update(five);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** `[scope, bucket, value]` of a map keyed by `<scope>|<bucket>`, by scope then bucket. */
|
|
|
|
|
function sortedEntries<T>(map: Map<string, T>): [string, number, T][] {
|
|
|
|
|
return [...map.entries()]
|
|
|
|
|
.map(([key, value]): [string, number, T] => {
|
|
|
|
|
const separator = key.lastIndexOf('|');
|
|
|
|
|
|
|
|
|
|
return [key.slice(0, separator), Number(key.slice(separator + 1)), value];
|
|
|
|
|
})
|
|
|
|
|
.sort((a, b) => a[0].localeCompare(b[0]) || a[1] - b[1]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getOrCreate(map: Map<string, Bucket>, scope: string, bucketStart: number, roomSums: boolean): Bucket {
|
|
|
|
|
const key = `${scope}|${String(bucketStart)}`;
|
|
|
|
|
let bucket = map.get(key);
|
|
|
|
|
|
|
|
|
|
if (bucket === undefined) {
|
|
|
|
|
bucket = {
|
|
|
|
|
rooms: {
|
|
|
|
|
created: 0,
|
|
|
|
|
ended: 0,
|
|
|
|
|
endedByReason: {},
|
|
|
|
|
...(roomSums ? { participantsSum: 0, durationMsSum: 0 } : {})
|
|
|
|
|
},
|
|
|
|
|
participants: {
|
|
|
|
|
joined: 0,
|
|
|
|
|
active: 0,
|
|
|
|
|
aborted: 0,
|
|
|
|
|
left: 0,
|
|
|
|
|
byKind: {},
|
|
|
|
|
bySdk: {},
|
|
|
|
|
byConnectionType: {},
|
|
|
|
|
byOs: {},
|
|
|
|
|
byBrowser: {},
|
|
|
|
|
disconnectReasons: {},
|
|
|
|
|
connectTimeSumMs: 0,
|
|
|
|
|
connectTimeCount: 0,
|
|
|
|
|
minutes: 0
|
|
|
|
|
},
|
|
|
|
|
tracks: { published: 0, subscribed: 0, subscribeFailed: 0 },
|
|
|
|
|
egress: { started: 0, endedByStatus: {}, byType: {}, durationMs: 0 },
|
|
|
|
|
ingress: { sessionsStarted: 0, sessionsEnded: 0, byInputType: {}, durationMs: 0 },
|
|
|
|
|
bytes: { up: 0, down: 0 },
|
|
|
|
|
quality: { scoreSum: 0, scoreCount: 0, poor: 0, lost: 0 }
|
|
|
|
|
};
|
|
|
|
|
map.set(key, bucket);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bucket;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toDoc(scope: string, bucket: number, b: Bucket, daily: boolean): RollupDoc {
|
|
|
|
|
const date = new Date(bucket);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
_id: `${scope}:${date.toISOString().replace('.000Z', 'Z')}`,
|
|
|
|
|
scope,
|
|
|
|
|
bucket: date,
|
|
|
|
|
rooms: b.rooms,
|
|
|
|
|
participants: { ...b.participants, minutes: round2(b.participants.minutes) },
|
|
|
|
|
tracks: b.tracks,
|
|
|
|
|
egress: b.egress,
|
|
|
|
|
ingress: b.ingress,
|
|
|
|
|
bytes: { up: int64(b.bytes.up), down: int64(b.bytes.down) },
|
|
|
|
|
quality: { ...b.quality, scoreSum: round2(b.quality.scoreSum) },
|
|
|
|
|
...(daily ? { uniqueIdentities: b.identities?.size ?? 0 } : {})
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const BROWSER_KEYS = new Set(['chrome', 'firefox', 'safari', 'edge']);
|
|
|
|
|
|
2026-11-03 11:10:23 +01:00
|
|
|
/** Rollup key of a client browser: `none` without one, the lower-case name of a known browser, else `other`. */
|
2026-10-25 16:35:47 +01:00
|
|
|
function browserKey(browser: string): string {
|
2026-11-03 11:10:23 +01:00
|
|
|
if (browser === '') {
|
|
|
|
|
return 'none';
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-25 16:35:47 +01:00
|
|
|
const key = browser.toLowerCase();
|
|
|
|
|
|
|
|
|
|
return BROWSER_KEYS.has(key) ? key : 'other';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function inc(counter: Counter, key: string): void {
|
|
|
|
|
counter[key] = (counter[key] ?? 0) + 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function groupBy<T>(items: readonly T[], key: (item: T) => string): Map<string, T[]> {
|
|
|
|
|
const map = new Map<string, T[]>();
|
|
|
|
|
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
const k = key(item);
|
|
|
|
|
const list = map.get(k);
|
|
|
|
|
|
|
|
|
|
if (list === undefined) {
|
|
|
|
|
map.set(k, [item]);
|
|
|
|
|
} else {
|
|
|
|
|
list.push(item);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return map;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function round2(value: number): number {
|
|
|
|
|
return Math.round(value * 100) / 100;
|
|
|
|
|
}
|