├── .prettierignore ├── CODEOWNERS ├── .prettierrc ├── test ├── index.html ├── mock-sdp.json ├── mock-spec-stats-recvonly.json ├── mock-spec-stats-3.json ├── mock-spec-stats-initial.json ├── mock-spec-stats-1.json ├── mock-spec-stats-2.json └── test.ts ├── eslint.config.mjs ├── .gitignore ├── .editorconfig ├── README.md ├── jest.config.js ├── LICENSE.md ├── .vscode └── launch.json ├── package.json ├── CHANGELOG.md ├── src ├── interfaces.ts └── index.ts └── tsconfig.json /.prettierignore: -------------------------------------------------------------------------------- 1 | *.html 2 | *.json 3 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | @zservies @maxwellmooney13 @hjon @patrickfeeney03 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 120 4 | } 5 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

Hello

7 | 8 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | ...tseslint.configs.recommended, 9 | { 10 | rules: { 11 | 'no-prototype-builtins': 'off', 12 | '@typescript-eslint/no-var-requires': 'warn' 13 | } 14 | } 15 | ); 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | 7 | # dependencies 8 | /node_modules 9 | /bower_components 10 | 11 | bower.json.ember-try 12 | 13 | # misc 14 | /.sass-cache 15 | /connect.lock 16 | /coverage/* 17 | /libpeerconnection.log 18 | npm-debug.log 19 | testem.log 20 | .idea 21 | 22 | /test-results 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.js] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.hbs] 21 | insert_final_newline = false 22 | indent_style = space 23 | indent_size = 2 24 | 25 | [*.css] 26 | indent_style = space 27 | indent_size = 2 28 | 29 | [*.html] 30 | indent_style = space 31 | indent_size = 2 32 | 33 | [*.{diff,md}] 34 | trim_trailing_whitespace = false 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebRTC Stats Gatherer 2 | 3 | This module is designed to collect [RTCPeerConnection](https://github.com/otalk/rtcpeerconnection) stats on a regular interval 4 | and emit stats and trace data as appropriate. 5 | 6 | Note that this project makes use of event emitting capabilities of [RTCPeerConnection](https://github.com/otalk/rtcpeerconnection) as opposed to a raw browser RTCPeerConnection. 7 | 8 | ## API 9 | 10 | `constructor(peerConnection: RTCPeerConnection, opts: StatsGathererOpts)` 11 | 12 | ``` 13 | interface StatsGathererOpts { 14 | session?: string; // sessionId 15 | initiator?: string; 16 | conference?: string; // conversationId 17 | interval?: number; // interval, in seconds, at which stats are polled (default to 5) 18 | logger?: any; // defaults to console 19 | } 20 | ``` 21 | 22 | ## Usage 23 | ``` 24 | import StatsGatherer from 'webrtc-stats-gatherer'; 25 | 26 | const gatherer = new StatsGatherer(myPeerConnection); 27 | gatherer.on('stats', (statsEvent) => doSomethingWithStats(statsEvent)); 28 | ``` -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | roots: [ 4 | '/src', 5 | '/test' 6 | ], 7 | testMatch: [ 8 | '/test/**/*.(ts)' 9 | ], 10 | transform: { 11 | '^.+\\.tsx?$': [ 12 | 'ts-jest', 13 | { 14 | tsconfig: { 15 | target: 'es2020', 16 | moduleResolution: 'node', 17 | resolveJsonModule: true, 18 | }, 19 | }, 20 | ], 21 | }, 22 | collectCoverage: true, 23 | collectCoverageFrom: [ 24 | '/src/**/*.{ts,tsx}', 25 | '!**/node_modules/**', 26 | '!**/types/**' 27 | ], 28 | coverageReporters: [ 29 | 'lcov', 'text', 'text-summary' 30 | ], 31 | coverageDirectory: './coverage', 32 | coverageThreshold: { 33 | global: { 34 | functions: 100, 35 | lines: 100, 36 | statements: 100 37 | } 38 | }, 39 | preset: 'ts-jest', 40 | reporters: [ 41 | 'default', 42 | ['jest-junit', { 43 | outputDirectory: 'test-results/unit', 44 | outputName: 'test-results.xml' 45 | }] 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Genesys Cloud Services, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "name": "file tests", 10 | "request": "launch", 11 | "args": [ 12 | "--runInBand", 13 | "${fileBasenameNoExtension}", 14 | "--coverage=false" 15 | ], 16 | "cwd": "${workspaceFolder}", 17 | "console": "integratedTerminal", 18 | "internalConsoleOptions": "neverOpen", 19 | "disableOptimisticBPs": true, 20 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 21 | }, 22 | { 23 | "type": "node", 24 | "name": "all tests", 25 | "request": "launch", 26 | "args": [ 27 | "--runInBand", 28 | "--coverage=false" 29 | ], 30 | "cwd": "${workspaceFolder}", 31 | "console": "integratedTerminal", 32 | "internalConsoleOptions": "neverOpen", 33 | "disableOptimisticBPs": true, 34 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webrtc-stats-gatherer", 3 | "version": "9.0.11", 4 | "description": "Gathers stats on interval for webrtc peer connection", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "src", 9 | "dist" 10 | ], 11 | "scripts": { 12 | "clean": "rimraf dist lib", 13 | "build": "npm run clean && tsc", 14 | "_test": "jest", 15 | "test": "npm run lint && jest", 16 | "lint": "eslint src", 17 | "lint:fix": "npm run lint -- --fix", 18 | "format": "prettier src --write && prettier test --write", 19 | "greenkeep": "npx npm-check --update" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/mypurecloud/webrtc-stats-gatherer.git" 24 | }, 25 | "keywords": [ 26 | "webrtc", 27 | "stats" 28 | ], 29 | "author": "Xander Dumaine , Garrett Jensen , Fippo & Lance <3", 30 | "license": "MIT", 31 | "publishConfig": { 32 | "registry": "https://registry.npmjs.org/" 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/mypurecloud/webrtc-stats-gatherer/issues" 36 | }, 37 | "homepage": "https://github.com/mypurecloud/webrtc-stats-gatherer#readme", 38 | "devDependencies": { 39 | "@eslint/js": "^9.5.0", 40 | "@types/jest": "^29.5.12", 41 | "eslint": "^8.57.0", 42 | "jest": "^29.7.0", 43 | "jest-environment-jsdom": "^29.7.0", 44 | "jest-junit": "^16.0.0", 45 | "pre-commit": "^1.2.2", 46 | "pre-push": "^0.1.4", 47 | "prettier": "^3.3.2", 48 | "rimraf": "^5.0.7", 49 | "ts-jest": "^29.1.5", 50 | "typescript": "^5.5.2", 51 | "typescript-eslint": "^7.14.1" 52 | }, 53 | "pre-push": [ 54 | "test" 55 | ], 56 | "pre-commit": [ 57 | "lint", 58 | "format" 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 4 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 5 | 6 | # [Unreleased](https://github.com/mypurecloud/webrtc-stats-gatherer/compare/v9.0.11...HEAD) 7 | ### Added 8 | * [STREAM-884](https://inindca.atlassian.net/browse/STREAM-884) - Generate a test report in JUnit.xml format. 9 | 10 | # [v9.0.11](https://github.com/mypurecloud/webrtc-stats-gatherer/compare/v9.0.10...v9.0.11) 11 | ### Changed 12 | * [STREAM-621](https://inindca.atlassian.net/browse/STREAM-621) - Remove pipeline infra, update CODEOWNERS. 13 | 14 | # [v9.0.10](https://github.com/mypurecloud/webrtc-stats-gatherer/compare/v9.0.9...v9.0.10) 15 | ### Fixed 16 | * [NO-JIRA] Use target of `es5` to avoid breaking consumers 17 | 18 | # [v9.0.9](https://github.com/mypurecloud/webrtc-stats-gatherer/compare/v9.0.8...v9.0.9) 19 | ### Fixed 20 | * [STREAM-69](https://inindca.atlassian.net/browse/STREAM-69) Return an empty array when gathering stats for other states like `disconnected` to prevent errors in callers when network connectivity drops 21 | 22 | ### Changed 23 | * [STREAM-32](https://inindca.atlassian.net/browse/STREAM-32) Update dev dependencies, switch to ESLint, add Prettier 24 | 25 | # [v9.0.8](https://github.com/mypurecloud/webrtc-stats-gatherer/compare/v9.0.6...v9.0.8) 26 | ### Fixed 27 | * [PCM-2326](https://inindca.atlassian.net/browse/PCM-2326) - Stop stats gathering if the session ends in "fail" state 28 | 29 | # [v9.0.6](https://github.com/mypurecloud/webrtc-stats-gatherer/compare/v9.0.5...v9.0.6) 30 | ### Fixed 31 | * [PCM-2058](https://inindca.atlassian.net/browse/PCM-2058) - Fix issue with initial stats check generating an error 32 | 33 | # [v9.0.5](https://github.com/mypurecloud/webrtc-stats-gatherer/compare/v9.0.4...v9.0.5) 34 | 35 | ### Fixed 36 | * [PCM-1946](https://inindca.atlassian.net/browse/PCM-1946) - Fix issue with initial stats check generating an error 37 | 38 | # [v9.0.4](https://github.com/mypurecloud/webrtc-stats-gatherer/compare/v9.0.3...v9.0.4) 39 | 40 | ### Fixed 41 | * [PCM-1946](https://inindca.atlassian.net/browse/PCM-1946) - Fix the typo for calculating incoming tracks' packetloss 42 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface StatsEvent { 2 | name: string; 3 | session?: string; 4 | conference?: string; 5 | initiator?: string; 6 | } 7 | 8 | export interface StatsConnectEvent extends StatsEvent { 9 | name: 'connect'; 10 | userAgent: string; 11 | platform: string; 12 | cores: number; 13 | connectTime: number; 14 | localCandidateType?: string; 15 | remoteCandidateType?: string; 16 | candidatePair?: string; 17 | candidatePairDetails?: { 18 | local?: { 19 | id?: string; 20 | timestamp?: number; 21 | type?: string; 22 | transportId?: string; 23 | isRemote?: boolean; 24 | networkType?: string; 25 | ip?: string; 26 | port?: number; 27 | protocol?: string; 28 | candidateType?: string; 29 | priority?: number; 30 | deleted?: boolean; 31 | }; 32 | remote?: { 33 | id?: string; 34 | timestamp?: number; 35 | type?: string; 36 | transportId?: string; 37 | isRemote?: boolean; 38 | ip?: string; 39 | port?: number; 40 | protocol?: string; 41 | candidateType?: string; 42 | priority?: number; 43 | deleted?: boolean; 44 | }; 45 | pair?: { 46 | id?: string; 47 | timestamp?: number; 48 | type?: string; 49 | transportId?: string; 50 | localCandidateId?: string; 51 | remoteCandidateId?: string; 52 | state?: string; 53 | priority?: number; 54 | nominated?: boolean; 55 | writable?: boolean; 56 | bytesSent?: number; 57 | bytesReceived?: number; 58 | totalRoundTripTime?: number; 59 | currentRoundTripTime?: number; 60 | availableOutgoingBitrate?: number; 61 | requestsReceived?: number; 62 | requestsSent?: number; 63 | responsesReceived?: number; 64 | responsesSent?: number; 65 | consentRequestsSent?: number; 66 | }; 67 | }; 68 | transport?: string; 69 | networkType?: string; 70 | } 71 | 72 | export interface FailureEvent extends StatsEvent { 73 | name: 'failure'; 74 | failTime: number; 75 | iceRW?: number; 76 | numLocalHostCandidates?: number; 77 | numLocalSrflxCandidates?: number; 78 | numLocalRelayCandidates?: number; 79 | numRemoteHostCandidates?: number; 80 | numRemoteSrflxCandidates?: number; 81 | numRemoteRelayCandidates?: number; 82 | } 83 | 84 | export interface GetStatsEvent extends StatsEvent { 85 | name: 'getStats'; 86 | tracks: TrackStats[]; 87 | remoteTracks: TrackStats[]; 88 | type?: string; 89 | candidatePairHadActiveSource?: boolean; 90 | localCandidateChanged?: boolean; 91 | remoteCandidateChanged?: boolean; 92 | networkType?: string; 93 | candidatePair?: string; 94 | bytesSent?: number; 95 | bytesReceived?: number; 96 | requestsReceived?: number; 97 | requestsSent?: number; 98 | responsesReceived?: number; 99 | responsesSent?: number; 100 | consentRequestsSent?: number; 101 | totalRoundTripTime?: number; 102 | } 103 | 104 | export interface TrackStats { 105 | track: string; 106 | kind: 'audio' | 'video'; 107 | bytes: number; 108 | codec?: string; 109 | bitrate?: number; 110 | jitter?: number; 111 | roundTripTime: number; 112 | 113 | packetsSent?: number; 114 | packetsLost: number; 115 | packetLoss: number; 116 | intervalPacketsSent?: number; 117 | intervalPacketsLost?: number; 118 | intervalPacketLoss?: number; 119 | 120 | retransmittedBytesSent?: number; 121 | retransmittedPacketsSent?: number; 122 | 123 | packetsReceived?: number; 124 | intervalPacketsReceived?: number; 125 | 126 | audioLevel?: number; 127 | totalAudioEnergy?: number; 128 | echoReturnLoss?: number; 129 | echoReturnLossEnhancement?: number; 130 | } 131 | -------------------------------------------------------------------------------- /test/mock-sdp.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdp": "v=0\r\no=- 8723607365185958946 2 IN IP4 xx.xxx.xxx.xx\r\ns=-\r\nt=0 0\r\na=group:BUNDLE video audio data\r\na=msid-semantic: WMS 8vyiPwd1YGs8Kmz9Xxf3atma7zRmI3M1BVF3\r\nm=video 52643 UDP/TLS/RTP/SAVPF 100 116 117\r\nc=IN IP4 xx.xxx.xxx.xx\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=candidate:2099809157 1 udp 2122194687 xx.xxx.xxx.xx 56433 typ host generation 0 network-id 3\r\na=candidate:3471623853 1 udp 2122129151 xx.xxx.xxx.xx 64406 typ host generation 0 network-id 1\r\na=candidate:2281949109 1 udp 2122063615 xx.xxx.xxx.xx 51949 typ host generation 0 network-id 2 network-cost 10\r\na=candidate:155959553 1 udp 1685855999 xx.xxx.xxx.xx 51949 typ srflx raddr xx.xxx.xxx.xx rport 51949 generation 0 network-id 2 network-cost 10\r\na=candidate:2980426774 1 udp 1685987071 xx.xxx.xxx.xx 56433 typ srflx raddr xx.xxx.xxx.xx rport 56433 generation 0 network-id 3\r\na=candidate:3030388924 1 udp 41820159 xx.xxx.xxx.xx 52643 typ relay raddr xx.xxx.xxx.xx rport 56433 generation 0 network-id 3\r\na=candidate:2493585755 1 udp 41819903 xx.xxx.xxx.xx 62520 typ relay raddr xx.xxx.xxx.xx rport 56433 generation 0 network-id 3\r\na=candidate:3030388924 1 udp 41689087 xx.xxx.xxx.xx 50596 typ relay raddr xx.xxx.xxx.xx rport 51949 generation 0 network-id 2 network-cost 10\r\na=candidate:2493585755 1 udp 41688831 xx.xxx.xxx.xx 61197 typ relay raddr xx.xxx.xxx.xx rport 51949 generation 0 network-id 2 network-cost 10\r\na=candidate:23753127 1 tcp 1518280447 xx.xxx.xxx.xx 9 typ host tcptype active generation 0 network-id 45 network-cost 50\r\na=candidate:866875253 1 tcp 1518214911 xx.xxx.xxx.xx 9 typ host tcptype active generation 0 network-id 3\r\na=candidate:2154773085 1 tcp 1518149375 xx.xxx.xxx.xx 9 typ host tcptype active generation 0 network-id 1\r\na=candidate:3330292549 1 tcp 1518083839 xx.xxx.xxx.xx 9 typ host tcptype active generation 0 network-id 2 network-cost 10\r\na=ice-ufrag:UvdW\r\na=ice-pwd:ChoDd/FJYg8fwZXvkkUAGmtx\r\na=fingerprint:sha-256 97:24:9A:81:A6:31:0D:F2:40:DC:9B:77:DD:43:61:88:85:0E:97:C4:16:13:D8:BD:E2:D7:69:85:3C:96:56:79\r\na=setup:active\r\na=mid:video\r\na=extmap:2 urn:ietf:params:rtp-hdrext:toffset\r\na=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\na=sendrecv\r\na=rtcp-mux\r\na=rtpmap:100 VP8/90000\r\na=rtcp-fb:100 ccm fir\r\na=rtcp-fb:100 nack\r\na=rtcp-fb:100 nack pli\r\na=rtcp-fb:100 goog-remb\r\na=rtpmap:116 red/90000\r\na=rtpmap:117 ulpfec/90000\r\na=ssrc:1736533542 cname:9QXTjVRMGHpi+iFY\r\na=ssrc:1736533542 msid:8vyiPwd1YGs8Kmz9Xxf3atma7zRmI3M1BVF3 8deea3c8-f641-4617-968e-b8c459ce89e5\r\na=ssrc:1736533542 mslabel:8vyiPwd1YGs8Kmz9Xxf3atma7zRmI3M1BVF3\r\na=ssrc:1736533542 label:8deea3c8-f641-4617-968e-b8c459ce89e5\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8\r\nc=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=ice-ufrag:UvdW\r\na=ice-pwd:ChoDd/FJYg8fwZXvkkUAGmtx\r\na=fingerprint:sha-256 97:24:9A:81:A6:31:0D:F2:40:DC:9B:77:DD:43:61:88:85:0E:97:C4:16:13:D8:BD:E2:D7:69:85:3C:96:56:79\r\na=setup:active\r\na=mid:audio\r\na=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\na=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\na=sendrecv\r\na=rtcp-mux\r\na=rtpmap:111 opus/48000/2\r\na=fmtp:111 minptime=10;useinbandfec=1\r\na=rtpmap:103 ISAC/16000\r\na=rtpmap:104 ISAC/32000\r\na=rtpmap:9 G722/8000\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\na=ssrc:66009837 cname:9QXTjVRMGHpi+iFY\r\na=ssrc:66009837 msid:8vyiPwd1YGs8Kmz9Xxf3atma7zRmI3M1BVF3 51e382ec-ce22-4850-8c6e-7b3da44def98\r\na=ssrc:66009837 mslabel:8vyiPwd1YGs8Kmz9Xxf3atma7zRmI3M1BVF3\r\na=ssrc:66009837 label:51e382ec-ce22-4850-8c6e-7b3da44def98\r\nm=application 9 DTLS/SCTP 5000\r\nc=IN IP4 0.0.0.0\r\nb=AS:30\r\na=ice-ufrag:UvdW\r\na=ice-pwd:ChoDd/FJYg8fwZXvkkUAGmtx\r\na=fingerprint:sha-256 97:24:9A:81:A6:31:0D:F2:40:DC:9B:77:DD:43:61:88:85:0E:97:C4:16:13:D8:BD:E2:D7:69:85:3C:96:56:79\r\na=setup:active\r\na=mid:data\r\na=sctpmap:5000 webrtc-datachannel 1024" 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ], 5 | "exclude": [ 6 | "./test/**/*" 7 | ], 8 | "compilerOptions": { 9 | /* Basic Options */ 10 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 11 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 12 | "lib": [ 13 | "es6", 14 | "dom", 15 | "ES2017" 16 | ], /* Specify library files to be included in the compilation. */ 17 | // "allowJs": true, /* Allow javascript files to be compiled. */ 18 | // "checkJs": true, /* Report errors in .js files. */ 19 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 20 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 21 | // "declarationDir": "dist/typings", 22 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 23 | "sourceMap": true, /* Generates corresponding '.map' file. */ 24 | // "outFile": "./", /* Concatenate and emit output to single file. */ 25 | "outDir": "./dist", /* Redirect output structure to the directory. */ 26 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 27 | // "composite": true, /* Enable project compilation */ 28 | // "incremental": true, /* Enable incremental compilation */ 29 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 30 | // "removeComments": true, /* Do not emit comments to output. */ 31 | // "noEmit": true, /* Do not emit outputs. */ 32 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 33 | "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 34 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 35 | /* Strict Type-Checking Options */ 36 | "strict": true, /* Enable all strict type-checking options. */ 37 | "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ 38 | "strictNullChecks": false, /* Enable strict null checks. */ 39 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 40 | "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 41 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 42 | "noImplicitThis": false, /* Raise error on 'this' expressions with an implied 'any' type. */ 43 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 44 | /* Additional Checks */ 45 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 46 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 47 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 48 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 49 | /* Module Resolution Options */ 50 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 51 | "baseUrl": "./src/types", /* Base directory to resolve non-absolute module names. */ 52 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 53 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 54 | // "typeRoots": [], /* List of folders to include type definitions from. */ 55 | "types": [ 56 | "node", 57 | "jest" 58 | ], /* Type declaration files to be included in compilation. */ 59 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 60 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 61 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 62 | /* Source Map Options */ 63 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 64 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 65 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 66 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 67 | /* Experimental Options */ 68 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 69 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/mock-spec-stats-recvonly.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "key": "RTCCertificate_4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 4 | "value": { 5 | "id": "RTCCertificate_4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 6 | "timestamp": 1571687966413.522, 7 | "type": "certificate", 8 | "fingerprint": "4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 9 | "fingerprintAlgorithm": "sha-256", 10 | "base64Certificate": "MIIBFjCBvaADAgECAgkA+u7UIbM5Bf8wCgYIKoZIzj0EAwIwETEPMA0GA1UEAwwGV2ViUlRDMB4XDTE5MTAyMDE5NTgzNloXDTE5MTEyMDE5NTgzNlowETEPMA0GA1UEAwwGV2ViUlRDMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEL+6mSh54eIwC43u2GTcTbDPEy6qs2Ju+q/VihDS51nXSmOlPIZTWPTsKZyztHaO0H4XXwScdrbTfwo1Xy4XfoDAKBggqhkjOPQQDAgNIADBFAiEAh6bFRu+g7t7xZutMvp98wtEPoxRDHtiNAeD8wte3q+UCIHgL8SqpANHKjiYxB6iW3zq6CbMvd9KvwAijYWBt6a/H" 11 | } 12 | }, 13 | { 14 | "key": "RTCCertificate_DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23", 15 | "value": { 16 | "id": "RTCCertificate_DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23", 17 | "timestamp": 1571687966413.522, 18 | "type": "certificate", 19 | "fingerprint": "DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23", 20 | "fingerprintAlgorithm": "sha-1", 21 | "base64Certificate": "MIIBsTCCARqgAwIBAgIGAW3vQVhGMA0GCSqGSIb3DQEBBQUAMBwxGjAYBgNVBAMMEUpWQiAwLjEuYnVpbGQuU1ZOMB4XDTE5MTAyMDE2NTgyMFoXDTE5MTAyODE2NTgyMFowHDEaMBgGA1UEAwwRSlZCIDAuMS5idWlsZC5TVk4wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAOD5A2Y2+Du6AldslGzwXHQjrddaiQyHK3REbvew1qHTivVclTq450nVMV6TeLqkVJujX1KHp5X4umkyYYBzHFZUFzFo76JpxxxutuAkhuoAoajEMgWybUcR/S2BOWYDah7tgfv23QDhzXUbPc0MwLmDWsf2l6nmlNb92SCKxWmZAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEAvS3p12Lf7pko6B8mlh8uj5V/1Anqly8g0CRLFE/DsaUFGU6AIDLNyS3T5pzbAFZmhJyyTfWo4RHlivZo+16VIrzMGacGt6gD8VaFifKXxRaSz/zaLgTasx1KiKJfUWjVn5cpND0HgYweVZygh/paFCGSw0viKXjDB1ciu1M40bA=" 22 | } 23 | }, 24 | { 25 | "key": "RTCCodec_video_Inbound_100", 26 | "value": { 27 | "id": "RTCCodec_video_Inbound_100", 28 | "timestamp": 1571687966413.522, 29 | "type": "codec", 30 | "payloadType": 100, 31 | "mimeType": "video/VP8", 32 | "clockRate": 90000 33 | } 34 | }, 35 | { 36 | "key": "RTCCodec_video_Inbound_116", 37 | "value": { 38 | "id": "RTCCodec_video_Inbound_116", 39 | "timestamp": 1571687966413.522, 40 | "type": "codec", 41 | "payloadType": 116, 42 | "mimeType": "video/red", 43 | "clockRate": 90000 44 | } 45 | }, 46 | { 47 | "key": "RTCCodec_video_Inbound_117", 48 | "value": { 49 | "id": "RTCCodec_video_Inbound_117", 50 | "timestamp": 1571687966413.522, 51 | "type": "codec", 52 | "payloadType": 117, 53 | "mimeType": "video/ulpfec", 54 | "clockRate": 90000 55 | } 56 | }, 57 | { 58 | "key": "RTCIceCandidatePair_WzsdBtXT_nq8LUB9k", 59 | "value": { 60 | "id": "RTCIceCandidatePair_WzsdBtXT_nq8LUB9k", 61 | "timestamp": 1571687966413.522, 62 | "type": "candidate-pair", 63 | "transportId": "RTCTransport_audio_1", 64 | "localCandidateId": "RTCIceCandidate_WzsdBtXT", 65 | "remoteCandidateId": "RTCIceCandidate_nq8LUB9k", 66 | "state": "succeeded", 67 | "priority": 7962116751041233000, 68 | "nominated": true, 69 | "writable": true, 70 | "bytesSent": 0, 71 | "bytesReceived": 7881226, 72 | "totalRoundTripTime": 0.654, 73 | "currentRoundTripTime": 0.026, 74 | "availableOutgoingBitrate": 1777375, 75 | "availableIncomingBitrate": 3802216, 76 | "requestsReceived": 20, 77 | "requestsSent": 1, 78 | "responsesReceived": 24, 79 | "responsesSent": 20, 80 | "consentRequestsSent": 23 81 | } 82 | }, 83 | { 84 | "key": "RTCIceCandidate_WzsdBtXT", 85 | "value": { 86 | "id": "RTCIceCandidate_WzsdBtXT", 87 | "timestamp": 1571687966413.522, 88 | "type": "local-candidate", 89 | "transportId": "RTCTransport_audio_1", 90 | "isRemote": false, 91 | "networkType": "ethernet", 92 | "ip": "10.27.48.148", 93 | "port": 23033, 94 | "protocol": "udp", 95 | "candidateType": "prflx", 96 | "priority": 1853824767, 97 | "deleted": false 98 | } 99 | }, 100 | { 101 | "key": "RTCIceCandidate_nq8LUB9k", 102 | "value": { 103 | "id": "RTCIceCandidate_nq8LUB9k", 104 | "timestamp": 1571687966413.522, 105 | "type": "remote-candidate", 106 | "transportId": "RTCTransport_audio_1", 107 | "isRemote": true, 108 | "ip": "10.27.31.155", 109 | "port": 10000, 110 | "protocol": "udp", 111 | "candidateType": "host", 112 | "priority": 2130706431, 113 | "deleted": false 114 | } 115 | }, 116 | { 117 | "key": "RTCInboundRTPVideoStream_1043752814", 118 | "value": { 119 | "id": "RTCInboundRTPVideoStream_1043752814", 120 | "timestamp": 1571687966413.522, 121 | "type": "inbound-rtp", 122 | "ssrc": 1043752814, 123 | "isRemote": false, 124 | "mediaType": "video", 125 | "kind": "video", 126 | "trackId": "RTCMediaStreamTrack_receiver_2", 127 | "transportId": "RTCTransport_audio_1", 128 | "codecId": "RTCCodec_video_Inbound_100", 129 | "firCount": 0, 130 | "pliCount": 1, 131 | "nackCount": 1, 132 | "qpSum": 20207, 133 | "packetsReceived": 6905, 134 | "bytesReceived": 7637399, 135 | "packetsLost": 18, 136 | "lastPacketReceivedTimestamp": 260348.613, 137 | "framesDecoded": 833, 138 | "keyFramesDecoded": 3, 139 | "totalDecodeTime": 3.16 140 | } 141 | }, 142 | { 143 | "key": "RTCMediaStreamTrack_receiver_2", 144 | "value": { 145 | "id": "RTCMediaStreamTrack_receiver_2", 146 | "timestamp": 1571687966413.522, 147 | "type": "track", 148 | "trackIdentifier": "83d46c2c-5348-4902-a26a-d405c8e968de", 149 | "remoteSource": true, 150 | "ended": false, 151 | "detached": false, 152 | "kind": "video", 153 | "jitterBufferDelay": 30.04, 154 | "jitterBufferEmittedCount": 832, 155 | "frameWidth": 1280, 156 | "frameHeight": 720, 157 | "framesReceived": 836, 158 | "framesDecoded": 833, 159 | "framesDropped": 3 160 | } 161 | }, 162 | { 163 | "key": "RTCMediaStream_1c7msKqE3Egj7PoeyC20HkSzkHg1Y9cXUAMk", 164 | "value": { 165 | "id": "RTCMediaStream_1c7msKqE3Egj7PoeyC20HkSzkHg1Y9cXUAMk", 166 | "timestamp": 1571687966413.522, 167 | "type": "stream", 168 | "streamIdentifier": "1c7msKqE3Egj7PoeyC20HkSzkHg1Y9cXUAMk", 169 | "trackIds": [ 170 | "RTCMediaStreamTrack_receiver_1", 171 | "RTCMediaStreamTrack_receiver_2" 172 | ] 173 | } 174 | }, 175 | { 176 | "key": "RTCPeerConnection", 177 | "value": { 178 | "id": "RTCPeerConnection", 179 | "timestamp": 1571687966413.522, 180 | "type": "peer-connection", 181 | "dataChannelsOpened": 1, 182 | "dataChannelsClosed": 0 183 | } 184 | }, 185 | { 186 | "key": "RTCTransport_audio_1", 187 | "value": { 188 | "id": "RTCTransport_audio_1", 189 | "timestamp": 1571687966413.522, 190 | "type": "transport", 191 | "bytesSent": 0, 192 | "bytesReceived": 7881226, 193 | "dtlsState": "connected", 194 | "selectedCandidatePairId": "RTCIceCandidatePair_WzsdBtXT_nq8LUB9k", 195 | "localCertificateId": "RTCCertificate_4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 196 | "remoteCertificateId": "RTCCertificate_DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23" 197 | } 198 | } 199 | ] -------------------------------------------------------------------------------- /test/mock-spec-stats-3.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "key": "RTCAudioSource_1", 4 | "value": { 5 | "id": "RTCAudioSource_1", 6 | "timestamp": 1571687996412.929, 7 | "type": "media-source", 8 | "trackIdentifier": "b5565a72-53af-43c5-9108-2de5c8a3f9ab", 9 | "kind": "audio", 10 | "audioLevel": 0.0007629627368999298, 11 | "totalAudioEnergy": 1.2405401795363926, 12 | "totalSamplesDuration": 80.18000000000411 13 | } 14 | }, 15 | { 16 | "key": "RTCCertificate_4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 17 | "value": { 18 | "id": "RTCCertificate_4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 19 | "timestamp": 1571687996412.929, 20 | "type": "certificate", 21 | "fingerprint": "4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 22 | "fingerprintAlgorithm": "sha-256", 23 | "base64Certificate": "MIIBFjCBvaADAgECAgkA+u7UIbM5Bf8wCgYIKoZIzj0EAwIwETEPMA0GA1UEAwwGV2ViUlRDMB4XDTE5MTAyMDE5NTgzNloXDTE5MTEyMDE5NTgzNlowETEPMA0GA1UEAwwGV2ViUlRDMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEL+6mSh54eIwC43u2GTcTbDPEy6qs2Ju+q/VihDS51nXSmOlPIZTWPTsKZyztHaO0H4XXwScdrbTfwo1Xy4XfoDAKBggqhkjOPQQDAgNIADBFAiEAh6bFRu+g7t7xZutMvp98wtEPoxRDHtiNAeD8wte3q+UCIHgL8SqpANHKjiYxB6iW3zq6CbMvd9KvwAijYWBt6a/H" 24 | } 25 | }, 26 | { 27 | "key": "RTCCertificate_DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23", 28 | "value": { 29 | "id": "RTCCertificate_DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23", 30 | "timestamp": 1571687996412.929, 31 | "type": "certificate", 32 | "fingerprint": "DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23", 33 | "fingerprintAlgorithm": "sha-1", 34 | "base64Certificate": "MIIBsTCCARqgAwIBAgIGAW3vQVhGMA0GCSqGSIb3DQEBBQUAMBwxGjAYBgNVBAMMEUpWQiAwLjEuYnVpbGQuU1ZOMB4XDTE5MTAyMDE2NTgyMFoXDTE5MTAyODE2NTgyMFowHDEaMBgGA1UEAwwRSlZCIDAuMS5idWlsZC5TVk4wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAOD5A2Y2+Du6AldslGzwXHQjrddaiQyHK3REbvew1qHTivVclTq450nVMV6TeLqkVJujX1KHp5X4umkyYYBzHFZUFzFo76JpxxxutuAkhuoAoajEMgWybUcR/S2BOWYDah7tgfv23QDhzXUbPc0MwLmDWsf2l6nmlNb92SCKxWmZAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEAvS3p12Lf7pko6B8mlh8uj5V/1Anqly8g0CRLFE/DsaUFGU6AIDLNyS3T5pzbAFZmhJyyTfWo4RHlivZo+16VIrzMGacGt6gD8VaFifKXxRaSz/zaLgTasx1KiKJfUWjVn5cpND0HgYweVZygh/paFCGSw0viKXjDB1ciu1M40bA=" 35 | } 36 | }, 37 | { 38 | "key": "RTCCodec_audio_Inbound_0", 39 | "value": { 40 | "id": "RTCCodec_audio_Inbound_0", 41 | "timestamp": 1571687996412.929, 42 | "type": "codec", 43 | "payloadType": 0, 44 | "mimeType": "audio/PCMU", 45 | "clockRate": 8000 46 | } 47 | }, 48 | { 49 | "key": "RTCCodec_audio_Inbound_103", 50 | "value": { 51 | "id": "RTCCodec_audio_Inbound_103", 52 | "timestamp": 1571687996412.929, 53 | "type": "codec", 54 | "payloadType": 103, 55 | "mimeType": "audio/ISAC", 56 | "clockRate": 16000 57 | } 58 | }, 59 | { 60 | "key": "RTCCodec_audio_Inbound_104", 61 | "value": { 62 | "id": "RTCCodec_audio_Inbound_104", 63 | "timestamp": 1571687996412.929, 64 | "type": "codec", 65 | "payloadType": 104, 66 | "mimeType": "audio/ISAC", 67 | "clockRate": 32000 68 | } 69 | }, 70 | { 71 | "key": "RTCCodec_audio_Inbound_111", 72 | "value": { 73 | "id": "RTCCodec_audio_Inbound_111", 74 | "timestamp": 1571687996412.929, 75 | "type": "codec", 76 | "payloadType": 111, 77 | "mimeType": "audio/opus", 78 | "clockRate": 48000 79 | } 80 | }, 81 | { 82 | "key": "RTCCodec_audio_Inbound_8", 83 | "value": { 84 | "id": "RTCCodec_audio_Inbound_8", 85 | "timestamp": 1571687996412.929, 86 | "type": "codec", 87 | "payloadType": 8, 88 | "mimeType": "audio/PCMA", 89 | "clockRate": 8000 90 | } 91 | }, 92 | { 93 | "key": "RTCCodec_audio_Inbound_9", 94 | "value": { 95 | "id": "RTCCodec_audio_Inbound_9", 96 | "timestamp": 1571687996412.929, 97 | "type": "codec", 98 | "payloadType": 9, 99 | "mimeType": "audio/G722", 100 | "clockRate": 8000 101 | } 102 | }, 103 | { 104 | "key": "RTCCodec_audio_Outbound_0", 105 | "value": { 106 | "id": "RTCCodec_audio_Outbound_0", 107 | "timestamp": 1571687996412.929, 108 | "type": "codec", 109 | "payloadType": 0, 110 | "mimeType": "audio/PCMU", 111 | "clockRate": 8000 112 | } 113 | }, 114 | { 115 | "key": "RTCCodec_audio_Outbound_103", 116 | "value": { 117 | "id": "RTCCodec_audio_Outbound_103", 118 | "timestamp": 1571687996412.929, 119 | "type": "codec", 120 | "payloadType": 103, 121 | "mimeType": "audio/ISAC", 122 | "clockRate": 16000 123 | } 124 | }, 125 | { 126 | "key": "RTCCodec_audio_Outbound_104", 127 | "value": { 128 | "id": "RTCCodec_audio_Outbound_104", 129 | "timestamp": 1571687996412.929, 130 | "type": "codec", 131 | "payloadType": 104, 132 | "mimeType": "audio/ISAC", 133 | "clockRate": 32000 134 | } 135 | }, 136 | { 137 | "key": "RTCCodec_audio_Outbound_111", 138 | "value": { 139 | "id": "RTCCodec_audio_Outbound_111", 140 | "timestamp": 1571687996412.929, 141 | "type": "codec", 142 | "payloadType": 111, 143 | "mimeType": "audio/opus", 144 | "clockRate": 48000 145 | } 146 | }, 147 | { 148 | "key": "RTCCodec_audio_Outbound_8", 149 | "value": { 150 | "id": "RTCCodec_audio_Outbound_8", 151 | "timestamp": 1571687996412.929, 152 | "type": "codec", 153 | "payloadType": 8, 154 | "mimeType": "audio/PCMA", 155 | "clockRate": 8000 156 | } 157 | }, 158 | { 159 | "key": "RTCCodec_audio_Outbound_9", 160 | "value": { 161 | "id": "RTCCodec_audio_Outbound_9", 162 | "timestamp": 1571687996412.929, 163 | "type": "codec", 164 | "payloadType": 9, 165 | "mimeType": "audio/G722", 166 | "clockRate": 8000 167 | } 168 | }, 169 | { 170 | "key": "RTCCodec_video_Inbound_100", 171 | "value": { 172 | "id": "RTCCodec_video_Inbound_100", 173 | "timestamp": 1571687996412.929, 174 | "type": "codec", 175 | "payloadType": 100, 176 | "mimeType": "video/VP8", 177 | "clockRate": 90000 178 | } 179 | }, 180 | { 181 | "key": "RTCCodec_video_Inbound_116", 182 | "value": { 183 | "id": "RTCCodec_video_Inbound_116", 184 | "timestamp": 1571687996412.929, 185 | "type": "codec", 186 | "payloadType": 116, 187 | "mimeType": "video/red", 188 | "clockRate": 90000 189 | } 190 | }, 191 | { 192 | "key": "RTCCodec_video_Inbound_117", 193 | "value": { 194 | "id": "RTCCodec_video_Inbound_117", 195 | "timestamp": 1571687996412.929, 196 | "type": "codec", 197 | "payloadType": 117, 198 | "mimeType": "video/ulpfec", 199 | "clockRate": 90000 200 | } 201 | }, 202 | { 203 | "key": "RTCCodec_video_Outbound_100", 204 | "value": { 205 | "id": "RTCCodec_video_Outbound_100", 206 | "timestamp": 1571687996412.929, 207 | "type": "codec", 208 | "payloadType": 100, 209 | "mimeType": "video/VP8", 210 | "clockRate": 90000 211 | } 212 | }, 213 | { 214 | "key": "RTCCodec_video_Outbound_116", 215 | "value": { 216 | "id": "RTCCodec_video_Outbound_116", 217 | "timestamp": 1571687996412.929, 218 | "type": "codec", 219 | "payloadType": 116, 220 | "mimeType": "video/red", 221 | "clockRate": 90000 222 | } 223 | }, 224 | { 225 | "key": "RTCCodec_video_Outbound_117", 226 | "value": { 227 | "id": "RTCCodec_video_Outbound_117", 228 | "timestamp": 1571687996412.929, 229 | "type": "codec", 230 | "payloadType": 117, 231 | "mimeType": "video/ulpfec", 232 | "clockRate": 90000 233 | } 234 | }, 235 | { 236 | "key": "RTCDataChannel_0", 237 | "value": { 238 | "id": "RTCDataChannel_0", 239 | "timestamp": 1571687996412.929, 240 | "type": "data-channel", 241 | "label": "default", 242 | "protocol": "http://jitsi.org/protocols/colibri", 243 | "datachannelid": 0, 244 | "state": "open", 245 | "messagesSent": 7, 246 | "bytesSent": 1460, 247 | "messagesReceived": 8, 248 | "bytesReceived": 1505 249 | } 250 | }, 251 | { 252 | "key": "RTCIceCandidatePair_qwerty_asdfasdf", 253 | "value": { 254 | "id": "RTCIceCandidatePair_qwerty_asdfasdf", 255 | "timestamp": 1571687996412.929, 256 | "type": "candidate-pair", 257 | "transportId": "RTCTransport_audio_1", 258 | "localCandidateId": "RTCIceCandidate_qwerty", 259 | "remoteCandidateId": "RTCIceCandidate_asdfasdf", 260 | "state": "succeeded", 261 | "priority": 7962116751041233000, 262 | "nominated": true, 263 | "writable": true, 264 | "bytesSent": 12301506, 265 | "bytesReceived": 17210339, 266 | "totalRoundTripTime": 0.981, 267 | "currentRoundTripTime": 0.027, 268 | "availableOutgoingBitrate": 3824272, 269 | "availableIncomingBitrate": 3856816, 270 | "requestsReceived": 30, 271 | "requestsSent": 1, 272 | "responsesReceived": 36, 273 | "responsesSent": 30, 274 | "consentRequestsSent": 35 275 | } 276 | }, 277 | { 278 | "key": "RTCIceCandidate_qwerty", 279 | "value": { 280 | "id": "RTCIceCandidate_qwerty", 281 | "timestamp": 1571687996412.929, 282 | "type": "local-candidate", 283 | "transportId": "RTCTransport_audio_1", 284 | "isRemote": false, 285 | "networkType": "ethernet", 286 | "ip": "10.27.48.148", 287 | "port": 23033, 288 | "protocol": "udp", 289 | "candidateType": "prflx", 290 | "priority": 1853824767, 291 | "deleted": false 292 | } 293 | }, 294 | { 295 | "key": "RTCIceCandidate_asdfasdf", 296 | "value": { 297 | "id": "RTCIceCandidate_asdfasdf", 298 | "timestamp": 1571687996412.929, 299 | "type": "remote-candidate", 300 | "transportId": "RTCTransport_audio_1", 301 | "isRemote": true, 302 | "ip": "10.27.31.155", 303 | "port": 10000, 304 | "protocol": "udp", 305 | "candidateType": "host", 306 | "priority": 2130706431, 307 | "deleted": false 308 | } 309 | }, 310 | { 311 | "key": "RTCMediaStreamTrack_receiver_1", 312 | "value": { 313 | "id": "RTCMediaStreamTrack_receiver_1", 314 | "timestamp": 1571687996412.929, 315 | "type": "track", 316 | "trackIdentifier": "c6e25c3b-6ab3-4a84-8469-2fec574ffc94", 317 | "remoteSource": true, 318 | "ended": true, 319 | "detached": false, 320 | "kind": "audio", 321 | "jitterBufferDelay": 108576, 322 | "jitterBufferEmittedCount": 2782080, 323 | "audioLevel": 0.000640888698995941, 324 | "totalAudioEnergy": 2.0850051616069054, 325 | "totalSamplesReceived": 2783040, 326 | "totalSamplesDuration": 57.979999999997034, 327 | "concealedSamples": 1483, 328 | "silentConcealedSamples": 0, 329 | "concealmentEvents": 2, 330 | "insertedSamplesForDeceleration": 1171, 331 | "removedSamplesForAcceleration": 949 332 | } 333 | }, 334 | { 335 | "key": "RTCMediaStreamTrack_receiver_2", 336 | "value": { 337 | "id": "RTCMediaStreamTrack_receiver_2", 338 | "timestamp": 1571687996412.929, 339 | "type": "track", 340 | "trackIdentifier": "83d46c2c-5348-4902-a26a-d405c8e968de", 341 | "remoteSource": true, 342 | "ended": false, 343 | "detached": false, 344 | "kind": "video", 345 | "jitterBufferDelay": 58.849, 346 | "jitterBufferEmittedCount": 1732, 347 | "frameWidth": 1280, 348 | "frameHeight": 720, 349 | "framesReceived": 1736, 350 | "framesDecoded": 1733, 351 | "framesDropped": 3 352 | } 353 | }, 354 | { 355 | "key": "RTCMediaStreamTrack_sender_1", 356 | "value": { 357 | "id": "RTCMediaStreamTrack_sender_1", 358 | "timestamp": 1571687996412.929, 359 | "type": "track", 360 | "trackIdentifier": "b5565a72-53af-43c5-9108-2de5c8a3f9ab", 361 | "mediaSourceId": "RTCAudioSource_1", 362 | "remoteSource": false, 363 | "ended": false, 364 | "detached": false, 365 | "kind": "audio", 366 | "echoReturnLoss": -30, 367 | "echoReturnLossEnhancement": 0.17551203072071075 368 | } 369 | }, 370 | { 371 | "key": "RTCMediaStreamTrack_sender_2", 372 | "value": { 373 | "id": "RTCMediaStreamTrack_sender_2", 374 | "timestamp": 1571687996412.929, 375 | "type": "track", 376 | "trackIdentifier": "44d76ce5-e573-4e5b-8bfe-79e5bb71ff14", 377 | "mediaSourceId": "RTCVideoSource_2", 378 | "remoteSource": false, 379 | "ended": false, 380 | "detached": false, 381 | "kind": "video", 382 | "frameWidth": 1280, 383 | "frameHeight": 720, 384 | "framesSent": 2389, 385 | "hugeFramesSent": 3 386 | } 387 | }, 388 | { 389 | "key": "RTCMediaStream_1c7msKqE3Egj7PoeyC20HkSzkHg1Y9cXUAMk", 390 | "value": { 391 | "id": "RTCMediaStream_1c7msKqE3Egj7PoeyC20HkSzkHg1Y9cXUAMk", 392 | "timestamp": 1571687996412.929, 393 | "type": "stream", 394 | "streamIdentifier": "1c7msKqE3Egj7PoeyC20HkSzkHg1Y9cXUAMk", 395 | "trackIds": [ 396 | "RTCMediaStreamTrack_receiver_1", 397 | "RTCMediaStreamTrack_receiver_2" 398 | ] 399 | } 400 | }, 401 | { 402 | "key": "RTCMediaStream_tmNCW2EqWMSTEAYh3z2qql85GHYP2uXYxt8A", 403 | "value": { 404 | "id": "RTCMediaStream_tmNCW2EqWMSTEAYh3z2qql85GHYP2uXYxt8A", 405 | "timestamp": 1571687996412.929, 406 | "type": "stream", 407 | "streamIdentifier": "tmNCW2EqWMSTEAYh3z2qql85GHYP2uXYxt8A", 408 | "trackIds": [ 409 | "RTCMediaStreamTrack_sender_1", 410 | "RTCMediaStreamTrack_sender_2" 411 | ] 412 | } 413 | }, 414 | { 415 | "key": "RTCPeerConnection", 416 | "value": { 417 | "id": "RTCPeerConnection", 418 | "timestamp": 1571687996412.929, 419 | "type": "peer-connection", 420 | "dataChannelsOpened": 1, 421 | "dataChannelsClosed": 0 422 | } 423 | }, 424 | { 425 | "key": "RTCTransport_audio_1", 426 | "value": { 427 | "id": "RTCTransport_audio_1", 428 | "timestamp": 1571687996412.929, 429 | "type": "transport", 430 | "bytesSent": 12301506, 431 | "bytesReceived": 17210339, 432 | "dtlsState": "connected", 433 | "selectedCandidatePairId": "RTCIceCandidatePair_qwerty_asdfasdf", 434 | "localCertificateId": "RTCCertificate_4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 435 | "remoteCertificateId": "RTCCertificate_DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23" 436 | } 437 | }, 438 | { 439 | "key": "RTCVideoSource_2", 440 | "value": { 441 | "id": "RTCVideoSource_2", 442 | "timestamp": 1571687996412.929, 443 | "type": "media-source", 444 | "trackIdentifier": "44d76ce5-e573-4e5b-8bfe-79e5bb71ff14", 445 | "kind": "video", 446 | "width": 1280, 447 | "height": 720, 448 | "framesPerSecond": 30 449 | } 450 | } 451 | ] -------------------------------------------------------------------------------- /test/mock-spec-stats-initial.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "key": "RTCAudioSource_1", 4 | "value": { 5 | "id": "RTCAudioSource_1", 6 | "timestamp": 1571687916415.012, 7 | "type": "media-source", 8 | "trackIdentifier": "b5565a72-53af-43c5-9108-2de5c8a3f9ab", 9 | "kind": "audio", 10 | "audioLevel": 0.0011902218695638905, 11 | "totalAudioEnergy": 1.1333024790305303e-7, 12 | "totalSamplesDuration": 0.18000000000000002 13 | } 14 | }, 15 | { 16 | "key": "RTCCertificate_4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 17 | "value": { 18 | "id": "RTCCertificate_4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 19 | "timestamp": 1571687916415.012, 20 | "type": "certificate", 21 | "fingerprint": "4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 22 | "fingerprintAlgorithm": "sha-256", 23 | "base64Certificate": "MIIBFjCBvaADAgECAgkA+u7UIbM5Bf8wCgYIKoZIzj0EAwIwETEPMA0GA1UEAwwGV2ViUlRDMB4XDTE5MTAyMDE5NTgzNloXDTE5MTEyMDE5NTgzNlowETEPMA0GA1UEAwwGV2ViUlRDMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEL+6mSh54eIwC43u2GTcTbDPEy6qs2Ju+q/VihDS51nXSmOlPIZTWPTsKZyztHaO0H4XXwScdrbTfwo1Xy4XfoDAKBggqhkjOPQQDAgNIADBFAiEAh6bFRu+g7t7xZutMvp98wtEPoxRDHtiNAeD8wte3q+UCIHgL8SqpANHKjiYxB6iW3zq6CbMvd9KvwAijYWBt6a/H" 24 | } 25 | }, 26 | { 27 | "key": "RTCCodec_audio_Inbound_0", 28 | "value": { 29 | "id": "RTCCodec_audio_Inbound_0", 30 | "timestamp": 1571687916415.012, 31 | "type": "codec", 32 | "payloadType": 0, 33 | "mimeType": "audio/PCMU", 34 | "clockRate": 8000 35 | } 36 | }, 37 | { 38 | "key": "RTCCodec_audio_Inbound_103", 39 | "value": { 40 | "id": "RTCCodec_audio_Inbound_103", 41 | "timestamp": 1571687916415.012, 42 | "type": "codec", 43 | "payloadType": 103, 44 | "mimeType": "audio/ISAC", 45 | "clockRate": 16000 46 | } 47 | }, 48 | { 49 | "key": "RTCCodec_audio_Inbound_104", 50 | "value": { 51 | "id": "RTCCodec_audio_Inbound_104", 52 | "timestamp": 1571687916415.012, 53 | "type": "codec", 54 | "payloadType": 104, 55 | "mimeType": "audio/ISAC", 56 | "clockRate": 32000 57 | } 58 | }, 59 | { 60 | "key": "RTCCodec_audio_Inbound_111", 61 | "value": { 62 | "id": "RTCCodec_audio_Inbound_111", 63 | "timestamp": 1571687916415.012, 64 | "type": "codec", 65 | "payloadType": 111, 66 | "mimeType": "audio/opus", 67 | "clockRate": 48000 68 | } 69 | }, 70 | { 71 | "key": "RTCCodec_audio_Inbound_8", 72 | "value": { 73 | "id": "RTCCodec_audio_Inbound_8", 74 | "timestamp": 1571687916415.012, 75 | "type": "codec", 76 | "payloadType": 8, 77 | "mimeType": "audio/PCMA", 78 | "clockRate": 8000 79 | } 80 | }, 81 | { 82 | "key": "RTCCodec_audio_Inbound_9", 83 | "value": { 84 | "id": "RTCCodec_audio_Inbound_9", 85 | "timestamp": 1571687916415.012, 86 | "type": "codec", 87 | "payloadType": 9, 88 | "mimeType": "audio/G722", 89 | "clockRate": 8000 90 | } 91 | }, 92 | { 93 | "key": "RTCCodec_audio_Outbound_0", 94 | "value": { 95 | "id": "RTCCodec_audio_Outbound_0", 96 | "timestamp": 1571687916415.012, 97 | "type": "codec", 98 | "payloadType": 0, 99 | "mimeType": "audio/PCMU", 100 | "clockRate": 8000 101 | } 102 | }, 103 | { 104 | "key": "RTCCodec_audio_Outbound_103", 105 | "value": { 106 | "id": "RTCCodec_audio_Outbound_103", 107 | "timestamp": 1571687916415.012, 108 | "type": "codec", 109 | "payloadType": 103, 110 | "mimeType": "audio/ISAC", 111 | "clockRate": 16000 112 | } 113 | }, 114 | { 115 | "key": "RTCCodec_audio_Outbound_104", 116 | "value": { 117 | "id": "RTCCodec_audio_Outbound_104", 118 | "timestamp": 1571687916415.012, 119 | "type": "codec", 120 | "payloadType": 104, 121 | "mimeType": "audio/ISAC", 122 | "clockRate": 32000 123 | } 124 | }, 125 | { 126 | "key": "RTCCodec_audio_Outbound_111", 127 | "value": { 128 | "id": "RTCCodec_audio_Outbound_111", 129 | "timestamp": 1571687916415.012, 130 | "type": "codec", 131 | "payloadType": 111, 132 | "mimeType": "audio/opus", 133 | "clockRate": 48000 134 | } 135 | }, 136 | { 137 | "key": "RTCCodec_audio_Outbound_8", 138 | "value": { 139 | "id": "RTCCodec_audio_Outbound_8", 140 | "timestamp": 1571687916415.012, 141 | "type": "codec", 142 | "payloadType": 8, 143 | "mimeType": "audio/PCMA", 144 | "clockRate": 8000 145 | } 146 | }, 147 | { 148 | "key": "RTCCodec_audio_Outbound_9", 149 | "value": { 150 | "id": "RTCCodec_audio_Outbound_9", 151 | "timestamp": 1571687916415.012, 152 | "type": "codec", 153 | "payloadType": 9, 154 | "mimeType": "audio/G722", 155 | "clockRate": 8000 156 | } 157 | }, 158 | { 159 | "key": "RTCCodec_video_Inbound_100", 160 | "value": { 161 | "id": "RTCCodec_video_Inbound_100", 162 | "timestamp": 1571687916415.012, 163 | "type": "codec", 164 | "payloadType": 100, 165 | "mimeType": "video/VP8", 166 | "clockRate": 90000 167 | } 168 | }, 169 | { 170 | "key": "RTCCodec_video_Inbound_116", 171 | "value": { 172 | "id": "RTCCodec_video_Inbound_116", 173 | "timestamp": 1571687916415.012, 174 | "type": "codec", 175 | "payloadType": 116, 176 | "mimeType": "video/red", 177 | "clockRate": 90000 178 | } 179 | }, 180 | { 181 | "key": "RTCCodec_video_Inbound_117", 182 | "value": { 183 | "id": "RTCCodec_video_Inbound_117", 184 | "timestamp": 1571687916415.012, 185 | "type": "codec", 186 | "payloadType": 117, 187 | "mimeType": "video/ulpfec", 188 | "clockRate": 90000 189 | } 190 | }, 191 | { 192 | "key": "RTCCodec_video_Outbound_100", 193 | "value": { 194 | "id": "RTCCodec_video_Outbound_100", 195 | "timestamp": 1571687916415.012, 196 | "type": "codec", 197 | "payloadType": 100, 198 | "mimeType": "video/VP8", 199 | "clockRate": 90000 200 | } 201 | }, 202 | { 203 | "key": "RTCCodec_video_Outbound_116", 204 | "value": { 205 | "id": "RTCCodec_video_Outbound_116", 206 | "timestamp": 1571687916415.012, 207 | "type": "codec", 208 | "payloadType": 116, 209 | "mimeType": "video/red", 210 | "clockRate": 90000 211 | } 212 | }, 213 | { 214 | "key": "RTCCodec_video_Outbound_117", 215 | "value": { 216 | "id": "RTCCodec_video_Outbound_117", 217 | "timestamp": 1571687916415.012, 218 | "type": "codec", 219 | "payloadType": 117, 220 | "mimeType": "video/ulpfec", 221 | "clockRate": 90000 222 | } 223 | }, 224 | { 225 | "key": "RTCIceCandidatePair_WzsdBtXT_nq8LUB9k", 226 | "value": { 227 | "id": "RTCIceCandidatePair_WzsdBtXT_nq8LUB9k", 228 | "timestamp": 1571687916415.012, 229 | "type": "candidate-pair", 230 | "transportId": "RTCTransport_audio_1", 231 | "localCandidateId": "RTCIceCandidate_WzsdBtXT", 232 | "remoteCandidateId": "RTCIceCandidate_nq8LUB9k", 233 | "state": "succeeded", 234 | "priority": 7962116751041233000, 235 | "nominated": true, 236 | "writable": true, 237 | "bytesSent": 155, 238 | "bytesReceived": 0, 239 | "totalRoundTripTime": 0.047, 240 | "currentRoundTripTime": 0.047, 241 | "requestsReceived": 0, 242 | "requestsSent": 1, 243 | "responsesReceived": 1, 244 | "responsesSent": 0, 245 | "consentRequestsSent": 1 246 | } 247 | }, 248 | { 249 | "key": "RTCIceCandidatePair_yI+kvNvF_kJy6c1E9", 250 | "value": { 251 | "id": "RTCIceCandidatePair_yI+kvNvF_kJy6c1E9", 252 | "timestamp": 1571687916415.012, 253 | "type": "candidate-pair", 254 | "transportId": "RTCTransport_audio_1", 255 | "localCandidateId": "RTCIceCandidate_yI+kvNvF", 256 | "remoteCandidateId": "RTCIceCandidate_kJy6c1E9", 257 | "state": "waiting", 258 | "priority": 179896594039051780, 259 | "nominated": false, 260 | "writable": false, 261 | "bytesSent": 0, 262 | "bytesReceived": 0, 263 | "totalRoundTripTime": 0, 264 | "requestsReceived": 0, 265 | "requestsSent": 0, 266 | "responsesReceived": 0, 267 | "responsesSent": 0, 268 | "consentRequestsSent": 0 269 | } 270 | }, 271 | { 272 | "key": "RTCIceCandidate_WzsdBtXT", 273 | "value": { 274 | "id": "RTCIceCandidate_WzsdBtXT", 275 | "timestamp": 1571687916415.012, 276 | "type": "local-candidate", 277 | "transportId": "RTCTransport_audio_1", 278 | "isRemote": false, 279 | "networkType": "ethernet", 280 | "ip": "10.27.48.148", 281 | "port": 23033, 282 | "protocol": "udp", 283 | "candidateType": "prflx", 284 | "priority": 1853824767, 285 | "deleted": false 286 | } 287 | }, 288 | { 289 | "key": "RTCIceCandidate_kJy6c1E9", 290 | "value": { 291 | "id": "RTCIceCandidate_kJy6c1E9", 292 | "timestamp": 1571687916415.012, 293 | "type": "remote-candidate", 294 | "transportId": "RTCTransport_audio_1", 295 | "isRemote": true, 296 | "ip": "3.92.206.70", 297 | "port": 10000, 298 | "protocol": "udp", 299 | "candidateType": "srflx", 300 | "priority": 1677724415, 301 | "deleted": false 302 | } 303 | }, 304 | { 305 | "key": "RTCIceCandidate_nq8LUB9k", 306 | "value": { 307 | "id": "RTCIceCandidate_nq8LUB9k", 308 | "timestamp": 1571687916415.012, 309 | "type": "remote-candidate", 310 | "transportId": "RTCTransport_audio_1", 311 | "isRemote": true, 312 | "ip": "10.27.31.155", 313 | "port": 10000, 314 | "protocol": "udp", 315 | "candidateType": "host", 316 | "priority": 2130706431, 317 | "deleted": false 318 | } 319 | }, 320 | { 321 | "key": "RTCIceCandidate_yI+kvNvF", 322 | "value": { 323 | "id": "RTCIceCandidate_yI+kvNvF", 324 | "timestamp": 1571687916415.012, 325 | "type": "local-candidate", 326 | "transportId": "RTCTransport_audio_1", 327 | "isRemote": false, 328 | "networkType": "ethernet", 329 | "ip": "54.242.15.31", 330 | "port": 23033, 331 | "protocol": "udp", 332 | "relayProtocol": "udp", 333 | "candidateType": "relay", 334 | "priority": 41885439, 335 | "deleted": false 336 | } 337 | }, 338 | { 339 | "key": "RTCMediaStreamTrack_sender_1", 340 | "value": { 341 | "id": "RTCMediaStreamTrack_sender_1", 342 | "timestamp": 1571687916415.012, 343 | "type": "track", 344 | "trackIdentifier": "b5565a72-53af-43c5-9108-2de5c8a3f9ab", 345 | "mediaSourceId": "RTCAudioSource_1", 346 | "remoteSource": false, 347 | "ended": false, 348 | "detached": false, 349 | "kind": "audio" 350 | } 351 | }, 352 | { 353 | "key": "RTCMediaStreamTrack_sender_2", 354 | "value": { 355 | "id": "RTCMediaStreamTrack_sender_2", 356 | "timestamp": 1571687916415.012, 357 | "type": "track", 358 | "trackIdentifier": "44d76ce5-e573-4e5b-8bfe-79e5bb71ff14", 359 | "mediaSourceId": "RTCVideoSource_2", 360 | "remoteSource": false, 361 | "ended": false, 362 | "detached": false, 363 | "kind": "video", 364 | "frameWidth": 0, 365 | "frameHeight": 0, 366 | "framesSent": 0, 367 | "hugeFramesSent": 0 368 | } 369 | }, 370 | { 371 | "key": "RTCMediaStream_466fc587-c93d-407b-abbf-d4efa4ac0be6", 372 | "value": { 373 | "id": "RTCMediaStream_466fc587-c93d-407b-abbf-d4efa4ac0be6", 374 | "timestamp": 1571687916415.012, 375 | "type": "stream", 376 | "streamIdentifier": "466fc587-c93d-407b-abbf-d4efa4ac0be6", 377 | "trackIds": [ 378 | "RTCMediaStreamTrack_receiver_1", 379 | "RTCMediaStreamTrack_receiver_2" 380 | ] 381 | } 382 | }, 383 | { 384 | "key": "RTCMediaStream_tmNCW2EqWMSTEAYh3z2qql85GHYP2uXYxt8A", 385 | "value": { 386 | "id": "RTCMediaStream_tmNCW2EqWMSTEAYh3z2qql85GHYP2uXYxt8A", 387 | "timestamp": 1571687916415.012, 388 | "type": "stream", 389 | "streamIdentifier": "tmNCW2EqWMSTEAYh3z2qql85GHYP2uXYxt8A", 390 | "trackIds": [ 391 | "RTCMediaStreamTrack_sender_1", 392 | "RTCMediaStreamTrack_sender_2" 393 | ] 394 | } 395 | }, 396 | { 397 | "key": "RTCOutboundRTPAudioStream_545464236", 398 | "value": { 399 | "id": "RTCOutboundRTPAudioStream_545464236", 400 | "timestamp": 1571687916415.012, 401 | "type": "outbound-rtp", 402 | "ssrc": 545464236, 403 | "isRemote": false, 404 | "mediaType": "audio", 405 | "kind": "audio", 406 | "trackId": "RTCMediaStreamTrack_sender_1", 407 | "transportId": "RTCTransport_audio_1", 408 | "codecId": "RTCCodec_audio_Outbound_111", 409 | "mediaSourceId": "RTCAudioSource_1", 410 | "packetsSent": 0, 411 | "retransmittedPacketsSent": 0, 412 | "bytesSent": 0, 413 | "retransmittedBytesSent": 0 414 | } 415 | }, 416 | { 417 | "key": "RTCOutboundRTPVideoStream_780297609", 418 | "value": { 419 | "id": "RTCOutboundRTPVideoStream_780297609", 420 | "timestamp": 1571687916415.012, 421 | "type": "outbound-rtp", 422 | "ssrc": 780297609, 423 | "isRemote": false, 424 | "mediaType": "video", 425 | "kind": "video", 426 | "trackId": "RTCMediaStreamTrack_sender_2", 427 | "transportId": "RTCTransport_audio_1", 428 | "codecId": "RTCCodec_video_Outbound_100", 429 | "firCount": 0, 430 | "pliCount": 0, 431 | "nackCount": 0, 432 | "mediaSourceId": "RTCVideoSource_2", 433 | "packetsSent": 0, 434 | "retransmittedPacketsSent": 0, 435 | "bytesSent": 0, 436 | "retransmittedBytesSent": 0, 437 | "framesEncoded": 0, 438 | "keyFramesEncoded": 0, 439 | "totalEncodeTime": 0, 440 | "totalEncodedBytesTarget": 0, 441 | "totalPacketSendDelay": 0, 442 | "qualityLimitationReason": "bandwidth" 443 | } 444 | }, 445 | { 446 | "key": "RTCPeerConnection", 447 | "value": { 448 | "id": "RTCPeerConnection", 449 | "timestamp": 1571687916415.012, 450 | "type": "peer-connection", 451 | "dataChannelsOpened": 0, 452 | "dataChannelsClosed": 0 453 | } 454 | }, 455 | { 456 | "key": "RTCTransport_audio_1", 457 | "value": { 458 | "id": "RTCTransport_audio_1", 459 | "timestamp": 1571687916415.012, 460 | "type": "transport", 461 | "bytesSent": 155, 462 | "bytesReceived": 0, 463 | "dtlsState": "connecting", 464 | "selectedCandidatePairId": "RTCIceCandidatePair_WzsdBtXT_nq8LUB9k", 465 | "localCertificateId": "RTCCertificate_4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8" 466 | } 467 | }, 468 | { 469 | "key": "RTCVideoSource_2", 470 | "value": { 471 | "id": "RTCVideoSource_2", 472 | "timestamp": 1571687916415.012, 473 | "type": "media-source", 474 | "trackIdentifier": "44d76ce5-e573-4e5b-8bfe-79e5bb71ff14", 475 | "kind": "video", 476 | "width": 1280, 477 | "height": 720, 478 | "framesPerSecond": 35 479 | } 480 | } 481 | ] -------------------------------------------------------------------------------- /test/mock-spec-stats-1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "key": "RTCAudioSource_1", 4 | "value": { 5 | "id": "RTCAudioSource_1", 6 | "timestamp": 1571687926414.613, 7 | "type": "media-source", 8 | "trackIdentifier": "b5565a72-53af-43c5-9108-2de5c8a3f9ab", 9 | "kind": "audio", 10 | "audioLevel": 0.0009765923032319102, 11 | "totalAudioEnergy": 0.0229691871587937, 12 | "totalSamplesDuration": 10.179999999999827 13 | } 14 | }, 15 | { 16 | "key": "RTCCertificate_4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 17 | "value": { 18 | "id": "RTCCertificate_4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 19 | "timestamp": 1571687926414.613, 20 | "type": "certificate", 21 | "fingerprint": "4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 22 | "fingerprintAlgorithm": "sha-256", 23 | "base64Certificate": "MIIBFjCBvaADAgECAgkA+u7UIbM5Bf8wCgYIKoZIzj0EAwIwETEPMA0GA1UEAwwGV2ViUlRDMB4XDTE5MTAyMDE5NTgzNloXDTE5MTEyMDE5NTgzNlowETEPMA0GA1UEAwwGV2ViUlRDMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEL+6mSh54eIwC43u2GTcTbDPEy6qs2Ju+q/VihDS51nXSmOlPIZTWPTsKZyztHaO0H4XXwScdrbTfwo1Xy4XfoDAKBggqhkjOPQQDAgNIADBFAiEAh6bFRu+g7t7xZutMvp98wtEPoxRDHtiNAeD8wte3q+UCIHgL8SqpANHKjiYxB6iW3zq6CbMvd9KvwAijYWBt6a/H" 24 | } 25 | }, 26 | { 27 | "key": "RTCCertificate_DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23", 28 | "value": { 29 | "id": "RTCCertificate_DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23", 30 | "timestamp": 1571687926414.613, 31 | "type": "certificate", 32 | "fingerprint": "DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23", 33 | "fingerprintAlgorithm": "sha-1", 34 | "base64Certificate": "MIIBsTCCARqgAwIBAgIGAW3vQVhGMA0GCSqGSIb3DQEBBQUAMBwxGjAYBgNVBAMMEUpWQiAwLjEuYnVpbGQuU1ZOMB4XDTE5MTAyMDE2NTgyMFoXDTE5MTAyODE2NTgyMFowHDEaMBgGA1UEAwwRSlZCIDAuMS5idWlsZC5TVk4wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAOD5A2Y2+Du6AldslGzwXHQjrddaiQyHK3REbvew1qHTivVclTq450nVMV6TeLqkVJujX1KHp5X4umkyYYBzHFZUFzFo76JpxxxutuAkhuoAoajEMgWybUcR/S2BOWYDah7tgfv23QDhzXUbPc0MwLmDWsf2l6nmlNb92SCKxWmZAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEAvS3p12Lf7pko6B8mlh8uj5V/1Anqly8g0CRLFE/DsaUFGU6AIDLNyS3T5pzbAFZmhJyyTfWo4RHlivZo+16VIrzMGacGt6gD8VaFifKXxRaSz/zaLgTasx1KiKJfUWjVn5cpND0HgYweVZygh/paFCGSw0viKXjDB1ciu1M40bA=" 35 | } 36 | }, 37 | { 38 | "key": "RTCCodec_audio_Inbound_0", 39 | "value": { 40 | "id": "RTCCodec_audio_Inbound_0", 41 | "timestamp": 1571687926414.613, 42 | "type": "codec", 43 | "payloadType": 0, 44 | "mimeType": "audio/PCMU", 45 | "clockRate": 8000 46 | } 47 | }, 48 | { 49 | "key": "RTCCodec_audio_Inbound_103", 50 | "value": { 51 | "id": "RTCCodec_audio_Inbound_103", 52 | "timestamp": 1571687926414.613, 53 | "type": "codec", 54 | "payloadType": 103, 55 | "mimeType": "audio/ISAC", 56 | "clockRate": 16000 57 | } 58 | }, 59 | { 60 | "key": "RTCCodec_audio_Inbound_104", 61 | "value": { 62 | "id": "RTCCodec_audio_Inbound_104", 63 | "timestamp": 1571687926414.613, 64 | "type": "codec", 65 | "payloadType": 104, 66 | "mimeType": "audio/ISAC", 67 | "clockRate": 32000 68 | } 69 | }, 70 | { 71 | "key": "RTCCodec_audio_Inbound_111", 72 | "value": { 73 | "id": "RTCCodec_audio_Inbound_111", 74 | "timestamp": 1571687926414.613, 75 | "type": "codec", 76 | "payloadType": 111, 77 | "mimeType": "audio/opus", 78 | "clockRate": 48000 79 | } 80 | }, 81 | { 82 | "key": "RTCCodec_audio_Inbound_8", 83 | "value": { 84 | "id": "RTCCodec_audio_Inbound_8", 85 | "timestamp": 1571687926414.613, 86 | "type": "codec", 87 | "payloadType": 8, 88 | "mimeType": "audio/PCMA", 89 | "clockRate": 8000 90 | } 91 | }, 92 | { 93 | "key": "RTCCodec_audio_Inbound_9", 94 | "value": { 95 | "id": "RTCCodec_audio_Inbound_9", 96 | "timestamp": 1571687926414.613, 97 | "type": "codec", 98 | "payloadType": 9, 99 | "mimeType": "audio/G722", 100 | "clockRate": 8000 101 | } 102 | }, 103 | { 104 | "key": "RTCCodec_audio_Outbound_0", 105 | "value": { 106 | "id": "RTCCodec_audio_Outbound_0", 107 | "timestamp": 1571687926414.613, 108 | "type": "codec", 109 | "payloadType": 0, 110 | "mimeType": "audio/PCMU", 111 | "clockRate": 8000 112 | } 113 | }, 114 | { 115 | "key": "RTCCodec_audio_Outbound_103", 116 | "value": { 117 | "id": "RTCCodec_audio_Outbound_103", 118 | "timestamp": 1571687926414.613, 119 | "type": "codec", 120 | "payloadType": 103, 121 | "mimeType": "audio/ISAC", 122 | "clockRate": 16000 123 | } 124 | }, 125 | { 126 | "key": "RTCCodec_audio_Outbound_104", 127 | "value": { 128 | "id": "RTCCodec_audio_Outbound_104", 129 | "timestamp": 1571687926414.613, 130 | "type": "codec", 131 | "payloadType": 104, 132 | "mimeType": "audio/ISAC", 133 | "clockRate": 32000 134 | } 135 | }, 136 | { 137 | "key": "RTCCodec_audio_Outbound_111", 138 | "value": { 139 | "id": "RTCCodec_audio_Outbound_111", 140 | "timestamp": 1571687926414.613, 141 | "type": "codec", 142 | "payloadType": 111, 143 | "mimeType": "audio/opus", 144 | "clockRate": 48000 145 | } 146 | }, 147 | { 148 | "key": "RTCCodec_audio_Outbound_8", 149 | "value": { 150 | "id": "RTCCodec_audio_Outbound_8", 151 | "timestamp": 1571687926414.613, 152 | "type": "codec", 153 | "payloadType": 8, 154 | "mimeType": "audio/PCMA", 155 | "clockRate": 8000 156 | } 157 | }, 158 | { 159 | "key": "RTCCodec_audio_Outbound_9", 160 | "value": { 161 | "id": "RTCCodec_audio_Outbound_9", 162 | "timestamp": 1571687926414.613, 163 | "type": "codec", 164 | "payloadType": 9, 165 | "mimeType": "audio/G722", 166 | "clockRate": 8000 167 | } 168 | }, 169 | { 170 | "key": "RTCCodec_video_Inbound_100", 171 | "value": { 172 | "id": "RTCCodec_video_Inbound_100", 173 | "timestamp": 1571687926414.613, 174 | "type": "codec", 175 | "payloadType": 100, 176 | "mimeType": "video/VP8", 177 | "clockRate": 90000 178 | } 179 | }, 180 | { 181 | "key": "RTCCodec_video_Inbound_116", 182 | "value": { 183 | "id": "RTCCodec_video_Inbound_116", 184 | "timestamp": 1571687926414.613, 185 | "type": "codec", 186 | "payloadType": 116, 187 | "mimeType": "video/red", 188 | "clockRate": 90000 189 | } 190 | }, 191 | { 192 | "key": "RTCCodec_video_Inbound_117", 193 | "value": { 194 | "id": "RTCCodec_video_Inbound_117", 195 | "timestamp": 1571687926414.613, 196 | "type": "codec", 197 | "payloadType": 117, 198 | "mimeType": "video/ulpfec", 199 | "clockRate": 90000 200 | } 201 | }, 202 | { 203 | "key": "RTCCodec_video_Outbound_100", 204 | "value": { 205 | "id": "RTCCodec_video_Outbound_100", 206 | "timestamp": 1571687926414.613, 207 | "type": "codec", 208 | "payloadType": 100, 209 | "mimeType": "video/VP8", 210 | "clockRate": 90000 211 | } 212 | }, 213 | { 214 | "key": "RTCCodec_video_Outbound_116", 215 | "value": { 216 | "id": "RTCCodec_video_Outbound_116", 217 | "timestamp": 1571687926414.613, 218 | "type": "codec", 219 | "payloadType": 116, 220 | "mimeType": "video/red", 221 | "clockRate": 90000 222 | } 223 | }, 224 | { 225 | "key": "RTCCodec_video_Outbound_117", 226 | "value": { 227 | "id": "RTCCodec_video_Outbound_117", 228 | "timestamp": 1571687926414.613, 229 | "type": "codec", 230 | "payloadType": 117, 231 | "mimeType": "video/ulpfec", 232 | "clockRate": 90000 233 | } 234 | }, 235 | { 236 | "key": "RTCDataChannel_0", 237 | "value": { 238 | "id": "RTCDataChannel_0", 239 | "timestamp": 1571687926414.613, 240 | "type": "data-channel", 241 | "label": "default", 242 | "protocol": "http://jitsi.org/protocols/colibri", 243 | "datachannelid": 0, 244 | "state": "open", 245 | "messagesSent": 2, 246 | "bytesSent": 492, 247 | "messagesReceived": 2, 248 | "bytesReceived": 223 249 | } 250 | }, 251 | { 252 | "key": "RTCIceCandidatePair_WzsdBtXT_nq8LUB9k", 253 | "value": { 254 | "id": "RTCIceCandidatePair_WzsdBtXT_nq8LUB9k", 255 | "timestamp": 1571687926414.613, 256 | "type": "candidate-pair", 257 | "transportId": "RTCTransport_audio_1", 258 | "localCandidateId": "RTCIceCandidate_WzsdBtXT", 259 | "remoteCandidateId": "RTCIceCandidate_nq8LUB9k", 260 | "state": "succeeded", 261 | "priority": 7962116751041233000, 262 | "nominated": true, 263 | "writable": true, 264 | "bytesSent": 349198, 265 | "bytesReceived": 3907, 266 | "totalRoundTripTime": 0.231, 267 | "currentRoundTripTime": 0.026, 268 | "availableOutgoingBitrate": 300000, 269 | "requestsReceived": 7, 270 | "requestsSent": 1, 271 | "responsesReceived": 8, 272 | "responsesSent": 7, 273 | "consentRequestsSent": 7 274 | } 275 | }, 276 | { 277 | "key": "RTCIceCandidatePair_yI+kvNvF_kJy6c1E9", 278 | "value": { 279 | "id": "RTCIceCandidatePair_yI+kvNvF_kJy6c1E9", 280 | "timestamp": 1571687926414.613, 281 | "type": "candidate-pair", 282 | "transportId": "RTCTransport_audio_1", 283 | "localCandidateId": "RTCIceCandidate_yI+kvNvF", 284 | "remoteCandidateId": "RTCIceCandidate_kJy6c1E9", 285 | "state": "in-progress", 286 | "priority": 179896594039051780, 287 | "nominated": false, 288 | "writable": false, 289 | "bytesSent": 0, 290 | "bytesReceived": 0, 291 | "totalRoundTripTime": 0, 292 | "requestsReceived": 0, 293 | "requestsSent": 17, 294 | "responsesReceived": 0, 295 | "responsesSent": 0, 296 | "consentRequestsSent": 0 297 | } 298 | }, 299 | { 300 | "key": "RTCIceCandidate_WzsdBtXT", 301 | "value": { 302 | "id": "RTCIceCandidate_WzsdBtXT", 303 | "timestamp": 1571687926414.613, 304 | "type": "local-candidate", 305 | "transportId": "RTCTransport_audio_1", 306 | "isRemote": false, 307 | "networkType": "ethernet", 308 | "ip": "10.27.48.148", 309 | "port": 23033, 310 | "protocol": "udp", 311 | "candidateType": "prflx", 312 | "priority": 1853824767, 313 | "deleted": false 314 | } 315 | }, 316 | { 317 | "key": "RTCIceCandidate_kJy6c1E9", 318 | "value": { 319 | "id": "RTCIceCandidate_kJy6c1E9", 320 | "timestamp": 1571687926414.613, 321 | "type": "remote-candidate", 322 | "transportId": "RTCTransport_audio_1", 323 | "isRemote": true, 324 | "ip": "3.92.206.70", 325 | "port": 10000, 326 | "protocol": "udp", 327 | "candidateType": "srflx", 328 | "priority": 1677724415, 329 | "deleted": false 330 | } 331 | }, 332 | { 333 | "key": "RTCIceCandidate_nq8LUB9k", 334 | "value": { 335 | "id": "RTCIceCandidate_nq8LUB9k", 336 | "timestamp": 1571687926414.613, 337 | "type": "remote-candidate", 338 | "transportId": "RTCTransport_audio_1", 339 | "isRemote": true, 340 | "ip": "10.27.31.155", 341 | "port": 10000, 342 | "protocol": "udp", 343 | "candidateType": "host", 344 | "priority": 2130706431, 345 | "deleted": false 346 | } 347 | }, 348 | { 349 | "key": "RTCIceCandidate_yI+kvNvF", 350 | "value": { 351 | "id": "RTCIceCandidate_yI+kvNvF", 352 | "timestamp": 1571687926414.613, 353 | "type": "local-candidate", 354 | "transportId": "RTCTransport_audio_1", 355 | "isRemote": false, 356 | "networkType": "ethernet", 357 | "ip": "54.242.15.31", 358 | "port": 23033, 359 | "protocol": "udp", 360 | "relayProtocol": "udp", 361 | "candidateType": "relay", 362 | "priority": 41885439, 363 | "deleted": false 364 | } 365 | }, 366 | { 367 | "key": "RTCMediaStreamTrack_sender_1", 368 | "value": { 369 | "id": "RTCMediaStreamTrack_sender_1", 370 | "timestamp": 1571687926414.613, 371 | "type": "track", 372 | "trackIdentifier": "b5565a72-53af-43c5-9108-2de5c8a3f9ab", 373 | "mediaSourceId": "RTCAudioSource_1", 374 | "remoteSource": false, 375 | "ended": false, 376 | "detached": false, 377 | "kind": "audio" 378 | } 379 | }, 380 | { 381 | "key": "RTCMediaStreamTrack_sender_2", 382 | "value": { 383 | "id": "RTCMediaStreamTrack_sender_2", 384 | "timestamp": 1571687926414.613, 385 | "type": "track", 386 | "trackIdentifier": "44d76ce5-e573-4e5b-8bfe-79e5bb71ff14", 387 | "mediaSourceId": "RTCVideoSource_2", 388 | "remoteSource": false, 389 | "ended": false, 390 | "detached": false, 391 | "kind": "video", 392 | "frameWidth": 640, 393 | "frameHeight": 360, 394 | "framesSent": 288, 395 | "hugeFramesSent": 1 396 | } 397 | }, 398 | { 399 | "key": "RTCMediaStream_466fc587-c93d-407b-abbf-d4efa4ac0be6", 400 | "value": { 401 | "id": "RTCMediaStream_466fc587-c93d-407b-abbf-d4efa4ac0be6", 402 | "timestamp": 1571687926414.613, 403 | "type": "stream", 404 | "streamIdentifier": "466fc587-c93d-407b-abbf-d4efa4ac0be6", 405 | "trackIds": [ 406 | "RTCMediaStreamTrack_receiver_1", 407 | "RTCMediaStreamTrack_receiver_2" 408 | ] 409 | } 410 | }, 411 | { 412 | "key": "RTCMediaStream_tmNCW2EqWMSTEAYh3z2qql85GHYP2uXYxt8A", 413 | "value": { 414 | "id": "RTCMediaStream_tmNCW2EqWMSTEAYh3z2qql85GHYP2uXYxt8A", 415 | "timestamp": 1571687926414.613, 416 | "type": "stream", 417 | "streamIdentifier": "tmNCW2EqWMSTEAYh3z2qql85GHYP2uXYxt8A", 418 | "trackIds": [ 419 | "RTCMediaStreamTrack_sender_1", 420 | "RTCMediaStreamTrack_sender_2" 421 | ] 422 | } 423 | }, 424 | { 425 | "key": "RTCOutboundRTPAudioStream_545464236", 426 | "value": { 427 | "id": "RTCOutboundRTPAudioStream_545464236", 428 | "timestamp": 1571687926414.613, 429 | "type": "outbound-rtp", 430 | "ssrc": 545464236, 431 | "isRemote": false, 432 | "mediaType": "audio", 433 | "kind": "audio", 434 | "trackId": "RTCMediaStreamTrack_sender_1", 435 | "transportId": "RTCTransport_audio_1", 436 | "codecId": "RTCCodec_audio_Outbound_111", 437 | "mediaSourceId": "RTCAudioSource_1", 438 | "packetsSent": 481, 439 | "retransmittedPacketsSent": 0, 440 | "bytesSent": 41467, 441 | "retransmittedBytesSent": 0 442 | } 443 | }, 444 | { 445 | "key": "RTCOutboundRTPVideoStream_780297609", 446 | "value": { 447 | "id": "RTCOutboundRTPVideoStream_780297609", 448 | "timestamp": 1571687926414.613, 449 | "type": "outbound-rtp", 450 | "ssrc": 780297609, 451 | "isRemote": false, 452 | "mediaType": "video", 453 | "kind": "video", 454 | "trackId": "RTCMediaStreamTrack_sender_2", 455 | "transportId": "RTCTransport_audio_1", 456 | "codecId": "RTCCodec_video_Outbound_100", 457 | "firCount": 0, 458 | "pliCount": 0, 459 | "nackCount": 1, 460 | "qpSum": 17388, 461 | "mediaSourceId": "RTCVideoSource_2", 462 | "packetsSent": 366, 463 | "retransmittedPacketsSent": 1, 464 | "bytesSent": 295849, 465 | "retransmittedBytesSent": 760, 466 | "framesEncoded": 288, 467 | "keyFramesEncoded": 1, 468 | "totalEncodeTime": 0.537, 469 | "totalEncodedBytesTarget": 427706, 470 | "totalPacketSendDelay": 7.917, 471 | "qualityLimitationReason": "bandwidth" 472 | } 473 | }, 474 | { 475 | "key": "RTCPeerConnection", 476 | "value": { 477 | "id": "RTCPeerConnection", 478 | "timestamp": 1571687926414.613, 479 | "type": "peer-connection", 480 | "dataChannelsOpened": 1, 481 | "dataChannelsClosed": 0 482 | } 483 | }, 484 | { 485 | "key": "RTCTransport_audio_1", 486 | "value": { 487 | "id": "RTCTransport_audio_1", 488 | "timestamp": 1571687926414.613, 489 | "type": "transport", 490 | "bytesSent": 349198, 491 | "bytesReceived": 3907, 492 | "dtlsState": "connected", 493 | "selectedCandidatePairId": "RTCIceCandidatePair_WzsdBtXT_nq8LUB9k", 494 | "localCertificateId": "RTCCertificate_4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 495 | "remoteCertificateId": "RTCCertificate_DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23" 496 | } 497 | }, 498 | { 499 | "key": "RTCVideoSource_2", 500 | "value": { 501 | "id": "RTCVideoSource_2", 502 | "timestamp": 1571687926414.613, 503 | "type": "media-source", 504 | "trackIdentifier": "44d76ce5-e573-4e5b-8bfe-79e5bb71ff14", 505 | "kind": "video", 506 | "width": 1280, 507 | "height": 720, 508 | "framesPerSecond": 31 509 | } 510 | } 511 | ] -------------------------------------------------------------------------------- /test/mock-spec-stats-2.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "key": "RTCAudioSource_1", 4 | "value": { 5 | "id": "RTCAudioSource_1", 6 | "timestamp": 1571687966413.522, 7 | "type": "media-source", 8 | "trackIdentifier": "b5565a72-53af-43c5-9108-2de5c8a3f9ab", 9 | "kind": "audio", 10 | "audioLevel": 0.0008239997558519242, 11 | "totalAudioEnergy": 1.227674190176716, 12 | "totalSamplesDuration": 50.179999999998586 13 | } 14 | }, 15 | { 16 | "key": "RTCCertificate_4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 17 | "value": { 18 | "id": "RTCCertificate_4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 19 | "timestamp": 1571687966413.522, 20 | "type": "certificate", 21 | "fingerprint": "4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 22 | "fingerprintAlgorithm": "sha-256", 23 | "base64Certificate": "MIIBFjCBvaADAgECAgkA+u7UIbM5Bf8wCgYIKoZIzj0EAwIwETEPMA0GA1UEAwwGV2ViUlRDMB4XDTE5MTAyMDE5NTgzNloXDTE5MTEyMDE5NTgzNlowETEPMA0GA1UEAwwGV2ViUlRDMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEL+6mSh54eIwC43u2GTcTbDPEy6qs2Ju+q/VihDS51nXSmOlPIZTWPTsKZyztHaO0H4XXwScdrbTfwo1Xy4XfoDAKBggqhkjOPQQDAgNIADBFAiEAh6bFRu+g7t7xZutMvp98wtEPoxRDHtiNAeD8wte3q+UCIHgL8SqpANHKjiYxB6iW3zq6CbMvd9KvwAijYWBt6a/H" 24 | } 25 | }, 26 | { 27 | "key": "RTCCertificate_DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23", 28 | "value": { 29 | "id": "RTCCertificate_DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23", 30 | "timestamp": 1571687966413.522, 31 | "type": "certificate", 32 | "fingerprint": "DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23", 33 | "fingerprintAlgorithm": "sha-1", 34 | "base64Certificate": "MIIBsTCCARqgAwIBAgIGAW3vQVhGMA0GCSqGSIb3DQEBBQUAMBwxGjAYBgNVBAMMEUpWQiAwLjEuYnVpbGQuU1ZOMB4XDTE5MTAyMDE2NTgyMFoXDTE5MTAyODE2NTgyMFowHDEaMBgGA1UEAwwRSlZCIDAuMS5idWlsZC5TVk4wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAOD5A2Y2+Du6AldslGzwXHQjrddaiQyHK3REbvew1qHTivVclTq450nVMV6TeLqkVJujX1KHp5X4umkyYYBzHFZUFzFo76JpxxxutuAkhuoAoajEMgWybUcR/S2BOWYDah7tgfv23QDhzXUbPc0MwLmDWsf2l6nmlNb92SCKxWmZAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEAvS3p12Lf7pko6B8mlh8uj5V/1Anqly8g0CRLFE/DsaUFGU6AIDLNyS3T5pzbAFZmhJyyTfWo4RHlivZo+16VIrzMGacGt6gD8VaFifKXxRaSz/zaLgTasx1KiKJfUWjVn5cpND0HgYweVZygh/paFCGSw0viKXjDB1ciu1M40bA=" 35 | } 36 | }, 37 | { 38 | "key": "RTCCodec_audio_Inbound_0", 39 | "value": { 40 | "id": "RTCCodec_audio_Inbound_0", 41 | "timestamp": 1571687966413.522, 42 | "type": "codec", 43 | "payloadType": 0, 44 | "mimeType": "audio/PCMU", 45 | "clockRate": 8000 46 | } 47 | }, 48 | { 49 | "key": "RTCCodec_audio_Inbound_103", 50 | "value": { 51 | "id": "RTCCodec_audio_Inbound_103", 52 | "timestamp": 1571687966413.522, 53 | "type": "codec", 54 | "payloadType": 103, 55 | "mimeType": "audio/ISAC", 56 | "clockRate": 16000 57 | } 58 | }, 59 | { 60 | "key": "RTCCodec_audio_Inbound_104", 61 | "value": { 62 | "id": "RTCCodec_audio_Inbound_104", 63 | "timestamp": 1571687966413.522, 64 | "type": "codec", 65 | "payloadType": 104, 66 | "mimeType": "audio/ISAC", 67 | "clockRate": 32000 68 | } 69 | }, 70 | { 71 | "key": "RTCCodec_audio_Inbound_111", 72 | "value": { 73 | "id": "RTCCodec_audio_Inbound_111", 74 | "timestamp": 1571687966413.522, 75 | "type": "codec", 76 | "payloadType": 111, 77 | "mimeType": "audio/opus", 78 | "clockRate": 48000 79 | } 80 | }, 81 | { 82 | "key": "RTCCodec_audio_Inbound_8", 83 | "value": { 84 | "id": "RTCCodec_audio_Inbound_8", 85 | "timestamp": 1571687966413.522, 86 | "type": "codec", 87 | "payloadType": 8, 88 | "mimeType": "audio/PCMA", 89 | "clockRate": 8000 90 | } 91 | }, 92 | { 93 | "key": "RTCCodec_audio_Inbound_9", 94 | "value": { 95 | "id": "RTCCodec_audio_Inbound_9", 96 | "timestamp": 1571687966413.522, 97 | "type": "codec", 98 | "payloadType": 9, 99 | "mimeType": "audio/G722", 100 | "clockRate": 8000 101 | } 102 | }, 103 | { 104 | "key": "RTCCodec_audio_Outbound_0", 105 | "value": { 106 | "id": "RTCCodec_audio_Outbound_0", 107 | "timestamp": 1571687966413.522, 108 | "type": "codec", 109 | "payloadType": 0, 110 | "mimeType": "audio/PCMU", 111 | "clockRate": 8000 112 | } 113 | }, 114 | { 115 | "key": "RTCCodec_audio_Outbound_103", 116 | "value": { 117 | "id": "RTCCodec_audio_Outbound_103", 118 | "timestamp": 1571687966413.522, 119 | "type": "codec", 120 | "payloadType": 103, 121 | "mimeType": "audio/ISAC", 122 | "clockRate": 16000 123 | } 124 | }, 125 | { 126 | "key": "RTCCodec_audio_Outbound_104", 127 | "value": { 128 | "id": "RTCCodec_audio_Outbound_104", 129 | "timestamp": 1571687966413.522, 130 | "type": "codec", 131 | "payloadType": 104, 132 | "mimeType": "audio/ISAC", 133 | "clockRate": 32000 134 | } 135 | }, 136 | { 137 | "key": "RTCCodec_audio_Outbound_111", 138 | "value": { 139 | "id": "RTCCodec_audio_Outbound_111", 140 | "timestamp": 1571687966413.522, 141 | "type": "codec", 142 | "payloadType": 111, 143 | "mimeType": "audio/opus", 144 | "clockRate": 48000 145 | } 146 | }, 147 | { 148 | "key": "RTCCodec_audio_Outbound_8", 149 | "value": { 150 | "id": "RTCCodec_audio_Outbound_8", 151 | "timestamp": 1571687966413.522, 152 | "type": "codec", 153 | "payloadType": 8, 154 | "mimeType": "audio/PCMA", 155 | "clockRate": 8000 156 | } 157 | }, 158 | { 159 | "key": "RTCCodec_audio_Outbound_9", 160 | "value": { 161 | "id": "RTCCodec_audio_Outbound_9", 162 | "timestamp": 1571687966413.522, 163 | "type": "codec", 164 | "payloadType": 9, 165 | "mimeType": "audio/G722", 166 | "clockRate": 8000 167 | } 168 | }, 169 | { 170 | "key": "RTCCodec_video_Inbound_100", 171 | "value": { 172 | "id": "RTCCodec_video_Inbound_100", 173 | "timestamp": 1571687966413.522, 174 | "type": "codec", 175 | "payloadType": 100, 176 | "mimeType": "video/VP8", 177 | "clockRate": 90000 178 | } 179 | }, 180 | { 181 | "key": "RTCCodec_video_Inbound_116", 182 | "value": { 183 | "id": "RTCCodec_video_Inbound_116", 184 | "timestamp": 1571687966413.522, 185 | "type": "codec", 186 | "payloadType": 116, 187 | "mimeType": "video/red", 188 | "clockRate": 90000 189 | } 190 | }, 191 | { 192 | "key": "RTCCodec_video_Inbound_117", 193 | "value": { 194 | "id": "RTCCodec_video_Inbound_117", 195 | "timestamp": 1571687966413.522, 196 | "type": "codec", 197 | "payloadType": 117, 198 | "mimeType": "video/ulpfec", 199 | "clockRate": 90000 200 | } 201 | }, 202 | { 203 | "key": "RTCCodec_video_Outbound_100", 204 | "value": { 205 | "id": "RTCCodec_video_Outbound_100", 206 | "timestamp": 1571687966413.522, 207 | "type": "codec", 208 | "payloadType": 100, 209 | "mimeType": "video/VP8", 210 | "clockRate": 90000 211 | } 212 | }, 213 | { 214 | "key": "RTCCodec_video_Outbound_116", 215 | "value": { 216 | "id": "RTCCodec_video_Outbound_116", 217 | "timestamp": 1571687966413.522, 218 | "type": "codec", 219 | "payloadType": 116, 220 | "mimeType": "video/red", 221 | "clockRate": 90000 222 | } 223 | }, 224 | { 225 | "key": "RTCCodec_video_Outbound_117", 226 | "value": { 227 | "id": "RTCCodec_video_Outbound_117", 228 | "timestamp": 1571687966413.522, 229 | "type": "codec", 230 | "payloadType": 117, 231 | "mimeType": "video/ulpfec", 232 | "clockRate": 90000 233 | } 234 | }, 235 | { 236 | "key": "RTCDataChannel_0", 237 | "value": { 238 | "id": "RTCDataChannel_0", 239 | "timestamp": 1571687966413.522, 240 | "type": "data-channel", 241 | "label": "default", 242 | "protocol": "http://jitsi.org/protocols/colibri", 243 | "datachannelid": 0, 244 | "state": "open", 245 | "messagesSent": 7, 246 | "bytesSent": 1460, 247 | "messagesReceived": 8, 248 | "bytesReceived": 1505 249 | } 250 | }, 251 | { 252 | "key": "RTCIceCandidatePair_WzsdBtXT_nq8LUB9k", 253 | "value": { 254 | "id": "RTCIceCandidatePair_WzsdBtXT_nq8LUB9k", 255 | "timestamp": 1571687966413.522, 256 | "type": "candidate-pair", 257 | "transportId": "RTCTransport_audio_1", 258 | "localCandidateId": "RTCIceCandidate_WzsdBtXT", 259 | "remoteCandidateId": "RTCIceCandidate_nq8LUB9k", 260 | "state": "succeeded", 261 | "priority": 7962116751041233000, 262 | "nominated": true, 263 | "writable": true, 264 | "bytesSent": 3224052, 265 | "bytesReceived": 7881226, 266 | "totalRoundTripTime": 0.654, 267 | "currentRoundTripTime": 0.026, 268 | "availableOutgoingBitrate": 1777375, 269 | "availableIncomingBitrate": 3802216, 270 | "requestsReceived": 20, 271 | "requestsSent": 1, 272 | "responsesReceived": 24, 273 | "responsesSent": 20, 274 | "consentRequestsSent": 23 275 | } 276 | }, 277 | { 278 | "key": "RTCIceCandidate_WzsdBtXT", 279 | "value": { 280 | "id": "RTCIceCandidate_WzsdBtXT", 281 | "timestamp": 1571687966413.522, 282 | "type": "local-candidate", 283 | "transportId": "RTCTransport_audio_1", 284 | "isRemote": false, 285 | "networkType": "ethernet", 286 | "ip": "10.27.48.148", 287 | "port": 23033, 288 | "protocol": "udp", 289 | "candidateType": "prflx", 290 | "priority": 1853824767, 291 | "deleted": false 292 | } 293 | }, 294 | { 295 | "key": "RTCIceCandidate_nq8LUB9k", 296 | "value": { 297 | "id": "RTCIceCandidate_nq8LUB9k", 298 | "timestamp": 1571687966413.522, 299 | "type": "remote-candidate", 300 | "transportId": "RTCTransport_audio_1", 301 | "isRemote": true, 302 | "ip": "10.27.31.155", 303 | "port": 10000, 304 | "protocol": "udp", 305 | "candidateType": "host", 306 | "priority": 2130706431, 307 | "deleted": false 308 | } 309 | }, 310 | { 311 | "key": "RTCInboundRTPAudioStream_3047098519", 312 | "value": { 313 | "id": "RTCInboundRTPAudioStream_3047098519", 314 | "timestamp": 1571687966413.522, 315 | "type": "inbound-rtp", 316 | "ssrc": 3047098519, 317 | "isRemote": false, 318 | "mediaType": "audio", 319 | "kind": "audio", 320 | "trackId": "RTCMediaStreamTrack_receiver_1", 321 | "transportId": "RTCTransport_audio_1", 322 | "codecId": "RTCCodec_audio_Inbound_111", 323 | "packetsReceived": 1400, 324 | "bytesReceived": 118898, 325 | "packetsLost": 1, 326 | "lastPacketReceivedTimestamp": 260348.596, 327 | "jitter": 0.002 328 | } 329 | }, 330 | { 331 | "key": "RTCInboundRTPVideoStream_1043752814", 332 | "value": { 333 | "id": "RTCInboundRTPVideoStream_1043752814", 334 | "timestamp": 1571687966413.522, 335 | "type": "inbound-rtp", 336 | "ssrc": 1043752814, 337 | "isRemote": false, 338 | "mediaType": "video", 339 | "kind": "video", 340 | "trackId": "RTCMediaStreamTrack_receiver_2", 341 | "transportId": "RTCTransport_audio_1", 342 | "codecId": "RTCCodec_video_Inbound_100", 343 | "firCount": 0, 344 | "pliCount": 1, 345 | "nackCount": 1, 346 | "qpSum": 20207, 347 | "packetsReceived": 6905, 348 | "bytesReceived": 7637399, 349 | "packetsLost": 18, 350 | "lastPacketReceivedTimestamp": 260348.613, 351 | "framesDecoded": 833, 352 | "keyFramesDecoded": 3, 353 | "totalDecodeTime": 3.16 354 | } 355 | }, 356 | { 357 | "key": "RTCMediaStreamTrack_receiver_1", 358 | "value": { 359 | "id": "RTCMediaStreamTrack_receiver_1", 360 | "timestamp": 1571687966413.522, 361 | "type": "track", 362 | "trackIdentifier": "c6e25c3b-6ab3-4a84-8469-2fec574ffc94", 363 | "remoteSource": true, 364 | "ended": true, 365 | "detached": false, 366 | "kind": "audio", 367 | "jitterBufferDelay": 56294.4, 368 | "jitterBufferEmittedCount": 1343040, 369 | "audioLevel": 0.0008239997558519242, 370 | "totalAudioEnergy": 2.077036001267227, 371 | "totalSamplesReceived": 1343040, 372 | "totalSamplesDuration": 27.980000000001574, 373 | "concealedSamples": 446, 374 | "silentConcealedSamples": 0, 375 | "concealmentEvents": 1, 376 | "insertedSamplesForDeceleration": 1171, 377 | "removedSamplesForAcceleration": 949 378 | } 379 | }, 380 | { 381 | "key": "RTCMediaStreamTrack_receiver_2", 382 | "value": { 383 | "id": "RTCMediaStreamTrack_receiver_2", 384 | "timestamp": 1571687966413.522, 385 | "type": "track", 386 | "trackIdentifier": "83d46c2c-5348-4902-a26a-d405c8e968de", 387 | "remoteSource": true, 388 | "ended": false, 389 | "detached": false, 390 | "kind": "video", 391 | "jitterBufferDelay": 30.04, 392 | "jitterBufferEmittedCount": 832, 393 | "frameWidth": 1280, 394 | "frameHeight": 720, 395 | "framesReceived": 836, 396 | "framesDecoded": 833, 397 | "framesDropped": 3 398 | } 399 | }, 400 | { 401 | "key": "RTCMediaStreamTrack_sender_1", 402 | "value": { 403 | "id": "RTCMediaStreamTrack_sender_1", 404 | "timestamp": 1571687966413.522, 405 | "type": "track", 406 | "trackIdentifier": "b5565a72-53af-43c5-9108-2de5c8a3f9ab", 407 | "mediaSourceId": "RTCAudioSource_1", 408 | "remoteSource": false, 409 | "ended": false, 410 | "detached": false, 411 | "kind": "audio", 412 | "echoReturnLoss": -100, 413 | "echoReturnLossEnhancement": 0.18 414 | } 415 | }, 416 | { 417 | "key": "RTCMediaStreamTrack_sender_2", 418 | "value": { 419 | "id": "RTCMediaStreamTrack_sender_2", 420 | "timestamp": 1571687966413.522, 421 | "type": "track", 422 | "trackIdentifier": "44d76ce5-e573-4e5b-8bfe-79e5bb71ff14", 423 | "mediaSourceId": "RTCVideoSource_2", 424 | "remoteSource": false, 425 | "ended": false, 426 | "detached": false, 427 | "kind": "video", 428 | "frameWidth": 1280, 429 | "frameHeight": 720, 430 | "framesSent": 1489, 431 | "hugeFramesSent": 3 432 | } 433 | }, 434 | { 435 | "key": "RTCMediaStream_1c7msKqE3Egj7PoeyC20HkSzkHg1Y9cXUAMk", 436 | "value": { 437 | "id": "RTCMediaStream_1c7msKqE3Egj7PoeyC20HkSzkHg1Y9cXUAMk", 438 | "timestamp": 1571687966413.522, 439 | "type": "stream", 440 | "streamIdentifier": "1c7msKqE3Egj7PoeyC20HkSzkHg1Y9cXUAMk", 441 | "trackIds": [ 442 | "RTCMediaStreamTrack_receiver_1", 443 | "RTCMediaStreamTrack_receiver_2" 444 | ] 445 | } 446 | }, 447 | { 448 | "key": "RTCMediaStream_tmNCW2EqWMSTEAYh3z2qql85GHYP2uXYxt8A", 449 | "value": { 450 | "id": "RTCMediaStream_tmNCW2EqWMSTEAYh3z2qql85GHYP2uXYxt8A", 451 | "timestamp": 1571687966413.522, 452 | "type": "stream", 453 | "streamIdentifier": "tmNCW2EqWMSTEAYh3z2qql85GHYP2uXYxt8A", 454 | "trackIds": [ 455 | "RTCMediaStreamTrack_sender_1", 456 | "RTCMediaStreamTrack_sender_2" 457 | ] 458 | } 459 | }, 460 | { 461 | "key": "RTCOutboundRTPAudioStream_545464236", 462 | "value": { 463 | "id": "RTCOutboundRTPAudioStream_545464236", 464 | "timestamp": 1571687966413.522, 465 | "type": "outbound-rtp", 466 | "ssrc": 545464236, 467 | "isRemote": false, 468 | "mediaType": "audio", 469 | "kind": "audio", 470 | "trackId": "RTCMediaStreamTrack_sender_1", 471 | "transportId": "RTCTransport_audio_1", 472 | "codecId": "RTCCodec_audio_Outbound_111", 473 | "mediaSourceId": "RTCAudioSource_1", 474 | "packetsSent": 2481, 475 | "retransmittedPacketsSent": 18, 476 | "bytesSent": 210799, 477 | "retransmittedBytesSent": 0 478 | } 479 | }, 480 | { 481 | "key": "RTCOutboundRTPVideoStream_780297609", 482 | "value": { 483 | "id": "RTCOutboundRTPVideoStream_780297609", 484 | "timestamp": 1571687966413.522, 485 | "type": "outbound-rtp", 486 | "ssrc": 780297609, 487 | "isRemote": false, 488 | "mediaType": "video", 489 | "kind": "video", 490 | "trackId": "RTCMediaStreamTrack_sender_2", 491 | "transportId": "RTCTransport_audio_1", 492 | "codecId": "RTCCodec_video_Outbound_100", 493 | "firCount": 0, 494 | "pliCount": 1, 495 | "nackCount": 2, 496 | "qpSum": 70243, 497 | "mediaSourceId": "RTCVideoSource_2", 498 | "packetsSent": 3181, 499 | "retransmittedPacketsSent": 3, 500 | "bytesSent": 2934947, 501 | "retransmittedBytesSent": 2163, 502 | "framesEncoded": 1489, 503 | "keyFramesEncoded": 4, 504 | "totalEncodeTime": 3.425, 505 | "totalEncodedBytesTarget": 3379790, 506 | "totalPacketSendDelay": 84.162, 507 | "qualityLimitationReason": "none" 508 | } 509 | }, 510 | { 511 | "key": "RTCPeerConnection", 512 | "value": { 513 | "id": "RTCPeerConnection", 514 | "timestamp": 1571687966413.522, 515 | "type": "peer-connection", 516 | "dataChannelsOpened": 1, 517 | "dataChannelsClosed": 0 518 | } 519 | }, 520 | { 521 | "key": "RTCRemoteInboundRtpAudioStream_545464236", 522 | "value": { 523 | "id": "RTCRemoteInboundRtpAudioStream_545464236", 524 | "timestamp": 1571687960465.791, 525 | "type": "remote-inbound-rtp", 526 | "ssrc": 545464236, 527 | "kind": "audio", 528 | "transportId": "RTCTransport_audio_1", 529 | "codecId": "RTCCodec_audio_Outbound_111", 530 | "packetsLost": 1, 531 | "jitter": 0.0017708333333333332, 532 | "localId": "RTCOutboundRTPAudioStream_545464236", 533 | "roundTripTime": 0.052 534 | } 535 | }, 536 | { 537 | "key": "RTCRemoteInboundRtpVideoStream_780297609", 538 | "value": { 539 | "id": "RTCRemoteInboundRtpVideoStream_780297609", 540 | "timestamp": 1571687966353.445, 541 | "type": "remote-inbound-rtp", 542 | "ssrc": 780297609, 543 | "kind": "video", 544 | "transportId": "RTCTransport_audio_1", 545 | "codecId": "RTCCodec_video_Outbound_100", 546 | "packetsLost": 18, 547 | "jitter": 0.008344444444444444, 548 | "localId": "RTCOutboundRTPVideoStream_780297609", 549 | "roundTripTime": 0.053 550 | } 551 | }, 552 | { 553 | "key": "RTCTransport_audio_1", 554 | "value": { 555 | "id": "RTCTransport_audio_1", 556 | "timestamp": 1571687966413.522, 557 | "type": "transport", 558 | "bytesSent": 3224052, 559 | "bytesReceived": 7881226, 560 | "dtlsState": "connected", 561 | "selectedCandidatePairId": "RTCIceCandidatePair_WzsdBtXT_nq8LUB9k", 562 | "localCertificateId": "RTCCertificate_4A:9B:4E:5E:A6:34:A6:CF:EE:1E:FE:1D:16:9E:2C:AD:55:36:E4:F0:D4:89:8C:DA:F0:AC:DB:24:29:1A:63:D8", 563 | "remoteCertificateId": "RTCCertificate_DC:B5:07:DE:B1:97:30:F7:B8:A8:A7:7F:64:2B:CE:32:9C:1A:11:23" 564 | } 565 | }, 566 | { 567 | "key": "RTCVideoSource_2", 568 | "value": { 569 | "id": "RTCVideoSource_2", 570 | "timestamp": 1571687966413.522, 571 | "type": "media-source", 572 | "trackIdentifier": "44d76ce5-e573-4e5b-8bfe-79e5bb71ff14", 573 | "kind": "video", 574 | "width": 1280, 575 | "height": 720, 576 | "framesPerSecond": 31 577 | } 578 | } 579 | ] -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { FailureEvent, GetStatsEvent, StatsConnectEvent, TrackStats } from './interfaces'; 3 | 4 | export * from './interfaces'; 5 | 6 | let IS_BROWSER; 7 | 8 | const MAX_CANDIDATE_WAIT_ATTEMPTS = 3; 9 | 10 | export interface StatsGathererOpts { 11 | session?: string; // sessionId 12 | initiator?: string; 13 | conference?: string; // conversationId 14 | interval?: number; 15 | logger?: { error(...any); warn(...any) }; 16 | } 17 | 18 | export default class StatsGatherer extends EventEmitter { 19 | private session: string; 20 | private initiator: string; 21 | private conference: string; 22 | 23 | private statsInterval: number; 24 | private pollingInterval: number; 25 | 26 | /* eslint-disable @typescript-eslint/no-explicit-any */ 27 | private lastResult: Array<{ key: RTCStatsType; value: any }>; 28 | private lastActiveLocalCandidate: any; 29 | private lastActiveRemoteCandidate: any; 30 | /* eslint-enable @typescript-eslint/no-explicit-any */ 31 | 32 | private haveConnectionMetrics = false; 33 | private iceStartTime: number; 34 | private iceFailedTime: number; 35 | private iceConnectionTime: number; 36 | 37 | private logger: { error(...any); warn(...any) }; 38 | 39 | private statsArr: Array = []; 40 | 41 | constructor( 42 | public peerConnection: RTCPeerConnection, 43 | opts: StatsGathererOpts = {}, 44 | ) { 45 | super(); 46 | IS_BROWSER = typeof window !== 'undefined'; 47 | 48 | this.session = opts.session; 49 | this.initiator = opts.initiator; 50 | this.conference = opts.conference; 51 | 52 | this.statsInterval = (opts.interval || 5) * 1000; 53 | 54 | this.logger = opts.logger || console; 55 | 56 | if (['new', 'checking'].includes(peerConnection.iceConnectionState)) { 57 | if (peerConnection.iceConnectionState === 'checking') { 58 | this.logger.warn(`iceConnectionState is already in checking state so ice connect time may not be accurate`); 59 | this.handleIceStateChange(); 60 | } 61 | 62 | peerConnection.addEventListener('iceconnectionstatechange', this.handleIceStateChange.bind(this)); 63 | } 64 | 65 | peerConnection.addEventListener('connectionstatechange', this.handleConnectionStateChange.bind(this)); 66 | if (peerConnection.connectionState === 'connected') { 67 | this.pollForStats(); 68 | } 69 | } 70 | 71 | private handleIceStateChange() { 72 | const state = this.peerConnection.iceConnectionState; 73 | 74 | if (state === 'checking') { 75 | if (IS_BROWSER) { 76 | this.iceStartTime = window.performance.now(); 77 | } 78 | } 79 | 80 | if (state === 'connected') { 81 | if (this.haveConnectionMetrics) { 82 | return; 83 | } 84 | 85 | this.haveConnectionMetrics = true; 86 | let userAgent; 87 | let platform; 88 | let cores; 89 | if (IS_BROWSER) { 90 | this.iceConnectionTime = window.performance.now() - this.iceStartTime; 91 | userAgent = window.navigator.userAgent; 92 | platform = window.navigator.platform; 93 | cores = window.navigator.hardwareConcurrency; 94 | } 95 | 96 | const event: StatsConnectEvent = { 97 | name: 'connect', 98 | userAgent, 99 | platform, 100 | cores, 101 | session: this.session, 102 | conference: this.conference, 103 | connectTime: this.iceConnectionTime, 104 | }; 105 | 106 | return this.waitForSelectedCandidatePair().then((stats) => { 107 | this.gatherSelectedCandidateInfo(stats, event); 108 | this.emit('stats', event); 109 | }); 110 | } 111 | 112 | if (state === 'failed') { 113 | if (IS_BROWSER) { 114 | this.iceFailedTime = window.performance.now() - this.iceStartTime; 115 | } 116 | return this.gatherStats().then(() => { 117 | const event: FailureEvent = { 118 | name: 'failure', 119 | session: this.session, 120 | initiator: this.initiator, 121 | conference: this.conference, 122 | failTime: this.iceFailedTime, 123 | iceRW: 0, 124 | numLocalHostCandidates: 0, 125 | numLocalSrflxCandidates: 0, 126 | numLocalRelayCandidates: 0, 127 | numRemoteHostCandidates: 0, 128 | numRemoteSrflxCandidates: 0, 129 | numRemoteRelayCandidates: 0, 130 | }; 131 | 132 | const localCandidates = this.peerConnection.localDescription.sdp.split('\r\n').filter(function (line) { 133 | return line.indexOf('a=candidate:') > -1; 134 | }); 135 | const remoteCandidates = this.peerConnection.remoteDescription.sdp.split('\r\n').filter(function (line) { 136 | return line.indexOf('a=candidate:') > -1; 137 | }); 138 | 139 | ['Host', 'Srflx', 'Relay'].forEach(function (type) { 140 | event['numLocal' + type + 'Candidates'] = localCandidates.filter(function (line) { 141 | return line.split(' ')[7] === type.toLowerCase(); 142 | }).length; 143 | event['numRemote' + type + 'Candidates'] = remoteCandidates.filter(function (line) { 144 | return line.split(' ')[7] === type.toLowerCase(); 145 | }).length; 146 | }); 147 | 148 | this.emit('stats', event); 149 | }); 150 | } 151 | } 152 | 153 | private waitForSelectedCandidatePair(delay = 300, attempt = 1) { 154 | return this.gatherStats().then((reports) => { 155 | if (!this.getSelectedCandidatePair(reports)) { 156 | if (attempt > MAX_CANDIDATE_WAIT_ATTEMPTS) { 157 | return Promise.reject(new Error('Max wait attempts for connected candidate info reached')); 158 | } 159 | 160 | return new Promise((resolve, reject) => { 161 | setTimeout(() => this.waitForSelectedCandidatePair(delay, attempt + 1).then(resolve, reject), delay); 162 | }); 163 | } else { 164 | return reports; 165 | } 166 | }); 167 | } 168 | 169 | private getSelectedCandidatePair(reports) { 170 | let activeCandidatePair = null; 171 | reports.forEach(function ({ value }) { 172 | const report = value; 173 | const selected = report.type === 'candidate-pair' && report.nominated && report.state === 'succeeded'; 174 | 175 | if (selected) { 176 | activeCandidatePair = report; 177 | } 178 | }); 179 | 180 | return activeCandidatePair; 181 | } 182 | 183 | private gatherSelectedCandidateInfo(reports, event) { 184 | const activeCandidatePair = this.getSelectedCandidatePair(reports); 185 | 186 | if (activeCandidatePair) { 187 | const localId = activeCandidatePair.localCandidateId; 188 | const remoteId = activeCandidatePair.remoteCandidateId; 189 | let localCandidate; 190 | let remoteCandidate; 191 | 192 | reports.forEach(function ({ value }) { 193 | const report = value; 194 | if (localId && report.type === 'local-candidate' && report.id === localId) { 195 | localCandidate = report; 196 | event.localCandidateType = report.candidateType; 197 | } 198 | 199 | if (remoteId && report.type === 'remote-candidate' && report.id === remoteId) { 200 | remoteCandidate = report; 201 | event.remoteCandidateType = report.candidateType; 202 | } 203 | }); 204 | 205 | if (localCandidate && remoteCandidate) { 206 | event.candidatePair = localCandidate.candidateType + ';' + remoteCandidate.candidateType; 207 | event.candidatePairDetails = { 208 | local: localCandidate, 209 | remote: remoteCandidate, 210 | pair: activeCandidatePair, 211 | }; 212 | } 213 | 214 | if (localCandidate) { 215 | event.transport = localCandidate.transport || localCandidate.protocol; 216 | if (localCandidate.priority) { 217 | // Chrome-specific mapping; 218 | // but only chrome has priority set on the candidate currently. 219 | const turnTypes = { 220 | 2: 'udp', 221 | 1: 'tcp', 222 | 0: 'tls', 223 | }; 224 | 225 | const priority = parseInt(localCandidate.priority, 10); 226 | event.turnType = turnTypes[priority >> 24]; 227 | event.networkType = localCandidate.networkType; 228 | } 229 | 230 | event.usingIPv6 = localCandidate.ipAddress && localCandidate.ipAddress.indexOf('[') === 0; 231 | } 232 | } 233 | } 234 | 235 | private async handleConnectionStateChange() { 236 | const state = this.peerConnection.connectionState; 237 | 238 | if (state === 'connected') { 239 | this.pollForStats(); 240 | } else if (state === 'disconnected') { 241 | if (this.peerConnection.signalingState !== 'stable') { 242 | return; 243 | } 244 | 245 | return this.gatherStats().then((reports) => { 246 | const event = this.createStatsReport(reports); 247 | event.type = 'disconnected'; 248 | this.emit('stats', event); 249 | }); 250 | } else if (['closed', 'failed'].includes(state) && this.pollingInterval) { 251 | if (IS_BROWSER) { 252 | window.clearInterval(this.pollingInterval); 253 | } 254 | this.pollingInterval = null; 255 | } 256 | } 257 | 258 | private pollForStats() { 259 | if (this.pollingInterval) { 260 | return; 261 | } 262 | 263 | const statsPoll = () => { 264 | return this.gatherStats().then((reports) => { 265 | if (reports.length === 0) { 266 | this.logger.warn('Empty stats gathered, ignoring and not emitting stats'); 267 | return; 268 | } 269 | 270 | const event = this.createStatsReport(reports, true); 271 | if (event.tracks.length > 0 || event.remoteTracks.length > 0) { 272 | // If the last five stat events have a remote bitrate of 0, stop emitting. 273 | if (this.checkBitrate(event)) { 274 | this.emit('stats', event); 275 | } 276 | } 277 | }); 278 | }; 279 | 280 | if (IS_BROWSER) { 281 | window.setTimeout(statsPoll, 0); 282 | this.pollingInterval = window.setInterval(statsPoll, this.statsInterval); 283 | } 284 | } 285 | 286 | private checkBitrate(stat) { 287 | // If the stat does not have a bitrate of zero, automatically emit and clear the array. 288 | if (stat.remoteTracks.length && stat.remoteTracks[0]?.bitrate !== 0) { 289 | this.statsArr = []; 290 | return true; 291 | } 292 | 293 | // If we get five consecutive stats with zero bitrate, stop emitting. 294 | if (this.statsArr.length >= 5) { 295 | return false; 296 | } 297 | 298 | // Record stat with zero bitrate to array. 299 | this.statsArr.push(stat); 300 | return true; 301 | } 302 | 303 | private polyFillStats(results: RTCStatsReport): Array<{ key: RTCStatsType; value: unknown }> { 304 | if (!results) { 305 | return []; 306 | } 307 | 308 | if (Array.isArray(results)) { 309 | return results; 310 | } 311 | 312 | const betterResults = []; 313 | 314 | if (this.isNativeStatsReport(results)) { 315 | results.forEach((value, key) => { 316 | betterResults.push({ key, value }); 317 | }); 318 | } else if (Object.keys(results).length > 0) { 319 | Object.keys(results).forEach((key) => { 320 | betterResults.push({ 321 | key, 322 | value: results[key], 323 | }); 324 | }); 325 | } else { 326 | this.logger.warn('Unknown stats results format, returning unmodified', results); 327 | return []; 328 | } 329 | return betterResults as Array<{ key: RTCStatsType; value: unknown }>; 330 | } 331 | 332 | private isNativeStatsReport(results: RTCStatsReport) { 333 | return typeof window.RTCStatsReport !== 'undefined' && results instanceof window.RTCStatsReport; 334 | } 335 | 336 | private async gatherStats(): Promise> { 337 | try { 338 | if (['connecting', 'connected'].includes(this.peerConnection.connectionState)) { 339 | const stats = await this.peerConnection.getStats(null).then(this.polyFillStats.bind(this)); 340 | return stats; 341 | } else if (this.peerConnection.connectionState === 'closed') { 342 | if (this.pollingInterval) { 343 | if (IS_BROWSER) { 344 | window.clearInterval(this.pollingInterval); 345 | } 346 | this.pollingInterval = null; 347 | } 348 | return []; 349 | } else { 350 | return []; 351 | } 352 | } catch (e) { 353 | this.logger.error( 354 | 'Failed to gather stats. Are you using RTCPeerConnection as your connection? {expect peerconnection.getStats}', 355 | { peerConnection: this.peerConnection, err: e }, 356 | ); 357 | return Promise.reject(e); 358 | } 359 | } 360 | 361 | /* eslint-disable @typescript-eslint/no-explicit-any */ 362 | private createStatsReport( 363 | results: Array<{ key: RTCStatsType; value: any }>, 364 | updateLastResult: boolean = true, 365 | ): GetStatsEvent { 366 | /* eslint-enable @typescript-eslint/no-explicit-any */ 367 | const event: GetStatsEvent = { 368 | name: 'getStats', 369 | session: this.session, 370 | initiator: this.initiator, 371 | conference: this.conference, 372 | tracks: [], 373 | remoteTracks: [], 374 | }; 375 | 376 | if (results.length === 0) { 377 | return event; 378 | } 379 | 380 | const sources = results.filter((r) => ['inbound-rtp', 'outbound-rtp'].indexOf(r.value.type) > -1); 381 | 382 | sources.forEach((source) => { 383 | this.processSource({ 384 | source: source.value, 385 | event, 386 | results, 387 | }); 388 | }); 389 | 390 | const candidatePair = results.find( 391 | (r) => r.value.type === 'candidate-pair' && r.value.state === 'succeeded' && r.value.nominated === true, 392 | ); 393 | if (candidatePair) { 394 | this.processSelectedCandidatePair({ 395 | report: candidatePair.value, 396 | event, 397 | results, 398 | }); 399 | } 400 | 401 | if (updateLastResult) { 402 | this.lastResult = results; 403 | } 404 | 405 | return event; 406 | } 407 | 408 | // todo source should be RTCInboundRTPStreamStats | RTCOutboundRTPStreamStats but the lib.dom definitions are out of date or not accurate 409 | /* eslint-disable @typescript-eslint/no-explicit-any */ 410 | private processSource({ 411 | source, 412 | results, 413 | event, 414 | }: { 415 | source: any; 416 | results: Array<{ key: RTCStatsType; value: any }>; 417 | event: GetStatsEvent; 418 | }) { 419 | /* eslint-enable @typescript-eslint/no-explicit-any */ 420 | const now = new Date(source.timestamp); 421 | 422 | // todo lastResultSource should be RTCInboundRTPStreamStats | RTCOutboundRTPStreamStats 423 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 424 | let lastResultSource: any = this.lastResult && this.lastResult.find((r) => r.key === source.id); 425 | lastResultSource = lastResultSource && lastResultSource.value; 426 | 427 | let lastResultRemoteSource; 428 | if (lastResultSource) { 429 | lastResultRemoteSource = this.lastResult && this.lastResult.find((r) => r.value.localId === lastResultSource.id); 430 | lastResultRemoteSource = lastResultRemoteSource && lastResultRemoteSource.value; 431 | } 432 | 433 | // for outbound-rtp, the correspondingRemoteSource will be remote-inbound-rtp 434 | // for inbound-rtp, the correspondingRemoteSource will be remote-outbound-rtp 435 | let correspondingRemoteSource; 436 | let transport; 437 | let candidatePair; 438 | let track; 439 | let mediaSource; 440 | let codec; 441 | 442 | results.forEach((r) => { 443 | if (r.value.localId === source.id) { 444 | correspondingRemoteSource = r.value; 445 | } else if (r.key === source.transportId) { 446 | transport = r.value; 447 | } else if (r.key === source.trackId) { 448 | track = r.value; 449 | } else if (r.key === source.mediaSourceId) { 450 | mediaSource = r.value; 451 | } else if (r.key === source.codecId) { 452 | codec = r.value; 453 | } 454 | }); 455 | if (transport) { 456 | candidatePair = results.find((r) => r.key === transport.selectedCandidatePairId); 457 | candidatePair = candidatePair && candidatePair.value; 458 | } 459 | 460 | if (candidatePair) { 461 | event.candidatePairHadActiveSource = true; 462 | } 463 | 464 | const kind = source.kind || source.mediaType; 465 | const isOutbound = source.type === 'outbound-rtp'; 466 | const trackInfo: TrackStats = { 467 | track: track && track.trackIdentifier, 468 | kind, 469 | jitter: getDefinedValue('jitter', source, correspondingRemoteSource), 470 | roundTripTime: getDefinedValue('roundTripTime', source, correspondingRemoteSource), 471 | packetsLost: getDefinedValue('packetsLost', source, correspondingRemoteSource) || 0, 472 | packetLoss: 0, 473 | bytes: parseInt(isOutbound ? source.bytesSent : source.bytesReceived, 10) || 0, 474 | }; 475 | 476 | if (codec) { 477 | trackInfo.codec = `${codec.payloadType} ${codec.mimeType} ${codec.clockRate}`; 478 | } 479 | 480 | if (lastResultSource) { 481 | const previousBytesTotal = 482 | parseInt(isOutbound ? lastResultSource.bytesSent : lastResultSource.bytesReceived, 10) || 0; 483 | const deltaTime = now.getTime() - new Date(lastResultSource.timestamp).getTime(); 484 | trackInfo.bitrate = Math.floor((8 * (trackInfo.bytes - previousBytesTotal)) / deltaTime); 485 | } 486 | 487 | const lastPacketsLost = getDefinedValue('packetsLost', lastResultSource, lastResultRemoteSource); 488 | 489 | if (isOutbound) { 490 | trackInfo.packetsSent = source.packetsSent; 491 | trackInfo.packetLoss = (trackInfo.packetsLost / (trackInfo.packetsSent || 1)) * 100; 492 | 493 | if (lastResultSource) { 494 | trackInfo.intervalPacketsSent = trackInfo.packetsSent - (lastResultSource.packetsSent || 0); 495 | trackInfo.intervalPacketsLost = trackInfo.packetsLost - (lastPacketsLost || 0); 496 | trackInfo.intervalPacketLoss = (trackInfo.intervalPacketsLost / (trackInfo.intervalPacketsSent || 1)) * 100; 497 | } 498 | 499 | trackInfo.retransmittedBytesSent = source.retransmittedBytesSent; 500 | trackInfo.retransmittedPacketsSent = source.retransmittedPacketsSent; 501 | } else { 502 | trackInfo.packetsReceived = source.packetsReceived; 503 | trackInfo.packetLoss = (trackInfo.packetsLost / (trackInfo.packetsReceived || 1)) * 100; 504 | 505 | if (lastResultSource) { 506 | trackInfo.intervalPacketsReceived = trackInfo.packetsReceived - lastResultSource.packetsReceived; 507 | trackInfo.intervalPacketsLost = trackInfo.packetsLost - lastPacketsLost; 508 | trackInfo.intervalPacketLoss = (trackInfo.intervalPacketsLost / (trackInfo.intervalPacketsReceived || 1)) * 100; 509 | } 510 | } 511 | 512 | if (track && kind === 'audio') { 513 | if (track.remoteSource) { 514 | trackInfo.audioLevel = track.audioLevel; 515 | trackInfo.totalAudioEnergy = track.totalAudioEnergy; 516 | } else { 517 | trackInfo.echoReturnLoss = track.echoReturnLoss; 518 | trackInfo.echoReturnLossEnhancement = track.echoReturnLossEnhancement; 519 | } 520 | } 521 | 522 | if (kind === 'audio' && mediaSource && (!track || !track.remoteSource)) { 523 | trackInfo.audioLevel = mediaSource.audioLevel; 524 | trackInfo.totalAudioEnergy = mediaSource.totalAudioEnergy; 525 | } 526 | 527 | // remove undefined properties from trackInfo 528 | Object.keys(trackInfo).forEach((key) => trackInfo[key] === undefined && delete trackInfo[key]); 529 | 530 | if (isOutbound) { 531 | event.tracks.push(trackInfo); 532 | } else { 533 | event.remoteTracks.push(trackInfo); 534 | } 535 | } 536 | 537 | private processSelectedCandidatePair({ report, event, results }) { 538 | // this is the active candidate pair, check if it's the same id as last one 539 | const localId = report.localCandidateId; 540 | const remoteId = report.remoteCandidateId; 541 | 542 | event.localCandidateChanged = !!this.lastActiveLocalCandidate && localId !== this.lastActiveLocalCandidate.id; 543 | event.remoteCandidateChanged = !!this.lastActiveRemoteCandidate && remoteId !== this.lastActiveRemoteCandidate.id; 544 | 545 | if (!this.lastActiveLocalCandidate || event.localCandidateChanged || event.remoteCandidateChanged) { 546 | results.forEach((result) => { 547 | this.checkLastActiveCandidate({ 548 | localId, 549 | remoteId, 550 | report: result.value, 551 | }); 552 | }); 553 | } 554 | 555 | if (this.lastActiveLocalCandidate) { 556 | event.networkType = this.lastActiveLocalCandidate.networkType; 557 | if (this.lastActiveRemoteCandidate) { 558 | event.candidatePair = 559 | this.lastActiveLocalCandidate.candidateType + ';' + this.lastActiveRemoteCandidate.candidateType; 560 | } 561 | } 562 | 563 | event.bytesSent = report.bytesSent; 564 | event.bytesReceived = report.bytesReceived; 565 | event.requestsReceived = report.requestsReceived; 566 | event.requestsSent = report.requestsSent; 567 | event.responsesReceived = report.responsesReceived; 568 | event.responsesSent = report.responsesSent; 569 | event.consentRequestsSent = report.consentRequestsSent; 570 | event.totalRoundTripTime = report.totalRoundTripTime; 571 | } 572 | 573 | private checkLastActiveCandidate({ localId, remoteId, report }) { 574 | if (localId && report.type === 'local-candidate' && report.id === localId) { 575 | this.lastActiveLocalCandidate = report; 576 | } 577 | if (remoteId && report.type === 'remote-candidate' && report.id === remoteId) { 578 | this.lastActiveRemoteCandidate = report; 579 | } 580 | } 581 | } 582 | 583 | // returns the first value in the list of objects that is not undefined or null 584 | function getDefinedValue(propertyName, ...objects) { 585 | const item = objects.find((obj) => obj && (obj[propertyName] || obj[propertyName] === 0)); 586 | return item && item[propertyName]; 587 | } 588 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import StatsGatherer from '../src'; 2 | 3 | import mockSpecStatsInitial from './mock-spec-stats-initial.json'; 4 | import mockSpecStats1 from './mock-spec-stats-1.json'; 5 | import mockSpecStats2 from './mock-spec-stats-2.json'; 6 | import mockSpecStats3 from './mock-spec-stats-3.json'; 7 | import mockStatsRecvOnly from './mock-spec-stats-recvonly.json'; 8 | import mockSdp from './mock-sdp.json'; 9 | 10 | // if (typeof window === 'undefined') { 11 | // global.window = { 12 | // navigator: { 13 | // userAgent: 'user-agent', 14 | // hardwareConcurrency: 8, 15 | // platform: 'tests' 16 | // }, 17 | // performance: { 18 | // now: () => new Date().getTime() 19 | // }, 20 | // location: { 'host': 'localhost', 'protocol': 'http' } 21 | // }; 22 | 23 | // global.window.setTimeout = setTimeout.bind(global.window); 24 | // global.window.setInterval = setInterval.bind(global.window); 25 | // global.window.clearInterval = clearInterval.bind(global.window); 26 | // } 27 | 28 | class MockRtcPeerConnection extends EventTarget { 29 | constructor() { 30 | super(); 31 | } 32 | 33 | getStats() { 34 | return Promise.resolve(); 35 | } 36 | } 37 | 38 | describe('StatsGatherer', () => { 39 | let rtcPeerConnection; 40 | 41 | beforeEach(function () { 42 | rtcPeerConnection = new MockRtcPeerConnection(); 43 | }); 44 | 45 | describe('constructor', function () { 46 | it('should accept options and initialize the class', function () { 47 | const gatherer = new StatsGatherer(rtcPeerConnection); 48 | 49 | expect(gatherer.peerConnection).toEqual(rtcPeerConnection); 50 | expect(gatherer['statsInterval']).toEqual(5000); 51 | }); 52 | 53 | it('should poll stats immediately if already connected', () => { 54 | jest.useFakeTimers(); 55 | rtcPeerConnection.connectionState = 'connected'; 56 | const spy = jest.spyOn(rtcPeerConnection, 'getStats'); 57 | const gatherer = new StatsGatherer(rtcPeerConnection); 58 | 59 | jest.advanceTimersByTime(500); 60 | 61 | expect(spy).toHaveBeenCalled(); 62 | jest.useRealTimers(); 63 | }); 64 | 65 | it('should warn if iceConnectionState is already checking', () => { 66 | const logger = { 67 | error: jest.fn(), 68 | warn: jest.fn(), 69 | }; 70 | 71 | rtcPeerConnection.iceConnectionState = 'checking'; 72 | 73 | const gatherer = new StatsGatherer(rtcPeerConnection, { logger }); 74 | 75 | expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('iceConnectionState is already in checking')); 76 | }); 77 | }); 78 | 79 | describe('_gatherStats', function () { 80 | it('should call into the native getstats method', function () { 81 | const gatherer = new StatsGatherer(rtcPeerConnection); 82 | jest.spyOn(gatherer.peerConnection, 'getStats').mockResolvedValue(mockSpecStats1 as any); 83 | return gatherer['gatherStats'](); 84 | }); 85 | }); 86 | 87 | describe('createStatsReport', function () { 88 | let opts, gatherer, report1, report2, report3; 89 | 90 | beforeEach(function () { 91 | opts = { 92 | session: {}, 93 | conference: {}, 94 | }; 95 | gatherer = new StatsGatherer(rtcPeerConnection, opts); 96 | }); 97 | 98 | describe('intervalLoss', function () { 99 | it('should generate intervalLoss', function () { 100 | const stats1 = [ 101 | { 102 | key: 'RTCRemoteInboundRtpAudioStream_545464236', 103 | value: { 104 | id: 'RTCRemoteInboundRtpAudioStream_545464236', 105 | timestamp: 1571687960465.791, 106 | type: 'remote-inbound-rtp', 107 | ssrc: 545464236, 108 | kind: 'audio', 109 | transportId: 'RTCTransport_audio_1', 110 | codecId: 'RTCCodec_audio_Outbound_111', 111 | packetsLost: 1, 112 | jitter: 0.0017708333333333332, 113 | localId: 'RTCOutboundRTPAudioStream_545464236', 114 | roundTripTime: 0.052, 115 | }, 116 | }, 117 | { 118 | key: 'RTCOutboundRTPAudioStream_545464236', 119 | value: { 120 | id: 'RTCOutboundRTPAudioStream_545464236', 121 | timestamp: 1571687966413.522, 122 | type: 'outbound-rtp', 123 | ssrc: 545464236, 124 | isRemote: false, 125 | mediaType: 'audio', 126 | kind: 'audio', 127 | trackId: 'RTCMediaStreamTrack_sender_1', 128 | transportId: 'RTCTransport_audio_1', 129 | codecId: 'RTCCodec_audio_Outbound_111', 130 | mediaSourceId: 'RTCAudioSource_1', 131 | packetsSent: 2481, 132 | retransmittedPacketsSent: 18, 133 | bytesSent: 210799, 134 | retransmittedBytesSent: 0, 135 | }, 136 | }, 137 | ]; 138 | 139 | const stats2 = [ 140 | { 141 | key: 'RTCRemoteInboundRtpAudioStream_545464236', 142 | value: { 143 | id: 'RTCRemoteInboundRtpAudioStream_545464236', 144 | timestamp: 1571687961465.791, 145 | type: 'remote-inbound-rtp', 146 | ssrc: 545464236, 147 | kind: 'audio', 148 | transportId: 'RTCTransport_audio_1', 149 | codecId: 'RTCCodec_audio_Outbound_111', 150 | packetsLost: 61, 151 | jitter: 0.0017708333333333332, 152 | localId: 'RTCOutboundRTPAudioStream_545464236', 153 | roundTripTime: 0.052, 154 | }, 155 | }, 156 | { 157 | key: 'RTCOutboundRTPAudioStream_545464236', 158 | value: { 159 | id: 'RTCOutboundRTPAudioStream_545464236', 160 | timestamp: 1571687967413.522, 161 | type: 'outbound-rtp', 162 | ssrc: 545464236, 163 | isRemote: false, 164 | mediaType: 'audio', 165 | kind: 'audio', 166 | trackId: 'RTCMediaStreamTrack_sender_1', 167 | transportId: 'RTCTransport_audio_1', 168 | codecId: 'RTCCodec_audio_Outbound_111', 169 | mediaSourceId: 'RTCAudioSource_1', 170 | packetsSent: 3481, 171 | retransmittedPacketsSent: 18, 172 | bytesSent: 210799, 173 | retransmittedBytesSent: 0, 174 | }, 175 | }, 176 | ]; 177 | 178 | gatherer['createStatsReport'](stats1, true); 179 | const report2 = gatherer['createStatsReport'](stats2, true); 180 | expect(report2.tracks[0].intervalPacketLoss).toEqual(6); 181 | }); 182 | }); 183 | 184 | describe('basic stats checking', function () { 185 | beforeEach(function () { 186 | report1 = gatherer['createStatsReport'](mockSpecStats1, true); 187 | report2 = gatherer['createStatsReport'](mockSpecStats2, true); 188 | report3 = gatherer['createStatsReport'](mockSpecStats3, true); 189 | }); 190 | 191 | it('should create a report', function () { 192 | expect(report1).toBeTruthy(); 193 | expect(report1.name).toEqual('getStats'); 194 | expect(report1.session).toEqual(opts.session); 195 | expect(report1.conference).toEqual(opts.conference); 196 | expect(report1.tracks.length).toEqual(2); 197 | expect(report1.networkType).toEqual('ethernet'); 198 | expect(report1.localCandidateChanged).toEqual(false); 199 | expect(report1.candidatePair).toEqual('prflx;host'); 200 | 201 | expect(report2).toBeTruthy(); 202 | expect(report2.session).toEqual(opts.session); 203 | expect(report2.conference).toEqual(opts.conference); 204 | expect(report2.tracks.length).toEqual(2); 205 | expect(report2.name).toEqual('getStats'); 206 | // report 2 has same candidates 207 | expect(report2.localCandidateChanged).toEqual(false); 208 | expect(report2.networkType).toEqual('ethernet'); 209 | expect(report2.candidatePair).toEqual('prflx;host'); 210 | expect(report2.candidatePairHadActiveSource).toEqual(true); 211 | 212 | expect(report2).toBeTruthy(); 213 | expect(report3.name).toEqual('getStats'); 214 | expect(report3.session).toEqual(opts.session); 215 | expect(report3.conference).toEqual(opts.conference); 216 | expect(report3.tracks.length).toEqual(0); 217 | // report 3 has different candidates 218 | expect(report3.localCandidateChanged).toEqual(true); 219 | expect(report2.networkType).toEqual('ethernet'); 220 | expect(report2.candidatePair).toEqual('prflx;host'); 221 | }); 222 | 223 | it('should accurately get track properties for the report', function () { 224 | const audioTrack = report2.tracks[0]; 225 | expect(audioTrack.track).toBeTruthy(); 226 | expect(audioTrack.bitrate).toBeTruthy(); 227 | expect(isNaN(audioTrack.bitrate)).toBeFalsy(); 228 | expect(audioTrack.kind).toEqual('audio'); 229 | expect(audioTrack.packetLoss).toEqual(0.04030632809351068); 230 | expect(audioTrack.jitter).toEqual(0.0017708333333333332); 231 | expect(audioTrack.echoReturnLoss).toEqual(-100); 232 | expect(audioTrack.echoReturnLossEnhancement).toEqual(0.18); 233 | expect(audioTrack.audioLevel).toEqual(0.0008239997558519242); 234 | expect(audioTrack.totalAudioEnergy).toEqual(1.227674190176716); 235 | expect(audioTrack.codec).toEqual('111 audio/opus 48000'); 236 | 237 | const videoTrack = report2.tracks[1]; 238 | expect(videoTrack.track).toBeTruthy(); 239 | expect(videoTrack.bitrate).toBeTruthy(); 240 | expect(videoTrack.bitrate).toEqual(527); 241 | expect(videoTrack.kind).toEqual('video'); 242 | expect(videoTrack.packetLoss).toEqual(0.565859792518076); 243 | expect(videoTrack.codec).toEqual('100 video/VP8 90000'); 244 | }); 245 | 246 | // the test data is kind of bad for testing this since it requires 247 | // two stats reports which include the same track for most things, and in 248 | // the current state, it only has remote stats in mock-spec-stats-2, so there's no 249 | // delta to measure loss, bitrate, etc 250 | it('should include remote tracks', function () { 251 | expect(report2.remoteTracks.length).toEqual(2); 252 | const audioTrack = report2.remoteTracks[0]; 253 | expect(audioTrack.track).toBeTruthy(); 254 | expect(audioTrack.audioLevel).toEqual(0.0008239997558519242); 255 | expect(audioTrack.totalAudioEnergy).toEqual(2.077036001267227); 256 | expect(audioTrack.codec).toEqual('111 audio/opus 48000'); 257 | 258 | const videoTrack = report2.remoteTracks[1]; 259 | expect(videoTrack.track).toBeTruthy(); 260 | expect(videoTrack.bytes).toEqual(7637399); 261 | expect(videoTrack.codec).toEqual('100 video/VP8 90000'); 262 | }); 263 | 264 | it.skip('should properly determine a track kind', () => {}); 265 | it.skip('should determine bitrate accurately', () => {}); 266 | it.skip('should determine the track kind from the code type if not available otherwise', () => {}); 267 | }); 268 | 269 | describe('empty stats', function () { 270 | it('should not update lastResult', () => { 271 | gatherer['createStatsReport']([], true); 272 | expect(gatherer.lastResult).toBeUndefined(); 273 | }); 274 | 275 | it('should return a basic event', () => { 276 | const event = gatherer['createStatsReport']([], true); 277 | 278 | expect(Object.keys(event).length).toBe(6); 279 | expect(event.type).toBeFalsy(); 280 | expect(event.tracks.length).toBe(0); 281 | expect(event.remoteTracks.length).toBe(0); 282 | }); 283 | }); 284 | }); 285 | 286 | describe('collectStats', function () { 287 | let opts, gatherer; 288 | 289 | beforeEach(function () { 290 | opts = { 291 | session: {}, 292 | conference: {}, 293 | }; 294 | gatherer = new StatsGatherer(rtcPeerConnection, opts); 295 | }); 296 | 297 | afterEach(function () { 298 | gatherer.peerConnection.iceConnectionState = 'closed'; 299 | const event = new Event('iceconnectionstatechange'); 300 | gatherer.peerConnection.dispatchEvent(event); 301 | }); 302 | 303 | it.skip('should setup a polling interval when connected', () => {}); 304 | it.skip('should emit a stats event if already disconnected', () => {}); 305 | 306 | it('should collect stats for a recvonly stream', () => { 307 | return new Promise((resolve) => { 308 | jest.spyOn(gatherer.peerConnection, 'getStats').mockResolvedValue(mockStatsRecvOnly); 309 | 310 | let gotInitial = false; 311 | gatherer.statsInterval = 10; 312 | 313 | gatherer.on('stats', function (stats) { 314 | if (gotInitial) { 315 | expect(stats).toBeTruthy(); 316 | gatherer.peerConnection.iceConnectionState = 'closed'; 317 | var event = new Event('iceconnectionstatechange'); 318 | gatherer.peerConnection.dispatchEvent(event); 319 | 320 | expect(stats.remoteTracks.length).toEqual(1); 321 | expect(stats.remoteTracks[0].bytes).toEqual(7637399); 322 | resolve(); 323 | } else { 324 | console.log('got initial'); 325 | gotInitial = true; 326 | } 327 | }); 328 | gatherer.peerConnection.connectionState = 'connected'; 329 | const event = new Event('connectionstatechange'); 330 | gatherer.peerConnection.dispatchEvent(event); 331 | }); 332 | }); 333 | }); 334 | 335 | describe('collectInitialConnectionStats', function () { 336 | let opts, gatherer; 337 | 338 | beforeEach(function () { 339 | opts = { 340 | session: {}, 341 | conference: {}, 342 | }; 343 | rtcPeerConnection.iceConnectionState = 'new'; 344 | rtcPeerConnection.connectionState = 'connecting'; 345 | gatherer = new StatsGatherer(rtcPeerConnection, opts); 346 | }); 347 | 348 | afterEach(function () { 349 | gatherer.peerConnection.iceConnectionState = 'closed'; 350 | const event = new Event('iceconnectionstatechange'); 351 | gatherer.peerConnection.dispatchEvent(event); 352 | }); 353 | 354 | it('should get emit a stats event with all of the initial connection information', function (done) { 355 | jest.spyOn(gatherer.peerConnection, 'getStats').mockResolvedValue(mockSpecStatsInitial); 356 | gatherer.on('stats', function (stats) { 357 | expect(stats.cores).toBeTruthy(); 358 | expect(stats.networkType).toEqual('ethernet'); 359 | expect(stats.candidatePair).toEqual('prflx;host'); 360 | expect(stats.candidatePairDetails).toBeTruthy(); 361 | done(); 362 | }); 363 | gatherer.peerConnection.iceConnectionState = 'connected'; 364 | gatherer.peerConnection.connectionState = 'connected'; 365 | const event = new Event('iceconnectionstatechange'); 366 | gatherer.peerConnection.dispatchEvent(event); 367 | }); 368 | 369 | it('should emit a failure report if the state is failed', function (done) { 370 | jest.spyOn(gatherer.peerConnection, 'getStats').mockResolvedValue(mockSpecStats1); 371 | gatherer.on('stats', function (event) { 372 | expect(event.name).toEqual('failure'); 373 | try { 374 | expect(event.numLocalHostCandidates > 0).toBeTruthy(); 375 | expect(event.numLocalSrflxCandidates > 0).toBeTruthy(); 376 | expect(event.numLocalRelayCandidates > 0).toBeTruthy(); 377 | expect(event.numRemoteHostCandidates > 0).toBeTruthy(); 378 | expect(event.numRemoteSrflxCandidates > 0).toBeTruthy(); 379 | expect(event.numRemoteRelayCandidates > 0).toBeTruthy(); 380 | done(); 381 | } catch (e) { 382 | done(e); 383 | } 384 | }); 385 | gatherer.peerConnection.iceConnectionState = 'failed'; 386 | gatherer.peerConnection.localDescription = { sdp: mockSdp.sdp }; 387 | gatherer.peerConnection.remoteDescription = { sdp: mockSdp.sdp }; 388 | const event = new Event('iceconnectionstatechange'); 389 | gatherer.peerConnection.dispatchEvent(event); 390 | }); 391 | }); 392 | 393 | describe('gatherStats', function () { 394 | it('should log failure', async () => { 395 | rtcPeerConnection.connectionState = 'connected'; 396 | const gatherer = new StatsGatherer(rtcPeerConnection); 397 | 398 | const err = new Error('fake Error'); 399 | jest.spyOn(rtcPeerConnection, 'getStats').mockRejectedValue(err); 400 | const loggerSpy = jest.spyOn(gatherer['logger'], 'error').mockReturnValueOnce(null); 401 | 402 | await expect(gatherer['gatherStats']()).rejects.toThrowError(); 403 | expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to gather stats'), { 404 | peerConnection: rtcPeerConnection, 405 | err, 406 | }); 407 | }); 408 | 409 | it('should clear interval if closed', async () => { 410 | const gatherer = new StatsGatherer(rtcPeerConnection); 411 | gatherer['pollingInterval'] = 322589; 412 | gatherer['IS_BROWSER'] = true; 413 | rtcPeerConnection.connectionState = 'closed'; 414 | rtcPeerConnection.signalingState = 'bleh'; 415 | 416 | const pollSpy = jest.spyOn(gatherer as any, 'pollForStats').mockReturnValue(null); 417 | const intervalSpy = jest.spyOn(window, 'clearInterval'); 418 | 419 | const result = await gatherer['gatherStats'](); 420 | 421 | expect(result).toStrictEqual([]); 422 | expect(pollSpy).not.toHaveBeenCalled(); 423 | expect(intervalSpy).toHaveBeenCalled(); 424 | expect(pollSpy).not.toHaveBeenCalled(); 425 | expect.assertions(4); 426 | jest.resetAllMocks(); 427 | }); 428 | 429 | it('should return an empty array for other states (e.g. disconnected)', async () => { 430 | rtcPeerConnection.connectionState = 'disconnected'; 431 | const gatherer = new StatsGatherer(rtcPeerConnection); 432 | 433 | const result = await gatherer['gatherStats'](); 434 | expect(result).toStrictEqual([]); 435 | }); 436 | }); 437 | 438 | describe('polyFillStats', () => { 439 | it('should convert statsMap to array', async () => { 440 | const gatherer = new StatsGatherer(rtcPeerConnection); 441 | 442 | const map = new Map(); 443 | map.set('one', {}); 444 | map.set('two', { roger: 'dodger' }); 445 | 446 | jest.spyOn(gatherer as any, 'isNativeStatsReport').mockReturnValue(true); 447 | 448 | const stats = gatherer['polyFillStats'](map as any); 449 | expect(stats).toEqual([ 450 | { 451 | key: 'one', 452 | value: {}, 453 | }, 454 | { 455 | key: 'two', 456 | value: { 457 | roger: 'dodger', 458 | }, 459 | }, 460 | ]); 461 | }); 462 | 463 | it('should return empty array if no stats', async () => { 464 | const gatherer = new StatsGatherer(rtcPeerConnection); 465 | 466 | const stats = gatherer['polyFillStats'](null as any); 467 | expect(stats).toEqual([]); 468 | }); 469 | 470 | it('should return results if already an array', async () => { 471 | const gatherer = new StatsGatherer(rtcPeerConnection); 472 | 473 | const stats = gatherer['polyFillStats']([] as any); 474 | expect(stats).toEqual([]); 475 | }); 476 | 477 | it('should convert map to array', async () => { 478 | const gatherer = new StatsGatherer(rtcPeerConnection); 479 | 480 | const map = { 481 | one: {}, 482 | two: { 483 | roger: 'dodger', 484 | }, 485 | }; 486 | 487 | const stats = gatherer['polyFillStats'](map as any); 488 | expect(stats).toEqual([ 489 | { 490 | key: 'one', 491 | value: {}, 492 | }, 493 | { 494 | key: 'two', 495 | value: { 496 | roger: 'dodger', 497 | }, 498 | }, 499 | ]); 500 | }); 501 | 502 | it('should return empty array if empty object', async () => { 503 | const gatherer = new StatsGatherer(rtcPeerConnection); 504 | 505 | const map = new Map(); 506 | map.set('one', {}); 507 | map.set('two', { roger: 'dodger' }); 508 | 509 | const stats = gatherer['polyFillStats']({} as any); 510 | expect(stats).toEqual([]); 511 | }); 512 | }); 513 | 514 | describe('pollForStats', () => { 515 | it('should not poll stats if there is already an interval', () => { 516 | const gatherer = new StatsGatherer(rtcPeerConnection); 517 | 518 | gatherer['pollingInterval'] = 101280; 519 | const timeout = jest.spyOn(window, 'setTimeout'); 520 | const interval = jest.spyOn(window, 'setInterval'); 521 | 522 | gatherer['pollForStats'](); 523 | 524 | expect(timeout).not.toHaveBeenCalled(); 525 | expect(interval).not.toHaveBeenCalled(); 526 | }); 527 | 528 | it('should ignore empty stats', async () => { 529 | const gatherer = new StatsGatherer(rtcPeerConnection); 530 | 531 | const timeout = jest.spyOn(window, 'setTimeout'); 532 | gatherer['pollForStats'](); 533 | const statsPollFn = timeout.mock.calls[0][0]; 534 | 535 | const gatherSpy = jest.spyOn(gatherer as any, 'gatherStats').mockResolvedValue([]); 536 | const reportSpy = jest.spyOn(gatherer as any, 'createStatsReport'); 537 | 538 | await statsPollFn(); 539 | 540 | expect(reportSpy).not.toHaveBeenCalled(); 541 | }); 542 | }); 543 | 544 | describe('checkBitrate', () => { 545 | it('should return false if the last five remote audio bitrates are zero', () => { 546 | const gatherer = new StatsGatherer(rtcPeerConnection); 547 | const stat = { 548 | remoteTracks: [{ bitrate: 0 }], 549 | }; 550 | gatherer['statsArr'] = [stat, stat, stat, stat, stat]; 551 | 552 | expect(gatherer['checkBitrate'](stat)).toEqual(false); 553 | }); 554 | 555 | it('should return true if the bitrate is zero but array is not full.', () => { 556 | const gatherer = new StatsGatherer(rtcPeerConnection); 557 | const stat = { 558 | remoteTracks: [{ bitrate: 0 }], 559 | }; 560 | gatherer['statsArr'] = []; 561 | 562 | expect(gatherer['checkBitrate'](stat)).toEqual(true); 563 | }); 564 | }); 565 | describe('handleConnectionStateChange', () => { 566 | it('should pollStats if connected', () => { 567 | const gatherer = new StatsGatherer(rtcPeerConnection); 568 | rtcPeerConnection.connectionState = 'connected'; 569 | 570 | const spy = jest.spyOn(gatherer as any, 'pollForStats').mockReturnValue(null); 571 | gatherer['handleConnectionStateChange'](); 572 | 573 | expect(spy).toHaveBeenCalled(); 574 | }); 575 | 576 | it('should do nothing if signaling state is not stable', async () => { 577 | const gatherer = new StatsGatherer(rtcPeerConnection); 578 | rtcPeerConnection.connectionState = 'disconnected'; 579 | rtcPeerConnection.signalingState = 'bleh'; 580 | 581 | const pollSpy = jest.spyOn(gatherer as any, 'pollForStats').mockReturnValue(null); 582 | const gatherSpy = jest.spyOn(gatherer as any, 'gatherStats').mockResolvedValue(null); 583 | const reportSpy = jest.spyOn(gatherer as any, 'createStatsReport').mockReturnValue({} as any); 584 | const intervalSpy = jest.spyOn(window, 'clearInterval'); 585 | 586 | gatherer.on('stats', (event) => { 587 | expect(event.type).toEqual('disconnected'); 588 | }); 589 | await gatherer['handleConnectionStateChange'](); 590 | 591 | expect(pollSpy).not.toHaveBeenCalled(); 592 | expect(gatherSpy).not.toHaveBeenCalled(); 593 | expect(intervalSpy).not.toHaveBeenCalled(); 594 | expect.assertions(3); 595 | }); 596 | 597 | it('should generate a statsReport on disconnect', async () => { 598 | const gatherer = new StatsGatherer(rtcPeerConnection); 599 | rtcPeerConnection.connectionState = 'disconnected'; 600 | rtcPeerConnection.signalingState = 'stable'; 601 | 602 | const pollSpy = jest.spyOn(gatherer as any, 'pollForStats').mockReturnValue(null); 603 | const gatherSpy = jest.spyOn(gatherer as any, 'gatherStats').mockResolvedValue(null); 604 | const reportSpy = jest.spyOn(gatherer as any, 'createStatsReport').mockReturnValue({} as any); 605 | 606 | gatherer.on('stats', (event) => { 607 | expect(event.type).toEqual('disconnected'); 608 | }); 609 | await gatherer['handleConnectionStateChange'](); 610 | 611 | expect(pollSpy).not.toHaveBeenCalled(); 612 | expect.assertions(2); 613 | }); 614 | 615 | it('should clear interval if closed', async () => { 616 | const gatherer = new StatsGatherer(rtcPeerConnection); 617 | gatherer['pollingInterval'] = 12412; 618 | rtcPeerConnection.connectionState = 'closed'; 619 | rtcPeerConnection.signalingState = 'bleh'; 620 | 621 | const pollSpy = jest.spyOn(gatherer as any, 'pollForStats').mockReturnValue(null); 622 | const gatherSpy = jest.spyOn(gatherer as any, 'gatherStats').mockResolvedValue(null); 623 | const reportSpy = jest.spyOn(gatherer as any, 'createStatsReport').mockReturnValue({} as any); 624 | const intervalSpy = jest.spyOn(window, 'clearInterval'); 625 | 626 | gatherer.on('stats', (event) => { 627 | expect(event.type).toEqual('disconnected'); 628 | }); 629 | await gatherer['handleConnectionStateChange'](); 630 | 631 | expect(pollSpy).not.toHaveBeenCalled(); 632 | expect(gatherSpy).not.toHaveBeenCalled(); 633 | expect(intervalSpy).toHaveBeenCalled(); 634 | expect.assertions(3); 635 | }); 636 | }); 637 | 638 | describe('waitForSelectedCandidatePair', () => { 639 | let reportWithCandidatePair: any; 640 | 641 | beforeEach(() => { 642 | jest.useFakeTimers(); 643 | reportWithCandidatePair = [ 644 | { 645 | key: 'RTCIceCandidatePair_WzsdBtXT_nq8LUB9k', 646 | value: { 647 | id: 'RTCIceCandidatePair_WzsdBtXT_nq8LUB9k', 648 | timestamp: 1571687916415.012, 649 | type: 'candidate-pair', 650 | transportId: 'RTCTransport_audio_1', 651 | localCandidateId: 'RTCIceCandidate_WzsdBtXT', 652 | remoteCandidateId: 'RTCIceCandidate_nq8LUB9k', 653 | state: 'in-progress', 654 | priority: 7962116751041233000, 655 | nominated: false, 656 | writable: true, 657 | bytesSent: 155, 658 | bytesReceived: 0, 659 | totalRoundTripTime: 0.047, 660 | currentRoundTripTime: 0.047, 661 | requestsReceived: 0, 662 | requestsSent: 1, 663 | responsesReceived: 1, 664 | responsesSent: 0, 665 | consentRequestsSent: 1, 666 | }, 667 | }, 668 | { 669 | key: 'RTCIceCandidatePair_yI+kvNvF_kJy6c1E9', 670 | value: { 671 | id: 'RTCIceCandidatePair_yI+kvNvF_kJy6c1E9', 672 | timestamp: 1571687916415.012, 673 | type: 'candidate-pair', 674 | transportId: 'RTCTransport_audio_1', 675 | localCandidateId: 'RTCIceCandidate_yI+kvNvF', 676 | remoteCandidateId: 'RTCIceCandidate_kJy6c1E9', 677 | state: 'waiting', 678 | priority: 179896594039051780, 679 | nominated: false, 680 | writable: false, 681 | bytesSent: 0, 682 | bytesReceived: 0, 683 | totalRoundTripTime: 0, 684 | requestsReceived: 0, 685 | requestsSent: 0, 686 | responsesReceived: 0, 687 | responsesSent: 0, 688 | consentRequestsSent: 0, 689 | }, 690 | }, 691 | ]; 692 | }); 693 | 694 | it('should wait for candidate pair', async () => { 695 | const gatherer = new StatsGatherer(rtcPeerConnection); 696 | 697 | const gatherSpy = jest 698 | .spyOn(gatherer as any, 'gatherStats') 699 | .mockImplementationOnce(() => Promise.resolve(reportWithCandidatePair)) 700 | .mockImplementationOnce(() => { 701 | reportWithCandidatePair[0].value.state = 'succeeded'; 702 | return Promise.resolve(reportWithCandidatePair); 703 | }) 704 | .mockImplementationOnce(() => { 705 | reportWithCandidatePair[0].value.nominated = true; 706 | return Promise.resolve(reportWithCandidatePair); 707 | }); 708 | 709 | const promise = gatherer['waitForSelectedCandidatePair'](); 710 | await Promise.resolve(); 711 | expect(gatherSpy).toHaveBeenCalledTimes(1); 712 | jest.advanceTimersByTime(250); 713 | expect(gatherSpy).toHaveBeenCalledTimes(1); 714 | jest.advanceTimersByTime(51); 715 | expect(gatherSpy).toHaveBeenCalledTimes(2); 716 | await Promise.resolve(); 717 | jest.advanceTimersByTime(300); 718 | expect(gatherSpy).toHaveBeenCalledTimes(3); 719 | await Promise.resolve(); 720 | 721 | await promise; 722 | 723 | // make sure we stopped polling for stats 724 | jest.advanceTimersByTime(1300); 725 | expect(gatherSpy).toHaveBeenCalledTimes(3); 726 | }); 727 | 728 | it('should fail after too many tries', async () => { 729 | const gatherer = new StatsGatherer(rtcPeerConnection); 730 | 731 | const gatherSpy = jest.spyOn(gatherer as any, 'gatherStats').mockResolvedValue(reportWithCandidatePair); 732 | 733 | const promise = gatherer['waitForSelectedCandidatePair'](); 734 | await Promise.resolve(); 735 | expect(gatherSpy).toHaveBeenCalledTimes(1); 736 | jest.advanceTimersByTime(250); 737 | expect(gatherSpy).toHaveBeenCalledTimes(1); 738 | jest.advanceTimersByTime(51); 739 | expect(gatherSpy).toHaveBeenCalledTimes(2); 740 | await Promise.resolve(); 741 | jest.advanceTimersByTime(300); 742 | expect(gatherSpy).toHaveBeenCalledTimes(3); 743 | await Promise.resolve(); 744 | jest.advanceTimersByTime(300); 745 | expect(gatherSpy).toHaveBeenCalledTimes(4); 746 | 747 | await expect(promise).rejects.toThrowError(/Max wait attempts/); 748 | 749 | // make sure we stopped polling for stats 750 | jest.advanceTimersByTime(1300); 751 | expect(gatherSpy).toHaveBeenCalledTimes(4); 752 | }); 753 | }); 754 | 755 | describe('handleIceStateChange', () => { 756 | it('should do nothing if connected and already have connection metrics', () => { 757 | const gatherer = new StatsGatherer(rtcPeerConnection); 758 | rtcPeerConnection.iceConnectionState = 'connected'; 759 | gatherer['haveConnectionMetrics'] = true; 760 | const gatherSpy = jest.spyOn(gatherer as any, 'waitForSelectedCandidatePair'); 761 | 762 | gatherer['handleIceStateChange'](); 763 | 764 | expect(gatherSpy).not.toHaveBeenCalled(); 765 | }); 766 | 767 | it('should set iceStartTime', () => { 768 | const gatherer = new StatsGatherer(rtcPeerConnection); 769 | rtcPeerConnection.iceConnectionState = 'checking'; 770 | const gatherSpy = jest.spyOn(gatherer as any, 'waitForSelectedCandidatePair'); 771 | 772 | expect(gatherer['iceStartTime']).toBeFalsy(); 773 | 774 | gatherer['handleIceStateChange'](); 775 | 776 | expect(gatherSpy).not.toHaveBeenCalled(); 777 | expect(gatherer['iceStartTime']).toBeTruthy(); 778 | }); 779 | }); 780 | 781 | describe('processSelectedCandidatePair', () => { 782 | it('should set network pair', () => { 783 | const gatherer = new StatsGatherer(rtcPeerConnection); 784 | gatherer['lastActiveLocalCandidate'] = { networkType: 'srflx' }; 785 | gatherer['lastActiveRemoteCandidate'] = { networkType: 'prflx' }; 786 | 787 | const event: any = {}; 788 | 789 | const candidatePairReport = mockSpecStats1.find( 790 | (report) => report.key === 'RTCIceCandidatePair_WzsdBtXT_nq8LUB9k', 791 | ); 792 | gatherer['processSelectedCandidatePair']({ 793 | results: mockSpecStats1, 794 | event, 795 | report: candidatePairReport.value, 796 | }); 797 | 798 | expect(event.candidatePair).toEqual('prflx;host'); 799 | }); 800 | }); 801 | }); 802 | --------------------------------------------------------------------------------