🧊

chrome://webrtc-internals のしくみ

なんとなくアテはついてたけど、中身が気になるという話を聞いたので調べてみた。

WebRTCやってるみなさんならお馴染みのあのページです。

chrome://webrtc-internals

WebRTCのデバッグといえばこのページ。

特に何も仕込んでないのに、見てるページで`getUserMedia()`したり`RTCPeerConnection`が作られればその様子が見えるし、実際に流れてるメディアやデータのことまでわかる。

あれってどういうしくみ?っていうのを調べていきます。

ただのWebページ

URLが`chrome://`になってるけど、れっきとしたWebページです。

なのでDevToolsでNetworkタブ見ればだいたいわかる!というわけで。

構成要素はこんな感じ。

  • webrtc-internals.html
    • ただのHTML/CSS
    • 以下の2つのJSを読み込んでる
  • util.js
    • このページだけでなく、他のページでも使われる系util
    • ただし`chrome://`でのみ使える模様
  • webrtc_internals.js
    • 本体
    • この記事の主旨

ちなみにDevToolsでわざわざ解析しなくても、Chromeのソースを漁れば実は出てくる・・!

chromium/content/browser/webrtc/resources at master · chromium/chromium · GitHub

ただしこのソースを見るとわかるように謎の`include`要素があって、一括で読むには結局DevToolsから落とすしかなかった・・。

Dump of chrome://webrtc-internals by Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.79 Safari/537.36" · GitHub

util.js

まずは軽いほうから。

  • `assert(condition, opt_message)`
  • `$()`
  • `appendParam(url, key, value)`
  • `createElementWithClassName(type, className)`
  • etc...

などなど、ConsoleパネルのCommand Line APIとも違った、便利APIたち。

`chrome://`配下のページでだけ読み込まれる関数群であり、今回はどうでもいいやつらです。

次。

webrtc_internals.js

本丸は圧巻の3000行!

Overview

  • ひたすら関数やクラス定義が続いててエントリーが見つけにくい
    • 2700行目くらいにある`initialize()`ってやつがそう
  • コード自体はES5時代っぽく、`class`ではなく`prototype`を作ってる懐かしい感じ
  • タブ表示やグラフ表示などUI的なことを管理するクラスもあるし
    • グラフは`canvas`なのでそのあたりも
  • 時系列で溜まっていくstatsデータを管理するクラスもある
    • `getStats()`のデータを抽象化してるやつとか
  • Dumpをダウンロードする機能とかもここ

順に読んでいく。

initialize()

function initialize() {
  dumpCreator = new DumpCreator($("content-root"));
  $("content-root").appendChild(createStatsSelectionOptionElements());

  tabView = new TabView($("content-root"));
  ssrcInfoManager = new SsrcInfoManager();
  peerConnectionUpdateTable = new PeerConnectionUpdateTable();
  statsTable = new StatsTable(ssrcInfoManager);

  chrome.send("finishedDOMLoad");

  // Requests stats from all peer connections every second.
  window.setInterval(requestStats, 1000);
}
document.addEventListener("DOMContentLoaded", initialize);

ここからはじまる。
さっそくさっきみた`util.js`の`$()`が使われてる!

やってることとしては、

  • Create Dumpの機能
  • 各種タブのUI初期化
  • PeerConnectionとStatsのViewのセットアップ
  • 1秒ごとにStatsデータを要求

って感じ。

リアルタイムに・・とかではなく、愚直に1秒ごとのタイマーが頑張ってたんですね。

データはどこからくるのか

`getUserMedia()`したこととか、`RTCPeerConnection`が作られたこととか、そういう情報の出どころはどこか?

`initialize()`で呼んでる1秒ごとの`requestStats()`が何してるかを追えばわかるはず・・・。

function requestStats() {
  if (currentGetStatsMethod == OPTION_GETSTATS_STANDARD) {
    requestStandardStats();
  } else if (currentGetStatsMethod == OPTION_GETSTATS_LEGACY) {
    requestLegacyStats();
  }
}

/**
 * Sends a request to the browser to get peer connection statistics from the
 * standard getStats() API (promise-based).
 */
function requestStandardStats() {
  if (Object.keys(peerConnectionDataStore).length > 0) {
    chrome.send("getStandardStats");
  }
}

はい。

`getStats()`は`Promise`ベースかそうでないかで返り値が変わる仕様変更があった。
なのでそれをオプションで選べるようになってて、どちらにせよ「`getStats()`のデータをください」っていってる。

`chrome.send("getStandardStats")`すると、きっとどこかで待ち受けてる関数にそのデータが放り込まれてくるっぽい。

次はその受け口を探す。

横道: これが使えるなら

`chrome.send("getStandardStats")`が自由に使えるなら、どんなページでも`getStats()`し放題なのでは?!捗るのでは?!

って思ったけど、まぁできませんでした。

Chromeの場合、`chrome`オブジェクトが`window`にだいたい生えてるんですが、通常のWebページをブラウジングしてるときは肝心の`chrome.send()`がありません。

どうやら`chrome://`配下のページでだけ使えるものらしい。

Accessing `chrome.send()` method in chrome web browser out of chrome's internal pages - Stack Overflow

残念。

続・データはどこからくるのか

`chrome.send()`に対応したイベントハンドラみたいなやつが見当たらないので、きっとグローバルに見えてる関数のどれかがおもむろに叩かれるはず。(これは勘)

怪しい関数ないかなーと見てたら見つけたこの2つのグローバル変数

  • `userMediaRequests = []`
  • `peerConnectionDataStore = {}`

実際データがある状態でコンソールから見てもそれらしい値が入ってたので、ここがすべてだと見てよさそう。

ちなみに、`userMediaRequests`に入ってるのはこんなオブジェクト。

const userMediaRequests = [
  {
    origin: "https://conf.webrtc.ecl.ntt.com",
    pid: 41455,
    rid: 659,
    timestamp: 1576114434316.34,
    video: "{deviceId: {exact: ["02c4f48cf9bfd27aa15f8d7b1483cb3aefdbed87f8b4a189e9814774316f8723"]}}"
  },
]

実際にinternalsで見れるのとほぼ一緒でおもしろみはない。

`pid`ごとに背景の色を変えるとかしてほしいなといつも思ってる。
`rid`はリクエストIDかね?

`addGetUserMedia()`というグローバル関数がこの配列に値を入れてるので、ブレークポイント貼ってコールスタックたどればわかると思ったら、まさかのてっぺんだった・・。

ということは。

webrtc-internalsのしくみ

JavaScriptのコールスタックのてっぺんで既にデータがあるってことは、ブラウザ側 = C++から値が渡されてるはず。

そう思ってC++のコード見たらビンゴだった。

chromium/webrtc_internals.cc at master · chromium/chromium · GitHub

  • JavaScriptからは`chrome.send()`でシグナルする
  • するとC++がグローバルな関数に引数でデータを渡して叩く

というしくみ。

C++のコードでいう`SendUpdate(key, data)`の`key`が、このJSのグローバル関数になってる。

  • addPeerConnection
  • removePeerConnection
  • updatePeerConnection
  • addStandardStats
  • addLegacyStats
  • addGetUserMedia
  • eventLogRecordingsFileSelectionCancelled
  • audioDebugRecordingsFileSelectionCancelled
  • removeGetUserMediaForRenderer

現状で定義されてる`key`はこれら。

というわけで、あとは受け取ったデータを貯めて、整形して、表示してるだけ。

使えそうなコードはあるか

internalsで見れる情報は便利ではあるものの、Chromeでしかもちろん使えないし、見せ方も少し工夫したい(取捨選択したい)ことがある。

なのでここのコードを拝借すれば、オレオレinternals的UIをライブラリ化することができて、FirefoxSafariでも同じように使えるのでは?という。

そのために使えそうなコード片がないか見ておく。

StatsRatesCalculator

`getStats()`のデータはだいたい前のデータと最新のデータを比較することが多いけど、正にそういうことをやってるやつがいた。

class StatsRatesCalculator {
  constructor() {
    this.previousReport = null;
    this.currentReport = null;
  }

  addStatsReport(report) {
    this.previousReport = this.currentReport;
    this.currentReport = report;
    this.updateCalculatedMetrics_();
  }

  // Updates all "calculated metrics", which are metrics derived from standard
  // values, such as converting total counters (e.g. bytesSent) to rates (e.g.
  // bytesSent/s).
  updateCalculatedMetrics_() {
    const statsCalculators = [
      {
        type: "data-channel",
        metricCalculators: {
          messagesSent: new RateCalculator("messagesSent", "timestamp"),
          messagesReceived: new RateCalculator("messagesReceived", "timestamp"),
          bytesSent: new RateCalculator("bytesSent", "timestamp"),
          bytesReceived: new RateCalculator("bytesReceived", "timestamp")
        }
      },
      {
        type: "media-source",
        metricCalculators: {
          totalAudioEnergy: new AudioLevelRmsCalculator()
        }
      },
      {
        type: "track",
        metricCalculators: {
          framesSent: new RateCalculator("framesSent", "timestamp"),
          framesReceived: [
            new RateCalculator("framesReceived", "timestamp"),
            new DifferenceCalculator("framesReceived", "framesDecoded")
          ],
          totalAudioEnergy: new AudioLevelRmsCalculator(),
          jitterBufferDelay: new RateCalculator(
            "jitterBufferDelay",
            "jitterBufferEmittedCount",
            CalculatorModifier.kMillisecondsFromSeconds
          )
        }
      },
      {
        type: "outbound-rtp",
        metricCalculators: {
          bytesSent: new RateCalculator("bytesSent", "timestamp"),
          packetsSent: new RateCalculator("packetsSent", "timestamp"),
          totalPacketSendDelay: new RateCalculator(
            "totalPacketSendDelay",
            "packetsSent",
            CalculatorModifier.kMillisecondsFromSeconds
          ),
          framesEncoded: new RateCalculator("framesEncoded", "timestamp"),
          totalEncodedBytesTarget: new RateCalculator(
            "totalEncodedBytesTarget",
            "timestamp"
          ),
          totalEncodeTime: new RateCalculator(
            "totalEncodeTime",
            "framesEncoded",
            CalculatorModifier.kMillisecondsFromSeconds
          ),
          qpSum: new RateCalculator("qpSum", "framesEncoded"),
          codecId: new CodecCalculator()
        }
      },
      {
        type: "inbound-rtp",
        metricCalculators: {
          bytesReceived: new RateCalculator("bytesReceived", "timestamp"),
          packetsReceived: new RateCalculator("packetsReceived", "timestamp"),
          framesDecoded: new RateCalculator("framesDecoded", "timestamp"),
          totalDecodeTime: new RateCalculator(
            "totalDecodeTime",
            "framesDecoded",
            CalculatorModifier.kMillisecondsFromSeconds
          ),
          qpSum: new RateCalculator("qpSum", "framesDecoded"),
          codecId: new CodecCalculator()
        }
      },
      {
        type: "transport",
        metricCalculators: {
          bytesSent: new RateCalculator("bytesSent", "timestamp"),
          bytesReceived: new RateCalculator("bytesReceived", "timestamp")
          // TODO(https://crbug.com/webrtc/10568): Add packetsSent and
          // packetsReceived once implemented.
        }
      },
      {
        type: "candidate-pair",
        metricCalculators: {
          bytesSent: new RateCalculator("bytesSent", "timestamp"),
          bytesReceived: new RateCalculator("bytesReceived", "timestamp"),
          // TODO(https://crbug.com/webrtc/10569): Add packetsSent and
          // packetsReceived once implemented.
          requestsSent: new RateCalculator("requestsSent", "timestamp"),
          requestsReceived: new RateCalculator("requestsReceived", "timestamp"),
          responsesSent: new RateCalculator("responsesSent", "timestamp"),
          responsesReceived: new RateCalculator(
            "responsesReceived",
            "timestamp"
          ),
          consentRequestsSent: new RateCalculator(
            "consentRequestsSent",
            "timestamp"
          ),
          consentRequestsReceived: new RateCalculator(
            "consentRequestsReceived",
            "timestamp"
          ),
          totalRoundTripTime: new RateCalculator(
            "totalRoundTripTime",
            "responsesReceived",
            CalculatorModifier.kMillisecondsFromSeconds
          )
        }
      }
    ];

    statsCalculators.forEach(statsCalculator => {
      this.currentReport.getByType(statsCalculator.type).forEach(stats => {
        Object.keys(statsCalculator.metricCalculators).forEach(
          originalMetric => {
            let metricCalculators =
              statsCalculator.metricCalculators[originalMetric];
            if (!Array.isArray(metricCalculators)) {
              metricCalculators = [metricCalculators];
            }
            metricCalculators.forEach(metricCalculator => {
              this.currentReport.addCalculatedMetric(
                stats.id,
                originalMetric,
                metricCalculator.getCalculatedMetricName(),
                metricCalculator.calculate(
                  stats.id,
                  this.previousReport,
                  this.currentReport
                )
              );
            });
          }
        );
      });
    });
  }
}
  • `RTCStartsReport`の`type`ごとに
  • どういうメトリクスを取りたいかを定義して
  • その計算をした情報をアップデートする

ここに載せてる以外に依存してる`XxxCalculator`のコードもあわせれば、誰でもwebrtc-internalsで見れる情報をシュッと表示するrtcstats-wrapperが作れる!

まあかなり玄人向けの情報ではあるので、いい感じに訳すならだいたい自作するかなーって感じではあるけど、とても参考になるコードなはず。

TimelineGraphView

あのグラフを描画してるクラス。

実装もそのまんまで、`canvas`に対していい感じにデータを時系列でプロットするだけ。

縦軸の目盛りの単位をデータに応じてアップデートする処理とか参考になりますね。

DumpCreator.onDownloadData_

ダンプファイルのダウンロードのロジック。

ただやってることはなんてことなくて、貯めてたオブジェクトを`JSON.stringify()`して`Blob`にして`a`要素でダウンロードさせてるだけ。

onDownloadData_: function() {
  var dump_object = {
    getUserMedia: userMediaRequests,
    PeerConnections: peerConnectionDataStore,
    UserAgent: navigator.userAgent
  };
  var textBlob = new Blob([JSON.stringify(dump_object, null, " ")], {
    type: "octet/stream"
  });
  var URL = window.URL.createObjectURL(textBlob);

  var anchor = this.root_.getElementsByTagName("a")[0];
  anchor.href = URL;
  anchor.download = "webrtc_internals_dump.txt";
  // The default action of the anchor will download the URL.
},

これと同じ形式でダンプファイルを作れば、自分たちのSDKからダンプファイルが出力できる。

オーディオとパケットのダンプ機能は、`chrome.send()`してるだけだったので、どうやらブラウザ側でやってる模様。

やってみた

適当に`addPeerConnection()`して、`addStandardStats()`に`pc.getStats()`をちょっと整形したデータを投げ込めば、いい感じに表示された。

プラットフォームは選ばず、`getStats()`したデータさえあれば、いつでもどこでも`chrome://webrtc-internals`っぽいUIで解析できる・・!