@pexip/peer-connection-stats 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,665 @@
1
+ import type {
2
+ AnyStats,
3
+ AudioQualityStats,
4
+ CacheStats,
5
+ CallPacketsStats,
6
+ CallQualityStats,
7
+ InboundAudio,
8
+ InboundAudioMetrics,
9
+ InboundVideo,
10
+ InboundVideoMetrics,
11
+ Metrics,
12
+ NormalizedRTCStats,
13
+ OutboundAudio,
14
+ OutboundAudioMetrics,
15
+ OutboundVideo,
16
+ OutboundVideoMetrics,
17
+ PacketsStats,
18
+ RTCStats,
19
+ StatsCollector,
20
+ StatsCollectorOptions,
21
+ VideoQualityStats,
22
+ } from './statsCollector.types';
23
+ import {Quality} from './statsCollector.types';
24
+
25
+ export const STATS_SIZE = 60;
26
+
27
+ /**
28
+ * The WebRTC stats are given to us as a flat structure (an array of objects
29
+ * that contain id-fields pointing to other objects in the same array).
30
+ *
31
+ * This class takes such an array as input and can expand an entry so that
32
+ * references become nested objects.
33
+ *
34
+ * https://www.w3.org/TR/webrtc-stats/#summary
35
+ * https://www.w3.org/TR/webrtc-stats/#rtctatstype-*
36
+ */
37
+ export const createResolver = (statsReport: AnyStats[]) => {
38
+ const idLookup = statsReport.reduce(
39
+ (map, entry) => map.set(entry.id, entry),
40
+ new Map<string, AnyStats>(),
41
+ );
42
+
43
+ const expandTail = (stats: AnyStats, seenIds: string[] = []) =>
44
+ Object.keys(stats).reduce((expanded, key) => {
45
+ const id = stats[key];
46
+ expanded[key] = id;
47
+
48
+ if (!id || typeof id !== 'string' || seenIds.includes(id)) {
49
+ return expanded;
50
+ }
51
+
52
+ switch (key) {
53
+ case 'codecId': {
54
+ const codec = idLookup.get(id);
55
+ if (codec) {
56
+ expanded.codec = expandTail(codec, [...seenIds, id]);
57
+ }
58
+ break;
59
+ }
60
+ case 'remoteCandidateId': {
61
+ const remoteCandidate = idLookup.get(id);
62
+ if (remoteCandidate) {
63
+ expanded.remoteCandidate = expandTail(remoteCandidate, [
64
+ ...seenIds,
65
+ id,
66
+ ]);
67
+ }
68
+ break;
69
+ }
70
+ case 'remoteId': {
71
+ const remote = idLookup.get(id);
72
+ if (remote) {
73
+ expanded.remote = expandTail(remote, [...seenIds, id]);
74
+ }
75
+ break;
76
+ }
77
+ case 'selectedCandidatePairId': {
78
+ const selectedCandidatePair = idLookup.get(id);
79
+ if (selectedCandidatePair) {
80
+ expanded.selectedCandidatePair = expandTail(
81
+ selectedCandidatePair,
82
+ [...seenIds, id],
83
+ );
84
+ }
85
+ break;
86
+ }
87
+ case 'trackId': {
88
+ const track = idLookup.get(id);
89
+ if (track) {
90
+ expanded.track = expandTail(track, [...seenIds, id]);
91
+ }
92
+ break;
93
+ }
94
+ case 'trackIds': {
95
+ if (Array.isArray(id)) {
96
+ const ids: string[] = id;
97
+ const tracks = ids.flatMap(id => {
98
+ const track = idLookup.get(id);
99
+ return !track
100
+ ? []
101
+ : [expandTail(track, [...seenIds, ...ids])];
102
+ });
103
+
104
+ expanded.tracks = tracks;
105
+ }
106
+ break;
107
+ }
108
+ case 'transportId': {
109
+ const transport = idLookup.get(id);
110
+ if (transport) {
111
+ expanded.transport = expandTail(transport, [
112
+ ...seenIds,
113
+ id,
114
+ ]);
115
+ }
116
+ break;
117
+ }
118
+ case 'localCandidateId':
119
+ case 'localCertificateId':
120
+ case 'localId':
121
+ case 'mediaSourceId':
122
+ case 'remoteCertificateId':
123
+ default:
124
+ break;
125
+ }
126
+
127
+ return expanded;
128
+ }, {} as AnyStats);
129
+
130
+ function expand(audioIn: InboundAudio): InboundAudio;
131
+ function expand(audioOut: OutboundAudio): OutboundAudio;
132
+ function expand(videoIn: InboundVideo): InboundVideo;
133
+ function expand(videoOut: OutboundVideo): OutboundVideo;
134
+ function expand(rawStats: AnyStats): AnyStats {
135
+ return expandTail(rawStats);
136
+ }
137
+
138
+ return {
139
+ expand,
140
+ };
141
+ };
142
+
143
+ /**
144
+ * Normalize inbound audio stats
145
+ *
146
+ * @param statsData - Raw inbound audio stats @see InboundAudio
147
+ *
148
+ * @returns normalized stats for inbound audio from PeerConnection
149
+ */
150
+ export const inboundAudio = (statsData: InboundAudio): InboundAudioMetrics => {
151
+ const packetsReceived = statsData.packetsReceived ?? 0;
152
+ const packetsLost = statsData.packetsLost ?? 0;
153
+ const totalPackets = packetsReceived + packetsLost;
154
+
155
+ return {
156
+ type: statsData.type,
157
+ kind: statsData.kind,
158
+ jitter: statsData.jitter ?? 0,
159
+ timestamp: statsData.timestamp,
160
+ packetsTransmitted: packetsReceived,
161
+ packetsLost,
162
+ bytesTransmitted: statsData.bytesReceived,
163
+ codec: statsData.codec?.mimeType,
164
+ roundTripTime:
165
+ statsData.transport?.selectedCandidatePair?.currentRoundTripTime,
166
+ totalPercentageLost: totalPackets && packetsLost / totalPackets,
167
+ };
168
+ };
169
+
170
+ /**
171
+ * Normalize outbound audio stats
172
+ *
173
+ * @param statsData - Raw outbound audio stats @see OutboundAudio
174
+ *
175
+ * @returns normalized stats for outbound audio from PeerConnection
176
+ */
177
+ export const outboundAudio = (
178
+ statsData: OutboundAudio,
179
+ ): OutboundAudioMetrics => {
180
+ const packetsSent = statsData.packetsSent ?? 0;
181
+ const packetsLost = statsData.remote?.packetsLost ?? 0;
182
+ const totalPackets = packetsSent + packetsLost;
183
+
184
+ // firefox typically uses remote.roundTripTime and chrome uses currentRoundTripTime
185
+ const roundTripTime =
186
+ statsData.remote?.roundTripTime ??
187
+ statsData.transport?.selectedCandidatePair?.currentRoundTripTime;
188
+
189
+ return {
190
+ type: statsData.type,
191
+ kind: statsData.kind,
192
+ jitter: statsData?.remote?.jitter ?? 0,
193
+ timestamp: statsData.timestamp,
194
+ packetsTransmitted: packetsSent,
195
+ packetsLost,
196
+ bytesTransmitted: statsData.bytesSent,
197
+ codec: statsData.codec?.mimeType,
198
+ roundTripTime,
199
+ totalPercentageLost: totalPackets && packetsLost / totalPackets,
200
+ };
201
+ };
202
+
203
+ /**
204
+ * Normalize inbound video stats
205
+ *
206
+ * @param statsData - Raw inbound video stats @see InboundVideo
207
+ *
208
+ * @returns normalized stats for inbound video from PeerConnection
209
+ */
210
+ export const inboundVideo = (statsData: InboundVideo): InboundVideoMetrics => {
211
+ const packetsReceived = statsData.packetsReceived ?? 0;
212
+ const packetsLost = statsData.packetsLost ?? 0;
213
+ const totalPackets = packetsReceived + packetsLost;
214
+
215
+ return {
216
+ type: statsData.type,
217
+ kind: statsData.kind,
218
+ timestamp: statsData.timestamp,
219
+ packetsTransmitted: packetsReceived,
220
+ packetsLost,
221
+ bytesTransmitted: statsData.bytesReceived,
222
+ // firefox typically uses bitrateMean while chrome does not
223
+ bitrate: statsData.bitrateMean,
224
+ codec: statsData.codec?.mimeType,
225
+ resolutionWidth: statsData.frameWidth,
226
+ resolutionHeight: statsData.frameHeight,
227
+ resolution:
228
+ statsData.frameWidth && statsData.frameHeight
229
+ ? `${statsData.frameWidth}x${statsData.frameHeight}`
230
+ : undefined,
231
+ // firefox typically uses framerateMean while chrome does not
232
+ framesPerSecond: statsData.framerateMean ?? statsData.framesPerSecond,
233
+ roundTripTime:
234
+ statsData.transport?.selectedCandidatePair?.currentRoundTripTime,
235
+ totalPercentageLost: totalPackets && packetsLost / totalPackets,
236
+ };
237
+ };
238
+
239
+ /**
240
+ * Normalize outbound video stats
241
+ *
242
+ * @param statsData - Raw outbound video stats @see OutboundVideo
243
+ *
244
+ * @returns normalized stats for outbound video from PeerConnection
245
+ */
246
+ export const outboundVideo = (
247
+ statsData: OutboundVideo,
248
+ ): OutboundVideoMetrics => {
249
+ const totalPacketSendDelay = statsData?.totalPacketSendDelay ?? 0;
250
+ const packetsSent = statsData.packetsSent ?? 0;
251
+ const packetsLost = statsData.remote?.packetsLost ?? 0;
252
+ const totalPackets = packetsSent + packetsLost;
253
+
254
+ // firefox typically uses remote.roundTripTime and chrome uses currentRoundTripTime
255
+ const roundTripTime =
256
+ statsData.remote?.roundTripTime ??
257
+ statsData.transport?.selectedCandidatePair?.currentRoundTripTime;
258
+
259
+ return {
260
+ type: statsData.type,
261
+ kind: statsData.kind,
262
+ timestamp: statsData.timestamp,
263
+ packetsTransmitted: packetsSent,
264
+ packetsLost,
265
+ bytesTransmitted: statsData.bytesSent,
266
+ totalPacketSendDelay: statsData.totalPacketSendDelay,
267
+ averagePacketSendDelay:
268
+ totalPacketSendDelay && totalPacketSendDelay / packetsSent,
269
+ // firefox typically uses bitrateMean while chrome does not
270
+ bitrate: statsData.bitrateMean,
271
+ codec: statsData.codec?.mimeType,
272
+ resolutionWidth: statsData?.frameWidth,
273
+ resolutionHeight: statsData?.frameHeight,
274
+ resolution:
275
+ statsData.frameWidth && statsData.frameHeight
276
+ ? `${statsData.frameWidth}x${statsData.frameHeight}`
277
+ : undefined,
278
+ // firefox typically uses framerateMean while chrome does not
279
+ framesPerSecond: statsData.framerateMean ?? statsData.framesPerSecond,
280
+ roundTripTime,
281
+ totalPercentageLost: totalPackets && packetsLost / totalPackets,
282
+ };
283
+ };
284
+
285
+ const isInbound = (entry: AnyStats) => entry.type === 'inbound-rtp';
286
+ const isOutbound = (entry: AnyStats) => entry.type === 'outbound-rtp';
287
+ const isAudio = (entry: AnyStats) => entry.kind === 'audio';
288
+ const isVideo = (entry: AnyStats) => entry.kind === 'video';
289
+
290
+ const isAudioInbound = (entry: AnyStats): entry is InboundAudio =>
291
+ isInbound(entry) && isAudio(entry);
292
+ const isAudioOutbound = (entry: AnyStats): entry is OutboundAudio =>
293
+ isOutbound(entry) && isAudio(entry);
294
+ const isVideoInbound = (entry: AnyStats): entry is InboundVideo =>
295
+ isInbound(entry) && isVideo(entry);
296
+ const isVideoOutbound = (entry: AnyStats): entry is OutboundVideo =>
297
+ isOutbound(entry) && isVideo(entry);
298
+
299
+ /**
300
+ * Normalize stats into inbound and outbound video and audio stats
301
+ *
302
+ * @param statsReports - reports to for mapping
303
+ *
304
+ * @returns normalized stats for inbound, outbound audio and video
305
+ */
306
+ export const statsFrom = (statsReports: AnyStats[]) => {
307
+ const resolver = createResolver(statsReports);
308
+
309
+ // TODO: support them combined if we pass peer-connection obj instead of transceiver
310
+ // as now we return first found
311
+ const audioIn = statsReports.find(isAudioInbound);
312
+ if (audioIn) {
313
+ return inboundAudio(resolver.expand(audioIn));
314
+ }
315
+
316
+ const audioOut = statsReports.find(isAudioOutbound);
317
+ if (audioOut) {
318
+ return outboundAudio(resolver.expand(audioOut));
319
+ }
320
+
321
+ const videoIn = statsReports.find(isVideoInbound);
322
+ if (videoIn) {
323
+ return inboundVideo(resolver.expand(videoIn));
324
+ }
325
+
326
+ const videoOut = statsReports.find(isVideoOutbound);
327
+ if (videoOut) {
328
+ return outboundVideo(resolver.expand(videoOut));
329
+ }
330
+ };
331
+
332
+ /**
333
+ * Gets raw stats from peerConnection and normalized them
334
+ *
335
+ * https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_Statistics_API
336
+ *
337
+ * A RTCPeerConnection has getStats()
338
+ * - getStats() returns promise which resolve to a RTCStatsReport
339
+ * - RTCStatsReport behaves like an array of RTCStats objects, or more
340
+ * specific RTCRtpStreamStats objects
341
+ *
342
+ * https://developer.mozilla.org/en-US/docs/Web/API/RTCStatsReport
343
+ * https://developer.mozilla.org/en-US/docs/Web/API/RTCStats
344
+ * https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpStreamStats
345
+ *
346
+ */
347
+
348
+ export const statsFromRTCPeer = async (rtcPeer: {
349
+ getStats: (selector?: MediaStreamTrack | null) => Promise<RTCStatsReport>;
350
+ }): Promise<NormalizedRTCStats> => {
351
+ const stats = await rtcPeer?.getStats(null);
352
+ const reports: RTCStats[] = [];
353
+ stats.forEach(report => reports.push(report));
354
+ return (
355
+ statsFrom(reports) ?? {
356
+ type: 'inbound-rtp',
357
+ kind: 'audio',
358
+ packetsLost: 0,
359
+ packetsTransmitted: 0,
360
+ }
361
+ );
362
+ };
363
+
364
+ const getRecentPacketsLost = (oldMetrics: Metrics, newMetrics: Metrics) =>
365
+ Math.max(newMetrics.packetsLost - oldMetrics.packetsLost, 0);
366
+
367
+ const getRecentTotalPackets = (oldMetrics: Metrics, newMetrics: Metrics) => {
368
+ const totalPacketsDelta =
369
+ newMetrics.packetsTransmitted +
370
+ newMetrics.packetsLost -
371
+ (oldMetrics.packetsTransmitted + oldMetrics.packetsLost);
372
+ return Math.max(totalPacketsDelta, 1);
373
+ };
374
+
375
+ const getPacketStats = (oldMetrics: Metrics, newMetrics: Metrics) =>
376
+ [getRecentPacketsLost, getRecentTotalPackets].map(fn =>
377
+ fn(oldMetrics, newMetrics),
378
+ ) as [number, number];
379
+
380
+ const removeObsolete = (metric: unknown[], qualityHistorySize = STATS_SIZE) => {
381
+ if (metric.length === qualityHistorySize) {
382
+ metric.pop();
383
+ }
384
+ };
385
+
386
+ const addPacketsStats = (metric: PacketsStats, data: PacketsStats[0]) => {
387
+ removeObsolete(metric);
388
+ metric.unshift(data);
389
+ };
390
+
391
+ const recordCallPacketsStats = (
392
+ oldStats: NormalizedRTCStats,
393
+ newStats: NormalizedRTCStats,
394
+ callPacketsStats: CallPacketsStats,
395
+ ) => {
396
+ if (oldStats && newStats) {
397
+ if (!callPacketsStats) {
398
+ callPacketsStats = [];
399
+ }
400
+ addPacketsStats(callPacketsStats, getPacketStats(oldStats, newStats));
401
+ }
402
+
403
+ return callPacketsStats;
404
+ };
405
+
406
+ const addAudioQualityMetric = (
407
+ metric: AudioQualityStats,
408
+ data: AudioQualityStats[0],
409
+ ) => {
410
+ removeObsolete(metric);
411
+ metric.unshift(data);
412
+ };
413
+
414
+ const addVideoQualityMetric = (
415
+ metric: VideoQualityStats,
416
+ data: VideoQualityStats[0],
417
+ ) => {
418
+ removeObsolete(metric);
419
+ metric.unshift(data);
420
+ };
421
+
422
+ const recordCallQualityStats = (
423
+ prevStats: NormalizedRTCStats,
424
+ stats: NormalizedRTCStats,
425
+ callQualityStats: CallQualityStats,
426
+ ) => {
427
+ const calcRecentPacketLoss = ({pT = 0, prevPT = 0, pL = 0, prevPL = 0}) => {
428
+ if (pT <= 0) {
429
+ return 0;
430
+ }
431
+ if (pT - prevPT <= 0) {
432
+ return pL / pT;
433
+ }
434
+ return (pL - prevPL) / (pT - prevPT);
435
+ };
436
+
437
+ if (stats) {
438
+ if (!callQualityStats) {
439
+ callQualityStats = [];
440
+ }
441
+
442
+ if (stats.kind === 'audio') {
443
+ addAudioQualityMetric(
444
+ (callQualityStats || []) as AudioQualityStats,
445
+ [
446
+ calcRecentPacketLoss({
447
+ pT: stats.packetsTransmitted,
448
+ prevPT: prevStats?.packetsTransmitted,
449
+ pL: stats.packetsLost,
450
+ prevPL: prevStats?.packetsLost,
451
+ }),
452
+ stats.jitter ?? 0,
453
+ ],
454
+ );
455
+ }
456
+
457
+ if (stats.kind === 'video') {
458
+ addVideoQualityMetric(
459
+ (callQualityStats || []) as VideoQualityStats,
460
+ calcRecentPacketLoss({
461
+ pT: stats.packetsTransmitted,
462
+ prevPT: prevStats?.packetsTransmitted,
463
+ pL: stats.packetsLost,
464
+ prevPL: prevStats?.packetsLost,
465
+ }),
466
+ );
467
+ }
468
+ }
469
+
470
+ return callQualityStats;
471
+ };
472
+
473
+ export const getQuality = (stats: CallQualityStats) => {
474
+ const qualityOverTime = stats.map(stats => calculateQuality(stats));
475
+
476
+ const goodOrOk = qualityOverTime.filter(
477
+ stat => stat === Quality.GOOD || stat === Quality.OK,
478
+ );
479
+
480
+ const qualitySum = qualityOverTime.reduce((acc, val) => acc + val, 0);
481
+
482
+ return {
483
+ quality: Math.round(qualitySum / qualityOverTime.length) as Quality,
484
+ goodOrOkQuality: goodOrOk.length / qualityOverTime.length,
485
+ qualityOverTime,
486
+ };
487
+ };
488
+
489
+ export const addDeltaStats = (
490
+ newStats: NormalizedRTCStats,
491
+ cache: CacheStats,
492
+ ) => {
493
+ const oldStats =
494
+ cache.previous ??
495
+ Object.keys(newStats).reduce<NormalizedRTCStats>((stats, key) => {
496
+ const statKey = key as keyof NormalizedRTCStats;
497
+ if (
498
+ typeof newStats[statKey] === 'number' &&
499
+ statKey !== 'timestamp'
500
+ ) {
501
+ return {...stats, [statKey]: 0};
502
+ }
503
+ return {...stats, [statKey]: newStats[statKey]};
504
+ }, {} as NormalizedRTCStats);
505
+
506
+ const deltaStats = {
507
+ ...newStats,
508
+ };
509
+
510
+ const callPacketsStats = recordCallPacketsStats(
511
+ oldStats,
512
+ newStats,
513
+ cache.callPacketsStats,
514
+ );
515
+
516
+ const metrics = [
517
+ [newStats, oldStats, deltaStats, callPacketsStats ?? []],
518
+ ] as const;
519
+
520
+ metrics.forEach(([newMetrics, oldMetrics, deltaMetrics, packets]) => {
521
+ if (
522
+ newMetrics?.bytesTransmitted &&
523
+ newMetrics?.timestamp &&
524
+ oldMetrics?.bytesTransmitted &&
525
+ oldMetrics?.timestamp &&
526
+ deltaMetrics // && deltaMetrics.bitrate == null
527
+ ) {
528
+ const dMs = newMetrics.timestamp - oldMetrics.timestamp;
529
+
530
+ const dBytes =
531
+ newMetrics.bytesTransmitted - oldMetrics.bytesTransmitted;
532
+
533
+ if (dMs !== 0) {
534
+ deltaMetrics.bitrate = Math.round((dBytes * 8) / (dMs / 1000));
535
+ }
536
+ }
537
+
538
+ if (deltaMetrics) {
539
+ const [recentPacketsLost, recentTotalPackets] = packets.reduce(
540
+ (acc, [lost, total]) => {
541
+ acc[0] += lost;
542
+ acc[1] += total;
543
+ return acc;
544
+ },
545
+ [0, 0],
546
+ );
547
+
548
+ deltaMetrics.recentPercentageLost =
549
+ recentTotalPackets === 0
550
+ ? 0
551
+ : recentPacketsLost / recentTotalPackets;
552
+ }
553
+ });
554
+
555
+ const callQualityStats = recordCallQualityStats(
556
+ oldStats,
557
+ deltaStats,
558
+ cache.callQualityStats,
559
+ );
560
+
561
+ return [
562
+ deltaStats,
563
+ getQuality(callQualityStats ?? []).quality,
564
+ callQualityStats,
565
+ ] as const;
566
+ };
567
+
568
+ // https://docs.pexip.com/admin/media_statistics.htm
569
+ export const calculateQuality = (stats: number | [number, number]) => {
570
+ let packetLoss;
571
+ let jitter;
572
+
573
+ if (typeof stats === 'number') {
574
+ packetLoss == stats;
575
+ } else {
576
+ [packetLoss, jitter] = stats;
577
+ }
578
+
579
+ let callQuality = Quality.OK;
580
+
581
+ if (typeof packetLoss === 'number') {
582
+ if (packetLoss < 0.01) {
583
+ callQuality = Quality.GOOD;
584
+ } else if (packetLoss < 0.03) {
585
+ callQuality = Quality.OK;
586
+ } else if (packetLoss < 0.1) {
587
+ callQuality = Quality.BAD;
588
+ } else {
589
+ callQuality = Quality.TERRIBLE;
590
+ }
591
+ }
592
+
593
+ if (jitter && jitter > 0.04) {
594
+ if (callQuality === Quality.GOOD) {
595
+ callQuality = Quality.OK;
596
+ } else if (callQuality === Quality.OK) {
597
+ callQuality = Quality.BAD;
598
+ } else if (callQuality === Quality.BAD) {
599
+ callQuality = Quality.TERRIBLE;
600
+ }
601
+ }
602
+
603
+ return callQuality;
604
+ };
605
+
606
+ /**
607
+ * Creates stats collector
608
+ *
609
+ * @param input - input to get stats from
610
+ * @param signal - Signal which distributes stats
611
+ * @param interval - how often signal with fire with new stats
612
+ *
613
+ * @returns \{function to reset stats window, cleanup func\}
614
+ */
615
+ export const createStatsCollector = ({
616
+ input,
617
+ signals: {onCallQuality, onCallQualityStats, onRtcStats},
618
+ interval = 1000,
619
+ }: StatsCollectorOptions): StatsCollector => {
620
+ const newCache = (): CacheStats => ({
621
+ callQuality: Quality.GOOD,
622
+ callPacketsStats: [],
623
+ callQualityStats: [],
624
+ });
625
+
626
+ let cache = newCache();
627
+
628
+ const pushStats = () => {
629
+ void statsFromRTCPeer(input).then(newStats => {
630
+ const [stats, callQuality, callQualityStats] = addDeltaStats(
631
+ newStats,
632
+ cache,
633
+ );
634
+ if (callQuality != cache.callQuality) {
635
+ onCallQuality.emit(callQuality);
636
+ cache.callQuality = callQuality;
637
+ }
638
+
639
+ cache.previous = newStats;
640
+ onRtcStats.emit(stats);
641
+ onCallQualityStats.emit(callQualityStats);
642
+ });
643
+ };
644
+
645
+ let it = window.setInterval(pushStats, interval);
646
+ const clearInterval = () => {
647
+ window.clearInterval(it);
648
+ it = 0;
649
+ };
650
+ const resumeStats = () => {
651
+ if (it === 0) {
652
+ cache = newCache();
653
+ pushStats();
654
+ it = window.setInterval(pushStats, interval);
655
+ }
656
+ };
657
+
658
+ return {
659
+ resetStats: () => {
660
+ clearInterval();
661
+ return resumeStats;
662
+ },
663
+ cleanup: clearInterval,
664
+ };
665
+ };