/* global $, callstats */
const logger = require('jitsi-meet-logger').getLogger(__filename);
const GlobalOnErrorHandler = require('../util/GlobalOnErrorHandler');
const jsSHA = require('jssha');
const io = require('socket.io-client');
/**
* We define enumeration of wrtcFuncNames as we need them before
* callstats is initialized to queue events.
* @const
* @see http://www.callstats.io/api/#enumeration-of-wrtcfuncnames
*/
const wrtcFuncNames = {
createOffer: 'createOffer',
createAnswer: 'createAnswer',
setLocalDescription: 'setLocalDescription',
setRemoteDescription: 'setRemoteDescription',
addIceCandidate: 'addIceCandidate',
getUserMedia: 'getUserMedia',
iceConnectionFailure: 'iceConnectionFailure',
signalingError: 'signalingError',
applicationLog: 'applicationLog'
};
/**
* We define enumeration of fabricEvent as we need them before
* callstats is initialized to queue events.
* @const
* @see http://www.callstats.io/api/#enumeration-of-fabricevent
*/
const fabricEvent = {
fabricHold: 'fabricHold',
fabricResume: 'fabricResume',
audioMute: 'audioMute',
audioUnmute: 'audioUnmute',
videoPause: 'videoPause',
videoResume: 'videoResume',
fabricUsageEvent: 'fabricUsageEvent',
fabricStats: 'fabricStats',
fabricTerminated: 'fabricTerminated',
screenShareStart: 'screenShareStart',
screenShareStop: 'screenShareStop',
dominantSpeaker: 'dominantSpeaker',
activeDeviceList: 'activeDeviceList'
};
/**
* The user id to report to callstats as destination.
* @type {string}
*/
const DEFAULT_REMOTE_USER = 'jitsi';
/**
* Type of pending reports, can be event or an error.
* @type {{ERROR: string, EVENT: string}}
*/
const reportType = {
ERROR: 'error',
EVENT: 'event',
MST_WITH_USERID: 'mstWithUserID'
};
/**
* An instance of this class is a wrapper for the CallStats API fabric. A fabric
* reports one peer connection the the CallStats backend and is allocated with
* {@link callstats.addNewFabric}. It has a bunch of instance methods for
* reporting various events. A fabric is considered disposed when
* {@link CallStats.sendTerminateEvent} is executed.
*
* Currently only one backend instance can be created ever and it's done using
* {@link CallStats.initBackend}. At the time of this writing there is no way to
* explicitly shutdown the backend, but it's supposed to close it's connection
* automatically, after all fabrics have been terminated.
*/
export default class CallStats {
/**
* A callback passed to {@link callstats.addNewFabric}.
* @param {string} error 'success' means ok
* @param {string} msg some more details
* @private
*/
static _addNewFabricCallback(error, msg) {
if (CallStats.backend && error !== 'success') {
logger.error(`Monitoring status: ${error} msg: ${msg}`);
}
}
/**
* Callback passed to {@link callstats.initialize} (backend initialization)
* @param {string} error 'success' means ok
* @param {String} msg
* @private
*/
static _initCallback(error, msg) {
logger.log(`CallStats Status: err=${error} msg=${msg}`);
// there is no lib, nothing to report to
if (error !== 'success') {
return;
}
// I hate that
let atLeastOneFabric = false;
let defaultInstance = null;
for (const callStatsInstance of CallStats.fabrics.values()) {
if (!callStatsInstance.hasFabric) {
logger.debug('addNewFabric - initCallback');
if (callStatsInstance._addNewFabric()) {
atLeastOneFabric = true;
if (!defaultInstance) {
defaultInstance = callStatsInstance;
}
}
}
}
if (!atLeastOneFabric) {
return;
}
CallStats.initialized = true;
// There is no conference ID nor a PeerConnection available when some of
// the events are scheduled on the reportsQueue, so those will be
// reported on the first initialized fabric.
const defaultConfID = defaultInstance.confID;
const defaultPC = defaultInstance.peerconnection;
// notify callstats about failures if there were any
for (const report of CallStats.reportsQueue) {
if (report.type === reportType.ERROR) {
const errorData = report.data;
CallStats._reportError(
defaultInstance,
errorData.type,
errorData.error,
errorData.pc || defaultPC);
} else if (report.type === reportType.EVENT) {
// if we have and event to report and we failed to add
// fabric this event will not be reported anyway, returning
// an error
const eventData = report.data;
CallStats.backend.sendFabricEvent(
report.pc || defaultPC,
eventData.event,
defaultConfID,
eventData.eventData);
} else if (report.type === reportType.MST_WITH_USERID) {
const data = report.data;
CallStats.backend.associateMstWithUserID(
report.pc || defaultPC,
data.callStatsId,
defaultConfID,
data.ssrc,
data.usageLabel,
data.containerId
);
}
}
CallStats.reportsQueue.length = 0;
}
/* eslint-disable max-params */
/**
* Reports an error to callstats.
*
* @param {CallStats} [cs]
* @param type the type of the error, which will be one of the wrtcFuncNames
* @param error the error
* @param pc the peerconnection
* @private
*/
static _reportError(cs, type, error, pc) {
let _error = error;
if (!_error) {
logger.warn('No error is passed!');
_error = new Error('Unknown error');
}
if (CallStats.initialized) {
CallStats.backend.reportError(pc, cs && cs.confID, type, _error);
} else {
CallStats.reportsQueue.push({
type: reportType.ERROR,
data: {
error: _error,
pc,
type
}
});
}
// else just ignore it
}
/* eslint-enable max-params */
/**
* Reports an error to callstats.
*
* @param {CallStats} cs
* @param event the type of the event, which will be one of the fabricEvent
* @param eventData additional data to pass to event
* @private
*/
static _reportEvent(cs, event, eventData) {
const pc = cs && cs.peerconnection;
const confID = cs && cs.confID;
if (CallStats.initialized) {
CallStats.backend.sendFabricEvent(pc, event, confID, eventData);
} else {
CallStats.reportsQueue.push({
confID,
pc,
type: reportType.EVENT,
data: { event,
eventData }
});
}
}
/**
* Wraps some of the CallStats API method and logs their calls with
* arguments on the debug logging level. Also wraps some of the backend
* methods execution into try catch blocks to not crash the app in case
* there is a problem with the backend itself.
* @param {callstats} theBackend
* @private
*/
static _traceAndCatchBackendCalls(theBackend) {
const tryCatchMethods = [
'associateMstWithUserID',
'sendFabricEvent',
'sendUserFeedback'
// 'reportError', - this one needs special handling - see code below
];
for (const methodName of tryCatchMethods) {
const originalMethod = theBackend[methodName];
theBackend[methodName] = function(...theArguments) {
try {
return originalMethod.apply(theBackend, theArguments);
} catch (e) {
GlobalOnErrorHandler.callErrorHandler(e);
}
};
}
const debugMethods = [
'associateMstWithUserID',
'sendFabricEvent',
'sendUserFeedback'
// 'reportError', - this one needs special handling - see code below
];
for (const methodName of debugMethods) {
const originalMethod = theBackend[methodName];
theBackend[methodName] = function(...theArguments) {
logger.debug(methodName, theArguments);
originalMethod.apply(theBackend, theArguments);
};
}
const originalReportError = theBackend.reportError;
/* eslint-disable max-params */
theBackend.reportError
= function(pc, cs, type, ...args) {
// Logs from the logger are submitted on the applicationLog event
// "type". Logging the arguments on the logger will create endless
// loop, because it will put all the logs to the logger queue again.
if (type === wrtcFuncNames.applicationLog) {
// NOTE otherArguments are not logged to the console on purpose
// to not log the whole log batch
// FIXME check the current logging level (currently not exposed
// by the logger implementation)
console && console.debug('reportError', pc, cs, type);
} else {
logger.debug('reportError', pc, cs, type, ...args);
}
try {
originalReportError.call(theBackend, pc, cs, type, ...args);
} catch (exception) {
if (type === wrtcFuncNames.applicationLog) {
console && console.error('reportError', exception);
} else {
GlobalOnErrorHandler.callErrorHandler(exception);
}
}
};
/* eslint-enable max-params */
}
/**
* Initializes the CallStats backend. Should be called only if
* {@link CallStats.isBackendInitialized} returns false.
* @param {object} options
* @param {String} options.callStatsID CallStats credentials - ID
* @param {String} options.callStatsSecret CallStats credentials - secret
* @param {string} options.aliasName the aliasName part of
* the userID aka endpoint ID, see CallStats docs for more info.
* @param {string} options.userName the userName part of
* the userID aka display name, see CallStats docs for more info.
*
*/
static initBackend(options) {
if (CallStats.backend) {
throw new Error('CallStats backend has been initialized already!');
}
try {
CallStats.backend
= new callstats($, io, jsSHA); // eslint-disable-line new-cap
CallStats._traceAndCatchBackendCalls(CallStats.backend);
CallStats.userID = {
aliasName: options.aliasName,
userName: options.userName
};
CallStats.callStatsID = options.callStatsID;
CallStats.callStatsSecret = options.callStatsSecret;
// userID is generated or given by the origin server
CallStats.backend.initialize(
CallStats.callStatsID,
CallStats.callStatsSecret,
CallStats.userID,
CallStats._initCallback);
return true;
} catch (e) {
// The callstats.io API failed to initialize (e.g. because its
// download did not succeed in general or on time). Further attempts
// to utilize it cannot possibly succeed.
GlobalOnErrorHandler.callErrorHandler(e);
CallStats.backend = null;
logger.error(e);
return false;
}
}
/**
* Checks if the CallStats backend has been created. It does not mean that
* it has been initialized, but only that the API instance has been
* allocated successfully.
* @return {boolean} true if backend exists or false
* otherwise
*/
static isBackendInitialized() {
return Boolean(CallStats.backend);
}
/**
* Notifies CallStats about active device.
* @param {{deviceList: {String:String}}} devicesData list of devices with
* their data
* @param {CallStats} cs callstats instance related to the event
*/
static sendActiveDeviceListEvent(devicesData, cs) {
CallStats._reportEvent(cs, fabricEvent.activeDeviceList, devicesData);
}
/**
* Notifies CallStats that there is a log we want to report.
*
* @param {Error} e error to send or {String} message
* @param {CallStats} cs callstats instance related to the error (optional)
*/
static sendApplicationLog(e, cs) {
try {
CallStats._reportError(
cs,
wrtcFuncNames.applicationLog,
e,
cs && cs.peerconnection);
} catch (error) {
// If sendApplicationLog fails it should not be printed to
// the logger, because it will try to push the logs again
// (through sendApplicationLog) and an endless loop is created.
if (console && (typeof console.error === 'function')) {
// FIXME send analytics event as well
console.error('sendApplicationLog failed', error);
}
}
}
/**
* Sends the given feedback through CallStats.
*
* @param {string} conferenceID the conference ID for which the feedback
* will be reported.
* @param overallFeedback an integer between 1 and 5 indicating the
* user feedback
* @param detailedFeedback detailed feedback from the user. Not yet used
*/
static sendFeedback(conferenceID, overallFeedback, detailedFeedback) {
if (CallStats.backend) {
CallStats.backend.sendUserFeedback(
conferenceID, {
userID: CallStats.userID,
overall: overallFeedback,
comment: detailedFeedback
});
} else {
logger.error('Failed to submit feedback to CallStats - no backend');
}
}
/**
* Notifies CallStats that getUserMedia failed.
*
* @param {Error} e error to send
* @param {CallStats} cs callstats instance related to the error (optional)
*/
static sendGetUserMediaFailed(e, cs) {
CallStats._reportError(cs, wrtcFuncNames.getUserMedia, e, null);
}
/**
* Notifies CallStats for mute events
* @param mute {boolean} true for muted and false for not muted
* @param type {String} "audio"/"video"
* @param {CallStats} cs callstats instance related to the event
*/
static sendMuteEvent(mute, type, cs) {
let event;
if (type === 'video') {
event = mute ? fabricEvent.videoPause : fabricEvent.videoResume;
} else {
event = mute ? fabricEvent.audioMute : fabricEvent.audioUnmute;
}
CallStats._reportEvent(cs, event);
}
/**
* Creates new CallStats instance that handles all callstats API calls for
* given {@link TraceablePeerConnection}. Each instance is meant to handle
* one CallStats fabric added with 'addFabric' API method for the
* {@link TraceablePeerConnection} instance passed in the constructor.
* @param {TraceablePeerConnection} tpc
* @param {Object} options
* @param {string} options.confID the conference ID that wil be used to
* report the session.
* @param {string} [options.remoteUserID='jitsi'] the remote user ID to
* which given tpc is connected.
*/
constructor(tpc, options) {
if (!CallStats.backend) {
throw new Error('CallStats backend not intiialized!');
}
this.confID = options.confID;
this.tpc = tpc;
this.peerconnection = tpc.peerconnection;
this.remoteUserID = options.remoteUserID || DEFAULT_REMOTE_USER;
this.hasFabric = false;
CallStats.fabrics.add(this);
if (CallStats.initialized) {
this._addNewFabric();
}
}
/**
* Initializes CallStats fabric by calling "addNewFabric" for
* the peer connection associated with this instance.
* @return {boolean} true if the call was successful or false otherwise.
*/
_addNewFabric() {
logger.info('addNewFabric', this.remoteUserID, this);
try {
const ret
= CallStats.backend.addNewFabric(
this.peerconnection,
this.remoteUserID,
CallStats.backend.fabricUsage.multiplex,
this.confID,
CallStats._addNewFabricCallback);
this.hasFabric = true;
const success = ret.status === 'success';
if (!success) {
logger.error('callstats fabric not initilized', ret.message);
}
return success;
} catch (error) {
GlobalOnErrorHandler.callErrorHandler(error);
return false;
}
}
/* eslint-disable max-params */
/**
* Lets CallStats module know where is given SSRC rendered by providing
* renderer tag ID.
* If the lib is not initialized yet queue the call for later, when it's
* ready.
* @param {number} ssrc the SSRC of the stream
* @param {boolean} isLocal indicates whether this the stream is local
* @param {string|null} streamEndpointId if the stream is not local the it
* needs to contain the stream owner's ID
* @param {string} usageLabel meaningful usage label of this stream like
* 'microphone', 'camera' or 'screen'.
* @param {string} containerId the id of media 'audio' or 'video' tag which
* renders the stream.
*/
associateStreamWithVideoTag(
ssrc,
isLocal,
streamEndpointId,
usageLabel,
containerId) {
if (!CallStats.backend) {
return;
}
const callStatsId = isLocal ? CallStats.userID : streamEndpointId;
if (CallStats.initialized) {
CallStats.backend.associateMstWithUserID(
this.peerconnection,
callStatsId,
this.confID,
ssrc,
usageLabel,
containerId);
} else {
CallStats.reportsQueue.push({
type: reportType.MST_WITH_USERID,
pc: this.peerconnection,
data: {
callStatsId,
containerId,
ssrc,
usageLabel
}
});
}
}
/* eslint-enable max-params */
/**
* Notifies CallStats that we are the new dominant speaker in the
* conference.
*/
sendDominantSpeakerEvent() {
CallStats._reportEvent(this, fabricEvent.dominantSpeaker);
}
/**
* Notifies CallStats that the fabric for the underlying peerconnection was
* closed and no evens should be reported, after this call.
*/
sendTerminateEvent() {
if (CallStats.initialized) {
CallStats.backend.sendFabricEvent(
this.peerconnection,
CallStats.backend.fabricEvent.fabricTerminated,
this.confID);
}
CallStats.fabrics.delete(this);
}
/**
* Notifies CallStats for ice connection failed
*/
sendIceConnectionFailedEvent() {
CallStats._reportError(
this,
wrtcFuncNames.iceConnectionFailure,
null,
this.peerconnection);
}
/**
* Notifies CallStats that peer connection failed to create offer.
*
* @param {Error} e error to send
*/
sendCreateOfferFailed(e) {
CallStats._reportError(
this, wrtcFuncNames.createOffer, e, this.peerconnection);
}
/**
* Notifies CallStats that peer connection failed to create answer.
*
* @param {Error} e error to send
*/
sendCreateAnswerFailed(e) {
CallStats._reportError(
this, wrtcFuncNames.createAnswer, e, this.peerconnection);
}
/**
* Sends either resume or hold event for the fabric associated with
* the underlying peerconnection.
* @param {boolean} isResume true to resume or false to hold
*/
sendResumeOrHoldEvent(isResume) {
CallStats._reportEvent(
this,
isResume ? fabricEvent.fabricResume : fabricEvent.fabricHold);
}
/**
* Notifies CallStats for screen sharing events
* @param {boolean} start true for starting screen sharing and
* false for not stopping
*/
sendScreenSharingEvent(start) {
CallStats._reportEvent(
this,
start ? fabricEvent.screenShareStart : fabricEvent.screenShareStop);
}
/**
* Notifies CallStats that peer connection failed to set local description.
*
* @param {Error} e error to send
*/
sendSetLocalDescFailed(e) {
CallStats._reportError(
this, wrtcFuncNames.setLocalDescription, e, this.peerconnection);
}
/**
* Notifies CallStats that peer connection failed to set remote description.
*
* @param {Error} e error to send
*/
sendSetRemoteDescFailed(e) {
CallStats._reportError(
this, wrtcFuncNames.setRemoteDescription, e, this.peerconnection);
}
/**
* Notifies CallStats that peer connection failed to add ICE candidate.
*
* @param {Error} e error to send
*/
sendAddIceCandidateFailed(e) {
CallStats._reportError(
this, wrtcFuncNames.addIceCandidate, e, this.peerconnection);
}
}
/**
* The CallStats API backend instance
* @type {callstats}
*/
CallStats.backend = null;
// some errors/events may happen before CallStats init
// in this case we accumulate them in this array
// and send them to callstats on init
CallStats.reportsQueue = [];
/**
* Whether the library was successfully initialized using its initialize method.
* And whether we had successfully called addNewFabric at least once.
* @type {boolean}
*/
CallStats.initialized = false;
/**
* Part of the CallStats credentials - application ID
* @type {string}
*/
CallStats.callStatsID = null;
/**
* Part of the CallStats credentials - application secret
* @type {string}
*/
CallStats.callStatsSecret = null;
/**
* Local CallStats user ID structure. Can be set only once when
* {@link backend} is initialized, so it's static for the time being.
* See CallStats API for more info:
* https://www.callstats.io/api/#userid
* @type {object}
*/
CallStats.userID = null;
/**
* Set of currently existing {@link CallStats} instances.
* @type {Set}
*/
CallStats.fabrics = new Set();