Compare commits

...

17 Commits

Author SHA1 Message Date
76edbf0624 feat: add MongoDB and Mongo Express services with initialization script
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m41s
2025-02-09 17:16:16 +01:00
280a013c48 feat: CI-CD workflow for building and pushing Docker images (#2)
Reviewed-on: #2
Co-authored-by: Mirko Milovanovic <mir_ko@me.com>
Co-committed-by: Mirko Milovanovic <mir_ko@me.com>
2025-02-09 17:16:16 +01:00
a7bdf93abd Update README.md 2025-02-09 10:47:07 +00:00
7b78f54510 feat: enhance health checks in docker-compose and improve WebRTC stats collection 2025-02-09 02:24:38 +01:00
be0e0f8153 feat: add .env.example file and update README with prerequisites and setup instructions 2025-02-07 18:56:32 +01:00
583687af2b Update README.md 2025-02-07 16:22:37 +00:00
640932153b Update README.md 2025-02-07 16:21:19 +00:00
19ac4dec45 feat: disabled enabledOrigins handling and clean up unused code in webrtc-internals-exporter 2025-02-07 10:25:20 +01:00
3a31a30fc7 feat: add health checks and restart policies in docker-compose, update Telegraf configuration 2025-02-06 22:40:23 +01:00
55a31bdbb1 feat: adjust ChromeDriver options and update Telegraf flush interval 2025-02-06 10:39:31 +01:00
7a8a8ee260 minor improvements 2025-02-06 00:54:11 +01:00
4aa164d148 feat: update docker-compose and service configurations for improved connectivity 2025-02-05 23:37:49 +01:00
9d79cecb01 feat: update collector service image in docker-compose.yml 2025-02-05 16:47:38 +01:00
a50eec4fc4 docs: update README to reflect project structure changes 2025-02-05 16:20:13 +01:00
fb765920b4 feat: add environment variable support and update README instructions 2025-02-05 16:16:57 +01:00
6a7d4e922b docs: add README.md with project overview and setup instructions 2025-02-05 11:09:34 +01:00
c19ecae493 refactor: change driver type in setupStats function from Chrome to Remote 2025-02-05 01:57:40 +01:00
16 changed files with 425 additions and 272 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# User defined hostname persisted across all sessions, used to keep track of the same user
TELEGRAF_HOSTNAME=
# MongoDB connection string
TELEGRAF_MONGODB_DSN=mongodb://stats_user:%40z%5EVFhN7q%25vzit@tube.kobim.cloud:27107/?authSource=statistics
# MongoDB database name to store the data
TELEGRAF_MONGODB_DATABASE=statistics
# URL of the video to be analyzed
VIDEO_URL=https://tube.kobim.cloud/w/iN2T8PmbSb4HJTDA2rV3sg

View File

@@ -0,0 +1,14 @@
name: "Setup Docker Environment"
description: "Common steps for setting up Docker build environment (checkout, QEMU, and Buildx)"
runs:
using: "composite"
steps:
- name: Checkout repository
uses: actions/checkout@v4.2.2
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.4.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.9.0

27
.github/workflows/feature-pr-build.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Build Docker Image for Pull Request
on:
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository actions
uses: actions/checkout@v2
- name: Setup Docker Environment
uses: ./.github/actions/setup-docker-environment
- name: Build Docker image
uses: docker/build-push-action@v6.13.0
with:
context: .
push: false
tags: ${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.event.pull_request.number }}
platforms: |
linux/amd64
linux/arm64

36
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
env:
REGISTRY_URL: gitea.kobim.cloud
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository actions
uses: actions/checkout@v2
- name: Setup Docker Environment
uses: ./.github/actions/setup-docker-environment
- name: Log in to Docker registry
uses: docker/login-action@v3.3.0
with:
registry: ${{ env.REGISTRY_URL }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6.13.0
with:
context: .
push: true
tags: ${{ env.REGISTRY_URL }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
platforms: |
linux/amd64
linux/arm64

1
.gitignore vendored
View File

@@ -292,6 +292,7 @@ TSWLatexianTemp*
.ipynb_checkpoints/
env/
.env
__pycache__/
test/
venv/

101
README.md Normal file
View File

@@ -0,0 +1,101 @@
# PeerTube collector
peertube-collector is a project designed to collect and analyze WebRTC statistics from a Chromium browser and export them to a MongoDB service. This project includes a Docker setup for running the necessary services.
## Prerequisites
**Linux** based OS with the following:
### Software:
- Docker Engine Community version is required. To install Docker CE, follow the official [install instructions](https://docs.docker.com/engine/install/).
### Ports:
#### Localhost (REQUIRED):
- 4444 (Selenium)
Ports can be opened in the host machine's firewall with:
```sh
ufw allow from 172.30.0.0/16 to any port 4444
```
#### External (OPTIONAL):
These ports are actively used by selenium and the collector services. By defaut they should not be blocked by the firewall, but if so, they can be opened in the host machine's firewall.
- 50000:60000/udp (WebRTC)
- Needed for WebRTC NAT traversal, otherwise the browser will not connect to any peer.
The range needs to be fairly large since the port is chosen randomly by the STUN server.
- 27107/tcp (MongoDB)
Ports can be opened in the host machine's firewall with:
```sh
ufw allow 50000:60000/udp
ufw allow 27107/tcp
```
---
## Setup
1. Clone the repository:
```sh
git clone <repository-url>
cd peertube-collector
```
2. Create and configure the environment file based on the `.env.example` file:
```sh
cp .env.example .env
```
3. Ajust the firewall settings to allow the necessary ports if needed
4. Start the Docker containers:
```sh
docker compose up
```
or in detached mode:
```sh
docker compose up -d
```
To stop the Docker containers run: `docker compose down -v`
The collector will start gathering WebRTC stats from the Selenium container and sending them to the Telegraf service.
### Monitoring
A noVNC server is available at [http://localhost:7900](http://localhost:7900/?autoconnect=1&resize=scale&password=secret) to monitor the Selenium container. The password is `secret`.
## Components
### Docker Compose
The `docker-compose.yml` file defines the following services:
- **selenium**: Runs a Selenium standalone Chromium container.
- **telegraf**: Collects and sends metrics to the specified output.
- **collector**: Runs the main Python application to collect WebRTC stats.
### Dockerfile
The `Dockerfile` sets up the Python environment and installs the necessary dependencies to run the `main.py` script.
### Main Python Script
The `main.py` script sets up the Selenium WebDriver, collects WebRTC stats, and sends them to the Telegraf service.
### WebRTC Internals Exporter
The `webrtc-internals-exporter` directory contains a Chromium extension that collects WebRTC stats from the browser.
## Working Project Structure
```
peertube-collector/
├── requirements.txt
├── telegraf.conf
├── docker-compose.yml
├── Dockerfile
├── main.py
├── .env
└── utils/
└── webrtc-internals-exporter/
```

View File

@@ -2,40 +2,59 @@ services:
selenium:
container_name: selenium-standalone-chromium
image: selenium/standalone-chromium:129.0
ports:
- "7900:7900"
volumes:
- ./webrtc-internals-exporter:/tmp/webrtc-internals-exporter:ro
shm_size: "2g"
attach: false
depends_on:
telegraf:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4444/wd/hub/status"]
interval: 5s
timeout: 10s
retries: 5
networks:
- backend
network_mode: host
telegraf:
container_name: telegraf
image: telegraf:1.33.1
volumes:
- ./telegraf.conf:/etc/telegraf/telegraf.conf:ro
environment:
- DATABASE=${TELEGRAF_MONGODB_DATABASE:?"Database name is required"}
- DSN=${TELEGRAF_MONGODB_DSN:?"DSN is required"}
- HOSTNAME=${TELEGRAF_HOSTNAME:?"Hostname is required"}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080"]
interval: 5s
timeout: 10s
retries: 5
networks:
- backend
collector:
container_name: collector
build:
context: .
dockerfile: Dockerfile
image: gitea.kobim.cloud/kobim/peertube-collector
#build:
#context: .
#dockerfile: Dockerfile
depends_on:
selenium:
condition: service_healthy
telegraf:
condition: service_started
condition: service_healthy
environment:
- VIDEO_URL=${VIDEO_URL:?"Video URL is required"}
ports:
- "9092:9092"
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- backend
networks:
backend:
backend:
ipam:
config:
- subnet: 172.30.0.0/16

36
main.py
View File

@@ -28,7 +28,7 @@ def setupLogger():
)
logger.addHandler(logger_handler)
def interrupt_handler(signum, driver: webdriver.Chrome):
def interrupt_handler(signum, driver: webdriver.Remote):
logger.info(f'Handling signal {signum} ({signal.Signals(signum).name}).')
driver.quit()
@@ -41,7 +41,7 @@ def setupChromeDriver():
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("--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'))}")
@@ -49,7 +49,7 @@ def setupChromeDriver():
chrome_options.add_experimental_option('prefs', {'intl.accept_languages': 'en,en_US'})
#driver = webdriver.Chrome(options=chrome_options)
driver = webdriver.Remote(command_executor='http://selenium-standalone-chromium:4444', options=chrome_options)
driver = webdriver.Remote(command_executor='http://host.docker.internal:4444', options=chrome_options)
logger.log(logging.INFO, 'Chrome driver setup complete.')
return driver
@@ -101,9 +101,9 @@ def downloadStats(driver: webdriver.Chrome, peersDict: dict):
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])
speedBytes = float(speed) * (1024 ** {'B/s': 0, 'KB/s': 1, 'MB/s': 2, 'GB/s': 3}[unit])
playerStats[stat] = {'Speed': int(speedBytes), 'Granularity': 's'}
playerStats[stat] = {'Speed': speedBytes, 'Granularity': 's'}
if 'Network Activity' == stat:
downString, upString = playerStats[stat].split(' / ')
@@ -111,8 +111,8 @@ def downloadStats(driver: webdriver.Chrome, peersDict: dict):
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])
downBytes = convert_to_bytes(down, downUnit)
upBytes = convert_to_bytes(up, upUnit)
playerStats[stat] = {'Down': downBytes, 'Up': upBytes}
@@ -122,8 +122,8 @@ def downloadStats(driver: webdriver.Chrome, peersDict: dict):
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])
downBytes = convert_to_bytes(down, downUnit)
upBytes = convert_to_bytes(up, upUnit)
playerStats[stat] = {'Down': downBytes, 'Up': upBytes}
@@ -133,8 +133,8 @@ def downloadStats(driver: webdriver.Chrome, peersDict: dict):
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])
serverBytes = convert_to_bytes(server, serverUnit)
peerBytes = convert_to_bytes(peer, peerUnit)
playerStats[stat] = {'Server': serverBytes, 'Peers': peerBytes}
@@ -156,8 +156,11 @@ def downloadStats(driver: webdriver.Chrome, peersDict: dict):
}
saveStats([stats])
def convert_to_bytes(down, downUnit):
return float(down) * (1024 ** {'B': 0, 'KB': 1, 'MB': 2, 'GB': 3}[downUnit])
def setupStats(driver: webdriver.Chrome, url: str):
def setupStats(driver: webdriver.Remote, url: str):
logger.log(logging.INFO, 'Setting up stats.')
actions = ActionChains(driver)
wait = WebDriverWait(driver, 30, poll_frequency=0.2)
@@ -183,9 +186,14 @@ if __name__ == '__main__':
signal.signal(signal.SIGINT, lambda signum, frame: interrupt_handler(signum, driver))
setupStats(driver, "https://tube.kobim.cloud/w/iN2T8PmbSb4HJTDA2rV3sg")
url = os.getenv('VIDEO_URL')
if url is None:
logger.error('VIDEO_URL environment variable is not set.')
raise SystemExit(1)
setupStats(driver, url)
logger.log(logging.INFO, 'Starting server collector.')
httpd = HTTPServer(('collector', 9092), partial(Handler, downloadStats, driver, logger))
httpd = HTTPServer(('', 9092), partial(Handler, downloadStats, driver, logger))
logger.info('Server collector started.')
httpd.serve_forever()

5
server/.env.example Normal file
View File

@@ -0,0 +1,5 @@
# MongoDB Environment
MONGO_INITDB_ROOT_USERNAME=root
MONGO_INITDB_ROOT_PASSWORD=password
MONGO_EXPRESS_USERNAME=admin
MONGO_EXPRESS_PASSWORD=password

49
server/docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
services:
mongodb:
image: mongo:latest
container_name: mongodb
hostname: mongodb
volumes:
- ./mongodb/initdb.d/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
- mongodb-data:/data/db/
- mongodb-log:/var/log/mongodb/
env_file:
- .env
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}
ports:
- "27017:27017"
networks:
- mongodb_network
mongo-express:
image: mongo-express:latest
container_name: mongo-express
restart: always
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: ${MONGO_INITDB_ROOT_USERNAME}
ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}
ME_CONFIG_MONGODB_PORT: 27017
ME_CONFIG_MONGODB_SERVER: 'mongodb'
ME_CONFIG_BASICAUTH_USERNAME: ${MONGO_EXPRESS_USERNAME}
ME_CONFIG_BASICAUTH_PASSWORD: ${MONGO_EXPRESS_PASSWORD}
ports:
- 8081:8081
networks:
- mongodb_network
depends_on:
- mongodb
volumes:
mongodb-data:
driver: local
name: mongo-data
mongodb-log:
driver: local
name: mongo-log
networks:
mongodb_network:
driver: bridge
name: mongo-network

View File

@@ -0,0 +1,33 @@
db = db.getSiblingDB("statistics");
db.createRole({
role: "statsReadWrite",
privileges: [
{
resource: {
db: "statistics",
collection: "peertube",
},
actions: ["insert"],
},
],
roles: [
{
role: "read",
db: "statistics",
},
],
});
db.createUser({
user: "stats_user",
pwd: "@z^VFhN7q%vzit",
roles: [
{
role: 'statsReadWrite',
db: 'statistics',
},
],
});
db.createCollection("peertube");

View File

@@ -1,3 +1,8 @@
[agent]
flush_interval = "20s"
hostname = "${HOSTNAME}"
omit_hostname = false
[[processors.dedup]]
dedup_interval = "600s"
@@ -14,20 +19,20 @@
[inputs.socket_listener.xpath.tags]
url = "url"
session = "session"
#id = ??
#state = ??
host = "host"
[inputs.socket_listener.xpath.fields]
player = "player"
peers = "peers"
[[outputs.health]]
service_address = "http://:8080"
[[outputs.file]]
files = ["stdout"]
data_format = "json"
[[outputs.mongodb]]
dsn = "mongodb://stats_user:%40z%5EVFhN7q%25vzit@192.168.86.120:27017/?authSource=statistics"
database = "statistics"
granularity = "seconds"
# docker run --rm -v .\peertube\statnerd\telegraf.conf:/etc/telegraf/telegraf.conf:ro -p 8094:8094/udp telegraf
dsn = "${DSN}"
database = "${DATABASE}"
granularity = "seconds"

View File

@@ -21,4 +21,9 @@ class Handler(BaseHTTPRequestHandler):
else:
self.send_response(404)
self.end_headers()
self.wfile.write(b'404 Not Found')
self.wfile.write(b'404 Not Found')
def do_GET(self):
self.send_response(404)
self.end_headers()
self.wfile.write(b'404 Not Found')

View File

@@ -9,15 +9,13 @@ log("loaded");
import "/assets/pako.min.js";
const DEFAULT_OPTIONS = {
url: "http://collector:9092",
url: "http://localhost:9092",
username: "",
password: "",
updateInterval: 2,
gzip: false,
job: "webrtc-internals-exporter",
enabledOrigins: {
"https://tube.kobim.cloud": true,
},
enabledOrigins: { },
enabledStats: ["data-channel", "local-candidate", "remote-candidate"]
};
@@ -35,18 +33,13 @@ chrome.runtime.onInstalled.addListener(async ({ reason }) => {
...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) {
if (options.enabledOrigins) {
const { peerConnectionsPerOrigin } = await chrome.storage.local.get(
"peerConnectionsPerOrigin",
);
@@ -106,76 +99,8 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => {
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) {
// Send data to POST handler.
async function sendJsonData(method, data) {
const { url, username, password, gzip, job } = options;
const headers = {
"Content-Type": "application/json",
@@ -187,59 +112,7 @@ async function sendData(method, { id, origin }, data) {
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}`);
log(`sendJsonData: ${data} \n ${data.length} bytes (gzip: ${gzip}) url: ${url} job: ${job}`);
const start = Date.now();
const response = await fetch(
`${url}/${job}`,
@@ -271,90 +144,13 @@ async function sendJsonData(method, { id, origin }, data) {
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))
sendJsonData("POST", JSON.stringify(message.data))
.then(() => {
sendResponse({});
})
@@ -362,9 +158,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
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))
sendJsonData("POST", JSON.stringify(message.data))
.then(() => {
sendResponse({});
})
.catch((err) => {
sendResponse({ error: err.message });
});
} else {
sendResponse({ error: "unknown event" });
}

View File

@@ -46,8 +46,7 @@ if (window.location.protocol.startsWith("http")) {
log(`options loaded:`, ret);
options.url = ret.url || "";
options.enabled =
ret.enabledOrigins &&
ret.enabledOrigins[window.location.origin] === true;
ret.enabledOrigins;
options.updateInterval = (ret.updateInterval || 2) * 1000;
options.enabledStats = Object.values(ret.enabledStats || {});
sendOptions();
@@ -80,19 +79,14 @@ if (window.location.protocol.startsWith("http")) {
// Handle stats messages.
window.addEventListener("message", async (message) => {
const { event, url, id, state, values, stats } = message.data;
const { event, data, 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,
},
data: stats,
});
if (response.error) {
log(`error: ${response.error}`);
@@ -104,7 +98,7 @@ if (window.location.protocol.startsWith("http")) {
try {
const response = await chrome.runtime.sendMessage({
event: "peer-connections-stats",
data: stats,
data: data,
});
if (response.error) {
log(`error: ${response.error}`);

View File

@@ -31,15 +31,55 @@ class WebrtcInternalExporter {
);
}
/**
* @param {RTCPeerConnection} pc
*/
add(pc) {
const id = this.randomId();
pc.iceCandidates = [];
pc.iceCandidateErrors = [];
this.peerConnections.set(id, pc);
pc.addEventListener("connectionstatechange", () => {
if (pc.connectionState === "closed") {
this.peerConnections.delete(id);
}
});
//this.collectAndPostStats(id);
/**
* @param {RTCPeerConnectionIceErrorEvent} event
*/
pc.addEventListener("icecandidateerror", (event) => {
this.peerConnections.get(id).iceCandidateErrors.push({
timestamp: Date.now(),
address: event.errorAddress,
errorCode: event.errorCode,
errorText: event.errorText,
port: event.errorPort,
url: event.url,
});
});
/**
* @param {RTCPeerConnectionIceEvent} event
*/
pc.addEventListener("icecandidate", (event) => {
this.peerConnections.get(id).iceCandidates.push({
timestamp: Date.now(),
candidate: event.candidate?.candidate,
component: event.candidate?.component,
foundation: event.candidate?.foundation,
port: event.candidate?.port,
priority: event.candidate?.priority,
protocol: event.candidate?.protocol,
relatedAddress: event.candidate?.relatedAddress,
relatedPort: event.candidate?.relatedPort,
sdpMLineIndex: event.candidate?.sdpMLineIndex,
sdpMid: event.candidate?.sdpMid,
tcpType: event.candidate?.tcpType,
type: event.candidate?.type,
usernameFragment: event.candidate?.usernameFragment,
});
});
}
async collectAndPostSingleStat(id) {
@@ -49,9 +89,9 @@ class WebrtcInternalExporter {
window.postMessage(
{
event: "webrtc-internal-exporter:peer-connection-stats",
...stats,
stats
},
[stats],
stats
);
}
@@ -59,28 +99,30 @@ class WebrtcInternalExporter {
const stats = [];
for (const [id, pc] of this.peerConnections) {
if (this.url && this.enabled && pc.connectionState === "connected") {
if (this.url && this.enabled) {
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],
);
window.postMessage(
{
event: "webrtc-internal-exporter:peer-connections-stats",
data: JSON.parse(JSON.stringify(stats)),
},
);
log(`Stats collected:`, stats);
//}
log(`Stats collected:`, JSON.parse(JSON.stringify(stats)));
setTimeout(this.collectAllStats.bind(this), this.updateInterval);
return stats;
setTimeout(this.collectAllStats.bind(this), this.updateInterval);
return stats;
}
/**
* @param {string} id
* @param {RTCPeerConnection} pc
* @param {Function} binding
*/
async collectStats(id, pc, binding) {
var completeStats = {};
@@ -89,7 +131,7 @@ class WebrtcInternalExporter {
if (!pc) return;
}
if (this.url && this.enabled && pc.connectionState === "connected") {
if (this.url && this.enabled) {
try {
const stats = await pc.getStats();
const values = [...stats.values()].filter(
@@ -100,7 +142,12 @@ class WebrtcInternalExporter {
completeStats = {
url: window.location.href,
id,
state: pc.connectionState,
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
iceGatheringState: pc.iceGatheringState,
signalingState: pc.signalingState,
iceCandidateErrors: pc.iceCandidateErrors,
iceCandidates: pc.iceCandidates,
values,
};
} catch (error) {