You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

statistics.js 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635
  1. /* global require */
  2. import analytics from './AnalyticsAdapter';
  3. import CallStats from './CallStats';
  4. const EventEmitter = require('events');
  5. import JitsiTrackError from '../../JitsiTrackError';
  6. const logger = require('jitsi-meet-logger').getLogger(__filename);
  7. const LocalStats = require('./LocalStatsCollector.js');
  8. const RTPStats = require('./RTPStatsCollector.js');
  9. const ScriptUtil = require('../util/ScriptUtil');
  10. import * as StatisticsEvents from '../../service/statistics/Events';
  11. import Settings from '../settings/Settings';
  12. /**
  13. * True if callstats API is loaded
  14. */
  15. let isCallstatsLoaded = false;
  16. /**
  17. * Since callstats.io is a third party, we cannot guarantee the quality of their
  18. * service. More specifically, their server may take noticeably long time to
  19. * respond. Consequently, it is in our best interest (in the sense that the
  20. * intergration of callstats.io is pretty important to us but not enough to
  21. * allow it to prevent people from joining a conference) to (1) start
  22. * downloading their API as soon as possible and (2) do the downloading
  23. * asynchronously.
  24. *
  25. * @param customScriptUrl
  26. */
  27. function loadCallStatsAPI(customScriptUrl) {
  28. if (!isCallstatsLoaded) {
  29. ScriptUtil.loadScript(
  30. customScriptUrl ? customScriptUrl
  31. : 'https://api.callstats.io/static/callstats-ws.min.js',
  32. /* async */ true,
  33. /* prepend */ true);
  34. isCallstatsLoaded = true;
  35. }
  36. // FIXME At the time of this writing, we hope that the callstats.io API will
  37. // have loaded by the time we needed it (i.e. CallStats.init is invoked).
  38. }
  39. /**
  40. * callstats strips any additional fields from Error except for "name", "stack",
  41. * "message" and "constraintName". So we need to bundle additional information
  42. * from JitsiTrackError into error passed to callstats to preserve valuable
  43. * information about error.
  44. * @param {JitsiTrackError} error
  45. */
  46. function formatJitsiTrackErrorForCallStats(error) {
  47. const err = new Error();
  48. // Just copy original stack from error
  49. err.stack = error.stack;
  50. // Combine name from error's name plus (possibly) name of original GUM error
  51. err.name = (error.name || 'Unknown error') + (error.gum && error.gum.error
  52. && error.gum.error.name ? ` - ${error.gum.error.name}` : '');
  53. // Put all constraints into this field. For constraint failed errors we will
  54. // still know which exactly constraint failed as it will be a part of
  55. // message.
  56. err.constraintName = error.gum && error.gum.constraints
  57. ? JSON.stringify(error.gum.constraints) : '';
  58. // Just copy error's message.
  59. err.message = error.message;
  60. return err;
  61. }
  62. /**
  63. * Init statistic options
  64. * @param options
  65. */
  66. Statistics.init = function(options) {
  67. Statistics.audioLevelsEnabled = !options.disableAudioLevels;
  68. if (typeof options.audioLevelsInterval === 'number') {
  69. Statistics.audioLevelsInterval = options.audioLevelsInterval;
  70. }
  71. Statistics.disableThirdPartyRequests = options.disableThirdPartyRequests;
  72. };
  73. /**
  74. *
  75. * @param xmpp
  76. * @param options
  77. */
  78. function Statistics(xmpp, options) {
  79. this.rtpStats = null;
  80. this.eventEmitter = new EventEmitter();
  81. this.xmpp = xmpp;
  82. this.options = options || {};
  83. this.callStatsIntegrationEnabled
  84. = this.options.callStatsID && this.options.callStatsSecret
  85. // Even though AppID and AppSecret may be specified, the integration
  86. // of callstats.io may be disabled because of globally-disallowed
  87. // requests to any third parties.
  88. && (Statistics.disableThirdPartyRequests !== true);
  89. if (this.callStatsIntegrationEnabled) {
  90. loadCallStatsAPI(this.options.callStatsCustomScriptUrl);
  91. if (!this.options.callStatsConfIDNamespace) {
  92. logger.warn('"callStatsConfIDNamespace" is not defined');
  93. }
  94. }
  95. /**
  96. * Stores {@link CallStats} instances for each
  97. * {@link TraceablePeerConnection} (one {@link CallStats} instance serves
  98. * one TPC). The instances are mapped by {@link TraceablePeerConnection.id}.
  99. * @type {Map<number, CallStats>}
  100. */
  101. this.callsStatsInstances = new Map();
  102. Statistics.instances.add(this);
  103. }
  104. Statistics.audioLevelsEnabled = false;
  105. Statistics.audioLevelsInterval = 200;
  106. Statistics.disableThirdPartyRequests = false;
  107. Statistics.analytics = analytics;
  108. /**
  109. * Stores all active {@link Statistics} instances.
  110. * @type {Set<Statistics>}
  111. */
  112. Statistics.instances = new Set();
  113. Statistics.prototype.startRemoteStats = function(peerconnection) {
  114. this.stopRemoteStats();
  115. try {
  116. this.rtpStats
  117. = new RTPStats(peerconnection,
  118. Statistics.audioLevelsInterval, 2000, this.eventEmitter);
  119. this.rtpStats.start(Statistics.audioLevelsEnabled);
  120. } catch (e) {
  121. this.rtpStats = null;
  122. logger.error(`Failed to start collecting remote statistics: ${e}`);
  123. }
  124. };
  125. Statistics.localStats = [];
  126. Statistics.startLocalStats = function(stream, callback) {
  127. if (!Statistics.audioLevelsEnabled) {
  128. return;
  129. }
  130. const localStats = new LocalStats(stream, Statistics.audioLevelsInterval,
  131. callback);
  132. this.localStats.push(localStats);
  133. localStats.start();
  134. };
  135. Statistics.prototype.addAudioLevelListener = function(listener) {
  136. if (!Statistics.audioLevelsEnabled) {
  137. return;
  138. }
  139. this.eventEmitter.on(StatisticsEvents.AUDIO_LEVEL, listener);
  140. };
  141. Statistics.prototype.removeAudioLevelListener = function(listener) {
  142. if (!Statistics.audioLevelsEnabled) {
  143. return;
  144. }
  145. this.eventEmitter.removeListener(StatisticsEvents.AUDIO_LEVEL, listener);
  146. };
  147. Statistics.prototype.addBeforeDisposedListener = function(listener) {
  148. this.eventEmitter.on(StatisticsEvents.BEFORE_DISPOSED, listener);
  149. };
  150. Statistics.prototype.removeBeforeDisposedListener = function(listener) {
  151. this.eventEmitter.removeListener(
  152. StatisticsEvents.BEFORE_DISPOSED, listener);
  153. };
  154. Statistics.prototype.addConnectionStatsListener = function(listener) {
  155. this.eventEmitter.on(StatisticsEvents.CONNECTION_STATS, listener);
  156. };
  157. Statistics.prototype.removeConnectionStatsListener = function(listener) {
  158. this.eventEmitter.removeListener(
  159. StatisticsEvents.CONNECTION_STATS,
  160. listener);
  161. };
  162. Statistics.prototype.addByteSentStatsListener = function(listener) {
  163. this.eventEmitter.on(StatisticsEvents.BYTE_SENT_STATS, listener);
  164. };
  165. Statistics.prototype.removeByteSentStatsListener = function(listener) {
  166. this.eventEmitter.removeListener(StatisticsEvents.BYTE_SENT_STATS,
  167. listener);
  168. };
  169. Statistics.prototype.dispose = function() {
  170. try {
  171. // NOTE Before reading this please see the comment in stopCallStats...
  172. //
  173. // Here we prevent from emitting the event twice in case it will be
  174. // triggered from stopCallStats.
  175. // If the event is triggered from here it means that the logs will not
  176. // be submitted anyway (because there is no CallStats instance), but
  177. // we're doing that for the sake of some kind of consistency.
  178. if (!this.callsStatsInstances.size) {
  179. this.eventEmitter.emit(StatisticsEvents.BEFORE_DISPOSED);
  180. }
  181. for (const callStats of this.callsStatsInstances.values()) {
  182. this.stopCallStats(callStats.tpc);
  183. }
  184. this.stopRemoteStats();
  185. if (this.eventEmitter) {
  186. this.eventEmitter.removeAllListeners();
  187. }
  188. } finally {
  189. Statistics.instances.delete(this);
  190. }
  191. };
  192. Statistics.stopLocalStats = function(stream) {
  193. if (!Statistics.audioLevelsEnabled) {
  194. return;
  195. }
  196. for (let i = 0; i < Statistics.localStats.length; i++) {
  197. if (Statistics.localStats[i].stream === stream) {
  198. const localStats = Statistics.localStats.splice(i, 1);
  199. localStats[0].stop();
  200. break;
  201. }
  202. }
  203. };
  204. Statistics.prototype.stopRemoteStats = function() {
  205. if (!this.rtpStats) {
  206. return;
  207. }
  208. this.rtpStats.stop();
  209. this.rtpStats = null;
  210. };
  211. // CALSTATS METHODS
  212. /**
  213. * Initializes the callstats.io API.
  214. * @param {TraceablePeerConnection} tpc the {@link TraceablePeerConnection}
  215. * instance for which CalStats will be started.
  216. * @param {string} remoteUserID
  217. */
  218. Statistics.prototype.startCallStats = function(tpc, remoteUserID) {
  219. if (!this.callStatsIntegrationEnabled) {
  220. return;
  221. } else if (this.callsStatsInstances.has(tpc.id)) {
  222. logger.error('CallStats instance for ${tpc} exists already');
  223. return;
  224. }
  225. if (!CallStats.isBackendInitialized()) {
  226. const userName = Settings.getCallStatsUserName();
  227. if (!CallStats.initBackend({
  228. callStatsID: this.options.callStatsID,
  229. callStatsSecret: this.options.callStatsSecret,
  230. userName,
  231. aliasName: this.options.callStatsAliasName
  232. })) {
  233. // Backend initialization failed bad
  234. return;
  235. }
  236. }
  237. logger.info(`Starting CallStats for ${tpc}...`);
  238. const newInstance
  239. = new CallStats(
  240. tpc,
  241. {
  242. confID: this._getCallStatsConfID(),
  243. remoteUserID
  244. });
  245. this.callsStatsInstances.set(tpc.id, newInstance);
  246. };
  247. /**
  248. * Obtains the list of *all* {@link CallStats} instances collected from every
  249. * valid {@link Statistics} instance.
  250. * @return {Set<CallStats>}
  251. * @private
  252. */
  253. Statistics._getAllCallStatsInstances = function() {
  254. const csInstances = new Set();
  255. for (const statistics of Statistics.instances) {
  256. for (const cs of statistics.callsStatsInstances.values()) {
  257. csInstances.add(cs);
  258. }
  259. }
  260. return csInstances;
  261. };
  262. /**
  263. * Constructs the CallStats conference ID based on the options currently
  264. * configured in this instance.
  265. * @return {string}
  266. * @private
  267. */
  268. Statistics.prototype._getCallStatsConfID = function() {
  269. // The conference ID is case sensitive!!!
  270. return this.options.callStatsConfIDNamespace
  271. ? `${this.options.callStatsConfIDNamespace}/${this.options.roomName}`
  272. : this.options.roomName;
  273. };
  274. /**
  275. * Removes the callstats.io instances.
  276. */
  277. Statistics.prototype.stopCallStats = function(tpc) {
  278. const callStatsInstance = this.callsStatsInstances.get(tpc.id);
  279. if (callStatsInstance) {
  280. // FIXME the original purpose of adding BEFORE_DISPOSED event was to be
  281. // able to submit the last log batch from jitsi-meet to CallStats. After
  282. // recent changes we dispose the CallStats earlier
  283. // (before Statistics.dispose), so we need to emit this event here to
  284. // give this last chance for final log batch submission.
  285. //
  286. // Eventually there should be a separate module called "log storage"
  287. // which should emit proper events when it's underlying
  288. // CallStats instance is going away.
  289. if (this.callsStatsInstances.size === 1) {
  290. this.eventEmitter.emit(StatisticsEvents.BEFORE_DISPOSED);
  291. }
  292. this.callsStatsInstances.delete(tpc.id);
  293. // The fabric needs to be terminated when being stopped
  294. callStatsInstance.sendTerminateEvent();
  295. }
  296. };
  297. /**
  298. * Returns true if the callstats integration is enabled, otherwise returns
  299. * false.
  300. *
  301. * @returns true if the callstats integration is enabled, otherwise returns
  302. * false.
  303. */
  304. Statistics.prototype.isCallstatsEnabled = function() {
  305. return this.callStatsIntegrationEnabled;
  306. };
  307. /**
  308. * Logs either resume or hold event for the given peer connection.
  309. * @param {TraceablePeerConnection} tpc the connection for which event will be
  310. * reported
  311. * @param {boolean} isResume true for resume or false for hold
  312. */
  313. Statistics.prototype.sendConnectionResumeOrHoldEvent = function(tpc, isResume) {
  314. const instance = this.callsStatsInstances.get(tpc.id);
  315. if (instance) {
  316. instance.sendResumeOrHoldEvent(isResume);
  317. }
  318. };
  319. /**
  320. * Notifies CallStats and analytics(if present) for ice connection failed
  321. * @param {TraceablePeerConnection} tpc connection on which failure occurred.
  322. */
  323. Statistics.prototype.sendIceConnectionFailedEvent = function(tpc) {
  324. const instance = this.callsStatsInstances.get(tpc.id);
  325. if (instance) {
  326. instance.sendIceConnectionFailedEvent();
  327. }
  328. Statistics.analytics.sendEvent('connection.ice_failed');
  329. };
  330. /**
  331. * Notifies CallStats for mute events
  332. * @param {TraceablePeerConnection} tpc connection on which failure occurred.
  333. * @param {boolean} muted true for muted and false for not muted
  334. * @param {String} type "audio"/"video"
  335. */
  336. Statistics.prototype.sendMuteEvent = function(tpc, muted, type) {
  337. const instance = tpc && this.callsStatsInstances.get(tpc.id);
  338. CallStats.sendMuteEvent(muted, type, instance);
  339. };
  340. /**
  341. * Notifies CallStats for screen sharing events
  342. * @param start {boolean} true for starting screen sharing and
  343. * false for not stopping
  344. */
  345. Statistics.prototype.sendScreenSharingEvent = function(start) {
  346. for (const cs of this.callsStatsInstances.values()) {
  347. cs.sendScreenSharingEvent(start);
  348. }
  349. };
  350. /**
  351. * Notifies the statistics module that we are now the dominant speaker of the
  352. * conference.
  353. */
  354. Statistics.prototype.sendDominantSpeakerEvent = function() {
  355. for (const cs of this.callsStatsInstances.values()) {
  356. cs.sendDominantSpeakerEvent();
  357. }
  358. };
  359. /**
  360. * Notifies about active device.
  361. * @param {{deviceList: {String:String}}} devicesData - list of devices with
  362. * their data
  363. */
  364. Statistics.sendActiveDeviceListEvent = function(devicesData) {
  365. const globalSet = Statistics._getAllCallStatsInstances();
  366. if (globalSet.size) {
  367. for (const cs of globalSet) {
  368. CallStats.sendActiveDeviceListEvent(devicesData, cs);
  369. }
  370. } else {
  371. CallStats.sendActiveDeviceListEvent(devicesData, null);
  372. }
  373. };
  374. /* eslint-disable max-params */
  375. /**
  376. * Lets the underlying statistics module know where is given SSRC rendered by
  377. * providing renderer tag ID.
  378. * @param {TraceablePeerConnection} tpc the connection to which the stream
  379. * belongs to
  380. * @param {number} ssrc the SSRC of the stream
  381. * @param {boolean} isLocal
  382. * @param {string} userId
  383. * @param {string} usageLabel meaningful usage label of this stream like
  384. * 'microphone', 'camera' or 'screen'.
  385. * @param {string} containerId the id of media 'audio' or 'video' tag which
  386. * renders the stream.
  387. */
  388. Statistics.prototype.associateStreamWithVideoTag = function(
  389. tpc,
  390. ssrc,
  391. isLocal,
  392. userId,
  393. usageLabel,
  394. containerId) {
  395. const instance = this.callsStatsInstances.get(tpc.id);
  396. if (instance) {
  397. instance.associateStreamWithVideoTag(
  398. ssrc,
  399. isLocal,
  400. userId,
  401. usageLabel,
  402. containerId);
  403. }
  404. };
  405. /* eslint-enable max-params */
  406. /**
  407. * Notifies CallStats that getUserMedia failed.
  408. *
  409. * @param {Error} e error to send
  410. */
  411. Statistics.sendGetUserMediaFailed = function(e) {
  412. const error
  413. = e instanceof JitsiTrackError
  414. ? formatJitsiTrackErrorForCallStats(e) : e;
  415. const globalSet = Statistics._getAllCallStatsInstances();
  416. if (globalSet.size) {
  417. for (const cs of globalSet) {
  418. CallStats.sendGetUserMediaFailed(error, cs);
  419. }
  420. } else {
  421. CallStats.sendGetUserMediaFailed(error, null);
  422. }
  423. };
  424. /**
  425. * Notifies CallStats that peer connection failed to create offer.
  426. *
  427. * @param {Error} e error to send
  428. * @param {TraceablePeerConnection} tpc connection on which failure occurred.
  429. */
  430. Statistics.prototype.sendCreateOfferFailed = function(e, tpc) {
  431. const instance = this.callsStatsInstances.get(tpc.id);
  432. if (instance) {
  433. instance.sendCreateOfferFailed(e);
  434. }
  435. };
  436. /**
  437. * Notifies CallStats that peer connection failed to create answer.
  438. *
  439. * @param {Error} e error to send
  440. * @param {TraceablePeerConnection} tpc connection on which failure occured.
  441. */
  442. Statistics.prototype.sendCreateAnswerFailed = function(e, tpc) {
  443. const instance = this.callsStatsInstances.get(tpc.id);
  444. if (instance) {
  445. instance.sendCreateAnswerFailed(e);
  446. }
  447. };
  448. /**
  449. * Notifies CallStats that peer connection failed to set local description.
  450. *
  451. * @param {Error} e error to send
  452. * @param {TraceablePeerConnection} tpc connection on which failure occurred.
  453. */
  454. Statistics.prototype.sendSetLocalDescFailed = function(e, tpc) {
  455. const instance = this.callsStatsInstances.get(tpc.id);
  456. if (instance) {
  457. instance.sendSetLocalDescFailed(e);
  458. }
  459. };
  460. /**
  461. * Notifies CallStats that peer connection failed to set remote description.
  462. *
  463. * @param {Error} e error to send
  464. * @param {TraceablePeerConnection} tpc connection on which failure occurred.
  465. */
  466. Statistics.prototype.sendSetRemoteDescFailed = function(e, tpc) {
  467. const instance = this.callsStatsInstances.get(tpc.id);
  468. if (instance) {
  469. instance.sendSetRemoteDescFailed(e);
  470. }
  471. };
  472. /**
  473. * Notifies CallStats that peer connection failed to add ICE candidate.
  474. *
  475. * @param {Error} e error to send
  476. * @param {TraceablePeerConnection} tpc connection on which failure occurred.
  477. */
  478. Statistics.prototype.sendAddIceCandidateFailed = function(e, tpc) {
  479. const instance = this.callsStatsInstances.get(tpc.id);
  480. if (instance) {
  481. instance.sendAddIceCandidateFailed(e);
  482. }
  483. };
  484. /**
  485. * Adds to CallStats an application log.
  486. *
  487. * @param {String} m a log message to send or an {Error} object to be reported
  488. */
  489. Statistics.sendLog = function(m) {
  490. const globalSubSet = new Set();
  491. // FIXME we don't want to duplicate logs over P2P instance, but
  492. // here we should go over instances and call this method for each
  493. // unique conference ID rather than selecting the first one.
  494. // We don't have such use case though, so leaving as is for now.
  495. for (const stats of Statistics.instances) {
  496. if (stats.callsStatsInstances.size) {
  497. globalSubSet.add(stats.callsStatsInstances.values().next().value);
  498. }
  499. }
  500. if (globalSubSet.size) {
  501. for (const csPerStats of globalSubSet) {
  502. CallStats.sendApplicationLog(m, csPerStats);
  503. }
  504. } else {
  505. CallStats.sendApplicationLog(m, null);
  506. }
  507. };
  508. /**
  509. * Sends the given feedback through CallStats.
  510. *
  511. * @param overall an integer between 1 and 5 indicating the user feedback
  512. * @param detailed detailed feedback from the user. Not yet used
  513. */
  514. Statistics.prototype.sendFeedback = function(overall, detailed) {
  515. CallStats.sendFeedback(this._getCallStatsConfID(), overall, detailed);
  516. Statistics.analytics.sendEvent('feedback.rating',
  517. { value: overall,
  518. detailed });
  519. };
  520. Statistics.LOCAL_JID = require('../../service/statistics/constants').LOCAL_JID;
  521. /**
  522. * Reports global error to CallStats.
  523. *
  524. * @param {Error} error
  525. */
  526. Statistics.reportGlobalError = function(error) {
  527. if (error instanceof JitsiTrackError && error.gum) {
  528. Statistics.sendGetUserMediaFailed(error);
  529. } else {
  530. Statistics.sendLog(error);
  531. }
  532. };
  533. /**
  534. * Sends event to analytics and callstats.
  535. * @param {string} eventName the event name.
  536. * @param {Object} data the data to be sent.
  537. */
  538. Statistics.sendEventToAll = function(eventName, data) {
  539. this.analytics.sendEvent(eventName, data);
  540. Statistics.sendLog(JSON.stringify({ name: eventName,
  541. data }));
  542. };
  543. module.exports = Statistics;