Как подружиться
со статистикой WebRTC
и сэкономить
тысячи часов на отладке
Привет, Мы
Игорь Шеко
Lead of Front-end team
Ирина Ламарр
Senior SDK developer
Что мы делаем
WebRTC Media Pipeline
Input devices
(camera, microphone)
Output devices
(screen, speaker)
Sender device
Web browser
Receiver device
Web browser
Network interface controller
Network interface controller
Sender
buffer
Receiver
buffer
Packets
Packets
Encoder
Decoder
Echo canceller
Noise reduction
Image enhancements
Packetizer
Depacketizer
Jitter buffer
Frames samples
Packets
Raw
signal
Raw
signal
https://www.w3.org/TR/webrtc-stats
Статистика WebRTC
Стандарт WebRTC
Статистика WebRTC
311 метрик
- Сетевая статистика
- Статистика энкодинга
- Статистика декодинга
- Качество потока
- Синхронизация
- Шифрование
Статистика WebRTC
311 метрик
204
188
101
88
Статистика WebRTC
Откуда мы об этом знаем?
https://wpt.fyi/results/webrtc-stats
- Собирали только сеть
- Делали это максимально часто
- Собирали только статистику, которую поддерживают все браузеры
- Генерировали специальные QualityIssue события
Наша первая статистика
Какие проблемы можно увидеть
Сетевые потери
packetsLost/(packetsReceived+packetsLost)
packetLost
packetLoss (%)
Stats graphs for RTCInboundRTPVideoStream (inbound-rtp)
Сетевые потери
Что можно диагностировать
- Плохое качество сетевого соединения
- Важен именно % потерь:
- до 5% - можно игнорировать
- 5-10% - заметное для пользователя снижение качества
- > 15% - пикселизация/размытие видео, искажение голоса
Переполнение jitter buffer
Jitter - размер буффера в секунду
Stats graphs for RTCInboundRTPVideoStream (inbound-rtp)
Переполнение jitter buffer
- Если есть потери пакетов:
- - теряем ключевые кадры
Что можно диагностировать
- Без потери пакетов:
- - происходит постоянный реордеринг
- Если проблема у единичных пользователей - предложить сменить сеть
- Если проблема массовая - возможно проблемы на нашем сервере
- В итоге приводит к рассинхрону между участниками и сетевым задержка
Сетевые задержки
totalRoundTripTime - накопительная метрика, за все время соединение
roundTripTime - последний замер
Что можно диагностировать:
- можно понять проседал ли сигнал
- если jitter небольшой - проблема скорее всего серверная
Изменение битрейта
function calculateBitrate(
bytesNow: number,
bytesBefore: number,
timestampNow: number,
timestampBefore: number
): number {
const deltaBytes = bytesSentNow - bytesSentBefore;
const deltaMs = timestampNow - timestampBefore;
return (deltaBytes / deltaMs) * 8000;
}
- это метрика скорости передачи данных (бит/с)
-
Oпределяет размер и качество видео- и аудиофайлов: чем выше битрейт, тем лучше качество и больше размер файла.
-
Размер файла = битрейт (кбит/с) x продолжительность.
Изменение битрейта
- Видео:
- - если есть сетевые потери - WebRTC решило снизить битрейт из-за недостаточной пропускной способности канала
- - нет сетевых потерь - можно предположить, что девайс отправителя не справляется с нагрузкой
- Аудио:
- - можно отследить говорил человек или нет
- при вкл FEC и тишине битрейт будет резко падать
Что можно диагностировать
Резкое падение битрейта аудио
- - не декодируется аудио
- - проблемы с аудио девайсами на стороне remote пользователя
Что можно диагностировать
Что нельзя было увидеть у нас, но можно по статистике
- - низкое качество аудио/видео
- - сетевые задержки
Что можно диагностировать
Смена ICE/DTLS и последствия
- Как диагностировать:
- - новая пара в candidate-pair
- - часть статистики считается с нуля (bytesSent, bytesReceived, roundTripTime, totalRoundTripTime, availableOutgoingBitrate и др.)
- Что и зачем?
- - скорее всего был ice-restart
- - может меняться DTLS и может согласоваться неправильно
- - выбрано соединение с худшей пропускной способностью
- - выбрана новая пара relay candidates
availableOutgoingBitrate
суммарный максимальный битрейт для отправки (по данным самого WebRTC)
Что можем диагностировать
- низкий битрейт / низкое качество аудио/видео
всего 193 Мбит/с
из них для WebRTC доступно лишь 3.2 Мбит/с
Video compression picture types
I-frame
P-frame
B-frame
I-frame
P-frame
I-frame
Согласованные кодеки
Statistics RTCInboundRTPVideoStream_2006534193
Скорость установки соединения
Как правильно считать?
- Обычно считают от запроса пользователя на начало звонка до iceState connected
- ! В реальности это не значит, что в этот момент удаленная сторона начала слышать/видеть данного участника
- Необходимо считать до framesDecoded >= 1
- если говорить про вход нового участника в конференцию, то от создания нового трансивера до framesDecoded >= 1
Frames && KeyFrames
Что можно диагностировать
- framesReceived - сколько всего фреймов получили
- framesDecoded - сколько смогли декодировать
- - framesReceived есть, framesDecoded = 0 - видео не декодируется
- - необходимо обратить внимание на keyFramesDecoded: скорее всего не получили I-frame
- - или мы получили от сервера неправильно закодированные кадры
Frames && KeyFrames
Что можно диагностировать
- - framesReceived есть, framesDecoded есть, keyFrameDecoded есть, но все полученные фреймы были отброшены (framesDropped)
- - скорее всего забыли отрендерить видео элемент в DOM-дереве
- - или девайс клиента не справляется по CPU
Большой объем I-frames
Что можно диагностировать
- На стороне отправителя - keyFramesEncoded
- На стороне получателя - keyFramesDecoded
- + pliCount, firCount
- - если pliCount/firCount равны 0, проверяем настройки сервера/кодеков
- если pliCount/firCount растут, скорее всего что-то пошло не так на стороне получателя и он перезапрашивает ключевики
Реальный FPS
- - изначальное качество видео от камеры
- - сравнить frameEncoded/s и frameSents/s, если есть значительная разница - низкая пропускная способность сети
Что можно диагностировать
Реальный FPS
- Кодеки без SVС - frameEncoded/s равны framePerSecond вVideoSource -
- - если нет, не справляется encoder и дропает
- Кодеки с SVC - framePerSecond * на количество слоев
- - если нет, значит какие-то слои не кодируются
Что можно диагностировать
Реальный FPS
- - Кодек не позволяет кодировать в заданном битрейте
- Проверить сколько времени тратится на кодирование одного фрейма:
- delta totalEncodeTime / delta frameEncoded
- сравнить с max временем для 60fps = 16ms
- - Если время выше - не справляется девайс пользователя
Возможные причины:
Разрешение исходного трека больше разрешения отправляемого
- qualityLimitationReason - причина ограничения битрейта
- qualityLimitation ResolutionsChanges - сколько раз за соединение происходила смена qualityLimitationReason
- - можем попробовать снизить frameRate
- - или уменьшить количество видеопоток, которые декодируются
Что можно диагностировать
Что такое simulcast?
Media
Server
1080p
720p
360p
Переключение слоев симулкаста
- По статистике RTCOutboundRTPVideoStream можно отследить наличие слоев и их отключение/подключение, т.ч. изменения сделанные WebRTC без нашего участия
- В RTCInboundRTPVideoStream:
frameWidth и frameHeight - изменение разрешения входящего видео (переключение отправляемых слоев на стороне сервера/удаленного участника)
Audio samples metrics
- insertedSamplesForDeceleration - сколько фреймов было вставлено, чтобы замедлить аудио
- removedSamplesForAcceleration - сколько удалено, чтобы ускорить аудио
- - скорее всего в данный момент рассинхрон аудио/видео
Что можно диагностировать
Не о формате, а о способе
Стратегия адаптивного сбора
//we can't use user-agent because
// 1. browsers often improve API,
// 2. user-agent is legacy
function detectStrategy(statsSample: RTCStatsReport): StatsStrategy {
//Browsers will check by global usage
// strategy for a chromium-based browsers
if (checkBlink(statsSample, IS_DEBUG)) {
return blinkStatsStrategy;
}
// strategy for a webkit-based browsers
if (checkWebkit(statsSample, IS_DEBUG)) {
return webkitStatsStrategy;
}
// strategy for a firefox-based browsers
if (checkGecko(statsSample, IS_DEBUG)) {
return geckoStatsStrategy;
}
// fallback rfc-like strategy
return baseStatsStrategy;
}
Сколько вешать граммов?
Default interval time | 1000 ms |
> 16ms |
2000 ms |
> 32ms |
3000 ms |
> 48ms |
4000 ms |
// This will only working in the Chrome. For other browsers we can't get battery.
//@ts-ignore because it is deprecated API
try {
const batteryFunction = navigator['getBattery'];
if (batteryFunction) {
const batteryInfo = await batteryFunction();
if (!batteryInfo.charging) {
if (batteryInfo.level && batteryInfo.level <= 0.3)
return LOW_BATTERY_COLLECT_INTERVAL;
return BATTERY_COLLECT_INTERVAL;
}
}
} catch (e){}
-
не ориентироваться на стандарт
-
описано много, в реальности работает мало что из этого или написано что-то похожее, но свое
-
собирать динамически и адаптивно
-
не все браузеры охотно сообщают, что они там поменяли
-
возможно придется самостоятельно пересчитывать какие-то метрики, если они вам нужны
-
не забывать про слабые девайсы и мобильники - слишком частый запрос статистики ведет к плохому UХ
Статистика WebRTC
Вопросы
Зачем сервис
Задачи
Сбор статистики для решения конкретных инцидентов
Мониторинг инцидентов при обновлениях сервиса
Мониторинг инцидентов при обновлении браузеров
Определение качества работы сети
Помощь в разработке
Что хочется разработчику
Красивые графики
Простые флаги и healthcheck
Подсветка типичных проблем и советы по решению
Законно
White label
Наша первая попытка
GW
ClickHouse
Проблемы
GW
ClickHouse
0.8 Gbps
1.1 TB / day
Не очень...
1.1 TB / day
Готовые сервисы
Callstats.io
TestRTC
не расширяется
написаны под конкретные кейсы
писали не мы
Анализ объема
- 204 уникальных метрик в браузере
- Метрики повторяются на каждый входящий и исходящий поток
- ~ 32 на входящий аудио
- ~ 41 на входящий видео поток
- Исходящие потоки в симулкасте тоже в тройном размере
- В конференции на 10 человек ~1700 метрик
- У каждого участника
- Итого всего ~17000 параметров со всех на замер
Анализ объема
-
Частота сбора: 1 сек
-
Средний объем конференции: 10 человек
-
Конференций на инстанс: 300
-
Хотим хранить: 30 дней
-
~168 Kbps
-
~1680 Kbps
-
~ 165 Mbps
-
~ 5.06 TB
Оптимизируем формат
Уменьшаем json
Меняем формат
Переход к дельтам
Два типа кадров
Уменьшаем json
- certificate
- codec
- не активные candidate-pair
- не активные *-candidate
- peer-connection
- в основном transport
Что выкинуть?
~ 600 метрик
Меняем формат
{
id: 'RTCInboundRTPVideoStream_1635808784',
timestamp: 1628606438021.0002,
type: 'inbound-rtp',
codecId: 'RTCCodec_v4_Inbound_104',
kind: 'video',
mediaType: 'video',
ssrc: 1635808784,
transportId: 'RTCTransport_0_1',
packetsLost: 49,
packetsReceived: 5198,
bytesReceived: 5720775,
estimatedPlayoutTimestamp: 3837595237710,
firCount: 0,
frameHeight: 180,
frameWidth: 320,
framesDecoded: 516,
framesPerSecond: 17,
framesReceived: 518,
headerBytesReceived: 240747,
keyFramesDecoded: 38,
lastPacketReceivedTimestamp: 46062.656,
nackCount: 43,
pliCount: 3,
qpSum: 9966,
totalDecodeTime: 1.038,
totalInterFrameDelay: 60.22499999999982,
totalSquaredInterFrameDelay: 57.38087100000012,
trackId: 'RTCMediaStreamTrack_receiver_11',
},
Меняем формат
{
timestamp: 1628606438021.0002,
type: 'inbound-rtp',
mediaType: 'video',
ssrc: 1635808784,
packetsLost: 49,
packetsReceived: 5198,
bytesReceived: 5720775,
estimatedPlayoutTimestamp: 3837595237710,
firCount: 0,
frameHeight: 180,
frameWidth: 320,
framesDecoded: 516,
framesPerSecond: 17,
framesReceived: 518,
headerBytesReceived: 240747,
keyFramesDecoded: 38,
lastPacketReceivedTimestamp: 46062.656,
nackCount: 43,
pliCount: 3,
qpSum: 9966,
totalDecodeTime: 1.038,
totalInterFrameDelay: 60.22499999999982,
totalSquaredInterFrameDelay: 57.38087100000012,
},
Меняем формат
[
1628606438021.0002,
"inbound-rtp",
"video",
1635808784,
49,
5198,
5720775,
3837595237710,
0,
180,
320,
516,
17,
518,
240747,
38,
46062.656,
43,
3,
9966,
1.038,
60.22499999999982,
57.38087100000012,
],
Переход к дельтам
{
timestamp: 1628606438021.0002,
type: 'inbound-rtp',
mediaType: 'video',
ssrc: 1635808784,
packetsLost: 49,
packetsReceived: 5198,
bytesReceived: 5720775,
estimatedPlayoutTimestamp: 3837595237710,
firCount: 0,
frameHeight: 180,
frameWidth: 320,
framesDecoded: 516,
framesPerSecond: 17,
framesReceived: 518,
headerBytesReceived: 240747,
keyFramesDecoded: 38,
lastPacketReceivedTimestamp: 46062.656,
nackCount: 43,
pliCount: 3,
qpSum: 9966,
totalDecodeTime: 1.038,
totalInterFrameDelay: 60.22499999999982,
totalSquaredInterFrameDelay: 57.38087100000012,
},
Переход к дельтам
{
type: 'inbound-rtp',
mediaType: 'video',
ssrc: 1635808784,
framesPerSecond: 17,
delta:[
[
timestamp: 1628606438021.0002,
packetsReceived: 5198,
bytesReceived: 5720775,
estimatedPlayoutTimestamp: 3837595237710,
framesDecoded: 516,
framesReceived: 518,
headerBytesReceived: 240747,
lastPacketReceivedTimestamp: 46062.656,
qpSum: 9966,
totalDecodeTime: 1.038,
totalInterFrameDelay: 60.22499999999982,
totalSquaredInterFrameDelay: 57.38087100000012,
keyFramesDecoded: 38,
],[
packetsLost: 49,
nackCount: 43,
pliCount: 3,
frameHeight: 180,
frameWidth: 320,
firCount: 0,
]
],
},
Переход к дельтам
[
'inbound-rtp',
'video',
1635808784,
17,
[
[
1628606438021.0002,
5198,
5720775,
3837595237710,
516,
518,
240747,
46062.656,
9966,
1.038,
60.22499999999982,
57.38087100000012,
38,
],[
49,
43,
3,
180,
320,
0,
]
],
},
Переход к дельтам
[
'inbound-rtp',
'video',
1635808784,
17,
[
[
1021.0001,
5198,
775,
710,
17,
17,
1747,
62.256,
367,
0.316,
1.22499999999982,
2.38087100000012,
0,
],[
1,
2,
0,
0,
0,
0,
]
],
},
Переход к дельтам
[
'inbound-rtp',
'video',
1635808784,
17,
[
[
1021.0001,
5198,
775,
710,
17,
17,
1747,
62.256,
367,
0.316,
1.22499999999982,
2.38087100000012,
],[
1,
2,
]
],
},
Переход к дельтам
[
'inbound-rtp','video',1635808784,17,
[[1021.0001,5198,775,710,17,17,1747,62.256,367,0.316,1.22499999999982,2.38087100000012],[1,2]]
],
Переход к дельтам
{
id: 'RTCInboundRTPVideoStream_1635808784',
timestamp: 1628606438021.0002,
type: 'inbound-rtp',
codecId: 'RTCCodec_v4_Inbound_104',
kind: 'video',
mediaType: 'video',
ssrc: 1635808784,
transportId: 'RTCTransport_0_1',
packetsLost: 49,
packetsReceived: 5198,
bytesReceived: 5720775,
estimatedPlayoutTimestamp: 3837595237710,
firCount: 0,
frameHeight: 180,
frameWidth: 320,
framesDecoded: 516,
framesPerSecond: 17,
framesReceived: 518,
headerBytesReceived: 240747,
keyFramesDecoded: 38,
lastPacketReceivedTimestamp: 46062.656,
nackCount: 43,
pliCount: 3,
qpSum: 9966,
totalDecodeTime: 1.038,
totalInterFrameDelay: 60.22499999999982,
totalSquaredInterFrameDelay: 57.38087100000012,
trackId: 'RTCMediaStreamTrack_receiver_11',
},
Минусы дельт
- Важен порядок
- Потерянный "кадр" портит статистику
Два типа кадров
- Счетчик "кадров"
- Ключевые "кадры" с полной статистикой раз в 60 секунд
Итоги оптимизации
Частота сбора: 1 сек
Средний объем конференции: 10 человек
Конференций на инстанс: 100
Хотим хранить: 30 дней
-
~2 Kbps
-
~20 Kbps
-
~1.95 Mbps
-
~ 0.6 TB
-
~168 Kbps
-
~1680 Kbps
-
~ 165 Mbps
-
~ 5.06 TB
Выбираем канал передачи
-
Data channel
-
fetch
-
WebSocket
-
Beaсon API
Выбираем канал передачи
Data channel
- Тот же канал, что и media
- UDP
- Механизм возобновления соединения
- Готовые реализации
- Нагружает тот же сервер, что и media
- Нет гарантированной доставки или порядка
- Нет сжатия
Выбираем канал передачи
fetch
- Самый "простой" протокол - https
- Доставка и порядок гарантированы
- Сжатие из коробки
- SSL хендшейки
- Высокий приоритет доставки
Выбираем канал передачи
WebSocket
- Почти как fetch, но лучше!
- Нет затрат на лишние SSL хендшейки
- Сложно балансировать
- Множество активных коннектов
Выбираем канал передачи
Beacon API
- fetch без ожидания доставки
- минимальный приоритет
- легко балансировать на сервере
Масштабирование
GW
ClickHouse
Масштабирование
GW
ClickHouse
GW
GW
k8s
Направления анализа
-
Потоковый анализ
-
Анализ клиента
-
Анализ сессии
Направления анализа
Потоковый анализ
- Сетевые потери
- Переполнение jitter
- Сетевые задержки
- Изменение битрейта
- Relay
- Скорость кодирования /декодирования
Масштабирование
GW
ClickHouse
GW
GW
k8s
SA
SA
SA
SA
Направления анализа
Анализ клиента
- ICE/DTLS рестарт
- availableOutgoingBitrate
- Скорость установки соединения
- Отсутствие Iframes
- Много IFrames
- реальный FPS
- разрешения видео
Масштабирование
GW
ClickHouse
GW
GW
k8s
SA
SA
SA
SA
CA
Направления анализа
Анализ сессии
- Слои simulcast
- Pассинхрон аудио
Масштабирование
GW
ClickHouse
GW
GW
k8s
SA
SA
SA
SA
CA
GA
Выводы
Вопросы
Новый формат отчета по статистике
interface StatsReport {
connection: ConnectionStatsReport;
outbound: Record<string, BaseOutboundStatsReport[]>;
inbound: Record<string, InboundStatsReport>;
}
Нельзя просто взять и понять что это за браузер
//we can't use user-agent because
// 1. browsers often improve API,
// 2. user-agent is legacy
function detectStrategy(statsSample: RTCStatsReport): StatsStrategy {
//Browsers will check by global usage
// strategy for a chromium-based browsers
if (checkBlink(statsSample, IS_DEBUG)) {
return blinkStatsStrategy;
}
// strategy for a webkit-based browsers
if (checkWebkit(statsSample, IS_DEBUG)) {
return webkitStatsStrategy;
}
// strategy for a firefox-based browsers
if (checkGecko(statsSample, IS_DEBUG)) {
return geckoStatsStrategy;
}
// fallback rfc-like strategy
return baseStatsStrategy;
}
События QualityIssues
QualityIssueCodecMismatch
QualityIssueHighMediaLatency
QualityIssueICEDisconnected
Заменен на события CallEvents.Reconnectiong и CallEvents.Reconnected
QualityIssueLocalVideoDegradation
Для определения деградации видео теперь лучше всего использовать OutboundVideoStatsReport.qualityLimitationResolutionChanges если значение не четное, значит сейчас наблюдается ограничение качества исходящего видео. В поле OutboundVideoStatsReport.qualityLimitationReason будет находиться причина.
QualityIssuePacketLoss
Пересчет отсутствующих метрик
function calculateBitrate(
bytesNow: number,
bytesBefore: number,
timestampNow: number,
timestampBefore: number
): number {
const deltaBytes = bytesNow - bytesBefore;
const deltaMs = timestampNow - timestampBefore;
return (deltaBytes / deltaMs) * 8000;
}
// Safari
let frameRate = 0;
if (prevReport && (prevReport as InboundVideoStatsReport).framesReceived) {
const lastReceived =
(prevReport as InboundVideoStatsReport).framesReceived || report.framesReceived;
const deltaFrames = report.framesReceived - lastReceived;
const deltaMs = report.timestamp - prevReport.timestamp;
frameRate = ((deltaFrames / deltaMs) * 1000) | 0;
}
Connection
Эта секция статистики описывает транспортную часть, ICE и текущий канал.
! Показывается именно текущее активное ICE соединение. Если WebRTC решит сменить пару кандидатов на ходу, или произойдет ICE restart, то данные изменятся и отсчет bytesSent и bytesReceived будет начат заново. Также обновится расчет rtt и availableOutgoingBitrate
interface ConnectionStatsReport {
timestamp: number;
remoteType: 'host' | 'srflx' | 'relay';
remoteIp?: string; // only Chromium + FF
remoteProtocol: 'udp' | 'tcp';
remotePort: number;
localType: 'host' | 'srflx' | 'relay';
localIp?: string; // only Chromium + FF
localProtocol: 'udp' | 'tcp';
localPort: number;
bytesSent: number;
bytesReceived: number;
availableOutgoingBitrate?: number; // only Chromium + Safari
rtt?: number; // only Chromium + Safari
currentRtt?: number; // only Chromium + Safari
}
Outbound
Статистика исходящего трафика
interface OutboundAudioStatsReport extends BaseOutboundStatsReport {
kind: 'audio';
audioLevel?: number;
totalAudioEnergy?: number;
}
interface OutboundVideoStatsReport extends BaseOutboundStatsReport {
kind: 'video';
firCount: number;
pliCount: number;
nackCount: number;
width?: number;
baseWidth?: number;
height: number;
baseHeight?: number;
framesPerSecond?: number;
baseFramesPerSecond?: number;
qualityLimitationReason?: 'cpu' | 'bandwidth' | 'other' | 'none';
qualityLimitationResolutionChanges?: number;
qpSum?: number;
framesEncoded?: number;
keyFramesEncoded?: number;
}
interface BaseOutboundStatsReport {
timestamp: number;
bytesSent: number;
packetsSent: number;
rid?: string;
jitter: number;
rtt: number;
packetsLost: number;
loss: number;
codec?: string;
pt?: number;
totalRtt?: number;
bitrate: number;
}
Передается как объект, ключом которого является id media track, а значением - массив статистики для этого трека. Массив будет состоять либо из 1 элемента для аудио и видео без симулкаста, и из 2-3 элементов для видео с симулкастом.
Inbound
Статистика входящего трафика
interface InboundAudioStatsReport extends BasicInboundStatsReport {
audioLevel?: number;
totalAudioEnergy?: number;
totalSamplesReceived?: number;
totalSamplesDuration?: number;
insertedSamplesForDeceleration?: number;
silentConcealedSamples?: number;
}
interface InboundVideoStatsReport extends BasicInboundStatsReport {
height?: number;
width?: number;
framesReceived?: number;
framesDecoded?: number;
framesDropped?: number;
framesPerSecond?: number;
freezeCount?: number;
pauseCount?: number;
totalFramesDuration?: number;
totalFreezesDuration?: number;
totalPausesDuration?: number;
}
interface BasicInboundStatsReport {
timestamp: number;
kind: 'audio' | 'video';
bytesReceived: number;
packetsReceived: number;
packetsLost: number;
loss: number;
jitter: number;
bitrate: number;
endpoint?: string;
}
Передается как объект, ключом которого является id mediaRenderer для которого получается статистика, а значением - объект с данными статистики
Для видео конференций только сети уже не достаточно
Проблемой становится не только канал, но и CPU
Мы закладывались только на 1 поток видео, а не на 3 потока видео с симулкастом
Статистика не работала с Safari
adapter.js создавал боль
Почему решили переписать с нуля весь модуль статистики
Copy of deck
By Igor Sheko
Copy of deck
- 467