/* global require */
/* jshint -W101 */
var GlobalOnErrorHandler = require("../util/GlobalOnErrorHandler");
var logger = require("jitsi-meet-logger").getLogger(__filename);
var RTCBrowserType = require("../RTC/RTCBrowserType");
import * as StatisticsEvents from "../../service/statistics/Events";
/* Whether we support the browser we are running into for logging statistics */
var browserSupported = RTCBrowserType.isChrome() ||
RTCBrowserType.isOpera() || RTCBrowserType.isFirefox();
/**
* The LibJitsiMeet browser-agnostic names of the browser-specific keys reported
* by RTCPeerConnection#getStats mapped by RTCBrowserType.
*/
var KEYS_BY_BROWSER_TYPE = {};
KEYS_BY_BROWSER_TYPE[RTCBrowserType.RTC_BROWSER_FIREFOX] = {
"ssrc": "ssrc",
"packetsReceived": "packetsReceived",
"packetsLost": "packetsLost",
"packetsSent": "packetsSent",
"bytesReceived": "bytesReceived",
"bytesSent": "bytesSent"
};
KEYS_BY_BROWSER_TYPE[RTCBrowserType.RTC_BROWSER_CHROME] = {
"receiveBandwidth": "googAvailableReceiveBandwidth",
"sendBandwidth": "googAvailableSendBandwidth",
"remoteAddress": "googRemoteAddress",
"transportType": "googTransportType",
"localAddress": "googLocalAddress",
"activeConnection": "googActiveConnection",
"ssrc": "ssrc",
"packetsReceived": "packetsReceived",
"packetsSent": "packetsSent",
"packetsLost": "packetsLost",
"bytesReceived": "bytesReceived",
"bytesSent": "bytesSent",
"googFrameHeightReceived": "googFrameHeightReceived",
"googFrameWidthReceived": "googFrameWidthReceived",
"googFrameHeightSent": "googFrameHeightSent",
"googFrameWidthSent": "googFrameWidthSent",
"audioInputLevel": "audioInputLevel",
"audioOutputLevel": "audioOutputLevel"
};
KEYS_BY_BROWSER_TYPE[RTCBrowserType.RTC_BROWSER_OPERA] =
KEYS_BY_BROWSER_TYPE[RTCBrowserType.RTC_BROWSER_CHROME];
KEYS_BY_BROWSER_TYPE[RTCBrowserType.RTC_BROWSER_IEXPLORER] =
KEYS_BY_BROWSER_TYPE[RTCBrowserType.RTC_BROWSER_CHROME];
KEYS_BY_BROWSER_TYPE[RTCBrowserType.RTC_BROWSER_SAFARI] =
KEYS_BY_BROWSER_TYPE[RTCBrowserType.RTC_BROWSER_CHROME];
KEYS_BY_BROWSER_TYPE[RTCBrowserType.RTC_BROWSER_REACT_NATIVE] =
KEYS_BY_BROWSER_TYPE[RTCBrowserType.RTC_BROWSER_CHROME];
/**
* Calculates packet lost percent using the number of lost packets and the
* number of all packet.
* @param lostPackets the number of lost packets
* @param totalPackets the number of all packets.
* @returns {number} packet loss percent
*/
function calculatePacketLoss(lostPackets, totalPackets) {
if(!totalPackets || totalPackets <= 0 || !lostPackets || lostPackets <= 0)
return 0;
return Math.round((lostPackets/totalPackets)*100);
}
function formatAudioLevel(audioLevel) {
return Math.min(Math.max(audioLevel, 0), 1);
}
/**
* Checks whether a certain record should be included in the logged statistics.
*/
function acceptStat(reportId, reportType, statName) {
if (reportType == "googCandidatePair") {
if (statName == "googChannelId")
return false;
} else if (reportType == "ssrc") {
if (statName == "googTrackId" ||
statName == "transportId" ||
statName == "ssrc")
return false;
}
return true;
}
/**
* Checks whether a certain record should be included in the logged statistics.
*/
function acceptReport(id, type) {
if (type == "googComponent")
return false;
if (id.substring(0, 15) == "googCertificate" ||
id.substring(0, 9) == "googTrack" ||
id.substring(0, 20) == "googLibjingleSession")
return false;
return true;
}
/**
* Peer statistics data holder.
* @constructor
*/
function PeerStats() {
this.ssrc2Loss = {};
this.ssrc2AudioLevel = {};
this.ssrc2bitrate = {
download: 0,
upload: 0
};
this.ssrc2resolution = {};
}
/**
* Sets packets loss rate for given ssrc that belong to the peer
* represented by this instance.
* @param lossRate new packet loss rate value to be set.
*/
PeerStats.prototype.setSsrcLoss = function (lossRate) {
this.ssrc2Loss = lossRate || {};
};
/**
* Sets resolution that belong to the ssrc
* represented by this instance.
* @param resolution new resolution value to be set.
*/
PeerStats.prototype.setSsrcResolution = function (resolution) {
this.ssrc2resolution = resolution || {};
};
/**
* Sets the bit rate for given ssrc that belong to the peer
* represented by this instance.
* @param bitrate new bitrate value to be set.
*/
PeerStats.prototype.setSsrcBitrate = function (bitrate) {
this.ssrc2bitrate.download += bitrate.download;
this.ssrc2bitrate.upload += bitrate.upload;
};
/**
* Resets the bit rate for given ssrc that belong to the peer
* represented by this instance.
*/
PeerStats.prototype.resetSsrcBitrate = function () {
this.ssrc2bitrate.download = 0;
this.ssrc2bitrate.upload = 0;
};
/**
* Sets new audio level(input or output) for given ssrc that identifies
* the stream which belongs to the peer represented by this instance.
* @param audioLevel the new audio level value to be set. Value is truncated to
* fit the range from 0 to 1.
*/
PeerStats.prototype.setSsrcAudioLevel = function (audioLevel) {
// Range limit 0 - 1
this.ssrc2AudioLevel = formatAudioLevel(audioLevel);
};
function ConferenceStats() {
/**
* The bandwidth
* @type {{}}
*/
this.bandwidth = {};
/**
* The bit rate
* @type {{}}
*/
this.bitrate = {};
/**
* The packet loss rate
* @type {{}}
*/
this.packetLoss = null;
/**
* Array with the transport information.
* @type {Array}
*/
this.transport = [];
}
/**
* StatsCollector registers for stats updates of given
* peerconnection in given interval. On each update particular
* stats are extracted and put in {@link PeerStats} objects. Once the processing
* is done audioLevelsUpdateCallback is called with this
* instance as an event source.
*
* @param peerconnection WebRTC PeerConnection object.
* @param audioLevelsInterval
* @param statsInterval stats refresh interval given in ms.
* @param eventEmitter
* @constructor
*/
function StatsCollector(
peerconnection,
audioLevelsInterval,
statsInterval,
eventEmitter) {
// StatsCollector depends entirely on the format of the reports returned by
// RTCPeerConnection#getStats. Given that the value of
// RTCBrowserType#getBrowserType() is very unlikely to change at runtime, it
// makes sense to discover whether StatsCollector supports the executing
// browser as soon as possible. Otherwise, (1) getStatValue would have to
// needlessly check a "static" condition multiple times very very often and
// (2) the lack of support for the executing browser would be discovered and
// reported multiple times very very often too late in the execution in some
// totally unrelated callback.
/**
* The RTCBrowserType supported by this StatsCollector. In other words, the
* RTCBrowserType of the browser which initialized this StatsCollector
* instance.
* @private
*/
this._browserType = RTCBrowserType.getBrowserType();
var keys = KEYS_BY_BROWSER_TYPE[this._browserType];
if (!keys)
throw "The browser type '" + this._browserType + "' isn't supported!";
/**
* The function which is to be used to retrieve the value associated in a
* report returned by RTCPeerConnection#getStats with a LibJitsiMeet
* browser-agnostic name/key.
* @function
* @private
*/
this._getStatValue = this._defineGetStatValueMethod(keys);
this.peerconnection = peerconnection;
this.baselineAudioLevelsReport = null;
this.currentAudioLevelsReport = null;
this.currentStatsReport = null;
this.baselineStatsReport = null;
this.audioLevelsIntervalId = null;
this.eventEmitter = eventEmitter;
this.conferenceStats = new ConferenceStats();
/**
* Gather PeerConnection stats once every this many milliseconds.
*/
this.GATHER_INTERVAL = 15000;
/**
* Gather stats and store them in this.statsToBeLogged.
*/
this.gatherStatsIntervalId = null;
/**
* Stores the statistics which will be send to the focus to be logged.
*/
this.statsToBeLogged = {
timestamps: [],
stats: {}
};
// Updates stats interval
this.audioLevelsIntervalMilis = audioLevelsInterval;
this.statsIntervalId = null;
this.statsIntervalMilis = statsInterval;
// Map of ssrcs to PeerStats
this.ssrc2stats = {};
}
module.exports = StatsCollector;
/**
* Stops stats updates.
*/
StatsCollector.prototype.stop = function () {
if (this.audioLevelsIntervalId) {
clearInterval(this.audioLevelsIntervalId);
this.audioLevelsIntervalId = null;
}
if (this.statsIntervalId) {
clearInterval(this.statsIntervalId);
this.statsIntervalId = null;
}
if (this.gatherStatsIntervalId) {
clearInterval(this.gatherStatsIntervalId);
this.gatherStatsIntervalId = null;
}
};
/**
* Callback passed to getStats method.
* @param error an error that occurred on getStats call.
*/
StatsCollector.prototype.errorCallback = function (error) {
GlobalOnErrorHandler.callErrorHandler(error);
logger.error("Get stats error", error);
this.stop();
};
/**
* Starts stats updates.
*/
StatsCollector.prototype.start = function (startAudioLevelStats) {
var self = this;
if(startAudioLevelStats) {
this.audioLevelsIntervalId = setInterval(
function () {
// Interval updates
self.peerconnection.getStats(
function (report) {
var results = null;
if (!report || !report.result ||
typeof report.result != 'function') {
results = report;
}
else {
results = report.result();
}
self.currentAudioLevelsReport = results;
self.processAudioLevelReport();
self.baselineAudioLevelsReport =
self.currentAudioLevelsReport;
},
self.errorCallback
);
},
self.audioLevelsIntervalMilis
);
}
if (browserSupported) {
this.statsIntervalId = setInterval(
function () {
// Interval updates
self.peerconnection.getStats(
function (report) {
var results = null;
if (!report || !report.result ||
typeof report.result != 'function') {
//firefox
results = report;
}
else {
//chrome
results = report.result();
}
self.currentStatsReport = results;
try {
self.processStatsReport();
}
catch (e) {
GlobalOnErrorHandler.callErrorHandler(e);
logger.error("Unsupported key:" + e, e);
}
self.baselineStatsReport = self.currentStatsReport;
},
self.errorCallback
);
},
self.statsIntervalMilis
);
}
if (browserSupported
// logging statistics does not support firefox
&& this._browserType !== RTCBrowserType.RTC_BROWSER_FIREFOX) {
this.gatherStatsIntervalId = setInterval(
function () {
self.peerconnection.getStats(
function (report) {
self.addStatsToBeLogged(report.result());
},
function () {
}
);
},
this.GATHER_INTERVAL
);
}
};
/**
* Converts the stats to the format used for logging, and saves the data in
* this.statsToBeLogged.
* @param reports Reports as given by webkitRTCPerConnection.getStats.
*/
StatsCollector.prototype.addStatsToBeLogged = function (reports) {
var self = this;
var num_records = this.statsToBeLogged.timestamps.length;
this.statsToBeLogged.timestamps.push(new Date().getTime());
reports.forEach(function (report) {
if (!acceptReport(report.id, report.type))
return;
var stat = self.statsToBeLogged.stats[report.id];
if (!stat) {
stat = self.statsToBeLogged.stats[report.id] = {};
}
stat.type = report.type;
report.names().forEach(function (name) {
if (!acceptStat(report.id, report.type, name))
return;
var values = stat[name];
if (!values) {
values = stat[name] = [];
}
while (values.length < num_records) {
values.push(null);
}
values.push(report.stat(name));
});
});
};
StatsCollector.prototype.getCollectedStats = function () {
return this.statsToBeLogged;
};
StatsCollector.prototype.clearCollectedStats = function () {
// Reset the stats
this.statsToBeLogged.stats = {};
this.statsToBeLogged.timestamps = [];
};
/**
* Defines a function which (1) is to be used as a StatsCollector method and (2)
* gets the value from a specific report returned by RTCPeerConnection#getStats
* associated with a LibJitsiMeet browser-agnostic name.
*
* @param {Object.} keys the map of LibJitsi browser-agnostic
* names to RTCPeerConnection#getStats browser-specific keys
*/
StatsCollector.prototype._defineGetStatValueMethod = function (keys) {
// Define the function which converts a LibJitsiMeet browser-asnostic name
// to a browser-specific key of a report returned by
// RTCPeerConnection#getStats.
var keyFromName = function (name) {
var key = keys[name];
if (key)
return key;
else
throw "The property '" + name + "' isn't supported!";
};
// Define the function which retrieves the value from a specific report
// returned by RTCPeerConnection#getStats associated with a given
// browser-specific key.
var itemStatByKey;
switch (this._browserType) {
case RTCBrowserType.RTC_BROWSER_CHROME:
case RTCBrowserType.RTC_BROWSER_OPERA:
// TODO What about other types of browser which are based on Chrome such
// as NW.js? Every time we want to support a new type browser we have to
// go and add more conditions (here and in multiple other places).
// Cannot we do a feature detection instead of a browser type check? For
// example, if item has a stat property of type function, then it's very
// likely that whoever defined it wanted you to call it in order to
// retrieve the value associated with a specific key.
itemStatByKey = function (item, key) { return item.stat(key) };
break;
case RTCBrowserType.RTC_BROWSER_REACT_NATIVE:
// The implementation provided by react-native-webrtc follows the
// Objective-C WebRTC API: RTCStatsReport has a values property of type
// Array in which each element is a key-value pair.
itemStatByKey = function (item, key) {
var value;
item.values.some(function (pair) {
if (pair.hasOwnProperty(key)) {
value = pair[key];
return true;
} else {
return false;
}
});
return value;
};
break;
default:
itemStatByKey = function (item, key) { return item[key] };
}
// Compose the 2 functions defined above to get a function which retrieves
// the value from a specific report returned by RTCPeerConnection#getStats
// associated with a specific LibJitsiMeet browser-agnostic name.
return function (item, name) {
return itemStatByKey(item, keyFromName(name))
};
};
/**
* Stats processing logic.
*/
StatsCollector.prototype.processStatsReport = function () {
if (!this.baselineStatsReport) {
return;
}
var getStatValue = this._getStatValue;
var byteSentStats = {};
for (var idx in this.currentStatsReport) {
var now = this.currentStatsReport[idx];
try {
var receiveBandwidth = getStatValue(now, 'receiveBandwidth');
var sendBandwidth = getStatValue(now, 'sendBandwidth');
if (receiveBandwidth || sendBandwidth) {
this.conferenceStats.bandwidth = {
"download": Math.round(receiveBandwidth / 1000),
"upload": Math.round(sendBandwidth / 1000)
};
}
}
catch(e){/*not supported*/}
if(now.type == 'googCandidatePair')
{
var ip, type, localip, active;
try {
ip = getStatValue(now, 'remoteAddress');
type = getStatValue(now, "transportType");
localip = getStatValue(now, "localAddress");
active = getStatValue(now, "activeConnection");
}
catch(e){/*not supported*/}
if(!ip || !type || !localip || active != "true")
continue;
// Save the address unless it has been saved already.
var conferenceStatsTransport = this.conferenceStats.transport;
if(!conferenceStatsTransport.some(function (t) { return (
t.ip == ip && t.type == type && t.localip == localip
)})) {
conferenceStatsTransport.push(
{ip: ip, type: type, localip: localip});
}
continue;
}
if(now.type == "candidatepair") {
if(now.state == "succeeded")
continue;
var local = this.currentStatsReport[now.localCandidateId];
var remote = this.currentStatsReport[now.remoteCandidateId];
this.conferenceStats.transport.push({
ip: remote.ipAddress + ":" + remote.portNumber,
type: local.transport,
localip: local.ipAddress + ":" + local.portNumber
});
}
if (now.type != 'ssrc' && now.type != "outboundrtp" &&
now.type != "inboundrtp") {
continue;
}
var before = this.baselineStatsReport[idx];
var ssrc = getStatValue(now, 'ssrc');
if (!before) {
logger.warn(ssrc + ' not enough data');
continue;
}
if(!ssrc)
continue;
var ssrcStats
= this.ssrc2stats[ssrc] || (this.ssrc2stats[ssrc] = new PeerStats());
var isDownloadStream = true;
var key = 'packetsReceived';
var packetsNow = getStatValue(now, key);
if (typeof packetsNow === 'undefined'
|| packetsNow === null || packetsNow === "") {
isDownloadStream = false;
key = 'packetsSent';
packetsNow = getStatValue(now, key);
if (typeof packetsNow === 'undefined' || packetsNow === null) {
logger.warn("No packetsReceived nor packetsSent stat found");
continue;
}
}
if (!packetsNow || packetsNow < 0)
packetsNow = 0;
var packetsBefore = getStatValue(before, key);
if (!packetsBefore || packetsBefore < 0)
packetsBefore = 0;
var packetRate = packetsNow - packetsBefore;
if (!packetRate || packetRate < 0)
packetRate = 0;
var currentLoss = getStatValue(now, 'packetsLost');
if (!currentLoss || currentLoss < 0)
currentLoss = 0;
var previousLoss = getStatValue(before, 'packetsLost');
if (!previousLoss || previousLoss < 0)
previousLoss = 0;
var lossRate = currentLoss - previousLoss;
if (!lossRate || lossRate < 0)
lossRate = 0;
var packetsTotal = (packetRate + lossRate);
ssrcStats.setSsrcLoss({
packetsTotal: packetsTotal,
packetsLost: lossRate,
isDownloadStream: isDownloadStream
});
var bytesReceived = 0, bytesSent = 0;
var nowBytesTransmitted = getStatValue(now, "bytesReceived");
if(nowBytesTransmitted) {
bytesReceived
= nowBytesTransmitted - getStatValue(before, "bytesReceived");
}
nowBytesTransmitted = getStatValue(now, "bytesSent");
if(typeof(nowBytesTransmitted) === "number" ||
typeof(nowBytesTransmitted) === "string") {
nowBytesTransmitted = Number(nowBytesTransmitted);
if(!isNaN(nowBytesTransmitted)){
byteSentStats[ssrc] = nowBytesTransmitted;
if (nowBytesTransmitted > 0) {
bytesSent = nowBytesTransmitted -
getStatValue(before, "bytesSent");
}
}
}
var time = Math.round((now.timestamp - before.timestamp) / 1000);
if (bytesReceived <= 0 || time <= 0) {
bytesReceived = 0;
} else {
bytesReceived = Math.round(((bytesReceived * 8) / time) / 1000);
}
if (bytesSent <= 0 || time <= 0) {
bytesSent = 0;
} else {
bytesSent = Math.round(((bytesSent * 8) / time) / 1000);
}
//detect audio issues (receiving data but audioLevel == 0)
if(bytesReceived > 10 && ssrcStats.ssrc2AudioLevel === 0) {
this.eventEmitter.emit(StatisticsEvents.AUDIO_NOT_WORKING, ssrc);
}
ssrcStats.setSsrcBitrate({
"download": bytesReceived,
"upload": bytesSent
});
var resolution = {height: null, width: null};
try {
var height, width;
if ((height = getStatValue(now, "googFrameHeightReceived")) &&
(width = getStatValue(now, "googFrameWidthReceived"))) {
resolution.height = height;
resolution.width = width;
}
else if ((height = getStatValue(now, "googFrameHeightSent")) &&
(width = getStatValue(now, "googFrameWidthSent"))) {
resolution.height = height;
resolution.width = width;
}
}
catch(e){/*not supported*/}
if (resolution.height && resolution.width) {
ssrcStats.setSsrcResolution(resolution);
} else {
ssrcStats.setSsrcResolution(null);
}
}
// process stats
var totalPackets = {
download: 0,
upload: 0
};
var lostPackets = {
download: 0,
upload: 0
};
var bitrateDownload = 0;
var bitrateUpload = 0;
var resolutions = {};
Object.keys(this.ssrc2stats).forEach(
function (ssrc) {
var ssrcStats = this.ssrc2stats[ssrc];
// process package loss stats
var ssrc2Loss = ssrcStats.ssrc2Loss;
var type = ssrc2Loss.isDownloadStream ? "download" : "upload";
totalPackets[type] += ssrc2Loss.packetsTotal;
lostPackets[type] += ssrc2Loss.packetsLost;
// process bitrate stats
var ssrc2bitrate = ssrcStats.ssrc2bitrate;
bitrateDownload += ssrc2bitrate.download;
bitrateUpload += ssrc2bitrate.upload;
ssrcStats.resetSsrcBitrate();
// collect resolutions
resolutions[ssrc] = ssrcStats.ssrc2resolution;
},
this
);
this.eventEmitter.emit(StatisticsEvents.BYTE_SENT_STATS, byteSentStats);
this.conferenceStats.bitrate
= {"upload": bitrateUpload, "download": bitrateDownload};
this.conferenceStats.packetLoss = {
total:
calculatePacketLoss(lostPackets.download + lostPackets.upload,
totalPackets.download + totalPackets.upload),
download:
calculatePacketLoss(lostPackets.download, totalPackets.download),
upload:
calculatePacketLoss(lostPackets.upload, totalPackets.upload)
};
this.eventEmitter.emit(StatisticsEvents.CONNECTION_STATS, {
"bandwidth": this.conferenceStats.bandwidth,
"bitrate": this.conferenceStats.bitrate,
"packetLoss": this.conferenceStats.packetLoss,
"resolution": resolutions,
"transport": this.conferenceStats.transport
});
this.conferenceStats.transport = [];
};
/**
* Stats processing logic.
*/
StatsCollector.prototype.processAudioLevelReport = function () {
if (!this.baselineAudioLevelsReport) {
return;
}
var getStatValue = this._getStatValue;
for (var idx in this.currentAudioLevelsReport) {
var now = this.currentAudioLevelsReport[idx];
if (now.type != 'ssrc')
continue;
var before = this.baselineAudioLevelsReport[idx];
var ssrc = getStatValue(now, 'ssrc');
if (!before) {
logger.warn(ssrc + ' not enough data');
continue;
}
if (!ssrc) {
if ((Date.now() - now.timestamp) < 3000)
logger.warn("No ssrc: ");
continue;
}
var ssrcStats
= this.ssrc2stats[ssrc]
|| (this.ssrc2stats[ssrc] = new PeerStats());
// Audio level
try {
var audioLevel
= getStatValue(now, 'audioInputLevel')
|| getStatValue(now, 'audioOutputLevel');
}
catch(e) {/*not supported*/
logger.warn("Audio Levels are not available in the statistics.");
clearInterval(this.audioLevelsIntervalId);
return;
}
if (audioLevel) {
const isLocal = !getStatValue(now, 'packetsReceived');
// TODO: Can't find specs about what this value really is, but it
// seems to vary between 0 and around 32k.
audioLevel = audioLevel / 32767;
ssrcStats.setSsrcAudioLevel(audioLevel);
this.eventEmitter.emit(
StatisticsEvents.AUDIO_LEVEL, ssrc, audioLevel, isLocal);
}
}
};