# Privacy Tracker Scanner

URL: /docs/experiments/privacy-tracker-scanner
> Load a page without accepting consent and report third-party trackers with Browser Rendering - privacy and CMP audit tool
Tags: browser, privacy, security, trackers, cookies, consent, gdpr
Bindings: BROWSER
Markdown: /llms.mdx/experiments/privacy-tracker-scanner/content.md



See what a page does to a first-time visitor **before they click anything**. The Worker loads the URL in **Browser Rendering**, never touches the consent banner, records every network request and cookie, and classifies third-party hosts against a built-in tracker list.

## Features [#features]

* **Pre-consent capture** - no clicks, no scrolling; just what loads on arrival
* **Every request recorded** - `page.on("request")` across the main frame and iframes
* **Full cookie jar** - CDP `Network.getAllCookies` returns cookies for all domains, not just the page's
* **Tracker classification** - analytics, advertising, social, and session-replay vendors
* **Verdict** - `clean`, `some-trackers`, or `heavy-tracking`

## API Reference [#api-reference]

### GET /scan [#get-scan]

<TypeTable
  type="{
  url: {
    description: &#x22;Page URL to scan (`http://` or `https://`)&#x22;,
    type: &#x22;string&#x22;,
    required: true,
  },
}"
/>

#### Example Request [#example-request]

```bash
curl "https://your-worker.workers.dev/scan?url=https://www.cnn.com"
```

#### Success Response [#success-response]

```json
{
  "url": "https://www.cnn.com/",
  "finalUrl": "https://www.cnn.com/",
  "firstPartyDomain": "cnn.com",
  "timedOut": true,
  "requests": { "total": 336, "thirdParty": 250 },
  "thirdPartyDomains": [
    {
      "domain": "securepubads.g.doubleclick.net",
      "requests": 9,
      "tracker": { "name": "Google DoubleClick", "category": "advertising" }
    },
    { "domain": "media.cnn.com", "requests": 1 }
  ],
  "trackers": [
    {
      "name": "Google DoubleClick",
      "category": "advertising",
      "domains": ["securepubads.g.doubleclick.net"],
      "requests": 15
    }
  ],
  "cookies": {
    "firstParty": 57,
    "thirdParty": 3,
    "list": [
      {
        "name": "IDE",
        "domain": ".doubleclick.net",
        "expires": "2027-10-26T12:00:00.000Z",
        "secure": true,
        "httpOnly": true,
        "sameSite": "None",
        "thirdParty": true
      }
    ]
  },
  "verdict": "heavy-tracking"
}
```

**`firstPartyDomain`** `string`

Registrable domain of the final page URL (after redirects).

**`thirdPartyDomains`** `array`

Every third-party hostname with its request count, sorted by requests. `tracker` is present when the host matches the tracker list.

**`trackers`** `array`

Matched trackers aggregated by vendor: `name`, `category` (`analytics` | `advertising` | `social` | `session-replay`), matching `domains`, and total `requests`.

**`cookies.list[].expires`** `string | null`

ISO timestamp, or `null` for session cookies.

**`timedOut`** `boolean`

`true` when the page didn't reach network idle within 15 seconds. The report covers everything that loaded until then.

**`verdict`** `string`

* `clean` - no known trackers
* `some-trackers` - 1–4 distinct trackers and fewer than 10 third-party cookies
* `heavy-tracking` - 5+ distinct trackers or 10+ third-party cookies

#### Error Codes [#error-codes]

* `400` - Missing or invalid `url` (`INVALID_URL`)
* `502` - Browser launch or navigation failed (`BROWSER_ERROR`)

## Implementation Details [#implementation-details]

* **Third party** - a host is third party when its registrable domain differs from the page's. `src/lib/domain.ts` uses an eTLD+1 heuristic: the last two labels, or three when the suffix is a known two-part TLD (`co.uk`, `com.au`, `co.jp`, …). It is not a full Public Suffix List lookup.
* **Tracker list** - `src/constants/trackers.ts` matches hostname suffixes, and the most specific entry wins (`connect.facebook.net` → Facebook Pixel, `www.facebook.com` → Facebook). Includes Google Analytics, Tag Manager, DoubleClick, AdSense, Facebook Pixel, Hotjar, Microsoft Clarity, FullStory, Segment, Mixpanel, Amplitude, TikTok Pixel, LinkedIn Insight, X Ads, Criteo, Taboola, Outbrain, and more.
* **Navigation** - `networkidle2` with a 15 s cap. On timeout the scan keeps the partial capture instead of failing.

## Use Cases [#use-cases]

* Check whether a site loads ad or analytics tags before consent (GDPR / ePrivacy audits)
* Compare tracker footprints between competitors or releases
* Verify that a consent management platform actually blocks tags
* Learn request interception and CDP sessions in Browser Rendering

## Limitations [#limitations]

* Built-in tracker list is intentionally small; unknown vendors appear only in `thirdPartyDomains`
* Registrable-domain detection is heuristic and can misjudge uncommon multi-part TLDs
* Some sites block headless browsers or show a different page to bots
* One visit from Cloudflare's network; geo-targeted consent behavior may differ from your region
* Same-company domains (e.g. `cnn.com` and `turner.com`) count as third party

## Use in your project [#use-in-your-project]

<UseInYourProject
  experiment="privacy-tracker-scanner"
  dependencies="[&#x22;@cloudflare/puppeteer&#x22;]"
  bindings="[&#x22;Browser Rendering&#x22;]"
  platform="[&#x22;Workers runtime&#x22;]"
  files="[
  &#x22;src/lib/browser.ts&#x22;,
  &#x22;src/lib/report.ts&#x22;,
  &#x22;src/lib/domain.ts&#x22;,
  &#x22;src/lib/trackers.ts&#x22;,
  &#x22;src/constants/trackers.ts&#x22;,
  &#x22;examples/usage.ts&#x22;,
]"
  sources="[{&#x22;path&#x22;:&#x22;src/lib/browser.ts&#x22;,&#x22;title&#x22;:&#x22;browser.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import puppeteer from \&#x22;@cloudflare/puppeteer\&#x22;;\nimport {\n  DEFAULT_VIEWPORT,\n  MAX_RECORDED_REQUESTS,\n  NAVIGATION_TIMEOUT_MS,\n} from \&#x22;../constants/defaults\&#x22;;\nimport type { PageCapture } from \&#x22;../types/scan\&#x22;;\n\nfunction isTimeoutError(e: unknown): boolean {\n  return e instanceof Error && (e.name === \&#x22;TimeoutError\&#x22; || /timeout/i.test(e.message));\n}\n\n/**\n * Loads `url` without clicking anything (consent banners stay untouched), recording every\n * request and every cookie in the browser jar (all frames, all domains).\n */\nexport async function capturePage(browserBinding: Fetcher, url: string): Promise<PageCapture> {\n  let browser: Awaited<ReturnType<typeof puppeteer.launch>> | null = null;\n  try {\n    browser = await puppeteer.launch(browserBinding);\n    const page = await browser.newPage();\n    await page.setViewport(DEFAULT_VIEWPORT);\n\n    const requestUrls: string[] = [];\n    page.on(\&#x22;request\&#x22;, (request) => {\n      if (requestUrls.length < MAX_RECORDED_REQUESTS) requestUrls.push(request.url());\n    });\n\n    let timedOut = false;\n    try {\n      await page.goto(url, { waitUntil: \&#x22;networkidle2\&#x22;, timeout: NAVIGATION_TIMEOUT_MS });\n    } catch (e) {\n      // Pages with long-polling/beacons never go idle; keep what loaded within the cap.\n      if (!isTimeoutError(e) || requestUrls.length === 0) throw e;\n      timedOut = true;\n    }\n\n    const client = await page.createCDPSession();\n    const { cookies } = await client.send(\&#x22;Network.getAllCookies\&#x22;);\n\n    return {\n      finalUrl: page.url(),\n      requestUrls,\n      cookies: cookies.map((cookie) => ({\n        name: cookie.name,\n        domain: cookie.domain,\n        expires: cookie.expires,\n        secure: cookie.secure,\n        httpOnly: cookie.httpOnly,\n        sameSite: cookie.sameSite,\n      })),\n      timedOut,\n    };\n  } finally {\n    if (browser) {\n      try {\n        await browser.close();\n      } catch {\n        /* ignore close errors */\n      }\n    }\n  }\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/report.ts&#x22;,&#x22;title&#x22;:&#x22;report.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { HEAVY_THIRD_PARTY_COOKIE_THRESHOLD, HEAVY_TRACKER_THRESHOLD } from \&#x22;../constants/defaults\&#x22;;\nimport type {\n  CookieReport,\n  DetectedTracker,\n  PageCapture,\n  RawCookie,\n  ScanReport,\n  ThirdPartyDomain,\n  Verdict,\n} from \&#x22;../types/scan\&#x22;;\nimport { getHostname, getRegistrableDomain, isThirdParty } from \&#x22;./domain\&#x22;;\nimport { classifyHost } from \&#x22;./trackers\&#x22;;\n\nexport function computeVerdict(trackerCount: number, thirdPartyCookies: number): Verdict {\n  if (trackerCount === 0) return \&#x22;clean\&#x22;;\n  if (\n    trackerCount >= HEAVY_TRACKER_THRESHOLD ||\n    thirdPartyCookies >= HEAVY_THIRD_PARTY_COOKIE_THRESHOLD\n  ) {\n    return \&#x22;heavy-tracking\&#x22;;\n  }\n  return \&#x22;some-trackers\&#x22;;\n}\n\nexport function toCookieReport(cookie: RawCookie, firstPartyDomain: string): CookieReport {\n  return {\n    name: cookie.name,\n    domain: cookie.domain,\n    expires: cookie.expires > 0 ? new Date(cookie.expires * 1000).toISOString() : null,\n    secure: cookie.secure,\n    httpOnly: cookie.httpOnly,\n    sameSite: cookie.sameSite ?? null,\n    thirdParty: isThirdParty(cookie.domain, firstPartyDomain),\n  };\n}\n\nexport function buildScanReport(url: string, capture: PageCapture): ScanReport {\n  const firstPartyDomain = getRegistrableDomain(\n    getHostname(capture.finalUrl) ?? getHostname(url) ?? \&#x22;\&#x22;\n  );\n\n  let total = 0;\n  let thirdParty = 0;\n  const domainCounts = new Map<string, number>();\n  const trackerMap = new Map<string, DetectedTracker>();\n\n  for (const requestUrl of capture.requestUrls) {\n    const host = getHostname(requestUrl);\n    if (!host) continue;\n    total += 1;\n    if (!isThirdParty(host, firstPartyDomain)) continue;\n    thirdParty += 1;\n    domainCounts.set(host, (domainCounts.get(host) ?? 0) + 1);\n\n    const tracker = classifyHost(host);\n    if (!tracker) continue;\n    const existing = trackerMap.get(tracker.name);\n    if (existing) {\n      existing.requests += 1;\n      if (!existing.domains.includes(host)) existing.domains.push(host);\n    } else {\n      trackerMap.set(tracker.name, { ...tracker, domains: [host], requests: 1 });\n    }\n  }\n\n  const thirdPartyDomains: ThirdPartyDomain[] = [...domainCounts.entries()]\n    .map(([domain, requests]) => {\n      const tracker = classifyHost(domain);\n      return tracker ? { domain, requests, tracker } : { domain, requests };\n    })\n    .sort((a, b) => b.requests - a.requests || a.domain.localeCompare(b.domain));\n\n  const cookieList = capture.cookies.map((cookie) => toCookieReport(cookie, firstPartyDomain));\n  const thirdPartyCookies = cookieList.filter((cookie) => cookie.thirdParty).length;\n  const trackers = [...trackerMap.values()].sort((a, b) => b.requests - a.requests);\n\n  return {\n    url,\n    finalUrl: capture.finalUrl,\n    firstPartyDomain,\n    timedOut: capture.timedOut,\n    requests: { total, thirdParty },\n    thirdPartyDomains,\n    trackers,\n    cookies: {\n      firstParty: cookieList.length - thirdPartyCookies,\n      thirdParty: thirdPartyCookies,\n      list: cookieList,\n    },\n    verdict: computeVerdict(trackers.length, thirdPartyCookies),\n  };\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/domain.ts&#x22;,&#x22;title&#x22;:&#x22;domain.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { TWO_PART_SUFFIXES } from \&#x22;../constants/defaults\&#x22;;\n\n/** Lowercased hostname for http(s) URLs; null for data:, blob:, chrome-extension:, etc. */\nexport function getHostname(url: string): string | null {\n  try {\n    const parsed = new URL(url);\n    if (parsed.protocol !== \&#x22;http:\&#x22; && parsed.protocol !== \&#x22;https:\&#x22;) return null;\n    return parsed.hostname.toLowerCase();\n  } catch {\n    return null;\n  }\n}\n\nfunction isIpAddress(host: string): boolean {\n  return /^\\d{1,3}(\\.\\d{1,3}){3}$/.test(host) || host.includes(\&#x22;:\&#x22;) || host.startsWith(\&#x22;[\&#x22;);\n}\n\n/**\n * eTLD+1 heuristic: last two labels, or last three when the last two are a known\n * two-part public suffix (co.uk, com.au, …). Not a full Public Suffix List lookup.\n */\nexport function getRegistrableDomain(host: string): string {\n  const normalized = host.toLowerCase().replace(/^\\.+/, \&#x22;\&#x22;).replace(/\\.$/, \&#x22;\&#x22;);\n  if (isIpAddress(normalized)) return normalized;\n  const labels = normalized.split(\&#x22;.\&#x22;).filter(Boolean);\n  if (labels.length <= 2) return labels.join(\&#x22;.\&#x22;);\n  const lastTwo = labels.slice(-2).join(\&#x22;.\&#x22;);\n  const take = TWO_PART_SUFFIXES.has(lastTwo) ? 3 : 2;\n  return labels.slice(-take).join(\&#x22;.\&#x22;);\n}\n\nexport function isThirdParty(host: string, firstPartyDomain: string): boolean {\n  return getRegistrableDomain(host) !== firstPartyDomain;\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/trackers.ts&#x22;,&#x22;title&#x22;:&#x22;trackers.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { TRACKERS } from \&#x22;../constants/trackers\&#x22;;\nimport type { TrackerDefinition, TrackerInfo } from \&#x22;../types/scan\&#x22;;\n\n/** Most specific (longest) matching tracker suffix wins, e.g. connect.facebook.net over facebook.net. */\nexport function classifyHost(\n  host: string,\n  trackers: TrackerDefinition[] = TRACKERS\n): TrackerInfo | null {\n  const normalized = host.toLowerCase();\n  let best: TrackerDefinition | null = null;\n  for (const tracker of trackers) {\n    const matches = normalized === tracker.domain || normalized.endsWith(`.${tracker.domain}`);\n    if (matches && (!best || tracker.domain.length > best.domain.length)) best = tracker;\n  }\n  return best ? { name: best.name, category: best.category } : null;\n}&#x22;},{&#x22;path&#x22;:&#x22;src/constants/trackers.ts&#x22;,&#x22;title&#x22;:&#x22;trackers.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import type { TrackerDefinition } from \&#x22;../types/scan\&#x22;;\n\nexport const TRACKERS: TrackerDefinition[] = [\n  { domain: \&#x22;google-analytics.com\&#x22;, name: \&#x22;Google Analytics\&#x22;, category: \&#x22;analytics\&#x22; },\n  { domain: \&#x22;analytics.google.com\&#x22;, name: \&#x22;Google Analytics\&#x22;, category: \&#x22;analytics\&#x22; },\n  { domain: \&#x22;googletagmanager.com\&#x22;, name: \&#x22;Google Tag Manager\&#x22;, category: \&#x22;analytics\&#x22; },\n  { domain: \&#x22;doubleclick.net\&#x22;, name: \&#x22;Google DoubleClick\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;googlesyndication.com\&#x22;, name: \&#x22;Google AdSense\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;googleadservices.com\&#x22;, name: \&#x22;Google Ads\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;adservice.google.com\&#x22;, name: \&#x22;Google Ads\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;connect.facebook.net\&#x22;, name: \&#x22;Facebook Pixel\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;facebook.com\&#x22;, name: \&#x22;Facebook\&#x22;, category: \&#x22;social\&#x22; },\n  { domain: \&#x22;hotjar.com\&#x22;, name: \&#x22;Hotjar\&#x22;, category: \&#x22;session-replay\&#x22; },\n  { domain: \&#x22;hotjar.io\&#x22;, name: \&#x22;Hotjar\&#x22;, category: \&#x22;session-replay\&#x22; },\n  { domain: \&#x22;clarity.ms\&#x22;, name: \&#x22;Microsoft Clarity\&#x22;, category: \&#x22;session-replay\&#x22; },\n  { domain: \&#x22;fullstory.com\&#x22;, name: \&#x22;FullStory\&#x22;, category: \&#x22;session-replay\&#x22; },\n  { domain: \&#x22;mouseflow.com\&#x22;, name: \&#x22;Mouseflow\&#x22;, category: \&#x22;session-replay\&#x22; },\n  { domain: \&#x22;logrocket.io\&#x22;, name: \&#x22;LogRocket\&#x22;, category: \&#x22;session-replay\&#x22; },\n  { domain: \&#x22;lr-ingest.io\&#x22;, name: \&#x22;LogRocket\&#x22;, category: \&#x22;session-replay\&#x22; },\n  { domain: \&#x22;segment.com\&#x22;, name: \&#x22;Segment\&#x22;, category: \&#x22;analytics\&#x22; },\n  { domain: \&#x22;segment.io\&#x22;, name: \&#x22;Segment\&#x22;, category: \&#x22;analytics\&#x22; },\n  { domain: \&#x22;mixpanel.com\&#x22;, name: \&#x22;Mixpanel\&#x22;, category: \&#x22;analytics\&#x22; },\n  { domain: \&#x22;mxpnl.com\&#x22;, name: \&#x22;Mixpanel\&#x22;, category: \&#x22;analytics\&#x22; },\n  { domain: \&#x22;amplitude.com\&#x22;, name: \&#x22;Amplitude\&#x22;, category: \&#x22;analytics\&#x22; },\n  { domain: \&#x22;heapanalytics.com\&#x22;, name: \&#x22;Heap\&#x22;, category: \&#x22;analytics\&#x22; },\n  { domain: \&#x22;scorecardresearch.com\&#x22;, name: \&#x22;Comscore\&#x22;, category: \&#x22;analytics\&#x22; },\n  { domain: \&#x22;quantserve.com\&#x22;, name: \&#x22;Quantcast\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;analytics.tiktok.com\&#x22;, name: \&#x22;TikTok Pixel\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;snap.licdn.com\&#x22;, name: \&#x22;LinkedIn Insight\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;px.ads.linkedin.com\&#x22;, name: \&#x22;LinkedIn Insight\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;bat.bing.com\&#x22;, name: \&#x22;Microsoft Advertising\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;ads-twitter.com\&#x22;, name: \&#x22;X (Twitter) Ads\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;analytics.twitter.com\&#x22;, name: \&#x22;X (Twitter) Ads\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;platform.twitter.com\&#x22;, name: \&#x22;X (Twitter) Embed\&#x22;, category: \&#x22;social\&#x22; },\n  { domain: \&#x22;ct.pinterest.com\&#x22;, name: \&#x22;Pinterest Tag\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;sc-static.net\&#x22;, name: \&#x22;Snap Pixel\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;criteo.com\&#x22;, name: \&#x22;Criteo\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;criteo.net\&#x22;, name: \&#x22;Criteo\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;adnxs.com\&#x22;, name: \&#x22;Xandr (AppNexus)\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;taboola.com\&#x22;, name: \&#x22;Taboola\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;outbrain.com\&#x22;, name: \&#x22;Outbrain\&#x22;, category: \&#x22;advertising\&#x22; },\n  { domain: \&#x22;amazon-adsystem.com\&#x22;, name: \&#x22;Amazon Ads\&#x22;, category: \&#x22;advertising\&#x22; },\n];&#x22;},{&#x22;path&#x22;:&#x22;examples/usage.ts&#x22;,&#x22;title&#x22;:&#x22;usage.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;/**\n * Minimal paste example for docs (\&#x22;Use in your project\&#x22;).\n * Not part of the Worker runtime — copy into your own project.\n *\n * Typical API: GET /scan?url=https://example.com\n * Requires wrangler.json: \&#x22;browser\&#x22;: { \&#x22;binding\&#x22;: \&#x22;BROWSER\&#x22; }\n */\nimport { capturePage } from \&#x22;../src/lib/browser\&#x22;;\nimport { buildScanReport } from \&#x22;../src/lib/report\&#x22;;\nimport { validateUrl } from \&#x22;../src/lib/url\&#x22;;\n\nexport default {\n  async fetch(request: Request, env: { BROWSER: Fetcher }): Promise<Response> {\n    const url = validateUrl(new URL(request.url).searchParams.get(\&#x22;url\&#x22;) ?? undefined);\n    if (!url) {\n      return Response.json(\n        { error: \&#x22;Missing or invalid url\&#x22;, code: \&#x22;INVALID_URL\&#x22; },\n        { status: 400 }\n      );\n    }\n    const capture = await capturePage(env.BROWSER, url);\n    return Response.json(buildScanReport(url, capture));\n  },\n};&#x22;}]"
/>

## Deployment [#deployment]

<Steps>
  <Step>
    ### Click the deploy button [#click-the-deploy-button]

    [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shrinathsnayak/cloudflare-experiments/tree/main/apps/experiments/privacy-tracker-scanner)
  </Step>

  <Step>
    ### Enable Browser Rendering [#enable-browser-rendering]

    The `BROWSER` binding in `wrangler.json` requires Browser Rendering on your account. See the [Browser Rendering docs](https://developers.cloudflare.com/browser-rendering/).
  </Step>

  <Step>
    ### Test your deployment [#test-your-deployment]

    ```bash
    curl "https://your-worker.workers.dev/scan?url=https://example.com"
    ```
  </Step>
</Steps>

## Local Development [#local-development]

```bash
cd apps/experiments/privacy-tracker-scanner
npm install
npm run dev
```

```bash
curl "http://localhost:8787/scan?url=https://example.com"
```

## Cloudflare Features Used [#cloudflare-features-used]

* **[Workers](https://developers.cloudflare.com/workers/)** - Edge compute runtime
* **[Browser Rendering](https://developers.cloudflare.com/browser-rendering/)** - Headless Chromium via `@cloudflare/puppeteer`, request events, and a CDP session for cookies

## Next Steps [#next-steps]

<Cards>
  <Card title="Browser CDP Inspect" href="/experiments/browser-cdp-inspect">
    Title, cookies, and performance timing via Puppeteer
  </Card>

  <Card title="Security Headers Grader" href="/experiments/security-headers-grader">
    Grade HSTS, CSP, and related headers
  </Card>
</Cards>
