|
@@ -2,6 +2,7 @@ import browser from '../browser';
|
2
|
2
|
import { browsers } from 'js-utils';
|
3
|
3
|
|
4
|
4
|
import * as StatisticsEvents from '../../service/statistics/Events';
|
|
5
|
+import * as MediaType from '../../service/RTC/MediaType';
|
5
|
6
|
|
6
|
7
|
const GlobalOnErrorHandler = require('../util/GlobalOnErrorHandler');
|
7
|
8
|
const logger = require('jitsi-meet-logger').getLogger(__filename);
|
|
@@ -10,7 +11,8 @@ const logger = require('jitsi-meet-logger').getLogger(__filename);
|
10
|
11
|
const browserSupported = browser.isChrome()
|
11
|
12
|
|| browser.isOpera() || browser.isFirefox()
|
12
|
13
|
|| browser.isNWJS() || browser.isElectron()
|
13
|
|
- || browser.isTemasysPluginUsed() || browser.isEdge();
|
|
14
|
+ || browser.isTemasysPluginUsed() || browser.isEdge()
|
|
15
|
+ || browser.isSafariWithWebrtc();
|
14
|
16
|
|
15
|
17
|
/**
|
16
|
18
|
* The lib-jitsi-meet browser-agnostic names of the browser-specific keys
|
|
@@ -25,7 +27,10 @@ KEYS_BY_BROWSER_TYPE[browsers.FIREFOX] = {
|
25
|
27
|
'packetsSent': 'packetsSent',
|
26
|
28
|
'bytesReceived': 'bytesReceived',
|
27
|
29
|
'bytesSent': 'bytesSent',
|
28
|
|
- 'framerateMean': 'framerateMean'
|
|
30
|
+ 'framerateMean': 'framerateMean',
|
|
31
|
+ 'ip': 'ipAddress',
|
|
32
|
+ 'port': 'portNumber',
|
|
33
|
+ 'protocol': 'transport'
|
29
|
34
|
};
|
30
|
35
|
KEYS_BY_BROWSER_TYPE[browsers.CHROME] = {
|
31
|
36
|
'receiveBandwidth': 'googAvailableReceiveBandwidth',
|
|
@@ -50,7 +55,10 @@ KEYS_BY_BROWSER_TYPE[browsers.CHROME] = {
|
50
|
55
|
'audioOutputLevel': 'audioOutputLevel',
|
51
|
56
|
'currentRoundTripTime': 'googRtt',
|
52
|
57
|
'remoteCandidateType': 'googRemoteCandidateType',
|
53
|
|
- 'localCandidateType': 'googLocalCandidateType'
|
|
58
|
+ 'localCandidateType': 'googLocalCandidateType',
|
|
59
|
+ 'ip': 'ip',
|
|
60
|
+ 'port': 'port',
|
|
61
|
+ 'protocol': 'protocol'
|
54
|
62
|
};
|
55
|
63
|
KEYS_BY_BROWSER_TYPE[browsers.EDGE] = {
|
56
|
64
|
'sendBandwidth': 'googAvailableSendBandwidth',
|
|
@@ -242,6 +250,7 @@ export default function StatsCollector(
|
242
|
250
|
* @private
|
243
|
251
|
*/
|
244
|
252
|
this._getStatValue = this._defineGetStatValueMethod(keys);
|
|
253
|
+ this._getNewStatValue = this._defineNewGetStatValueMethod(keys);
|
245
|
254
|
|
246
|
255
|
this.peerconnection = peerconnection;
|
247
|
256
|
this.baselineAudioLevelsReport = null;
|
|
@@ -313,7 +322,13 @@ StatsCollector.prototype.start = function(startAudioLevelStats) {
|
313
|
322
|
results = report.result();
|
314
|
323
|
}
|
315
|
324
|
self.currentAudioLevelsReport = results;
|
316
|
|
- self.processAudioLevelReport();
|
|
325
|
+ if (browser.isSafariWithWebrtc()
|
|
326
|
+ || browser.isFirefox()) {
|
|
327
|
+ self.processNewAudioLevelReport();
|
|
328
|
+ } else {
|
|
329
|
+ self.processAudioLevelReport();
|
|
330
|
+ }
|
|
331
|
+
|
317
|
332
|
self.baselineAudioLevelsReport
|
318
|
333
|
= self.currentAudioLevelsReport;
|
319
|
334
|
},
|
|
@@ -343,7 +358,12 @@ StatsCollector.prototype.start = function(startAudioLevelStats) {
|
343
|
358
|
|
344
|
359
|
self.currentStatsReport = results;
|
345
|
360
|
try {
|
346
|
|
- self.processStatsReport();
|
|
361
|
+ if (browser.isSafariWithWebrtc()
|
|
362
|
+ || browser.isFirefox()) {
|
|
363
|
+ self.processNewStatsReport();
|
|
364
|
+ } else {
|
|
365
|
+ self.processStatsReport();
|
|
366
|
+ }
|
347
|
367
|
} catch (e) {
|
348
|
368
|
GlobalOnErrorHandler.callErrorHandler(e);
|
349
|
369
|
logger.error(`Unsupported key:${e}`, e);
|
|
@@ -722,6 +742,16 @@ StatsCollector.prototype.processStatsReport = function() {
|
722
|
742
|
}
|
723
|
743
|
}
|
724
|
744
|
|
|
745
|
+ this.eventEmitter.emit(
|
|
746
|
+ StatisticsEvents.BYTE_SENT_STATS, this.peerconnection, byteSentStats);
|
|
747
|
+
|
|
748
|
+ this._processAndEmitReport();
|
|
749
|
+};
|
|
750
|
+
|
|
751
|
+/**
|
|
752
|
+ *
|
|
753
|
+ */
|
|
754
|
+StatsCollector.prototype._processAndEmitReport = function() {
|
725
|
755
|
// process stats
|
726
|
756
|
const totalPackets = {
|
727
|
757
|
download: 0,
|
|
@@ -804,9 +834,6 @@ StatsCollector.prototype.processStatsReport = function() {
|
804
|
834
|
ssrcStats.resetBitrate();
|
805
|
835
|
}
|
806
|
836
|
|
807
|
|
- this.eventEmitter.emit(
|
808
|
|
- StatisticsEvents.BYTE_SENT_STATS, this.peerconnection, byteSentStats);
|
809
|
|
-
|
810
|
837
|
this.conferenceStats.bitrate = {
|
811
|
838
|
'upload': bitrateUpload,
|
812
|
839
|
'download': bitrateDownload
|
|
@@ -940,3 +967,342 @@ StatsCollector.prototype.processAudioLevelReport = function() {
|
940
|
967
|
};
|
941
|
968
|
|
942
|
969
|
/* eslint-enable no-continue */
|
|
970
|
+
|
|
971
|
+/**
|
|
972
|
+ * New promised based getStats report processing.
|
|
973
|
+ * Tested with chrome, firefox and safari. Not switching it on for for chrome as
|
|
974
|
+ * frameRate stat is missing and calculating it using framesSent,
|
|
975
|
+ * gives values double the values seen in webrtc-internals.
|
|
976
|
+ * https://w3c.github.io/webrtc-stats/
|
|
977
|
+ */
|
|
978
|
+
|
|
979
|
+/**
|
|
980
|
+ * Defines a function which (1) is to be used as a StatsCollector method and (2)
|
|
981
|
+ * gets the value from a specific report returned by RTCPeerConnection#getStats
|
|
982
|
+ * associated with a lib-jitsi-meet browser-agnostic name in case of using
|
|
983
|
+ * Promised based getStats.
|
|
984
|
+ *
|
|
985
|
+ * @param {Object.<string,string>} keys the map of LibJitsi browser-agnostic
|
|
986
|
+ * names to RTCPeerConnection#getStats browser-specific keys
|
|
987
|
+ */
|
|
988
|
+StatsCollector.prototype._defineNewGetStatValueMethod = function(keys) {
|
|
989
|
+ // Define the function which converts a lib-jitsi-meet browser-asnostic name
|
|
990
|
+ // to a browser-specific key of a report returned by
|
|
991
|
+ // RTCPeerConnection#getStats.
|
|
992
|
+ const keyFromName = function(name) {
|
|
993
|
+ const key = keys[name];
|
|
994
|
+
|
|
995
|
+ if (key) {
|
|
996
|
+ return key;
|
|
997
|
+ }
|
|
998
|
+
|
|
999
|
+ // eslint-disable-next-line no-throw-literal
|
|
1000
|
+ throw `The property '${name}' isn't supported!`;
|
|
1001
|
+ };
|
|
1002
|
+
|
|
1003
|
+ // Compose the 2 functions defined above to get a function which retrieves
|
|
1004
|
+ // the value from a specific report returned by RTCPeerConnection#getStats
|
|
1005
|
+ // associated with a specific lib-jitsi-meet browser-agnostic name.
|
|
1006
|
+ return (item, name) => item[keyFromName(name)];
|
|
1007
|
+};
|
|
1008
|
+
|
|
1009
|
+/**
|
|
1010
|
+ * Converts the value to a non-negative number.
|
|
1011
|
+ * If the value is either invalid or negative then 0 will be returned.
|
|
1012
|
+ * @param {*} v
|
|
1013
|
+ * @return {number}
|
|
1014
|
+ * @private
|
|
1015
|
+ */
|
|
1016
|
+StatsCollector.prototype.getNonNegativeValue = function(v) {
|
|
1017
|
+ let value = v;
|
|
1018
|
+
|
|
1019
|
+ if (typeof value !== 'number') {
|
|
1020
|
+ value = Number(value);
|
|
1021
|
+ }
|
|
1022
|
+
|
|
1023
|
+ if (isNaN(value)) {
|
|
1024
|
+ return 0;
|
|
1025
|
+ }
|
|
1026
|
+
|
|
1027
|
+ return Math.max(0, value);
|
|
1028
|
+};
|
|
1029
|
+
|
|
1030
|
+/**
|
|
1031
|
+ * Calculates bitrate between before and now using a supplied field name and its
|
|
1032
|
+ * value in the stats.
|
|
1033
|
+ * @param {RTCInboundRtpStreamStats|RTCSentRtpStreamStats} now the current stats
|
|
1034
|
+ * @param {RTCInboundRtpStreamStats|RTCSentRtpStreamStats} before the
|
|
1035
|
+ * previous stats.
|
|
1036
|
+ * @param fieldName the field to use for calculations.
|
|
1037
|
+ * @return {number} the calculated bitrate between now and before.
|
|
1038
|
+ * @private
|
|
1039
|
+ */
|
|
1040
|
+StatsCollector.prototype._calculateBitrate = function(now, before, fieldName) {
|
|
1041
|
+ const bytesNow = this.getNonNegativeValue(now[fieldName]);
|
|
1042
|
+ const bytesBefore = this.getNonNegativeValue(before[fieldName]);
|
|
1043
|
+ const bytesProcessed = Math.max(0, bytesNow - bytesBefore);
|
|
1044
|
+
|
|
1045
|
+ const timeMs = now.timestamp - before.timestamp;
|
|
1046
|
+ let bitrateKbps = 0;
|
|
1047
|
+
|
|
1048
|
+ if (timeMs > 0) {
|
|
1049
|
+ // TODO is there any reason to round here?
|
|
1050
|
+ bitrateKbps = Math.round((bytesProcessed * 8) / timeMs);
|
|
1051
|
+ }
|
|
1052
|
+
|
|
1053
|
+ return bitrateKbps;
|
|
1054
|
+};
|
|
1055
|
+
|
|
1056
|
+/**
|
|
1057
|
+ * Stats processing new getStats logic.
|
|
1058
|
+ */
|
|
1059
|
+StatsCollector.prototype.processNewStatsReport = function() {
|
|
1060
|
+ if (!this.previousStatsReport) {
|
|
1061
|
+ return;
|
|
1062
|
+ }
|
|
1063
|
+
|
|
1064
|
+ const getStatValue = this._getNewStatValue;
|
|
1065
|
+ const byteSentStats = {};
|
|
1066
|
+
|
|
1067
|
+ this.currentStatsReport.forEach(now => {
|
|
1068
|
+
|
|
1069
|
+ // RTCIceCandidatePairStats
|
|
1070
|
+ // https://w3c.github.io/webrtc-stats/#candidatepair-dict*
|
|
1071
|
+ if (now.type === 'candidate-pair'
|
|
1072
|
+ && now.nominated
|
|
1073
|
+ && now.state === 'succeeded') {
|
|
1074
|
+
|
|
1075
|
+ const availableIncomingBitrate = now.availableIncomingBitrate;
|
|
1076
|
+ const availableOutgoingBitrate = now.availableOutgoingBitrate;
|
|
1077
|
+
|
|
1078
|
+ if (availableIncomingBitrate || availableOutgoingBitrate) {
|
|
1079
|
+ this.conferenceStats.bandwidth = {
|
|
1080
|
+ 'download': Math.round(availableIncomingBitrate / 1000),
|
|
1081
|
+ 'upload': Math.round(availableOutgoingBitrate / 1000)
|
|
1082
|
+ };
|
|
1083
|
+ }
|
|
1084
|
+
|
|
1085
|
+ const remoteUsedCandidate
|
|
1086
|
+ = this.currentStatsReport.get(now.remoteCandidateId);
|
|
1087
|
+ const localUsedCandidate
|
|
1088
|
+ = this.currentStatsReport.get(now.localCandidateId);
|
|
1089
|
+
|
|
1090
|
+ // RTCIceCandidateStats
|
|
1091
|
+ // https://w3c.github.io/webrtc-stats/#icecandidate-dict*
|
|
1092
|
+ // safari currently does not provide ice candidates in stats
|
|
1093
|
+ if (remoteUsedCandidate && localUsedCandidate) {
|
|
1094
|
+ // FF uses non-standard ipAddress, portNumber, transport
|
|
1095
|
+ // instead of ip, port, protocol
|
|
1096
|
+ const remoteIpAddress = getStatValue(remoteUsedCandidate, 'ip');
|
|
1097
|
+ const remotePort = getStatValue(remoteUsedCandidate, 'port');
|
|
1098
|
+ const ip = `${remoteIpAddress}:${remotePort}`;
|
|
1099
|
+
|
|
1100
|
+ const localIpAddress = getStatValue(localUsedCandidate, 'ip');
|
|
1101
|
+ const localPort = getStatValue(localUsedCandidate, 'port');
|
|
1102
|
+
|
|
1103
|
+ const localIp = `${localIpAddress}:${localPort}`;
|
|
1104
|
+ const type = getStatValue(remoteUsedCandidate, 'protocol');
|
|
1105
|
+
|
|
1106
|
+ // Save the address unless it has been saved already.
|
|
1107
|
+ const conferenceStatsTransport = this.conferenceStats.transport;
|
|
1108
|
+
|
|
1109
|
+ if (!conferenceStatsTransport.some(
|
|
1110
|
+ t =>
|
|
1111
|
+ t.ip === ip
|
|
1112
|
+ && t.type === type
|
|
1113
|
+ && t.localip === localIp)) {
|
|
1114
|
+ conferenceStatsTransport.push({
|
|
1115
|
+ ip,
|
|
1116
|
+ type,
|
|
1117
|
+ localIp,
|
|
1118
|
+ p2p: this.peerconnection.isP2P,
|
|
1119
|
+ localCandidateType: localUsedCandidate.candidateType,
|
|
1120
|
+ remoteCandidateType: remoteUsedCandidate.candidateType,
|
|
1121
|
+ networkType: localUsedCandidate.networkType,
|
|
1122
|
+ rtt: now.currentRoundTripTime * 1000
|
|
1123
|
+ });
|
|
1124
|
+ }
|
|
1125
|
+ }
|
|
1126
|
+
|
|
1127
|
+ // RTCReceivedRtpStreamStats
|
|
1128
|
+ // https://w3c.github.io/webrtc-stats/#receivedrtpstats-dict*
|
|
1129
|
+ // RTCSentRtpStreamStats
|
|
1130
|
+ // https://w3c.github.io/webrtc-stats/#sentrtpstats-dict*
|
|
1131
|
+ } else if (now.type === 'inbound-rtp' || now.type === 'outbound-rtp') {
|
|
1132
|
+ const before = this.previousStatsReport.get(now.id);
|
|
1133
|
+ const ssrc = this.getNonNegativeValue(now.ssrc);
|
|
1134
|
+
|
|
1135
|
+ if (!before || !ssrc) {
|
|
1136
|
+ return;
|
|
1137
|
+ }
|
|
1138
|
+
|
|
1139
|
+ let ssrcStats = this.ssrc2stats.get(ssrc);
|
|
1140
|
+
|
|
1141
|
+ if (!ssrcStats) {
|
|
1142
|
+ ssrcStats = new SsrcStats();
|
|
1143
|
+ this.ssrc2stats.set(ssrc, ssrcStats);
|
|
1144
|
+ }
|
|
1145
|
+
|
|
1146
|
+ let isDownloadStream = true;
|
|
1147
|
+ let key = 'packetsReceived';
|
|
1148
|
+
|
|
1149
|
+ if (now.type === 'outbound-rtp') {
|
|
1150
|
+ isDownloadStream = false;
|
|
1151
|
+ key = 'packetsSent';
|
|
1152
|
+ }
|
|
1153
|
+
|
|
1154
|
+ let packetsNow = now[key];
|
|
1155
|
+
|
|
1156
|
+ if (!packetsNow || packetsNow < 0) {
|
|
1157
|
+ packetsNow = 0;
|
|
1158
|
+ }
|
|
1159
|
+
|
|
1160
|
+ const packetsBefore = this.getNonNegativeValue(before[key]);
|
|
1161
|
+ const packetsDiff = Math.max(0, packetsNow - packetsBefore);
|
|
1162
|
+
|
|
1163
|
+ const packetsLostNow
|
|
1164
|
+ = this.getNonNegativeValue(now.packetsLost);
|
|
1165
|
+ const packetsLostBefore
|
|
1166
|
+ = this.getNonNegativeValue(before.packetsLost);
|
|
1167
|
+ const packetsLostDiff
|
|
1168
|
+ = Math.max(0, packetsLostNow - packetsLostBefore);
|
|
1169
|
+
|
|
1170
|
+ ssrcStats.setLoss({
|
|
1171
|
+ packetsTotal: packetsDiff + packetsLostDiff,
|
|
1172
|
+ packetsLost: packetsLostDiff,
|
|
1173
|
+ isDownloadStream
|
|
1174
|
+ });
|
|
1175
|
+
|
|
1176
|
+ if (now.type === 'inbound-rtp') {
|
|
1177
|
+
|
|
1178
|
+ ssrcStats.addBitrate({
|
|
1179
|
+ 'download': this._calculateBitrate(
|
|
1180
|
+ now, before, 'bytesReceived'),
|
|
1181
|
+ 'upload': 0
|
|
1182
|
+ });
|
|
1183
|
+
|
|
1184
|
+ // RTCInboundRtpStreamStats
|
|
1185
|
+ // https://w3c.github.io/webrtc-stats/#inboundrtpstats-dict*
|
|
1186
|
+ // TODO: can we use framesDecoded for frame rate, available
|
|
1187
|
+ // in chrome
|
|
1188
|
+ } else {
|
|
1189
|
+ byteSentStats[ssrc] = this.getNonNegativeValue(now.bytesSent);
|
|
1190
|
+ ssrcStats.addBitrate({
|
|
1191
|
+ 'download': 0,
|
|
1192
|
+ 'upload': this._calculateBitrate(
|
|
1193
|
+ now, before, 'bytesSent')
|
|
1194
|
+ });
|
|
1195
|
+
|
|
1196
|
+ // RTCOutboundRtpStreamStats
|
|
1197
|
+ // https://w3c.github.io/webrtc-stats/#outboundrtpstats-dict*
|
|
1198
|
+ // TODO: can we use framesEncoded for frame rate, available
|
|
1199
|
+ // in chrome
|
|
1200
|
+ }
|
|
1201
|
+
|
|
1202
|
+ // FF has framerateMean out of spec
|
|
1203
|
+ const framerateMean = now.framerateMean;
|
|
1204
|
+
|
|
1205
|
+ if (framerateMean) {
|
|
1206
|
+ ssrcStats.setFramerate(Math.round(framerateMean || 0));
|
|
1207
|
+ }
|
|
1208
|
+
|
|
1209
|
+ // track for resolution
|
|
1210
|
+ // RTCVideoHandlerStats
|
|
1211
|
+ // https://w3c.github.io/webrtc-stats/#vststats-dict*
|
|
1212
|
+ // RTCMediaHandlerStats
|
|
1213
|
+ // https://w3c.github.io/webrtc-stats/#mststats-dict*
|
|
1214
|
+ } else if (now.type === 'track') {
|
|
1215
|
+
|
|
1216
|
+ const resolution = {
|
|
1217
|
+ height: now.frameHeight,
|
|
1218
|
+ width: now.frameWidth
|
|
1219
|
+ };
|
|
1220
|
+
|
|
1221
|
+ // Tries to get frame rate
|
|
1222
|
+ let frameRate = now.framesPerSecond;
|
|
1223
|
+
|
|
1224
|
+ if (!frameRate) {
|
|
1225
|
+ // we need to calculate it
|
|
1226
|
+ const before = this.previousStatsReport.get(now.id);
|
|
1227
|
+
|
|
1228
|
+ if (before) {
|
|
1229
|
+ const timeMs = now.timestamp - before.timestamp;
|
|
1230
|
+
|
|
1231
|
+ if (timeMs > 0 && now.framesSent) {
|
|
1232
|
+ const numberOfFramesSinceBefore
|
|
1233
|
+ = now.framesSent - before.framesSent;
|
|
1234
|
+
|
|
1235
|
+ frameRate = (numberOfFramesSinceBefore / timeMs) * 1000;
|
|
1236
|
+ }
|
|
1237
|
+ }
|
|
1238
|
+
|
|
1239
|
+ if (!frameRate) {
|
|
1240
|
+ return;
|
|
1241
|
+ }
|
|
1242
|
+ }
|
|
1243
|
+
|
|
1244
|
+ const trackIdentifier = now.trackIdentifier;
|
|
1245
|
+ const ssrc = this.peerconnection.getSsrcByTrackId(trackIdentifier);
|
|
1246
|
+ let ssrcStats = this.ssrc2stats.get(ssrc);
|
|
1247
|
+
|
|
1248
|
+ if (!ssrcStats) {
|
|
1249
|
+ ssrcStats = new SsrcStats();
|
|
1250
|
+ this.ssrc2stats.set(ssrc, ssrcStats);
|
|
1251
|
+ }
|
|
1252
|
+ ssrcStats.setFramerate(Math.round(frameRate || 0));
|
|
1253
|
+
|
|
1254
|
+ if (resolution.height && resolution.width) {
|
|
1255
|
+ ssrcStats.setResolution(resolution);
|
|
1256
|
+ } else {
|
|
1257
|
+ ssrcStats.setResolution(null);
|
|
1258
|
+ }
|
|
1259
|
+ }
|
|
1260
|
+ });
|
|
1261
|
+
|
|
1262
|
+ this.eventEmitter.emit(
|
|
1263
|
+ StatisticsEvents.BYTE_SENT_STATS, this.peerconnection, byteSentStats);
|
|
1264
|
+
|
|
1265
|
+ this._processAndEmitReport();
|
|
1266
|
+};
|
|
1267
|
+
|
|
1268
|
+/**
|
|
1269
|
+ * Stats processing logic.
|
|
1270
|
+ */
|
|
1271
|
+StatsCollector.prototype.processNewAudioLevelReport = function() {
|
|
1272
|
+ if (!this.baselineAudioLevelsReport) {
|
|
1273
|
+ return;
|
|
1274
|
+ }
|
|
1275
|
+
|
|
1276
|
+ this.currentAudioLevelsReport.forEach(now => {
|
|
1277
|
+ if (now.type !== 'track') {
|
|
1278
|
+ return;
|
|
1279
|
+ }
|
|
1280
|
+
|
|
1281
|
+ // Audio level
|
|
1282
|
+ const audioLevel = now.audioLevel;
|
|
1283
|
+
|
|
1284
|
+ if (!audioLevel) {
|
|
1285
|
+ return;
|
|
1286
|
+ }
|
|
1287
|
+
|
|
1288
|
+ const trackIdentifier = now.trackIdentifier;
|
|
1289
|
+ const ssrc = this.peerconnection.getSsrcByTrackId(trackIdentifier);
|
|
1290
|
+
|
|
1291
|
+ if (ssrc) {
|
|
1292
|
+ const isLocal
|
|
1293
|
+ = ssrc === this.peerconnection.getLocalSSRC(
|
|
1294
|
+ this.peerconnection.getLocalTracks(MediaType.AUDIO));
|
|
1295
|
+
|
|
1296
|
+ this.eventEmitter.emit(
|
|
1297
|
+ StatisticsEvents.AUDIO_LEVEL,
|
|
1298
|
+ this.peerconnection,
|
|
1299
|
+ ssrc,
|
|
1300
|
+ audioLevel,
|
|
1301
|
+ isLocal);
|
|
1302
|
+ }
|
|
1303
|
+ });
|
|
1304
|
+};
|
|
1305
|
+
|
|
1306
|
+/**
|
|
1307
|
+ * End new promised based getStats processing methods.
|
|
1308
|
+ */
|