Add WebRTC Internals Exporter extension with initial files and functionality

This commit is contained in:
2025-01-25 14:33:58 +01:00
parent 5bb7cb4aab
commit c2f9206624
26 changed files with 911 additions and 294 deletions

View File

@@ -4,6 +4,10 @@ import json
import time
import socket
import logging
import os
from functools import partial
from http.server import HTTPServer
from utils.PostHandler import Handler
from utils.ColoredFormatter import ColoredFormatter
from bs4 import BeautifulSoup as bs
from selenium import webdriver
@@ -26,7 +30,7 @@ def setupLogger():
logger.addHandler(logger_handler)
def interrupt_handler(signum, driver: webdriver.Chrome):
print(f'Handling signal {signum} ({signal.Signals(signum).name}).')
logger.log(logging.INFO, f'Handling signal {signum} ({signal.Signals(signum).name}).')
schedule.clear()
driver.quit()
@@ -35,29 +39,33 @@ def interrupt_handler(signum, driver: webdriver.Chrome):
def setupChromeDriver():
logger.log(logging.INFO, 'Setting up Chrome driver.')
chrome_options = Options()
chrome_options.add_argument("--headless")
#chrome_options.add_argument("--headless")
chrome_options.add_argument("--no-sandbox")
chrome_options.add_argument("--mute-audio")
chrome_options.add_argument("--window-size=1280,720")
chrome_options.add_argument("--disable-dev-shm-usage")
chrome_options.add_argument("--no-default-browser-check")
chrome_options.add_argument("--disable-features=WebRtcHideLocalIpsWithMdns")
chrome_options.add_argument(f"--load-extension={os.path.abspath(os.path.join(os.path.dirname(__file__), 'webrtc-internals-exporter'))}")
chrome_options.add_experimental_option('prefs', {'intl.accept_languages': 'en,en_US'})
#chrome_options.add_extension('./qryn-webrtc-exporter.crx')
driver = webdriver.Chrome(options=chrome_options)
#driver = webdriver.Remote(command_executor='http://localhost:4444', options=chrome_options)
logger.log(logging.INFO, 'Chrome driver setup complete.')
return driver
def saveStats(stats: dict):
def saveStats(stats: list):
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
logger.log(logging.DEBUG, f'Saving stats: {json.dumps(stats, indent=4)}')
sock.sendto(json.dumps(stats).encode(), ('localhost', 8094))
sock.close()
print(f'Sent stats: {stats}')
logger.log(logging.DEBUG, 'Sent stats to socket.')
except socket.error as e:
print(f'Got socket error: {e}')
logger.error(f'Got socket error: {e}')
def downloadStats(driver: webdriver.Chrome):
def downloadStats(driver: webdriver.Chrome, peersDict: dict):
html = driver.find_element(By.CLASS_NAME ,'vjs-stats-list').get_attribute('innerHTML')
if html is not None:
htmlBS = bs(html, 'html.parser')
@@ -66,32 +74,91 @@ def downloadStats(driver: webdriver.Chrome):
stats = htmlBS.find_all('div', attrs={'style': 'display: block;'})
statsDict = {
playerStats = {
stat.div.text: stat.span.text.replace('\u21d3', 'down').replace('down/', 'down /').replace('\u21d1 ', 'up').replace('\u21d1', 'up').replace('\u00b7', '-').strip()
for stat in stats
}
for stat in statsDict:
keys = list(playerStats.keys())
for stat in keys:
if 'Viewport / Frames' == stat:
viewport, frames = playerStats[stat].split(' / ')
width, height = viewport.split('x')
height, devicePixelRatio = height.split('*')
dropped, total = frames.split(' of ')[0].split()[0], frames.split(' of ')[1].split()[0]
playerStats[stat] = {'Width': int(width), 'Height': int(height), 'Pixel ratio': float(devicePixelRatio), 'Frames': {'Dropped': int(dropped), 'Total': int(total)}}
if 'Codecs' == stat:
video, audio = playerStats[stat].split(' / ')
playerStats[stat] = {'Video': video, 'Audio': audio}
if 'Volume' == stat:
if ' (' in playerStats[stat]:
volume, muted = playerStats[stat].split(' (')
playerStats[stat] = {'Volume': int(volume), 'Muted': 'muted' in muted}
else:
playerStats[stat] = {'Volume': int(playerStats[stat]), 'Muted': False}
if 'Connection Speed' == stat:
speed, unit = playerStats[stat].split()
speedBytes = int(speed) * (1024 ** {'B/s': 0, 'KB/s': 1, 'MB/s': 2, 'GB/s': 3}[unit])
playerStats[stat] = {'Speed': int(speedBytes), 'Granularity': 's'}
if 'Network Activity' == stat:
downString, upString = playerStats[stat].split(' / ')
down, downUnit = downString.replace('down', '').strip().split()
up, upUnit = upString.replace('up', '').strip().split()
downBytes = int(down) * (1024 ** {'B': 0, 'KB': 1, 'MB': 2, 'GB': 3}[downUnit])
upBytes = int(up) * (1024 ** {'B': 0, 'KB': 1, 'MB': 2, 'GB': 3}[upUnit])
playerStats[stat] = {'Down': downBytes, 'Up': upBytes}
if 'Total Transfered' == stat:
downString, upString = playerStats[stat].split(' / ')
down, downUnit = downString.replace('down', '').strip().split()
up, upUnit = upString.replace('up', '').strip().split()
downBytes = int(down) * (1024 ** {'B': 0, 'KB': 1, 'MB': 2, 'GB': 3}[downUnit])
upBytes = int(up) * (1024 ** {'B': 0, 'KB': 1, 'MB': 2, 'GB': 3}[upUnit])
playerStats[stat] = {'Down': downBytes, 'Up': upBytes}
if 'Download Breakdown' == stat:
server, peer = playerStats[stat].split(' - ')
server, serverUnit = server.replace('from servers', '').strip().split()
peer, peerUnit = peer.replace('from peers', '').strip().split()
serverBytes = int(server) * (1024 ** {'B': 0, 'KB': 1, 'MB': 2, 'GB': 3}[serverUnit])
peerBytes = int(peer) * (1024 ** {'B': 0, 'KB': 1, 'MB': 2, 'GB': 3}[peerUnit])
playerStats[stat] = {'Server': serverBytes, 'Peer': peerBytes}
if 'Buffer State' == stat:
statsDict[stat] = statsDict[stat][1:-1].split(', ')
del(playerStats[stat])
# statsDict = {
# 'userName': dict(
# map(
# lambda stat: (
# stat.div.text,
# stat.span.text.replace('\u21d3', 'down').replace('down/', 'down /').replace('\u21d1 ', 'up').replace('\u21d1', 'up').replace('\u00b7', '-').strip()
# ), stats
# )
# )
# }
if 'Live Latency' == stat:
latency, edge = playerStats[stat].split(' (from edge: ')
latency = sum(int(x) * 60 ** i for i, x in enumerate(reversed([part for part in latency.replace('s', '').split('m') if part])))
edge = sum(int(x) * 60 ** i for i, x in enumerate(reversed([part for part in edge.replace('s', '').replace(')', '').split('m') if part])))
playerStats[stat] = {'Latency': latency, 'Edge': edge}
statsDict.update({'Timestamp': time.strftime('%Y-%m-%dT%H:%M:%S%z')})
statsDict['userName'] = 'user'
stats = {
'player': playerStats,
'peers': peersDict,
'url': driver.current_url,
'timestamp': int(time.time())
}
saveStats(statsDict)
saveStats([stats])
def setupStats(driver: webdriver.Chrome, url: str):
logger.log(logging.INFO, 'Setting up stats.')
actions = ActionChains(driver)
wait = WebDriverWait(driver, 30, poll_frequency=0.2)
@@ -99,11 +166,13 @@ def setupStats(driver: webdriver.Chrome, url: str):
wait.until(ec.presence_of_element_located((By.CLASS_NAME, 'vjs-big-play-button')))
actions.click(driver.find_element(By.CLASS_NAME ,'video-js')).perform()
actions.pause(2)
actions.context_click(driver.find_element(By.CLASS_NAME ,'video-js')).perform()
statsForNerds = driver.find_elements(By.CLASS_NAME ,'vjs-menu-item')
actions.pause(2)
actions.click(statsForNerds[-1]).perform()
wait.until(ec.text_to_be_present_in_element((By.CLASS_NAME, 'vjs-stats-list'), 'Player'))
logger.log(logging.INFO, 'Stats setup complete.')
return driver
@@ -116,6 +185,10 @@ if __name__ == '__main__':
setupStats(driver, "https://tube.kobim.cloud/w/9hAbiwai4rsbw9QnPpPkCd")
schedule.every(1).seconds.do(downloadStats, driver)
while True:
schedule.run_pending()
logger.log(logging.INFO, 'Starting server collector.')
httpd = HTTPServer(('localhost', 9092), partial(Handler, downloadStats, driver, logger))
httpd.serve_forever()
#schedule.every(2).seconds.do(downloadStats, driver)
#while True:
#schedule.run_pending()

View File

@@ -1,3 +1 @@
jupyterlab
selenium
schedule

View File

@@ -1,212 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "27784c31-2579-4c9f-802a-de9b9e1471d1",
"metadata": {},
"source": [
"# Statistiche per Nerd homebrew"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "5d3f30ae-baa7-4703-870f-921829fd8c34",
"metadata": {},
"outputs": [],
"source": [
"import schedule\n",
"import signal\n",
"import json\n",
"import time\n",
"import socket\n",
"from bs4 import BeautifulSoup as bs\n",
"from selenium import webdriver\n",
"from selenium.webdriver.chrome.options import Options\n",
"from selenium.webdriver.common.by import By\n",
"from selenium.webdriver import ActionChains\n",
"from selenium.webdriver.support.wait import WebDriverWait\n",
"from selenium.webdriver.support import expected_conditions as ec\n",
"from IPython.display import display, DisplayHandle, Image"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "aa49216c",
"metadata": {},
"outputs": [],
"source": [
"def interrupt_handler(signum, driver: webdriver.Chrome):\n",
" print(f'Handling signal {signum} ({signal.Signals(signum).name}).')\n",
"\n",
" schedule.clear()\n",
" driver.quit()\n",
" %reset_selective -f driver\n",
" raise SystemExit"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "db04c5f7",
"metadata": {},
"outputs": [],
"source": [
"def setupChromeDriver():\n",
" chrome_options = Options()\n",
" chrome_options.add_argument(\"--headless\")\n",
" chrome_options.add_argument(\"--no-sandbox\")\n",
" chrome_options.add_argument(\"--mute-audio\")\n",
" chrome_options.add_argument(\"--window-size=1280,720\")\n",
" chrome_options.add_argument(\"--disable-dev-shm-usage\")\n",
" chrome_options.add_argument(\"--disable-features=WebRtcHideLocalIpsWithMdns\")\n",
" chrome_options.add_experimental_option('prefs', {'intl.accept_languages': 'en,en_US'})\n",
" #chrome_options.add_extension('./qryn-webrtc-exporter.crx')\n",
"\n",
" driver = webdriver.Chrome(options=chrome_options)\n",
" #driver = webdriver.Remote(command_executor='http://localhost:4444', options=chrome_options)\n",
" return driver"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9c6ed068",
"metadata": {},
"outputs": [],
"source": [
"def saveStats(stats: dict):\n",
" try:\n",
" sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n",
" sock.sendto(json.dumps(stats).encode(), ('localhost', 8094))\n",
" sock.close()\n",
" except socket.error as e:\n",
" print(f'Got socket error: {e}')\n",
"\n",
"def downloadStats(driver: webdriver.Chrome, display_handle: DisplayHandle):\n",
" html = driver.find_element(By.CLASS_NAME ,'vjs-stats-list')\n",
" htmlBS = bs(html.get_attribute('innerHTML'), 'html.parser')\n",
"\n",
" timestampDiv = htmlBS.new_tag('div', attrs={'style': 'display: block;'})\n",
" timestampTitleDiv = htmlBS.new_tag('div')\n",
" timestampTitleDiv.string = 'Timestamp'\n",
" timestampDiv.append(timestampTitleDiv)\n",
" timestampSpan = htmlBS.new_tag('span')\n",
" timestampSpan.string = time.strftime('%Y-%m-%dT%H:%M:%S%z')\n",
" timestampDiv.append(timestampSpan)\n",
" htmlBS.div.insert_before(timestampDiv)\n",
"\n",
" peers = driver.find_element(By.CLASS_NAME ,'peers-number').text\n",
" peersDiv = htmlBS.new_tag('div', attrs={'style': 'display: block;'})\n",
" peersTitleDiv = htmlBS.new_tag('div')\n",
" peersTitleDiv.string = 'Peers'\n",
" peersDiv.append(peersTitleDiv)\n",
" peersSpan = htmlBS.new_tag('span')\n",
" peersSpan.string = peers\n",
" peersDiv.append(peersSpan)\n",
" htmlBS.div.insert_before(peersDiv)\n",
" \n",
" stats = htmlBS.find_all('div', attrs={'style': 'display: block;'})\n",
"\n",
" statsDict = {\n",
" stat.div.text: stat.span.text.replace('\\u21d3', 'down').replace('down/', 'down /').replace('\\u21d1 ', 'up').replace('\\u21d1', 'up').replace('\\u00b7', '-').strip()\n",
" for stat in stats\n",
" }\n",
" \n",
" for stat in statsDict:\n",
" if 'Buffer State' == stat:\n",
" statsDict[stat] = statsDict[stat][1:-1].split(', ')\n",
"\n",
" # statsDict = {\n",
" # 'userName': dict(\n",
" # map(\n",
" # lambda stat: (\n",
" # stat.div.text, \n",
" # stat.span.text.replace('\\u21d3', 'down').replace('down/', 'down /').replace('\\u21d1 ', 'up').replace('\\u21d1', 'up').replace('\\u00b7', '-').strip()\n",
" # ), stats\n",
" # )\n",
" # )\n",
" # }\n",
"\n",
" statsDict.update({'Timestamp': time.strftime('%Y-%m-%dT%H:%M:%S%z')})\n",
" statsDict['userName'] = 'user'\n",
"\n",
" display_handle.update(json.dumps(statsDict))\n",
"\n",
" saveStats(statsDict)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a812fea1-55f9-4c5d-87d6-d884df5ed1ba",
"metadata": {},
"outputs": [],
"source": [
"def setupStats(driver: webdriver.Chrome, url: str):\n",
" actions = ActionChains(driver)\n",
" wait = WebDriverWait(driver, 30, poll_frequency=0.2)\n",
"\n",
" driver.get(url)\n",
"\n",
" wait.until(ec.presence_of_element_located((By.CLASS_NAME, 'vjs-big-play-button')))\n",
" actions.click(driver.find_element(By.CLASS_NAME ,'video-js')).perform()\n",
" actions.context_click(driver.find_element(By.CLASS_NAME ,'video-js')).perform()\n",
" statsForNerds = driver.find_elements(By.CLASS_NAME ,'vjs-menu-item')\n",
" actions.pause(2)\n",
" actions.click(statsForNerds[-1]).perform()\n",
" wait.until(ec.text_to_be_present_in_element((By.CLASS_NAME, 'vjs-stats-list'), 'Player'))\n",
" actions.move_to_element(driver.find_element(By.CLASS_NAME ,'vjs-peertube')).perform()\n",
"\n",
" display(Image(driver.get_screenshot_as_png()))\n",
"\n",
" return driver"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "221fa21b-aaf4-493f-b344-2d1a86c85c64",
"metadata": {
"scrolled": true
},
"outputs": [],
"source": [
"if __name__ == '__main__':\n",
" driver = setupChromeDriver()\n",
"\n",
" signal.signal(signal.SIGINT, lambda signum, frame: interrupt_handler(signum, driver))\n",
" \n",
" setupStats(driver, \"https://tube.kobim.cloud/w/9hAbiwai4rsbw9QnPpPkCd\")\n",
"\n",
" display_handle = display(\"Loading...\", display_id=True)\n",
" \n",
" schedule.every(1).seconds.do(downloadStats, driver, display_handle)\n",
" while True:\n",
" schedule.run_pending()"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.5"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@@ -1,60 +1,78 @@
[[processors.dedup]]
dedup_interval = "600s"
[[outputs.file]]
files = ["stdout"]
# [[outputs.mongodb]]
# dsn = "mongodb://192.168.86.40:27017"
# database = "peertube"
# granularity = "seconds"
# authentication = "SCRAM"
# username = "root"
# password = "example"
[[inputs.socket_listener]]
service_address = "udp://:8094"
data_format = "xpath_json"
[[inputs.socket_listener.xpath]]
metric_name = "'stats'"
metric_name = "'statistics'"
metric_selection = "/*"
timestamp = "timestamp"
timestamp_format = "unix"
[inputs.socket_listener.xpath.tags]
url = "url"
#id = "''"
#state = "'connected'"
[inputs.socket_listener.xpath.fields]
P2P = "/P2P"
#Buffer_State = "string-join(/Buffer_State/*, ',')"
Buffer_State = "/text()='Buffer State'"
Resolution = "/Resolution"
player = "player"
#peerValues = "concat('[', string-join(peers/*, ','), ']')"
peers = "peers"
# [[inputs.socket_listener]]
# service_address = "udp://:8094"
# data_format = "json_v2"
# [[inputs.socket_listener.json_v2]]
# measurement_name = "stats"
#[[inputs.http_listener_v2]]
# service_address = ":9091"
# paths = ["/webrtc-internals-exporter"]
# data_format = "json_v2"
# methods = ["POST"]
# http_success_code = 204
#
# [[inputs.http_listener_v2.json_v2]]
# measurement_name = "stats"
# timestamp_path = "@this.0.values.0.timestamp"
# timestamp_format = "unix_ms"
#
# [[inputs.http_listener_v2.json_v2.object]]
# path = "@this"
# tags = ["id", "url", "state"]
# [[inputs.socket_listener.json_v2.field]]
# path = "P2P"
# [[inputs.socket_listener.json_v2.field]]
# path = "Player mode"
# [[inputs.socket_listener.json_v2.field]]
# path = "P2P"
# [[inputs.socket_listener.json_v2.field]]
# path = "Video UUID"
# [[inputs.socket_listener.json_v2.field]]
# path = "Viewport / Frames"
# [[inputs.socket_listener.json_v2.field]]
# path = "Resolution"
# [[inputs.socket_listener.json_v2.field]]
# path = "Volume"
# [[inputs.socket_listener.json_v2.field]]
# path = "Codecs"
# [[inputs.socket_listener.json_v2.field]]
# path = "Connection Speed"
# [[inputs.socket_listener.json_v2.field]]
# path = "Network Activity"
# [[inputs.socket_listener.json_v2.field]]
# path = "Total Transfered"
# [[inputs.socket_listener.json_v2.field]]
# path = "Download Breakdown"
# [[inputs.socket_listener.json_v2.field]]
# path = "Buffer State"
# [[inputs.socket_listener.json_v2.field]]
# path = "Live Latency"
[[inputs.http_listener_v2]]
service_address = ":9091"
paths = ["/webrtc-internals-exporter"]
methods = ["POST"]
http_success_code = 204
data_format = "xpath_json"
[[inputs.http_listener_v2.xpath]]
metric_name = "'statistics'"
metric_selection = "/*"
field_selection = "values/*"
field_name = "type"
field_value = "."
timestamp = "/*/values[last()]/*/timestamp"
timestamp_format = "unix_ms"
[inputs.http_listener_v2.xpath.tags]
url = "url"
state = "state"
id = "''"
[inputs.http_listener_v2.xpath.fields]
#values = "values/*"
peerConnectionId = "id"
peerValues = "concat('[', string-join(values/*, ','), ']')"
[[outputs.file]]
files = ["stdout"]
data_format = "json"
[[outputs.mongodb]]
dsn = "mongodb://192.168.68.204:27017"
database = "peertube"
granularity = "seconds"
#authentication = "SCRAM"
#username = "root"
#password = "example"

View File

@@ -0,0 +1,24 @@
import json
import logging
from http.server import BaseHTTPRequestHandler
class Handler(BaseHTTPRequestHandler):
def __init__(self, custom_func, driver, logger, *args, **kwargs):
self._custom_func = custom_func
self.logger = logger
self.driver = driver
super().__init__(*args, **kwargs)
def do_POST(self):
if self.path == '/webrtc-internals-exporter':
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
self.logger.log(logging.DEBUG, f"POST request,\nPath: {self.path}\nHeaders:\n{self.headers}\n\nBody:\n{post_data.decode('utf-8')}")
self._custom_func(self.driver, json.loads(post_data.decode('utf-8')))
self.send_response(200)
self.end_headers()
self.wfile.write(b'POST request received')
else:
self.send_response(404)
self.end_headers()
self.wfile.write(b'404 Not Found')

View File

@@ -0,0 +1,37 @@
# WebRTC Internals Exporter
A Chromium browser extension that allows to collect WebRTC stats and export them to a Prometheus PushGateway service.
## Install
### Using the Chrome Web Store
[Link](https://chromewebstore.google.com/detail/webrtc-internals-exporter/jbgkajlogkmfemdjhiiicelanbipacpa)
### Using the packed extension
Download the `.crx` file from the [releases page](https://github.com/vpalmisano/webrtc-internals-exporter/releases) and drop it
into the [chrome://extensions/](chrome://extensions/) page.
Alternatively, you can download a `.zip` or `tar.gz` file from the releases page
and load the decompressed folder as an unpacked extension.
Ref. https://developer.chrome.com/docs/extensions/mv3/hosting/
### From sources
Run the `./build.sh` script and load the `build` folder as an unpacked extension
in your Chromium browser after enabling the developer mode.
## Usage
1. Visit the extension options page, set the PushGateway URL and, optionally, the username and password.
2. Load the page where you want to collect the stats and click on the extension icon to enable the stats collection on that URL (disabled by default).
3. The stats will be collected and sent to the PushGateway service. You can use the provided [Grafana dashboard](https://github.com/vpalmisano/webrtc-internals-exporter/tree/main/grafana) to visualize them.
## Debugging
The extension logs are available in the browser console after setting:
```js
localStorage.setItem("webrtc-internal-exporter:debug", "true")
```
The running PeerConnections objects can be manually inspected using the following
command in the browser console:
```js
> webrtcInternalExporter.peerConnections
Map(1) {'b03c3616-3f91-42b5-85df-7dbebefae8bd' => RTCPeerConnection}
```

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.version[data-v-4cce5501]{text-decoration:none}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{_ as y,a as O,h as o,j as c,k as _,l as a,m as t,x,q as w,t as k,z as V}from"./_plugin-vue_export-helper-deb87276.js";const C={class:"version",href:"https://github.com/vpalmisano/webrtc-internals-exporter",target:"_blank",title:"Homepage"},B={__name:"Popup",setup(N){var i;const e=O({version:"0.1.9",error:"",info:"",enabled:!1,origin:"",enabledOrigins:{}});async function d(){const{enabledOrigins:n}=await chrome.storage.sync.get("enabledOrigins");e.enabledOrigins=n,e.enabled=!!e.enabledOrigins[e.origin]}async function p(n){if(console.log("saveOptions",e.origin,n),n)e.enabledOrigins={...e.enabledOrigins,[e.origin]:n};else{const r={...e.enabledOrigins};delete r[e.origin],e.enabledOrigins=r}chrome.storage&&await chrome.storage.sync.set({enabledOrigins:e.enabledOrigins})}return(i=chrome.tabs)==null||i.query({active:!0,currentWindow:!0}).then(n=>{const r=n[0];return chrome.scripting.executeScript({target:{tabId:r.id},function:()=>window.location.origin})}).then(n=>{e.origin=n[0].result}).then(()=>d()).catch(n=>{console.error("Load options error:",n),e.error=`Load options error: ${n.message}`}),(n,r)=>{const u=o("v-app-bar"),g=o("v-alert"),m=o("v-checkbox"),s=o("v-col"),l=o("v-row"),b=o("v-container"),v=o("v-main"),f=o("v-layout");return c(),_(f,null,{default:a(()=>[t(u,{title:"WebRTC Internals Exporter",color:"primary",density:"compact"}),t(v,{class:"d-flex align-center justify-left",style:{"min-width":"20rem"}},{default:a(()=>[t(b,null,{default:a(()=>[t(l,null,{default:a(()=>[t(s,{cols:"12",md:"12"},{default:a(()=>[e.error.length>0?(c(),_(g,{key:0,text:e.error,type:"error"},null,8,["text"])):x("",!0),t(m,{color:"indigo",modelValue:e.enabled,"onUpdate:modelValue":[r[0]||(r[0]=h=>e.enabled=h),p],label:"Enable for "+e.origin,"hide-details":""},null,8,["modelValue","label"])]),_:1})]),_:1}),t(l,null,{default:a(()=>[t(s,{cols:"12",md:"12"},{default:a(()=>[w("a",C,"v"+k(e.version),1)]),_:1})]),_:1})]),_:1})]),_:1})]),_:1})}}},I=y(B,[["__scopeId","data-v-16634bc6"]]);V(I);

View File

@@ -0,0 +1 @@
.version[data-v-16634bc6]{font-size:smaller;text-decoration:none}

View File

@@ -0,0 +1,373 @@
/* global chrome, pako */
function log(...args) {
console.log.apply(null, ["[webrtc-internal-exporter:background]", ...args]);
}
log("loaded");
import "/assets/pako.min.js";
const DEFAULT_OPTIONS = {
url: "http://localhost:9092",
username: "",
password: "",
updateInterval: 2,
gzip: false,
job: "webrtc-internals-exporter",
enabledOrigins: {
"https://tube.kobim.cloud": true,
},
enabledStats: ["data-channel", "local-candidate", "remote-candidate"]
};
const options = {};
// Handle install/update.
chrome.runtime.onInstalled.addListener(async ({ reason }) => {
log("onInstalled", reason);
if (reason === "install") {
await chrome.storage.sync.set(DEFAULT_OPTIONS);
} else if (reason === "update") {
const options = await chrome.storage.sync.get();
await chrome.storage.sync.set({
...DEFAULT_OPTIONS,
...options,
});
}
await chrome.alarms.create("webrtc-internals-exporter-alarm", {
delayInMinutes: 1,
periodInMinutes: 1,
});
});
async function updateTabInfo(tab) {
const tabId = tab.id;
const origin = new URL(tab.url || tab.pendingUrl).origin;
if (options.enabledOrigins && options.enabledOrigins[origin] === true) {
const { peerConnectionsPerOrigin } = await chrome.storage.local.get(
"peerConnectionsPerOrigin",
);
const peerConnections =
(peerConnectionsPerOrigin && peerConnectionsPerOrigin[origin]) || 0;
chrome.action.setTitle({
title: `WebRTC Internals Exporter\nActive Peer Connections: ${peerConnections}`,
tabId,
});
chrome.action.setBadgeText({ text: `${peerConnections}`, tabId });
chrome.action.setBadgeBackgroundColor({ color: "rgb(63, 81, 181)", tabId });
} else {
chrome.action.setTitle({
title: `WebRTC Internals Exporter (disabled)`,
tabId,
});
chrome.action.setBadgeText({ text: "", tabId });
}
}
async function optionsUpdated() {
const [tab] = await chrome.tabs.query({
active: true,
lastFocusedWindow: true,
});
await updateTabInfo(tab);
}
chrome.storage.sync.get().then((ret) => {
Object.assign(options, ret);
log("options loaded");
optionsUpdated();
});
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "sync") return;
for (let [key, { newValue }] of Object.entries(changes)) {
options[key] = newValue;
}
log("options changed");
optionsUpdated();
});
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
try {
const tab = await chrome.tabs.get(tabId);
await updateTabInfo(tab);
} catch (err) {
log(`get tab error: ${err.message}`);
}
});
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => {
if (!changeInfo.url) return;
await updateTabInfo({ id: tabId, url: changeInfo.url });
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "webrtc-internals-exporter-alarm") {
cleanupPeerConnections().catch((err) => {
log(`cleanup peer connections error: ${err.message}`);
});
}
});
async function setPeerConnectionLastUpdate({ id, origin }, lastUpdate = 0) {
let { peerConnectionsLastUpdate } = await chrome.storage.local.get(
"peerConnectionsLastUpdate",
);
if (!peerConnectionsLastUpdate) {
peerConnectionsLastUpdate = {};
}
if (lastUpdate) {
peerConnectionsLastUpdate[id] = { origin, lastUpdate };
} else {
delete peerConnectionsLastUpdate[id];
}
await chrome.storage.local.set({ peerConnectionsLastUpdate });
const peerConnectionsPerOrigin = {};
Object.values(peerConnectionsLastUpdate).forEach(({ origin: o }) => {
if (!peerConnectionsPerOrigin[o]) {
peerConnectionsPerOrigin[o] = 0;
}
peerConnectionsPerOrigin[o]++;
});
await chrome.storage.local.set({ peerConnectionsPerOrigin });
await optionsUpdated();
}
async function cleanupPeerConnections() {
let { peerConnectionsLastUpdate } = await chrome.storage.local.get(
"peerConnectionsLastUpdate",
);
if (
!peerConnectionsLastUpdate ||
!Object.keys(peerConnectionsLastUpdate).length
) {
return;
}
log(
`checking stale peer connections (${
Object.keys(peerConnectionsLastUpdate).length
} total)`,
);
const now = Date.now();
await Promise.allSettled(
Object.entries(peerConnectionsLastUpdate)
.map(([id, { origin, lastUpdate }]) => {
if (
now - lastUpdate >
Math.max(2 * options.updateInterval, 30) * 1000
) {
return { id, origin };
}
})
.filter((ret) => !!ret?.id)
.map(({ id, origin }) => {
log(`removing stale peer connection metrics: ${id} ${origin}`);
return sendData("DELETE", { id, origin });
}),
);
}
// Send data to pushgateway.
async function sendData(method, { id, origin }, data) {
const { url, username, password, gzip, job } = options;
const headers = {
"Content-Type": "application/json",
};
if (username && password) {
headers.Authorization = "Basic " + btoa(`${username}:${password}`);
}
if (data && gzip) {
headers["Content-Encoding"] = "gzip";
data = await pako.gzip(data);
}
log(`sendData: ${data} \n ${data.length} bytes (gzip: ${gzip}) url: ${url} job: ${job}`);
const start = Date.now();
const response = await fetch(
`${url}/metrics/job/${job}/peerConnectionId/${id}`,
{
method,
headers,
body: method === "POST" ? data : undefined,
},
);
const stats = await chrome.storage.local.get([
"messagesSent",
"bytesSent",
"totalTime",
"errors",
]);
if (data) {
stats.messagesSent = (stats.messagesSent || 0) + 1;
stats.bytesSent = (stats.bytesSent || 0) + data.length;
stats.totalTime = (stats.totalTime || 0) + Date.now() - start;
}
if (!response.ok) {
stats.errors = (stats.errors || 0) + 1;
}
await chrome.storage.local.set(stats);
if (!response.ok) {
const text = await response.text();
throw new Error(`Response status: ${response.status} error: ${text}`);
}
await setPeerConnectionLastUpdate(
{ id, origin },
method === "POST" ? start : undefined,
);
return response.text();
}
async function sendJsonData(method, { id, origin }, data) {
const { url, username, password, gzip, job } = options;
const headers = {
"Content-Type": "application/json",
};
if (username && password) {
headers.Authorization = "Basic " + btoa(`${username}:${password}`);
}
if (data && gzip) {
headers["Content-Encoding"] = "gzip";
data = await pako.gzip(data);
}
log(`sendData: ${data} \n ${data.length} bytes (gzip: ${gzip}) url: ${url} job: ${job}`);
const start = Date.now();
const response = await fetch(
`${url}/${job}`,
{
method,
headers,
body: method === "POST" ? data : undefined,
},
);
const stats = await chrome.storage.local.get([
"messagesSent",
"bytesSent",
"totalTime",
"errors",
]);
if (data) {
stats.messagesSent = (stats.messagesSent || 0) + 1;
stats.bytesSent = (stats.bytesSent || 0) + data.length;
stats.totalTime = (stats.totalTime || 0) + Date.now() - start;
}
if (!response.ok) {
stats.errors = (stats.errors || 0) + 1;
}
await chrome.storage.local.set(stats);
if (!response.ok) {
const text = await response.text();
throw new Error(`Response status: ${response.status} error: ${text}`);
}
await setPeerConnectionLastUpdate(
{ id, origin },
method === "POST" ? start : undefined,
);
return response.text();
}
const QualityLimitationReasons = {
none: 0,
bandwidth: 1,
cpu: 2,
other: 3,
};
/**
* sendPeerConnectionStats
* @param {string} url
* @param {string} id
* @param {RTCPeerConnectionState} state
* @param {any} values
*/
async function sendPeerConnectionStats(url, id, state, values) {
const origin = new URL(url).origin;
if (state === "closed") {
return sendData("DELETE", { id, origin });
}
let data = "";
const sentTypes = new Set();
values.forEach((value) => {
const type = value.type.replace(/-/g, "_");
const labels = [`pageUrl="${url}"`];
const metrics = [];
if (value.type === "peer-connection") {
labels.push(`state="${state}"`);
}
Object.entries(value).forEach(([key, v]) => {
if (typeof v === "number") {
metrics.push([key, v]);
} else if (typeof v === "object") {
Object.entries(v).forEach(([subkey, subv]) => {
if (typeof subv === "number") {
metrics.push([`${key}_${subkey}`, subv]);
}
});
} else if (
key === "qualityLimitationReason" &&
QualityLimitationReasons[v] !== undefined
) {
metrics.push([key, QualityLimitationReasons[v]]);
} else if (key === "googTimingFrameInfo") {
// TODO
} else {
labels.push(`${key}="${v}"`);
}
});
metrics.forEach(([key, v]) => {
const name = `${type}_${key.replace(/-/g, "_")}`;
let typeDesc = "";
if (!sentTypes.has(name)) {
typeDesc = `# TYPE ${name} gauge\n`;
sentTypes.add(name);
}
data += `${typeDesc}${name}{${labels.join(",")}} ${v}\n`;
});
});
if (data.length > 0) {
return sendData("POST", { id, origin }, data + "\n");
}
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.event === "peer-connection-stats") {
const { url, id, state, values } = message.data;
sendData("POST", { id, origin: new URL(url).origin }, JSON.stringify(message.data))
.then(() => {
sendResponse({});
})
.catch((err) => {
sendResponse({ error: err.message });
});
} else if (message.event === "peer-connections-stats") {
const { stats } = message.data;
sendJsonData("POST", { id: "all", origin: "all" }, JSON.stringify(message.data))
} else {
sendResponse({ error: "unknown event" });
}
return true;
});

View File

@@ -0,0 +1,123 @@
/* global chrome */
if (window.location.protocol.startsWith("http")) {
const log = (...args) => {
try {
if (localStorage.getItem("webrtc-internal-exporter:debug") === "true") {
console.log.apply(null, [
"[webrtc-internal-exporter:content-script]",
...args,
]);
}
} catch (error) {
// Ignore localStorage errors.
}
};
const injectScript = (file_path) => {
const head = document.querySelector("head");
const script = document.createElement("script");
script.setAttribute("type", "text/javascript");
script.setAttribute("src", file_path);
head.appendChild(script);
};
setTimeout(() => injectScript(chrome.runtime.getURL("override.js")));
// Handle options.
const options = {
url: "",
enabled: false,
updateInterval: 2000,
enabledStats: [],
};
const sendOptions = () => {
window.postMessage({
event: "webrtc-internal-exporter:options",
options,
});
};
try {
chrome.storage.sync
.get(["url", "enabledOrigins", "updateInterval", "enabledStats"])
.then((ret) => {
log(`options loaded:`, ret);
options.url = ret.url || "";
options.enabled =
ret.enabledOrigins &&
ret.enabledOrigins[window.location.origin] === true;
options.updateInterval = (ret.updateInterval || 2) * 1000;
options.enabledStats = Object.values(ret.enabledStats || {});
sendOptions();
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "sync") return;
let changed = false;
for (let [key, { newValue }] of Object.entries(changes)) {
if (key === "url") {
options.url = newValue;
changed = true;
} else if (key === "enabledOrigins") {
options.enabled = newValue[window.location.origin] === true;
changed = true;
} else if (key === "updateInterval") {
options.updateInterval = newValue * 1000;
changed = true;
} else if (key === "enabledStats") {
options.enabledStats = Object.values(newValue);
changed = true;
}
}
if (changed) {
log(`options changed:`, options);
sendOptions();
}
});
// Handle stats messages.
window.addEventListener("message", async (message) => {
const { event, url, id, state, values, stats } = message.data;
if (event === "webrtc-internal-exporter:ready") {
sendOptions();
} else if (event === "webrtc-internal-exporter:peer-connection-stats") {
try {
const response = await chrome.runtime.sendMessage({
event: "peer-connection-stats",
data: {
url,
id,
state,
values,
},
});
if (response.error) {
log(`error: ${response.error}`);
}
} catch (error) {
log(`error: ${error.message}`);
}
} else if (event === "webrtc-internal-exporter:peer-connections-stats") {
try {
const response = await chrome.runtime.sendMessage({
event: "peer-connections-stats",
data: stats,
});
if (response.error) {
log(`error: ${response.error}`);
}
} catch (error) {
log(`error: ${error.message}`);
}
}
});
} catch (error) {
console.error(
`[webrtc-internal-exporter:content-script] error: ${error.message}`,
error,
);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebRTC Internals Exporter</title>
<script type="module" crossorigin src="/assets/options-8c2aaa1b.js"></script>
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-deb87276.js">
<link rel="stylesheet" href="/assets/_plugin-vue_export-helper.css">
<link rel="stylesheet" href="/assets/options.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,135 @@
function log(...args) {
console.log.apply(null, ["[webrtc-internal-exporter:override]", ...args]);
}
log("Override RTCPeerConnection.");
class WebrtcInternalExporter {
peerConnections = new Map();
url = "";
enabled = false;
updateInterval = 2000;
enabledStats = [];
constructor() {
window.addEventListener("message", async (message) => {
const { event, options } = message.data;
if (event === "webrtc-internal-exporter:options") {
log("options updated:", options);
Object.assign(this, options);
}
});
window.postMessage({ event: "webrtc-internal-exporter:ready" });
this.collectAllStats();
}
randomId() {
return (
window.crypto?.randomUUID() || (2 ** 64 * Math.random()).toString(16)
);
}
add(pc) {
const id = this.randomId();
this.peerConnections.set(id, pc);
pc.addEventListener("connectionstatechange", () => {
if (pc.connectionState === "closed") {
this.peerConnections.delete(id);
}
});
//this.collectAndPostStats(id);
}
async collectAndPostSingleStat(id) {
const stats = await this.collectStats(id, this.collectAndPostSingleStat);
if (Object.keys(stats).length === 0 || !stats) return;
window.postMessage(
{
event: "webrtc-internal-exporter:peer-connection-stats",
...stats,
},
[stats],
);
}
async collectAllStats() {
const stats = [];
for (const [id, pc] of this.peerConnections) {
if (this.url && this.enabled && pc.connectionState === "connected") {
const pcStats = await this.collectStats(id, pc);
stats.push(pcStats);
}
}
//if (stats.length !== 0) {
window.postMessage(
{
event: "webrtc-internal-exporter:peer-connections-stats",
stats,
},
[stats],
);
log(`Stats collected:`, stats);
//}
setTimeout(this.collectAllStats.bind(this), this.updateInterval);
return stats;
}
async collectStats(id, pc, binding) {
var completeStats = {};
if (!pc) {
pc = this.peerConnections.get(id);
if (!pc) return;
}
if (this.url && this.enabled && pc.connectionState === "connected") {
try {
const stats = await pc.getStats();
const values = [...stats.values()].filter(
(v) =>
["peer-connection", ...this.enabledStats].indexOf(v.type) !== -1
);
completeStats = {
url: window.location.href,
id,
state: pc.connectionState,
values,
};
} catch (error) {
log(`collectStats error: ${error.message}`);
}
}
if (pc.connectionState === "closed") {
this.peerConnections.delete(id);
} else {
if (binding) {
setTimeout(binding.bind(this), this.updateInterval, id);
}
}
return completeStats;
}
}
const webrtcInternalExporter = new WebrtcInternalExporter();
window.RTCPeerConnection = new Proxy(window.RTCPeerConnection, {
construct(target, argumentsList) {
log(`RTCPeerConnection`, argumentsList);
const pc = new target(...argumentsList);
webrtcInternalExporter.add(pc);
return pc;
},
});

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebRTC Internals Exporter</title>
<script type="module" crossorigin src="/assets/popup-7cc154e5.js"></script>
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-deb87276.js">
<link rel="stylesheet" href="/assets/_plugin-vue_export-helper.css">
<link rel="stylesheet" href="/assets/popup.css">
</head>
<body>
<div id="app"></div>
</body>
</html>