Cases / #59 · 2026-10-18 · Easy
Block analytics and beacons, keep the page: what telemetry a scraper sends without knowing
Load three real sites with and without a telemetry block list (analytics, measurement and real-user-monitoring hosts, plus beacons). Count what each page reports about your visit — including the beacons it fires as you leave — and check the content stays the same.
Run on production on 2026-10-18: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Every page a scraper opens also reports the visit: analytics hits, measurement pixels, performance beacons, session counters. That traffic goes out through your proxy, tells the site's analytics about your session, and keeps going until the moment you leave — some beacons are only sent when the page is hidden or closed. Blocking everything third-party (as in the bandwidth diet) saves the most bytes but can break pages that need a consent or anti-bot script. A narrow telemetry list is the safe middle: does it keep the page intact, and how much does it actually stop?
What we used, and why
| What | Why |
|---|---|
| A telemetry host list | Analytics, measurement and real-user-monitoring services (Google Analytics and Tag Manager, comScore, Akamai mPulse, New Relic, Sentry, Datadog, Hotjar, Chartbeat, Adobe, the Guardian's own Ophan…), subdomains included. Ads aren't on it: this is about what reports your visit, not what sells it. |
request.resourceType() === 'ping' | Catches beacons from any host: navigator.sendBeacon and <a ping> requests are typed ping. |
context.route('**/*') + route.abort() | Drops telemetry before it leaves the browser; everything else continues. |
request events and request.sizes() | Count every request and the telemetry among them; bytes on the wire where Chromium reports them. |
Leaving the page (goto('about:blank')) | Fires the page-hide beacons a closing tab sends; they're counted separately. |
| A fresh context per load | Empty cache and cookies, so both loads of a site start the same. |
How it works
- For each site (The Guardian's international front, NPR's home page, a Wikipedia article): load it in a fresh context, wait for DOM ready plus 6 s, read the title and count the headings, then leave the page and wait 1.5 s.
- Count all requests, the telemetry requests, the telemetry sent while leaving, and bytes.
- Load it again in a fresh context with telemetry blocked, and count what was blocked and whether the title and headings are the same.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000PROXY_URL_US— a proxy exit in the United States
// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL_US
import { chromium } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const SITES = [
{ name: 'The Guardian', url: 'https://www.theguardian.com/international' },
{ name: 'NPR', url: 'https://www.npr.org/' },
{ name: 'Wikipedia', url: 'https://en.wikipedia.org/wiki/Web_browser' },
];
// Analytics, measurement and real-user-monitoring hosts (subdomains included) — not ads.
const TELEMETRY = ['google-analytics.com', 'analytics.google.com', 'googletagmanager.com', 'ophan.theguardian.com',
'performance-events.guardianapis.com', 'feast-events.guardianapis.com', 'scorecardresearch.com', 'permutive.app',
'permutive.com', 'go-mpulse.net', 'akstat.io', 'nr-data.net', 'newrelic.com', 'sentry.io', 'datadoghq.com',
'hotjar.com', 'clarity.ms', 'segment.io', 'segment.com', 'chartbeat.com', 'chartbeat.net', 'parsely.com',
'analytics.yahoo.com', 'omtrdc.net', 'demdex.net', 'bat.bing.com', 'cloudflareinsights.com', 'brandmetrics.com'];
// A request is telemetry if it goes to one of those hosts or is a beacon (navigator.sendBeacon, <a ping>).
const isTelemetry = (req) => {
if (req.resourceType() === 'ping') return true;
const host = new URL(req.url()).hostname;
return TELEMETRY.some((d) => host === d || host.endsWith(`.${d}`));
};
const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: process.env.PROXY_URL_US, headless: true }),
});
if (!res.ok) throw new Error(`launch: ${res.status} ${await res.text()}`);
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
// One load in a fresh context: DOM ready + 6 s, then leave the page (about:blank) and give the
// page-hide beacons 1.5 s. Counts every request, the telemetry among them, and bytes on the wire.
async function load(site, block) {
const context = await browser.newContext();
let blocked = 0;
if (block) {
await context.route('**/*', (route) => {
if (isTelemetry(route.request())) { blocked++; return route.abort(); }
return route.continue();
});
}
const page = await context.newPage();
const m = { requests: 0, telemetry: 0, onLeave: 0, bytes: 0, telemetryBytes: 0 };
let leaving = false;
page.on('request', (req) => {
m.requests++;
if (isTelemetry(req)) { m.telemetry++; if (leaving) m.onLeave++; }
});
const sizing = []; // sizes() is async: wait for them all before closing the context
page.on('requestfinished', (req) => {
sizing.push(req.sizes().then((s) => {
const n = s.requestHeadersSize + s.requestBodySize + s.responseHeadersSize + s.responseBodySize;
m.bytes += n;
if (isTelemetry(req)) m.telemetryBytes += n;
}).catch(() => {}));
});
const t = Date.now();
await page.goto(site.url, { waitUntil: 'domcontentloaded', timeout: 90000 });
const domReady = Date.now() - t;
await page.waitForTimeout(6000);
const title = await page.title();
const headings = await page.locator('h1, h2, h3').count(); // the content is still there
leaving = true;
await page.goto('about:blank');
await page.waitForTimeout(1500);
await Promise.all(sizing);
await context.close();
return { ...m, blocked, domReady, title, headings };
}
try {
const rows = [];
for (const site of SITES) {
const off = await load(site, false);
const on = await load(site, true);
rows.push({
site: site.name,
title_ok: Boolean(off.title) && off.title === on.title,
requests: off.requests,
telemetry_requests: off.telemetry,
telemetry_on_leave: off.onLeave,
telemetry_kb: Math.round(off.telemetryBytes / 1024),
total_kb: Math.round(off.bytes / 1024),
blocked: on.blocked,
headings: off.headings,
headings_blocked_run: on.headings,
dom_ready_ms: off.domReady,
dom_ready_ms_blocked_run: on.domReady,
});
}
console.log(JSON.stringify(rows, null, 2));
} finally {
await browser.close();
}
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL_US
import json
import os
import time
from urllib.parse import urlparse
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
SITES = [
("The Guardian", "https://www.theguardian.com/international"),
("NPR", "https://www.npr.org/"),
("Wikipedia", "https://en.wikipedia.org/wiki/Web_browser"),
]
# Analytics, measurement and real-user-monitoring hosts (subdomains included) — not ads.
TELEMETRY = ["google-analytics.com", "analytics.google.com", "googletagmanager.com", "ophan.theguardian.com",
"performance-events.guardianapis.com", "feast-events.guardianapis.com", "scorecardresearch.com", "permutive.app",
"permutive.com", "go-mpulse.net", "akstat.io", "nr-data.net", "newrelic.com", "sentry.io", "datadoghq.com",
"hotjar.com", "clarity.ms", "segment.io", "segment.com", "chartbeat.com", "chartbeat.net", "parsely.com",
"analytics.yahoo.com", "omtrdc.net", "demdex.net", "bat.bing.com", "cloudflareinsights.com", "brandmetrics.com"]
def is_telemetry(req):
"""Telemetry: one of those hosts, or a beacon (navigator.sendBeacon, <a ping>)."""
if req.resource_type == "ping":
return True
host = urlparse(req.url).hostname or ""
return any(host == d or host.endswith("." + d) for d in TELEMETRY)
res = requests.post("https://starter.cdpfleet.com/chromium/session", headers={"x-api-key": KEY},
json={"proxy": os.environ["PROXY_URL_US"], "headless": True}, timeout=60)
res.raise_for_status()
def load(browser, url, block):
"""One load in a fresh context: DOM ready + 6 s, then leave the page (about:blank) and give the
page-hide beacons 1.5 s. Counts every request, the telemetry among them, and bytes on the wire."""
context = browser.new_context()
m = {"requests": 0, "telemetry": 0, "on_leave": 0, "bytes": 0, "telemetry_bytes": 0, "blocked": 0, "leaving": False}
if block:
def handle(route):
if is_telemetry(route.request):
m["blocked"] += 1
return route.abort()
return route.continue_()
context.route("**/*", handle)
page = context.new_page()
def on_request(req):
m["requests"] += 1
if is_telemetry(req):
m["telemetry"] += 1
if m["leaving"]:
m["on_leave"] += 1
def on_finished(req):
try:
s = req.sizes()
except Exception:
return
n = s["requestHeadersSize"] + s["requestBodySize"] + s["responseHeadersSize"] + s["responseBodySize"]
m["bytes"] += n
if is_telemetry(req):
m["telemetry_bytes"] += n
page.on("request", on_request)
page.on("requestfinished", on_finished)
t = time.time()
page.goto(url, wait_until="domcontentloaded", timeout=90000)
dom_ready = round((time.time() - t) * 1000)
page.wait_for_timeout(6000)
title = page.title()
headings = page.locator("h1, h2, h3").count() # the content is still there
m["leaving"] = True
page.goto("about:blank")
page.wait_for_timeout(1500)
context.close()
return {**m, "dom_ready": dom_ready, "title": title, "headings": headings}
with sync_playwright() as p:
browser = p.chromium.connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
try:
rows = []
for name, url in SITES:
off = load(browser, url, False)
on = load(browser, url, True)
rows.append({
"site": name,
"title_ok": bool(off["title"]) and off["title"] == on["title"],
"requests": off["requests"],
"telemetry_requests": off["telemetry"],
"telemetry_on_leave": off["on_leave"],
"telemetry_kb": round(off["telemetry_bytes"] / 1024),
"total_kb": round(off["bytes"] / 1024),
"blocked": on["blocked"],
"headings": off["headings"],
"headings_blocked_run": on["headings"],
"dom_ready_ms": off["dom_ready"],
"dom_ready_ms_blocked_run": on["dom_ready"],
})
print(json.dumps(rows, indent=2, ensure_ascii=False))
finally:
browser.close()
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1. env: CDPFLEET_API_KEY, PROXY_URL_US
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.*;
import java.net.URI;
import java.net.http.*;
import java.util.*;
import java.util.regex.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String[][] SITES = {
{"The Guardian", "https://www.theguardian.com/international"},
{"NPR", "https://www.npr.org/"},
{"Wikipedia", "https://en.wikipedia.org/wiki/Web_browser"}};
// Analytics, measurement and real-user-monitoring hosts (subdomains included) — not ads.
static final List<String> TELEMETRY = List.of("google-analytics.com", "analytics.google.com", "googletagmanager.com", "ophan.theguardian.com",
"performance-events.guardianapis.com", "feast-events.guardianapis.com", "scorecardresearch.com", "permutive.app",
"permutive.com", "go-mpulse.net", "akstat.io", "nr-data.net", "newrelic.com", "sentry.io", "datadoghq.com",
"hotjar.com", "clarity.ms", "segment.io", "segment.com", "chartbeat.com", "chartbeat.net", "parsely.com",
"analytics.yahoo.com", "omtrdc.net", "demdex.net", "bat.bing.com", "cloudflareinsights.com", "brandmetrics.com");
static final Pattern HOST = Pattern.compile("^[a-z]+://([^/:?#]+)");
// Telemetry: one of those hosts, or a beacon (navigator.sendBeacon, <a ping>).
static boolean isTelemetry(Request req) {
if (req.resourceType().equals("ping")) return true;
// A lenient host match: URI.create() rejects the unencoded { } some pages put in query strings.
Matcher u = HOST.matcher(req.url());
if (!u.find()) return false;
String host = u.group(1).toLowerCase();
return TELEMETRY.stream().anyMatch(d -> host.equals(d) || host.endsWith("." + d));
}
// One load in a fresh context: DOM ready + 6 s, then leave the page (about:blank) and give the
// page-hide beacons 1.5 s. Counts every request, the telemetry among them, and bytes on the wire.
static Map<String, Object> load(Browser browser, String url, boolean block) {
BrowserContext context = browser.newContext();
long[] m = new long[6]; // requests, telemetry, on leave, bytes, telemetry bytes, blocked
boolean[] leaving = {false};
if (block) {
context.route("**/*", route -> {
if (isTelemetry(route.request())) { m[5]++; route.abort(); } else route.resume();
});
}
Page page = context.newPage();
page.onRequest(req -> {
m[0]++;
if (isTelemetry(req)) { m[1]++; if (leaving[0]) m[2]++; }
});
page.onRequestFinished(req -> {
Sizes s;
try { s = req.sizes(); } catch (PlaywrightException e) { return; }
long n = s.requestHeadersSize + s.requestBodySize + s.responseHeadersSize + s.responseBodySize;
m[3] += n;
if (isTelemetry(req)) m[4] += n;
});
long t = System.currentTimeMillis();
page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(90000));
long domReady = System.currentTimeMillis() - t;
page.waitForTimeout(6000);
String title = page.title();
int headings = page.locator("h1, h2, h3").count(); // the content is still there
leaving[0] = true;
page.navigate("about:blank");
page.waitForTimeout(1500);
context.close();
Map<String, Object> r = new HashMap<>();
r.put("requests", m[0]); r.put("telemetry", m[1]); r.put("on_leave", m[2]); r.put("bytes", m[3]); r.put("telemetry_bytes", m[4]);
r.put("blocked", m[5]); r.put("dom_ready", domReady); r.put("title", title); r.put("headings", headings);
return r;
}
public static void main(String[] args) throws Exception {
String body = "{\"proxy\": " + new Gson().toJson(System.getenv("PROXY_URL_US")) + ", \"headless\": true}";
HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chromium/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("launch: " + res.statusCode() + " " + res.body());
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
JsonArray out = new JsonArray();
for (String[] site : SITES) {
Map<String, Object> off = load(browser, site[1], false);
Map<String, Object> on = load(browser, site[1], true);
String title = (String) off.get("title");
JsonObject row = new JsonObject();
row.addProperty("site", site[0]);
row.addProperty("title_ok", !title.isEmpty() && title.equals(on.get("title")));
row.addProperty("requests", (Long) off.get("requests"));
row.addProperty("telemetry_requests", (Long) off.get("telemetry"));
row.addProperty("telemetry_on_leave", (Long) off.get("on_leave"));
row.addProperty("telemetry_kb", Math.round((Long) off.get("telemetry_bytes") / 1024.0));
row.addProperty("total_kb", Math.round((Long) off.get("bytes") / 1024.0));
row.addProperty("blocked", (Long) on.get("blocked"));
row.addProperty("headings", (Integer) off.get("headings"));
row.addProperty("headings_blocked_run", (Integer) on.get("headings"));
row.addProperty("dom_ready_ms", (Long) off.get("dom_ready"));
row.addProperty("dom_ready_ms_blocked_run", (Long) on.get("dom_ready"));
out.add(row);
}
System.out.println(new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(out));
} finally {
browser.close();
}
}
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL_US
using System.Diagnostics;
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var sites = new[]
{
("The Guardian", "https://www.theguardian.com/international"),
("NPR", "https://www.npr.org/"),
("Wikipedia", "https://en.wikipedia.org/wiki/Web_browser"),
};
// Analytics, measurement and real-user-monitoring hosts (subdomains included) — not ads.
string[] telemetryHosts = { "google-analytics.com", "analytics.google.com", "googletagmanager.com", "ophan.theguardian.com",
"performance-events.guardianapis.com", "feast-events.guardianapis.com", "scorecardresearch.com", "permutive.app",
"permutive.com", "go-mpulse.net", "akstat.io", "nr-data.net", "newrelic.com", "sentry.io", "datadoghq.com",
"hotjar.com", "clarity.ms", "segment.io", "segment.com", "chartbeat.com", "chartbeat.net", "parsely.com",
"analytics.yahoo.com", "omtrdc.net", "demdex.net", "bat.bing.com", "cloudflareinsights.com", "brandmetrics.com" };
// Telemetry: one of those hosts, or a beacon (navigator.sendBeacon, <a ping>).
bool IsTelemetry(IRequest req)
{
if (req.ResourceType == "ping") return true;
var host = new Uri(req.Url).Host;
return telemetryHosts.Any(d => host == d || host.EndsWith("." + d));
}
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session",
new { proxy = Environment.GetEnvironmentVariable("PROXY_URL_US"), headless = true });
if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
// One load in a fresh context: DOM ready + 6 s, then leave the page (about:blank) and give the
// page-hide beacons 1.5 s. Counts every request, the telemetry among them, and bytes on the wire.
async Task<Dictionary<string, object>> Load(string url, bool block)
{
var context = await browser.NewContextAsync();
long requests = 0, telemetry = 0, onLeave = 0, bytes = 0, telemetryBytes = 0, blocked = 0;
var leaving = false;
if (block)
{
await context.RouteAsync("**/*", route =>
{
if (!IsTelemetry(route.Request)) return route.ContinueAsync();
Interlocked.Increment(ref blocked);
return route.AbortAsync();
});
}
var page = await context.NewPageAsync();
page.Request += (_, req) =>
{
Interlocked.Increment(ref requests);
if (!IsTelemetry(req)) return;
Interlocked.Increment(ref telemetry);
if (leaving) Interlocked.Increment(ref onLeave);
};
var sizing = new List<Task>(); // SizesAsync is async: wait for them all before closing the context
async Task Size(IRequest req)
{
try
{
var s = await req.SizesAsync();
var n = s.RequestHeadersSize + s.RequestBodySize + s.ResponseHeadersSize + s.ResponseBodySize;
Interlocked.Add(ref bytes, n);
if (IsTelemetry(req)) Interlocked.Add(ref telemetryBytes, n);
}
catch (PlaywrightException) { }
}
page.RequestFinished += (_, req) => { lock (sizing) sizing.Add(Size(req)); };
var sw = Stopwatch.StartNew();
await page.GotoAsync(url, new() { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 90000 });
var domReady = sw.ElapsedMilliseconds;
await page.WaitForTimeoutAsync(6000);
var title = await page.TitleAsync();
var headings = await page.Locator("h1, h2, h3").CountAsync(); // the content is still there
leaving = true;
await page.GotoAsync("about:blank");
await page.WaitForTimeoutAsync(1500);
Task[] pending;
lock (sizing) pending = sizing.ToArray();
await Task.WhenAll(pending);
await context.CloseAsync();
return new()
{
["requests"] = requests, ["telemetry"] = telemetry, ["on_leave"] = onLeave, ["bytes"] = bytes, ["telemetry_bytes"] = telemetryBytes,
["blocked"] = blocked, ["dom_ready"] = domReady, ["title"] = title, ["headings"] = headings,
};
}
try
{
var rows = new JsonArray();
foreach (var (name, url) in sites)
{
var off = await Load(url, false);
var on = await Load(url, true);
var title = (string)off["title"];
rows.Add(new JsonObject
{
["site"] = name,
["title_ok"] = title.Length > 0 && title == (string)on["title"],
["requests"] = (long)off["requests"],
["telemetry_requests"] = (long)off["telemetry"],
["telemetry_on_leave"] = (long)off["on_leave"],
["telemetry_kb"] = (long)Math.Round((long)off["telemetry_bytes"] / 1024.0),
["total_kb"] = (long)Math.Round((long)off["bytes"] / 1024.0),
["blocked"] = (long)on["blocked"],
["headings"] = (int)off["headings"],
["headings_blocked_run"] = (int)on["headings"],
["dom_ready_ms"] = (long)off["dom_ready"],
["dom_ready_ms_blocked_run"] = (long)on["dom_ready"],
});
}
Console.WriteLine(rows.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
}
finally
{
await browser.CloseAsync();
}
// go get github.com/playwright-community/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL_US
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
var sites = [][2]string{
{"The Guardian", "https://www.theguardian.com/international"},
{"NPR", "https://www.npr.org/"},
{"Wikipedia", "https://en.wikipedia.org/wiki/Web_browser"},
}
// Analytics, measurement and real-user-monitoring hosts (subdomains included) — not ads.
var telemetryHosts = []string{"google-analytics.com", "analytics.google.com", "googletagmanager.com", "ophan.theguardian.com",
"performance-events.guardianapis.com", "feast-events.guardianapis.com", "scorecardresearch.com", "permutive.app",
"permutive.com", "go-mpulse.net", "akstat.io", "nr-data.net", "newrelic.com", "sentry.io", "datadoghq.com",
"hotjar.com", "clarity.ms", "segment.io", "segment.com", "chartbeat.com", "chartbeat.net", "parsely.com",
"analytics.yahoo.com", "omtrdc.net", "demdex.net", "bat.bing.com", "cloudflareinsights.com", "brandmetrics.com"}
// Telemetry: one of those hosts, or a beacon (navigator.sendBeacon, <a ping>).
func isTelemetry(req playwright.Request) bool {
if req.ResourceType() == "ping" {
return true
}
u, err := url.Parse(req.URL())
if err != nil {
return false
}
host := u.Hostname()
for _, d := range telemetryHosts {
if host == d || strings.HasSuffix(host, "."+d) {
return true
}
}
return false
}
func launch(name string, options map[string]any) (map[string]any, error) {
body, _ := json.Marshal(options)
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+name+"/session", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
return nil, fmt.Errorf("launch %s: %s %s", name, res.Status, msg)
}
var session map[string]any
return session, json.NewDecoder(res.Body).Decode(&session)
}
type stats struct {
requests, telemetry, onLeave, bytes, telemetryBytes, blocked int64
domReady int64
title string
headings int
}
// One load in a fresh context: DOM ready + 6 s, then leave the page (about:blank) and give the
// page-hide beacons 1.5 s. Counts every request, the telemetry among them, and bytes on the wire.
func load(browser playwright.Browser, target string, block bool) stats {
context, err := browser.NewContext()
if err != nil {
log.Fatal(err)
}
var mu sync.Mutex
var st stats
leaving := false
if block {
context.Route("**/*", func(route playwright.Route) {
if isTelemetry(route.Request()) {
mu.Lock()
st.blocked++
mu.Unlock()
route.Abort()
} else {
route.Continue()
}
})
}
page, err := context.NewPage()
if err != nil {
log.Fatal(err)
}
page.OnRequest(func(req playwright.Request) {
mu.Lock()
defer mu.Unlock()
st.requests++
if isTelemetry(req) {
st.telemetry++
if leaving {
st.onLeave++
}
}
})
page.OnRequestFinished(func(req playwright.Request) {
s, err := req.Sizes()
if err != nil {
return
}
n := int64(s.RequestHeadersSize + s.RequestBodySize + s.ResponseHeadersSize + s.ResponseBodySize)
mu.Lock()
defer mu.Unlock()
st.bytes += n
if isTelemetry(req) {
st.telemetryBytes += n
}
})
t := time.Now()
if _, err := page.Goto(target, playwright.PageGotoOptions{WaitUntil: playwright.WaitUntilStateDomcontentloaded, Timeout: playwright.Float(90000)}); err != nil {
log.Fatal(err)
}
domReady := time.Since(t).Milliseconds()
page.WaitForTimeout(6000)
title, _ := page.Title()
headings, _ := page.Locator("h1, h2, h3").Count() // the content is still there
mu.Lock()
leaving = true
mu.Unlock()
if _, err := page.Goto("about:blank"); err != nil {
log.Fatal(err)
}
page.WaitForTimeout(1500)
context.Close()
mu.Lock()
defer mu.Unlock()
st.domReady, st.title, st.headings = domReady, title, headings
return st
}
type row struct {
Site string `json:"site"`
TitleOK bool `json:"title_ok"`
Requests int64 `json:"requests"`
TelemetryRequests int64 `json:"telemetry_requests"`
TelemetryOnLeave int64 `json:"telemetry_on_leave"`
TelemetryKB int64 `json:"telemetry_kb"`
TotalKB int64 `json:"total_kb"`
Blocked int64 `json:"blocked"`
Headings int `json:"headings"`
HeadingsBlockedRun int `json:"headings_blocked_run"`
DomReadyMs int64 `json:"dom_ready_ms"`
DomReadyMsBlockedRun int64 `json:"dom_ready_ms_blocked_run"`
}
func kb(n int64) int64 { return int64(math.Round(float64(n) / 1024)) }
func main() {
session, err := launch("chromium", map[string]any{"proxy": os.Getenv("PROXY_URL_US"), "headless": true})
if err != nil {
log.Fatal(err)
}
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
browser, err := pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
log.Fatal(err)
}
defer browser.Close()
rows := []row{}
for _, site := range sites {
off := load(browser, site[1], false)
on := load(browser, site[1], true)
rows = append(rows, row{
Site: site[0], TitleOK: off.title != "" && off.title == on.title,
Requests: off.requests, TelemetryRequests: off.telemetry, TelemetryOnLeave: off.onLeave,
TelemetryKB: kb(off.telemetryBytes), TotalKB: kb(off.bytes), Blocked: on.blocked,
Headings: off.headings, HeadingsBlockedRun: on.headings,
DomReadyMs: off.domReady, DomReadyMsBlockedRun: on.domReady,
})
}
out, _ := json.MarshalIndent(rows, "", " ")
fmt.Println(string(out))
}
What we got
| Site | Requests | Telemetry | …sent while leaving | Telemetry KB | Total KB | Blocked (2nd load) | Headings | Headings, blocked | DOM ready (ms) | DOM ready, blocked (ms) |
|---|---|---|---|---|---|---|---|---|---|---|
| The Guardian | 144 | 24 | 3 | 1 | 1628 | 24 | 182 | 182 | 2012 | 2416 |
| NPR | 66 | 3 | 0 | 198 | 2824 | 8 | 61 | 61 | 6055 | 4420 |
| Wikipedia | 40 | 1 | 0 | 2 | 592 | 1 | 12 | 12 | 4125 | 1628 |
From the Node.js run on 2026-10-18. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.
Raw output (Node.js)
[
{
"site": "The Guardian",
"title_ok": true,
"requests": 144,
"telemetry_requests": 24,
"telemetry_on_leave": 3,
"telemetry_kb": 1,
"total_kb": 1628,
"blocked": 24,
"headings": 182,
"headings_blocked_run": 182,
"dom_ready_ms": 2012,
"dom_ready_ms_blocked_run": 2416
},
{
"site": "NPR",
"title_ok": true,
"requests": 66,
"telemetry_requests": 3,
"telemetry_on_leave": 0,
"telemetry_kb": 198,
"total_kb": 2824,
"blocked": 8,
"headings": 61,
"headings_blocked_run": 61,
"dom_ready_ms": 6055,
"dom_ready_ms_blocked_run": 4420
},
{
"site": "Wikipedia",
"title_ok": true,
"requests": 40,
"telemetry_requests": 1,
"telemetry_on_leave": 0,
"telemetry_kb": 2,
"total_kb": 592,
"blocked": 1,
"headings": 12,
"headings_blocked_run": 12,
"dom_ready_ms": 4125,
"dom_ready_ms_blocked_run": 1628
}
]Takeaways
- News pages report a lot about each visit. The Guardian sent 13–43 of its 93–199 requests to analytics and measurement services across the five runs — its own analytics (Ophan) fires a request for nearly everything that happens on the page — and NPR 3–19 of 66–253. A Wikipedia article sent a single event beacon of about 2 KB.
- Telemetry keeps going as you leave. After the scraper navigated away, the Guardian sent 1–4 more telemetry requests in every run and NPR up to 2 — a script that closes its tabs still reports the visit on the way out. Blocking stops those too.
- Blocking it didn't change the content. Every site kept its title, and the headings matched the unblocked load within 3 (front pages rotate between loads).
- It's about requests and privacy more than bytes. Telemetry weighed 0–561 KB on the Guardian, depending on which measurement scripts were served, and about 200 KB on NPR (mostly Tag Manager and Akamai mPulse), out of 1–7 MB pages; fire-and-forget beacons report no size at all. To cut bandwidth, block images and third parties (bandwidth diet); to stop reporting your sessions, block this list.
- Counts move from load to load — what fires depends on the ads, A/B tests and consent state served that time. A blocked beacon can be retried, so the Guardian's blocked count (21–24) was often above what an unblocked load sent.