WebRTCで統計情報を収集する

# 統計情報とは？

WebRTCでは、基盤となるネットワーク環境や送受信されるメディアの情報を監視することが出来るようにように、統計情報のAPIが規定されています。
最新の仕様書は https://w3c.github.io/webrtc-stats/ です。

このAPIで取得可能な情報は、WebRTCを使ってアプリケーションを開発したことがある方なら、一度はお世話になっているであろう、[chrome://webrtc-internls](chrome://webrtc-internls)や[about:webrtc](about:webrtc)で表示される情報と同じ物となります。

~~注意: 記事中で、SkyWayのJavaScript SDKを利用して統計情報を収集していますが、こちらは非公式な方法となります。そのため、この方法は将来変更される可能性があります~~
  **[SkyWay JavaScript SDK v1.4.0](https://github.com/skyway/skyway-js-sdk/releases/tag/v1.4.0)から[RTCPeerConnectionを取得するAPI](https://webrtc.ecl.ntt.com/skyway-js-sdk-doc/ja/mediaconnection/#getpeerconnection)が追加されたため、こちらを利用すると統計情報収集API getStats()にアクセスが可能です。**

# どのような情報を収集することが出来るか

W3Cの仕様書で解説します。

## RTCStatsのWebIDL

```js:WebIDL
dictionary RTCStats {
  DOMHighResTimeStamp timestamp;
  RTCStatsType type;
  DOMString id;
};
```

ポイントは、RTCStatsTypeです。

### RTCStatsTypeで取得できる統計情報が規定されています。

```js:WebIDL
enum RTCStatsType {
    "codec",
    "inbound-rtp",
    "outbound-rtp",
    "remote-inbound-rtp",
    "remote-outbound-rtp",
    "csrc",
    "peer-connection",
    "data-channel",
    "stream",
    "track",
    "transport",
    "candidate-pair",
    "local-candidate",
    "remote-candidate",
    "certificate"
};
```

|タイプ|内容|
|:--|:--|
|codec|現在使用されている映像、音声のコーデックの種類|
|inbound-rtp|受信しているRTPストリームの統計|
|outbound-rtp|送信しているRTPストリームの統計|
|remote-inbound-rtp|送信したRTPストリームがリモートで受信された際の統計（RTCPプロトコルの受信レポートで収集）|
|remote-outbound-rtp|受信したRTPストリームがリモートで送信された際の統計（RTCPプロトコルの送信レポートで収集）|
|csrc|受信しているRTPストリームを識別するための情報|
|peer-connection|PeerConnectionに関する統計|
|data-channel|DataChannel IDに関する情報|
|stream|メディアストリームの統計情報|
|track|メディアストリームトラックの統計|
|transport|通信に利用しているトランスポートの統計|
|candidate-pair|ペアになるCandidate情報|
|local-candidate|自身のCandidate情報|
|remote-candidate|リモートのCandidate情報|
|certificate|トランスポートの生成に使用された証明書の情報|

### アプリ開発時に活用できそうな情報

全てのStatstypeについて解説すると長くなりすぎるので、筆者の主観ですが、アプリ開発で使いそうな項目をピックアップしてみます。

|定義|内容|備考|
|:--|:--|:--|
|RTCCodecStats.codec|現在使用されているコーデックの種類(VP8,H264,OPUS…)|使用しているコーデックが知りたいときって結構有りますよね|
|RTCInboundRTPStreamStats.bytesReceived<BR>RTCOutboundRTPStreamStats.bytesSent|送受信したデータ量（バイト）|上と同じく|
|RTCInboundRTPStreamStats.fractionLost|パケットロス率|ネットワーク品質などを見極めたい時は便利|
|RTCMediaStreamTrackStats.frameWidth<BR>RTCMediaStreamTrackStats.frameHeight<BR>RTCMediaStreamTrackStats.framesPerSecond|ビデオストリームトラックにおけるフレームサイズとフレームレート|WebRTCはネットワーク品質を考慮して自動的にこの辺りのパラメーターを調整します|
|RTCMediaStreamTrackStats.audioLevel|オーディオストリームトラックにおけるオーディオレベル（dB）|音が出ているかどうかがわかるかな|
|RTCTransportStats.activeConnection|トランスポートがアクティブかどうか|P2Pの通信経路が確立しているかどうかがわかる|
|RTCIceCandidateStats.ip<BR>RTCIceCandidateStats.port<BR>RTCIceCandidateStats.protocol|該当するCandidateのIPアドレス、ポート番号、プロトコル種別(UDP/TCP)|利用者のIPアドレス、ポート番号を収集したいってニーズはあるはず|
|RTCIceCandidateStats.candidateType|該当するCandidateのタイプ（host/srflx/prflx/relay）|各タイプの説明は [SkyWay FAQ](https://support.skyway.io/hc/articles/236031388)を参照<BR>TURN経由しているかどうか知りたいことって多いですよね|

# 実際に収集してみる

SkyWayのサンプルアプリを利用して統計情報を収集してみます。

- ソースコード : https://github.com/yusuke84/skyway-stats-sample 
- サンプルアプリ : https://yusuke84.github.io/skyway-stats-sample/index.html
- 動作確認済みブラウザ（2019/6/28現在）
  - Chrome M75 for macOS

## 動作イメージ

<img width="1028" alt="getstats.png" src="https://qiita-image-store.s3.amazonaws.com/0/6651/0239015f-7a08-488d-90f6-4668bd7cb3df.png">

<!-- <img width="963" alt="スクリーンショット 2017-12-01 5.08.10.png" src="https://qiita-image-store.s3.amazonaws.com/0/6651/c1330be7-bbf2-74b2-c752-2a6ffe078856.png"> -->

**注意**: W3Cの仕様書と実際にブラウザで取得出来る情報には差分があります。また、APIの実装方法もブラウザごとに差分があります。今回のサンプルアプリはChromeで動作するように作成してあります。Fireofxでは正常に情報が収集できません。

## skyway.jsで統計情報のAPIにアクセスする方法

skyway.jsは以下の方法でRTCPeerConnectionの`getStats API`にアクセスが可能です。

### 発信側（例）

```js:example
peer = new Peer({key:APIKEY});
const call = peer.call(peerid,localStream);
const stats = call.getPeerConnection().getStats();
```

### 受信側（例）

```js:example
peer = new Peer({key:APIKEY});
    peer.on('call', call => {
        call.answer(localStream);
        const stats = call.getPeerConnection().getStats();
    });
```

~~注意: この方法は将来変更される可能性があります~~ **[SkyWay JavaScript SDK v1.4.0](https://github.com/skyway/skyway-js-sdk/releases/tag/v1.4.0)から[RTCPeerConnectionを取得するAPI](https://webrtc.ecl.ntt.com/skyway-js-sdk-doc/ja/mediaconnection/#getpeerconnection)が追加されました**
**注意: SkyWay Android/iOS/IoT SDKでは統計情報の収集はできません**

## デモアプリのコード解説

`getStats`は実行した時点の情報を収集するため、連続して収集するには定期実行する必要があります。

```js:script.js
// `existingCall`には`call`オブジェクトが格納されており、getPeerConnection()を実行するとRTCPeerConnectionが取得できる
const _PC = existingCall.getPeerConnection();
// setInterval`で1000ms間隔でgetRTCStatsを実行する
timer = setInterval(() => {
    // `getRTCStats`に`getStats`オブジェクトを引き数で渡す
    getRTCStats(_PC.getStats());
}, 1000);
```

`getStatsは`非同期型で*promise*ベースとなります。サンプルアプリでは、`async/await`で記述しています。

```js:script.js
    async function getRTCStats(statsObject){

        // 宣言文は省略

        // getStats()を実行し結果が取得できるまで待機
        let stats = await statsObject;

        // 取得できた統計情報オブジェクトのIDから必要な物を操作しやすいように配列に格納
        stats.forEach(stat => {
            if(stat.id.indexOf('RTCTransport') !== -1){
                trasportArray.push(stat);
            }                
            if(stat.id.indexOf('RTCIceCandidatePair') !== -1){
                candidatePairArray.push(stat);
            }
            if(stat.id.indexOf('RTCIceCandidate_') !== -1){
                candidateArray.push(stat);
            }
            if(stat.id.indexOf('RTCInboundRTPAudioStream') !== -1){
                inboundRTPAudioStreamArray.push(stat);
            }
            if(stat.id.indexOf('RTCInboundRTPVideoStream') !== -1){
                inboundRTPVideoStreamArray.push(stat);
            }
            if(stat.id.indexOf('RTCOutboundRTPAudioStream') !== -1){
                outboundRTPAudioStreamArray.push(stat);
            }
            if(stat.id.indexOf('RTCOutboundRTPVideoStream') !== -1){
                outboundRTPVideoStreamArray.push(stat);
            }
            if(stat.id.indexOf('RTCMediaStreamTrack_sender') !== -1){
                mediaStreamTrack_senderArray.push(stat);
            }
            if(stat.id.indexOf('RTCMediaStreamTrack_receiver') !== -1){
                mediaStreamTrack_receiverArray.push(stat);
            }
            if(stat.id.indexOf('RTCCodec') !== -1){
                codecArray.push(stat);
            }
        });

        // Transportの統計からselectedCandidatePairIdを取得
        trasportArray.forEach(transport => {
            if(transport.dtlsState === 'connected'){
                candidatePairId = transport.selectedCandidatePairId;
            }
        });
        // selectedCandidatePairIdをもとに通信に成功している、LocalCandidateID/RemoteCandidateIDを取り出す
        candidatePairArray.forEach(candidatePair => {
            if(candidatePair.state === 'succeeded' && candidatePair.id === candidatePairId){
                localCandidateId = candidatePair.localCandidateId;
                remoteCandidateId = candidatePair.remoteCandidateId;
            }
        });
        // LocalCandidateID/RemoteCandidateIDから、LocalCandidate/RemoteCandidateを取り出す
        candidateArray.forEach(candidate => {
            if(candidate.id === localCandidateId){
                localCandidate = candidate;
            }
            if(candidate.id === remoteCandidateId){
                remoteCandidate = candidate;
            }
        });
        // InboundRTPAudioStreamのcodecIdをもとに、codecArrayから利用されているCodec情報を取り出す
        inboundRTPAudioStreamArray.forEach(inboundRTPAudioStream => {
            codecArray.forEach(codec => {
                if(inboundRTPAudioStream.codecId === codec.id){
                    inboundAudioCodec = codec;
                }
            });
        });
        // inboundRTPVideoStreamArrayのcodecIdをもとに、codecArrayから利用されているCodec情報を取り出す
        inboundRTPVideoStreamArray.forEach(inboundRTPVideoStream => {
            codecArray.forEach(codec => {
                if(inboundRTPVideoStream.codecId === codec.id){
                    inboundVideoCodec = codec;
                }
            });
        });
        // outboundRTPAudioStreamArrayのcodecIdをもとに、codecArrayから利用されているCodec情報を取り出す     
        outboundRTPAudioStreamArray.forEach(outboundRTPAudioStream => {
            codecArray.forEach(codec => {
                if(outboundRTPAudioStream.codecId === codec.id){
                    outboundAudioCodec = codec;
                }
            });
        });
        // outboundRTPVideoStreamArrayのcodecIdをもとに、codecArrayから利用されているCodec情報を取り出す
        outboundRTPVideoStreamArray.forEach(outboundRTPVideo => {
            codecArray.forEach(codec => {
                if(outboundRTPVideo.codecId === codec.id){
                    outboundVideoCodec = codec;
                }
            });
        });
        // mediaStreamTrack_senderArrayには送信中のMediaStreamTrack(Audio/Video共に)が格納されているのでそれぞれ取り出す        
        mediaStreamTrack_senderArray.forEach(mediaStreamTrack => {
            if(mediaStreamTrack.kind === 'audio'){
                mediaStreamTrack_local_audioArray.push(mediaStreamTrack)
            }else if(mediaStreamTrack.kind === 'video'){
                mediaStreamTrack_local_videoArray.push(mediaStreamTrack)
            }
        });
        // mediaStreamTrack_receiverArrayには受信中のMediaStreamTrack(Audio/Video共に)が格納されているのでそれぞれ取り出す        
        mediaStreamTrack_receiverArray.forEach(mediaStreamTrack => {
            if(mediaStreamTrack.kind === 'audio'){
                mediaStreamTrack_remote_audioArray.push(mediaStreamTrack)
            }else if(mediaStreamTrack.kind === 'video'){
                mediaStreamTrack_remote_videoArray.push(mediaStreamTrack)
            }
        });       

        // 収集した情報を画面に表示する
        $('#local-candidate').html(localCandidate.ip + ':' + localCandidate.port + '(' +localCandidate.protocol + ')' + '<BR>type:' + localCandidate.candidateType);
        $('#remote-candidate').html(remoteCandidate.ip + ':' + remoteCandidate.port + '(' +remoteCandidate.protocol + ')' + '<BR>type:' + remoteCandidate.candidateType);        
        $('#inbound-codec').html(inboundVideoCodec.mimeType + '<BR>' + inboundAudioCodec.mimeType);
        $('#outbound-codec').html(outboundVideoCodec.mimeType + '<BR>' + outboundAudioCodec.mimeType)
        $('#inbound-audio').html('bytesReceived:' + inboundRTPAudioStreamArray[0].bytesReceived + '<BR>jitter:' + inboundRTPAudioStreamArray[0].jitter + '<BR>fractionLost:' + inboundRTPAudioStreamArray[0].fractionLost);
        $('#inbound-video').html('bytesReceived:' + inboundRTPVideoStreamArray[0].bytesReceived + '<BR>fractionLost:' + inboundRTPVideoStreamArray[0].fractionLost);        
        $('#outbound-audio').html('bytesReceived:' + outboundRTPAudioStreamArray[0].bytesSent);
        $('#outbound-video').html('bytesReceived:' + outboundRTPVideoStreamArray[0].bytesSent);
        $('#local-audio-video').html('audioLevel:' + mediaStreamTrack_local_audioArray[0].audioLevel + '<BR>frameHeight:' + mediaStreamTrack_local_videoArray[0].frameHeight + '<BR>frameWidth:' + mediaStreamTrack_local_videoArray[0].frameWidth + '<BR>framesSent:' + mediaStreamTrack_local_videoArray[0].framesSent);
        $('#remote-audio-video').html('audioLevel:' + mediaStreamTrack_remote_audioArray[0].audioLevel + '<BR>frameHeight:' + mediaStreamTrack_local_videoArray[0].frameHeight + '<BR>frameWidth:' + mediaStreamTrack_remote_videoArray[0].frameWidth);

    }
```

* 2018/08/08 Chrome RTCStatsReportの仕様変更に伴いコードを修正
    * RTCMediaStreamTrack_sender/RTCMediaStreamTrack_receiverという項目が増え、RTCMediaStreamTrackStats相当の情報はその中に配列として格納されるようになった

## 参考：統計情報を収集するSaaS

WebRTCの統計情報収集に特化したSaaSとして、[callstats.io](https://www.callstats.io/)というサービスが有ります。
有償ですが、本格的に収集したい場合は活用してみるのも良いかもしれません。