なんとなくアテはついてたけど、中身が気になるという話を聞いたので調べてみた。
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から落とすしかなかった・・。
util.js
まずは軽いほうから。
- `assert(condition, opt_message)`
- `$()`
- `document.getElementById()`のエイリアス
- `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://`配下のページでだけ使えるものらしい。
残念。
続・データはどこからくるのか
`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をライブラリ化することができて、FirefoxやSafariでも同じように使えるのでは?という。
そのために使えそうなコード片がないか見ておく。
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()`してるだけだったので、どうやらブラウザ側でやってる模様。
やってみた
Tweaked chrome://webrtc-internals on Firefox 😂 pic.twitter.com/XwNaZ3nl0e
— りぃ (@leader22) January 7, 2020
適当に`addPeerConnection()`して、`addStandardStats()`に`pc.getStats()`をちょっと整形したデータを投げ込めば、いい感じに表示された。
プラットフォームは選ばず、`getStats()`したデータさえあれば、いつでもどこでも`chrome://webrtc-internals`っぽいUIで解析できる・・!