From 64a01f3d040c122ad37dbce8d7c4eec861430f55 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 14 Sep 2026 12:14:56 +0530 Subject: [PATCH] build minimal internet performance exporter --- .dockerignore | 2 ++ .github/workflows/release.yaml | 26 ++++++++++++++ .gitignore | 2 ++ Dockerfile | 10 ++++++ README.md | 5 +++ package-lock.json | 27 ++++++++++++++ package.json | 15 ++++++++ server.js | 66 ++++++++++++++++++++++++++++++++++ 8 files changed, 153 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/release.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..651665b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +node_modules +.git diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..7e79779 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,26 @@ +name: release +on: + release: + types: [published] +permissions: + contents: read + packages: write +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64 + tags: | + ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name }} + ghcr.io/${{ github.repository }}:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd4f2b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..91c6dc3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:24-alpine +RUN apk add --no-cache chromium +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY server.js ./ +ENV CHROMIUM_PATH=/usr/bin/chromium +USER node +EXPOSE 9798 +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f32b4b --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Internet Performance Exporter + +Prometheus metrics for Cloudflare edge speed tests from headless Chromium. + +`docker run -p 9798:9798 ghcr.io/priyanshujain/internet-performance-exporter:latest` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6ee3f44 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "internet-performance-exporter", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "internet-performance-exporter", + "version": "0.1.0", + "dependencies": { + "@cloudflare/speedtest": "1.14.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@cloudflare/speedtest": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@cloudflare/speedtest/-/speedtest-1.14.0.tgz", + "integrity": "sha512-cBx5TUNvb4LeGoFTyUo0KSFtU9WptqhPghAZFZZoGlKlsD87EVTgGsPj87E0/iR4mcM0eoPuRl5DrqVVPb7HSQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..47af7bf --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "internet-performance-exporter", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "@cloudflare/speedtest": "1.14.0" + }, + "engines": { + "node": ">=22" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..0e330a3 --- /dev/null +++ b/server.js @@ -0,0 +1,66 @@ +import { randomUUID } from 'node:crypto' +import { spawn } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { createServer } from 'node:http' + +const port = Number(process.env.PORT || 9798) +const interval = Number(process.env.INTERVAL_SECONDS || 3600) * 1000 +const timeout = Number(process.env.TIMEOUT_SECONDS || 600) * 1000 +const chromium = process.env.CHROMIUM_PATH || '/usr/bin/chromium' +const library = await readFile(new URL('./node_modules/@cloudflare/speedtest/dist/speedtest.js', import.meta.url)) +let result = { up: 0 } +let active + +const page = token => `` + +const metrics = () => { + const values = { + internet_speedtest_up: result.up, + internet_download_bits_per_second: result.download, + internet_upload_bits_per_second: result.upload, + internet_latency_milliseconds: result.latency, + internet_jitter_milliseconds: result.jitter, + internet_download_loaded_latency_milliseconds: result.downLoadedLatency, + internet_upload_loaded_latency_milliseconds: result.upLoadedLatency, + internet_speedtest_duration_seconds: result.totalDurationMs && result.totalDurationMs / 1000, + internet_speedtest_last_attempt_timestamp_seconds: result.attempt, + internet_speedtest_last_success_timestamp_seconds: result.timestamp + } + return Object.entries(values).filter(([, value]) => Number.isFinite(value)).map(([name, value]) => `# TYPE ${name} gauge\n${name} ${value}`).join('\n') + '\n' +} + +const server = createServer(async (req, res) => { + if (req.url === '/metrics') { res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); return res.end(metrics()) } + if (!['127.0.0.1', '::1'].includes(req.socket.remoteAddress)) { res.statusCode = 404; return res.end() } + if (active && req.url === `/run/${active.token}`) { res.setHeader('Content-Type', 'text/html'); return res.end(page(active.token)) } + if (active && req.url === `/hold/${active.token}`) { active.hold = res; return } + if (req.url === '/speedtest.js') { res.setHeader('Content-Type', 'text/javascript'); return res.end(library) } + if (active && req.method === 'POST' && req.url === `/result/${active.token}`) { + let body = '' + for await (const chunk of req) body += chunk + const data = JSON.parse(body) + if (data.result && ['download', 'upload', 'latency'].every(key => Number.isFinite(data.result[key]))) result = { attempt: result.attempt, ...data.result, up: 1, timestamp: Date.now() / 1000 } + else result.up = 0 + res.end() + active.hold?.end() + active.resolve() + return + } + res.statusCode = 404 + res.end() +}) + +const run = () => { + if (active) return + result.attempt = Date.now() / 1000 + const token = randomUUID() + let finish + const done = new Promise(resolve => { let settled = false; finish = () => { if (!settled) { settled = true; resolve() } }; active = { token, resolve: finish } }) + const child = spawn(chromium, ['--headless=new', '--no-sandbox', '--disable-dev-shm-usage', '--dump-dom', `http://127.0.0.1:${port}/run/${token}`], { stdio: 'ignore' }) + const timer = setTimeout(() => { result.up = 0; finish() }, timeout) + child.on('error', () => { result.up = 0; finish() }) + child.on('exit', () => { if (active) { result.up = 0; finish() } }) + done.finally(() => { clearTimeout(timer); active.hold?.end(); child.kill(); active = undefined }) +} + +server.listen(port, '0.0.0.0', () => { run(); setInterval(run, interval) })