[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #90 · 2026-11-14 · Benchmark · Medium · for [web scraping](https://cdpfleet.com/web-scraping), [ai agents](https://cdpfleet.com/ai-agents)

# What a warmup job leaves behind: the cookies, storage and visit history in the profile it warmed

Run a 150-second warmup job on three ordinary public sites into a new persistent profile, then read what it left: the pages it visited, the cookies it collected — by site, first- vs third-party, and lifetime — and the profile itself; then open a later session on that profile and compare what it starts with, and what its first requests send, with a fresh session. In five languages, with the bill.

[Camoufox](https://cdpfleet.com/docs/browsers/camoufox)

Run on production on 2026-11-14: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

A benchmark: the results below are what this setup measured on the dates shown, on our own test runs. Sites, networks and browsers change, so rerun the code before you rely on the numbers.

## The problem

A browser that has been somewhere before doesn't look like one that hasn't: it has the site's cookies, its consent choice, its saved preferences in localStorage, and a history of pages read. A [warmup job](https://cdpfleet.com/docs/jobs#warmup) builds that up for you — a browser of yours browses a few sites the way a visitor would, on one IP, and keeps what they leave in a [persistent profile](https://cdpfleet.com/docs/profiles). But what exactly does it leave? How many cookies, from whom, for how long — and does a later session on that profile actually start with them, and send them? This case runs a short warmup on three ordinary public sites, reads everything the job and the profile hold, and compares what a later session on the profile sees with a fresh session. It describes what is stored and what a site receives; it is not about getting past anything.

## What we used, and why

| What | Why |
| --- | --- |
| `POST /v1/functions/warmup` | `{url: Wikipedia's Main Page, sites: [books.toscrape.com, theguardian.com/us], browser: "camoufox", profile: "case90-<lang>-<random>", duration: 600, launch: {headless: true, proxy: <a sticky US exit>}}` → `202 {job_id}`. A new profile name per run: the job creates it. `duration` is only a safety net; the program stops the job after 150 s. |
| `behavior` | `{scroll: true, mouse: true, typing: false, dwell_s: [4, 10], pages_per_site: [2, 4]}`: scroll, move the mouse, read each page 4–10 s, 2–4 pages per site before moving on. `typing: false` because a warmup otherwise types words from the page into a site's search box now and then — this case submits no forms at all. |
| `GET /v1/jobs/{id}` and `DELETE /v1/jobs/{id}` | Progress every 5 s (`visits`, `sites`, `challenges`, `cookies_at`); `DELETE` at 150 s → `stopping`, then `stopped`. |
| `GET /v1/jobs/{id}/results` | The visit history: one `{type: "visit", url, status, dwell_s, challenged?}` line per page, and the `{type: "cookies"}` set delivered at the end. |
| `GET /v1/jobs/{id}/cookies` | The cookies the warmup's browser held — after the end, the last set it delivered (`live: false`). Counted by site, first-party (a site the warmup opened as a page) vs third-party, session vs persistent, and by how long they have left. |
| `GET /v1/me/profiles` | The profile the job created: its browser (`engine`), `version`, size, and when it was saved. |
| A Camoufox session with `"profile": {"id": …, "mode": "readonly"}` | The later session: restores the profile and never saves it, so looking doesn't change what the warmup left. A profile belongs to the browser that made it, so this has to be Camoufox too. Its context is `browser.contexts()[0]`. |
| `page.goto("https://<site>/robots.txt")` | One small request to each warmed site, the first in that session. `response.request().allHeaders()` gives the `Cookie` header it carried — what the site receives from this browser (only cookie names are printed, never values). The plain-text page belongs to the site's origin and runs none of the site's scripts, so the localStorage and IndexedDB a page there finds are what the session started with. |
| `DELETE /v1/me/profiles/{name}` | Removes the profile at the end, in a `finally`, once nothing holds it. |

## How it works

1.  Start the warmup on the three sites into a new profile, on a sticky US exit.
2.  Follow its progress every 5 s; after 150 s, `DELETE` it and wait until it is `stopped`.
3.  Read the job's results (the pages it visited) and its cookies; count them by site, party, lifetime and flags.
4.  Wait for the profile to be saved and read its size and browser.
5.  Launch Camoufox read-only on the profile: list the cookies it starts with and compare them with the job's; request each site's `/robots.txt` once, record the `Cookie` header it sent, and read the origin's localStorage and IndexedDB from that page.
6.  Do the same in a fresh Camoufox session without a profile.
7.  Read the bill from the session history, and delete the profile.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/90-what-a-warmup-leaves-behind), 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:pass@proxy.example.com:8000`
-   `PROXY_URL_US` — a proxy exit in the United States

 

```node
// npm install playwright@1.60.0
// env: CDPFLEET_API_KEY (a paid plan: jobs and profiles aren't on Community), PROXY_URL_US (a sticky US exit)
import { randomBytes } from 'node:crypto';
import { firefox } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const PROXY = process.env.PROXY_URL_US;
const API = 'https://cdpfleet.com';
const NAME = `case90-node-${randomBytes(4).toString('hex')}`; // a profile of its own for each run
// Three ordinary public sites: an encyclopedia, a scraping sandbox, a news front page.
const SITES = ['https://en.wikipedia.org/wiki/Main_Page', 'https://books.toscrape.com/', 'https://www.theguardian.com/us'];
const WARMUP = {
  url: SITES[0],
  sites: SITES.slice(1),
  browser: 'camoufox',
  profile: NAME,
  duration: 600, // a safety net: the program stops the job itself
  // No typing: the warmup would otherwise type into search boxes now and then.
  behavior: { scroll: true, mouse: true, typing: false, dwell_s: [4, 10], pages_per_site: [2, 4] },
};
const STOP_AFTER_S = 150;
const ENDED = ['done', 'failed', 'stopped', 'interrupted'];
const DAY = 86400;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const secs = (ms) => Math.round(ms / 100) / 10;

async function api(method, path, body) {
  const res = await fetch(`${API}${path}`, {
    method,
    headers: { 'x-api-key': KEY, ...(body ? { 'content-type': 'application/json' } : {}) },
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  let json = null;
  try { json = JSON.parse(text); } catch { /* not JSON */ }
  return { status: res.status, json, text };
}
const profileInfo = async () => (await api('GET', '/v1/me/profiles')).json?.profiles?.find((p) => p.name === NAME);

// The site a cookie or a host belongs to: its last two labels (three under co.uk and the like).
function site(host) {
  const labels = host.replace(/^\./, '').split('.');
  const n = /^(co|com|org|net|ac|gov)\.[a-z]{2}$/.test(labels.slice(-2).join('.')) ? 3 : 2;
  return labels.slice(-n).join('.');
}

// Cookies by site, first- vs third-party (first = a site the warmup visited as a page), and how long they last.
function summarize(cookies, firstParty, now) {
  const bySite = {};
  for (const c of cookies) bySite[site(c.domain)] = (bySite[site(c.domain)] || 0) + 1;
  const third = Object.entries(bySite).filter(([s]) => !firstParty.includes(s)).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
  const left = cookies.filter((c) => c.expires > 0).map((c) => (c.expires - now) / DAY);
  const keys = cookies.map((c) => `${c.name}|${c.domain}|${c.path}`);
  return {
    total: cookies.length,
    sites: Object.keys(bySite).length,
    first_party: cookies.filter((c) => firstParty.includes(site(c.domain))).length,
    third_party: cookies.length - cookies.filter((c) => firstParty.includes(site(c.domain))).length,
    third_party_sites: third.length,
    by_first_party_site: Object.fromEntries(firstParty.map((s) => [s, bySite[s] || 0])),
    top_third_party: third.slice(0, 5).map(([s, n]) => `${s} ${n}`),
    session: cookies.length - left.length,
    http_only: cookies.filter((c) => c.httpOnly).length,
    same_site_none: cookies.filter((c) => c.sameSite === 'None').length,
    expiry: {
      under_1_day: left.filter((d) => d < 1).length,
      under_30_days: left.filter((d) => d >= 1 && d < 30).length,
      under_1_year: left.filter((d) => d >= 30 && d < 365).length,
      a_year_or_more: left.filter((d) => d >= 365).length,
      max_days: left.length ? Math.round(Math.max(...left)) : null,
    },
    same_key_twice: keys.length - new Set(keys).size, // e.g. Firefox's per-site (partitioned) copies
  };
}

async function launch(profile) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch('https://starter.cdpfleet.com/camoufox/session', {
      method: 'POST',
      headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
      body: JSON.stringify({ proxy: PROXY, headless: true, ...(profile ? { profile } : {}) }),
    });
    if (res.ok) {
      const body = await res.json();
      const browser = await firefox.connect(body.wsUrl, { headers: { 'x-api-key': KEY } });
      return { sessionId: body.sessionId, browser };
    }
    if (res.status !== 503 || attempt === 5) throw new Error(`launch ${res.status} ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 5) * 1000);
  }
}

// What one later session sees: the cookies it starts with, then for each warmed site one request for its
// /robots.txt — the Cookie header that first request carries is what the site gets from this browser, and
// the plain-text page lets us read the origin's localStorage and IndexedDB with none of the site's scripts run.
async function inspect(profile) {
  const { sessionId, browser } = await launch(profile);
  const t = Date.now();
  try {
    // With a profile, its context is browser.contexts()[0]; without one, a fresh context.
    const context = profile ? browser.contexts()[0] : await browser.newContext();
    const page = context.pages()[0] ?? await context.newPage();
    const atStart = await context.cookies();
    const origins = [];
    for (const url of SITES) {
      const origin = new URL(url).origin;
      let res = null;
      for (let attempt = 1; attempt <= 3 && !res; attempt++) {
        try { res = await page.goto(`${origin}/robots.txt`, { timeout: 60000 }); } catch (err) { if (attempt === 3) throw err; await sleep(2000); }
      }
      const header = (await res.request().allHeaders()).cookie || '';
      const names = header ? header.split('; ').map((c) => c.slice(0, c.indexOf('='))) : [];
      const storage = await page.evaluate(async () => ({ local_storage: localStorage.length, indexeddb: (await indexedDB.databases()).length }));
      origins.push({ origin, status: res.status(), cookie_header_cookies: names.length, cookie_header_names: names.slice(0, 8), ...storage });
      await sleep(3000);
    }
    return { session_id: sessionId, cookies_at_start: atStart, origins, seconds: secs(Date.now() - t) };
  } finally {
    await browser.close();
  }
}

const out = { measured_at: new Date().toISOString().slice(0, 10), sites: SITES, warmup: { ...WARMUP, profile: 'case90-node-…', stop_after_s: STOP_AFTER_S } };
let id = null;
try {
  // 1. Start the warmup into a new profile, on a sticky US exit.
  const t0 = Date.now();
  const start = await api('POST', '/v1/functions/warmup', { ...WARMUP, launch: { headless: true, proxy: PROXY } });
  if (start.status !== 202) throw new Error(`warmup ${start.status} ${start.text}`);
  id = start.json.job_id;
  out.warmup.start_status = start.status;

  // 2. Follow its progress; stop it after STOP_AFTER_S seconds.
  const samples = [];
  let job;
  let stop = null;
  for (;;) {
    await sleep(5000);
    job = (await api('GET', `/v1/jobs/${id}`)).json;
    samples.push({ s: Math.round((Date.now() - t0) / 1000), ...job.progress });
    if (ENDED.includes(job.status)) break;
    if (!stop && Date.now() - t0 >= STOP_AFTER_S * 1000) {
      const r = await api('DELETE', `/v1/jobs/${id}`);
      stop = { after_seconds: secs(Date.now() - t0), status: r.status, reply: r.json?.status ?? null, at: Date.now() };
    }
    if (Date.now() - t0 > (STOP_AFTER_S + 120) * 1000) throw new Error(`job still ${job.status}`);
  }
  out.job = {
    status: job.status, own_session: job.own_session, runs: job.runs, progress: job.progress, error: job.error,
    billed_seconds: job.billed_seconds, run_seconds: secs(Date.parse(job.ended_at) - Date.parse(job.started_at)),
    stop: stop && { after_seconds: stop.after_seconds, status: stop.status, reply: stop.reply, seconds_to_ended: secs(Date.parse(job.ended_at) - stop.at) },
    progress_samples: samples.filter((p, i) => p.visits !== undefined && (i === samples.length - 1 || p.visits !== samples[i + 1]?.visits)).map(({ s, visits, sites, challenges }) => ({ s, visits, sites, challenges })),
  };

  // 3. The visit history: the job's results log every page it browsed.
  const lines = [];
  for (let cursor = 0; cursor !== null;) {
    const r = (await api('GET', `/v1/jobs/${id}/results?cursor=${cursor}`)).json;
    lines.push(...r.results);
    if (r.next_cursor === cursor) break;
    cursor = r.next_cursor;
  }
  const visits = lines.filter((l) => l.type === 'visit');
  const firstParty = [...new Set(visits.map((v) => site(new URL(v.url).hostname)))].sort();
  out.visits = {
    count: visits.length, challenged: visits.filter((v) => v.challenged).length,
    by_site: Object.fromEntries(firstParty.map((s) => [s, visits.filter((v) => site(new URL(v.url).hostname) === s).length])),
    statuses: [...new Set(visits.map((v) => v.status))].sort(),
    dwell_s: visits.length ? { min: Math.min(...visits.map((v) => v.dwell_s)), max: Math.max(...visits.map((v) => v.dwell_s)) } : null,
    paths: visits.map((v) => { const u = new URL(v.url); return `${u.hostname}${u.pathname}`; }),
    cookie_lines: lines.filter((l) => l.type === 'cookies').length,
  };

  // 4. The cookies it left: GET /v1/jobs/{id}/cookies (after the end, the last set it delivered).
  const ck = await api('GET', `/v1/jobs/${id}/cookies`);
  const jobCookies = ck.json?.cookies || [];
  const now = Date.now() / 1000;
  out.job_cookies = { status: ck.status, live: ck.json?.live ?? null, at_vs_end_seconds: secs(Date.parse(ck.json?.at) - Date.parse(job.ended_at)), ...summarize(jobCookies, firstParty, now) };

  // 5. The profile, saved after the job's browser exited.
  let p;
  for (let i = 0; i < 60; i++) { p = await profileInfo(); if (p?.version >= 1 && !p.in_use) break; await sleep(2000); }
  out.profile = p ? { engine: p.engine, version: p.version, bytes: p.bytes, saved_after_end_seconds: secs(Date.parse(p.saved_at) - Date.parse(job.ended_at)) } : null;

  // 6. A later session on the profile (read-only: it sees the profile and doesn't change it), then a fresh one.
  const later = await inspect({ id: NAME, mode: 'readonly' });
  const keys = new Set(later.cookies_at_start.map((c) => `${c.name}|${c.domain}|${c.path}`));
  const jobKeys = new Set(jobCookies.map((c) => `${c.name}|${c.domain}|${c.path}`));
  const session = jobCookies.filter((c) => !(c.expires > 0));
  out.later = {
    cookies_at_start: summarize(later.cookies_at_start, firstParty, Date.now() / 1000),
    job_cookies_restored: [...jobKeys].filter((k) => keys.has(k)).length,
    job_cookies_distinct: jobKeys.size,
    session_cookies_restored: session.filter((c) => keys.has(`${c.name}|${c.domain}|${c.path}`)).length,
    session_cookies_in_job: session.length,
    not_in_job: [...keys].filter((k) => !jobKeys.has(k)).length,
    origins: later.origins,
    seconds: later.seconds,
  };
  const fresh = await inspect(null);
  out.fresh = { cookies_at_start: fresh.cookies_at_start.length, origins: fresh.origins, seconds: fresh.seconds };

  // 7. The bill: the warmup's own session and the two later sessions, from the session history.
  let sessions = [];
  for (let i = 0; i < 15; i++) {
    sessions = (await api('GET', `/v1/me/sessions?from=${encodeURIComponent(new Date(t0 - 60000).toISOString())}&limit=200`)).json?.sessions || [];
    if ([job.session_id, later.session_id, fresh.session_id].every((s) => sessions.some((x) => (x.session_id || x.id) === s && x.ended_at))) break;
    await sleep(2000);
  }
  const bill = (sid) => {
    const s = sessions.find((x) => (x.session_id || x.id) === sid);
    return s ? { engine: s.engine, weight: s.weight, seconds: s.ended_at ? secs(Date.parse(s.ended_at) - Date.parse(s.started_at)) : null } : null;
  };
  out.bill = { warmup: { billed_seconds: job.billed_seconds, session: bill(job.session_id) }, later: bill(later.session_id), fresh: bill(fresh.session_id) };
} finally {
  // Stop the job if something failed early, then delete the profile once nothing holds it.
  if (id && !out.job) await api('DELETE', `/v1/jobs/${id}`);
  for (let i = 0; i < 60 && (await profileInfo())?.in_use; i++) await sleep(2000);
  const del = await api('DELETE', `/v1/me/profiles/${NAME}`);
  out.cleanup = { delete_status: del.status, deleted: del.json?.deleted === true && (await profileInfo()) === undefined };
}
console.log(JSON.stringify(out, null, 2));
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY (a paid plan: jobs and profiles aren't on Community), PROXY_URL_US (a sticky US exit)
import json
import os
import re
import secrets
import time
from datetime import datetime, timezone
from urllib.parse import quote, urlparse

import requests
from playwright.sync_api import sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]
PROXY = os.environ["PROXY_URL_US"]
API = "https://cdpfleet.com"
NAME = f"case90-python-{secrets.token_hex(4)}"  # a profile of its own for each run
# Three ordinary public sites: an encyclopedia, a scraping sandbox, a news front page.
SITES = ["https://en.wikipedia.org/wiki/Main_Page", "https://books.toscrape.com/", "https://www.theguardian.com/us"]
WARMUP = {
    "url": SITES[0],
    "sites": SITES[1:],
    "browser": "camoufox",
    "profile": NAME,
    "duration": 600,  # a safety net: the program stops the job itself
    # No typing: the warmup would otherwise type into search boxes now and then.
    "behavior": {"scroll": True, "mouse": True, "typing": False, "dwell_s": [4, 10], "pages_per_site": [2, 4]},
}
STOP_AFTER_S = 150
ENDED = ["done", "failed", "stopped", "interrupted"]
DAY = 86400
HEADERS = {"x-api-key": KEY}

def api(method, path, body=None):
    return requests.request(method, API + path, headers=HEADERS, json=body, timeout=90)

def ms(iso):  # an API timestamp as epoch milliseconds
    return datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp() * 1000

def now_ms():
    return time.time() * 1000

def secs(m):
    return round(m / 1000, 1)

def profile_info():
    return next((p for p in api("GET", "/v1/me/profiles").json().get("profiles", []) if p["name"] == NAME), None)

# The site a cookie or a host belongs to: its last two labels (three under co.uk and the like).
def site(host):
    labels = host.lstrip(".").split(".")
    n = 3 if re.match(r"^(co|com|org|net|ac|gov)\.[a-z]{2}$", ".".join(labels[-2:])) else 2
    return ".".join(labels[-n:])

def key(c):
    return f"{c['name']}|{c['domain']}|{c['path']}"

# Cookies by site, first- vs third-party (first = a site the warmup visited as a page), and how long they last.
def summarize(cookies, first_party, now):
    by_site = {}
    for c in cookies:
        by_site[site(c["domain"])] = by_site.get(site(c["domain"]), 0) + 1
    third = sorted(((s, n) for s, n in by_site.items() if s not in first_party), key=lambda x: (-x[1], x[0]))
    left = [(c["expires"] - now) / DAY for c in cookies if c["expires"] > 0]
    first = sum(1 for c in cookies if site(c["domain"]) in first_party)
    keys = [key(c) for c in cookies]
    return {
        "total": len(cookies),
        "sites": len(by_site),
        "first_party": first,
        "third_party": len(cookies) - first,
        "third_party_sites": len(third),
        "by_first_party_site": {s: by_site.get(s, 0) for s in first_party},
        "top_third_party": [f"{s} {n}" for s, n in third[:5]],
        "session": len(cookies) - len(left),
        "http_only": sum(1 for c in cookies if c["httpOnly"]),
        "same_site_none": sum(1 for c in cookies if c["sameSite"] == "None"),
        "expiry": {
            "under_1_day": sum(1 for d in left if d < 1),
            "under_30_days": sum(1 for d in left if 1 <= d < 30),
            "under_1_year": sum(1 for d in left if 30 <= d < 365),
            "a_year_or_more": sum(1 for d in left if d >= 365),
            "max_days": round(max(left)) if left else None,
        },
        "same_key_twice": len(keys) - len(set(keys)),  # e.g. Firefox's per-site (partitioned) copies
    }

def launch(pw, profile):
    body = {"proxy": PROXY, "headless": True}
    if profile:
        body["profile"] = profile
    for attempt in range(1, 6):
        res = requests.post("https://starter.cdpfleet.com/camoufox/session", headers=HEADERS, json=body, timeout=120)
        if res.ok:
            data = res.json()
            return data["sessionId"], pw.firefox.connect(data["wsUrl"], headers=HEADERS)
        if res.status_code != 503 or attempt == 5:
            raise RuntimeError(f"launch {res.status_code} {res.text}")
        time.sleep(int(res.headers.get("retry-after") or 5))

STORAGE_JS = "async () => ({ local_storage: localStorage.length, indexeddb: (await indexedDB.databases()).length })"

# What one later session sees: the cookies it starts with, then for each warmed site one request for its
# /robots.txt — the Cookie header that first request carries is what the site gets from this browser, and
# the plain-text page lets us read the origin's localStorage and IndexedDB with none of the site's scripts run.
def inspect(pw, profile):
    session_id, browser = launch(pw, profile)
    t = now_ms()
    try:
        # With a profile, its context is browser.contexts[0]; without one, a fresh context.
        context = browser.contexts[0] if profile else browser.new_context()
        page = context.pages[0] if context.pages else context.new_page()
        at_start = context.cookies()
        origins = []
        for url in SITES:
            u = urlparse(url)
            origin = f"{u.scheme}://{u.netloc}"
            res = None
            for attempt in range(1, 4):
                try:
                    res = page.goto(f"{origin}/robots.txt", timeout=60000)
                    break
                except Exception:
                    if attempt == 3:
                        raise
                    time.sleep(2)
            header = res.request.all_headers().get("cookie", "")
            names = [c.split("=", 1)[0] for c in header.split("; ")] if header else []
            storage = page.evaluate(STORAGE_JS)
            origins.append({"origin": origin, "status": res.status, "cookie_header_cookies": len(names), "cookie_header_names": names[:8], **storage})
            time.sleep(3)
        return {"session_id": session_id, "cookies_at_start": at_start, "origins": origins, "seconds": secs(now_ms() - t)}
    finally:
        browser.close()

out = {
    "measured_at": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
    "sites": SITES,
    "warmup": {**WARMUP, "profile": "case90-python-…", "stop_after_s": STOP_AFTER_S},
}
job_id = None
try:
    # 1. Start the warmup into a new profile, on a sticky US exit.
    t0 = now_ms()
    start = api("POST", "/v1/functions/warmup", {**WARMUP, "launch": {"headless": True, "proxy": PROXY}})
    if start.status_code != 202:
        raise RuntimeError(f"warmup {start.status_code} {start.text}")
    job_id = start.json()["job_id"]
    out["warmup"]["start_status"] = start.status_code

    # 2. Follow its progress; stop it after STOP_AFTER_S seconds.
    samples = []
    stop = None
    while True:
        time.sleep(5)
        job = api("GET", f"/v1/jobs/{job_id}").json()
        samples.append({"s": round((now_ms() - t0) / 1000), **(job.get("progress") or {})})
        if job["status"] in ENDED:
            break
        if not stop and now_ms() - t0 >= STOP_AFTER_S * 1000:
            r = api("DELETE", f"/v1/jobs/{job_id}")
            stop = {"after_seconds": secs(now_ms() - t0), "status": r.status_code, "reply": r.json().get("status"), "at": now_ms()}
        if now_ms() - t0 > (STOP_AFTER_S + 120) * 1000:
            raise RuntimeError(f"job still {job['status']}")
    kept = [p for i, p in enumerate(samples) if "visits" in p and (i == len(samples) - 1 or p["visits"] != samples[i + 1].get("visits"))]
    out["job"] = {
        "status": job["status"], "own_session": job.get("own_session"), "runs": job.get("runs"), "progress": job.get("progress"), "error": job.get("error"),
        "billed_seconds": job.get("billed_seconds"), "run_seconds": secs(ms(job["ended_at"]) - ms(job["started_at"])),
        "stop": stop and {"after_seconds": stop["after_seconds"], "status": stop["status"], "reply": stop["reply"], "seconds_to_ended": secs(ms(job["ended_at"]) - stop["at"])},
        "progress_samples": [{"s": p["s"], "visits": p.get("visits"), "sites": p.get("sites"), "challenges": p.get("challenges")} for p in kept],
    }

    # 3. The visit history: the job's results log every page it browsed.
    lines = []
    cursor = 0
    while cursor is not None:
        r = api("GET", f"/v1/jobs/{job_id}/results?cursor={cursor}").json()
        lines += r["results"]
        if r["next_cursor"] == cursor:
            break
        cursor = r["next_cursor"]
    visits = [l for l in lines if l["type"] == "visit"]
    first_party = sorted({site(urlparse(v["url"]).hostname) for v in visits})
    out["visits"] = {
        "count": len(visits), "challenged": sum(1 for v in visits if v.get("challenged")),
        "by_site": {s: sum(1 for v in visits if site(urlparse(v["url"]).hostname) == s) for s in first_party},
        "statuses": sorted({v["status"] for v in visits}),
        "dwell_s": {"min": min(v["dwell_s"] for v in visits), "max": max(v["dwell_s"] for v in visits)} if visits else None,
        "paths": [urlparse(v["url"]).hostname + urlparse(v["url"]).path for v in visits],
        "cookie_lines": sum(1 for l in lines if l["type"] == "cookies"),
    }

    # 4. The cookies it left: GET /v1/jobs/{id}/cookies (after the end, the last set it delivered).
    ck = api("GET", f"/v1/jobs/{job_id}/cookies")
    ckj = ck.json()
    job_cookies = ckj.get("cookies") or []
    out["job_cookies"] = {"status": ck.status_code, "live": ckj.get("live"), "at_vs_end_seconds": secs(ms(ckj["at"]) - ms(job["ended_at"])),
                          **summarize(job_cookies, first_party, time.time())}

    # 5. The profile, saved after the job's browser exited.
    p = None
    for _ in range(60):
        p = profile_info()
        if p and p["version"] >= 1 and not p["in_use"]:
            break
        time.sleep(2)
    out["profile"] = {"engine": p["engine"], "version": p["version"], "bytes": p["bytes"], "saved_after_end_seconds": secs(ms(p["saved_at"]) - ms(job["ended_at"]))} if p else None

    with sync_playwright() as pw:
        # 6. A later session on the profile (read-only: it sees the profile and doesn't change it), then a fresh one.
        later = inspect(pw, {"id": NAME, "mode": "readonly"})
        keys = {key(c) for c in later["cookies_at_start"]}
        job_keys = {key(c) for c in job_cookies}
        session = [c for c in job_cookies if not c["expires"] > 0]
        out["later"] = {
            "cookies_at_start": summarize(later["cookies_at_start"], first_party, time.time()),
            "job_cookies_restored": len(job_keys & keys),
            "job_cookies_distinct": len(job_keys),
            "session_cookies_restored": sum(1 for c in session if key(c) in keys),
            "session_cookies_in_job": len(session),
            "not_in_job": len(keys - job_keys),
            "origins": later["origins"],
            "seconds": later["seconds"],
        }
        fresh = inspect(pw, None)
        out["fresh"] = {"cookies_at_start": len(fresh["cookies_at_start"]), "origins": fresh["origins"], "seconds": fresh["seconds"]}

    # 7. The bill: the warmup's own session and the two later sessions, from the session history.
    since = datetime.fromtimestamp((t0 - 60000) / 1000, timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
    wanted = [job.get("session_id"), later["session_id"], fresh["session_id"]]
    sessions = []
    for _ in range(15):
        sessions = api("GET", f"/v1/me/sessions?from={quote(since)}&limit=200").json().get("sessions") or []
        if all(any((x.get("session_id") or x.get("id")) == s and x.get("ended_at") for x in sessions) for s in wanted):
            break
        time.sleep(2)

    def bill(sid):
        s = next((x for x in sessions if (x.get("session_id") or x.get("id")) == sid), None)
        if not s:
            return None
        return {"engine": s["engine"], "weight": s["weight"], "seconds": secs(ms(s["ended_at"]) - ms(s["started_at"])) if s.get("ended_at") else None}

    out["bill"] = {"warmup": {"billed_seconds": job.get("billed_seconds"), "session": bill(job.get("session_id"))}, "later": bill(later["session_id"]), "fresh": bill(fresh["session_id"])}
finally:
    # Stop the job if something failed early, then delete the profile once nothing holds it.
    if job_id and "job" not in out:
        api("DELETE", f"/v1/jobs/{job_id}")
    for _ in range(60):
        info = profile_info()
        if not (info and info["in_use"]):
            break
        time.sleep(2)
    d = api("DELETE", f"/v1/me/profiles/{NAME}")
    out["cleanup"] = {"delete_status": d.status_code, "deleted": d.status_code == 200 and d.json().get("deleted") is True and profile_info() is None}
print(json.dumps(out, indent=2, ensure_ascii=False))
```

```java
// Java 21. Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// env: CDPFLEET_API_KEY (a paid plan: jobs and profiles aren't on Community), PROXY_URL_US (a sticky US exit)
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.*;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.*;
import java.util.*;
import java.util.stream.*;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");
  static final String PROXY = System.getenv("PROXY_URL_US");
  static final String API = "https://cdpfleet.com";
  static final String NAME = "case90-java-" + hex(4); // a profile of its own for each run
  // Three ordinary public sites: an encyclopedia, a scraping sandbox, a news front page.
  static final List<String> SITES = List.of("https://en.wikipedia.org/wiki/Main_Page", "https://books.toscrape.com/", "https://www.theguardian.com/us");
  static final int STOP_AFTER_S = 150;
  static final List<String> ENDED = List.of("done", "failed", "stopped", "interrupted");
  static final double DAY = 86400;
  static final HttpClient HTTP = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
  static final Gson GSON = new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create();

  record Res(int status, JsonObject json, String text) {}
  // A cookie as the API and Playwright both describe it.
  record Ck(String name, String domain, String path, double expires, boolean httpOnly, String sameSite) {
    String key() { return name + "|" + domain + "|" + path; }
  }
  record Look(String sessionId, List<Ck> cookiesAtStart, JsonArray origins, double seconds) {}

  static String hex(int n) { byte[] b = new byte[n]; new SecureRandom().nextBytes(b); return HexFormat.of().formatHex(b); }
  static Res api(String method, String path, JsonObject body) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(API + path)).timeout(Duration.ofSeconds(90)).header("x-api-key", KEY);
    if (body != null) b.header("content-type", "application/json").method(method, HttpRequest.BodyPublishers.ofString(body.toString()));
    else b.method(method, HttpRequest.BodyPublishers.noBody());
    HttpResponse<String> r = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
    JsonObject json;
    try { json = JsonParser.parseString(r.body()).getAsJsonObject(); } catch (Exception e) { json = new JsonObject(); }
    return new Res(r.statusCode(), json, r.body());
  }
  static String str(JsonObject o, String k) { return o != null && o.has(k) && !o.get(k).isJsonNull() ? o.get(k).getAsString() : null; }
  static double secs(double ms) { return Math.round(ms / 100) / 10.0; }
  static long ms(String iso) { return Instant.parse(iso).toEpochMilli(); } // an API timestamp
  static void sleep(long ms) throws InterruptedException { Thread.sleep(ms); }

  static JsonObject profileInfo() throws Exception {
    JsonObject r = api("GET", "/v1/me/profiles", null).json();
    if (!r.has("profiles")) return null;
    for (JsonElement p : r.getAsJsonArray("profiles")) if (NAME.equals(str(p.getAsJsonObject(), "name"))) return p.getAsJsonObject();
    return null;
  }

  // The site a cookie or a host belongs to: its last two labels (three under co.uk and the like).
  static String site(String host) {
    String[] l = host.replaceFirst("^\\.", "").split("\\.");
    int n = l.length >= 2 && (l[l.length - 2] + "." + l[l.length - 1]).matches("^(co|com|org|net|ac|gov)\\.[a-z]{2}$") ? 3 : 2;
    return String.join(".", Arrays.copyOfRange(l, Math.max(0, l.length - n), l.length));
  }

  // Cookies by site, first- vs third-party (first = a site the warmup visited as a page), and how long they last.
  static JsonObject summarize(List<Ck> cookies, List<String> firstParty, double now) {
    Map<String, Integer> bySite = new HashMap<>();
    for (Ck c : cookies) bySite.merge(site(c.domain()), 1, Integer::sum);
    List<Map.Entry<String, Integer>> third = bySite.entrySet().stream().filter(e -> !firstParty.contains(e.getKey()))
        .sorted(Comparator.comparing((Map.Entry<String, Integer> e) -> -e.getValue()).thenComparing(Map.Entry::getKey)).toList();
    List<Double> left = cookies.stream().filter(c -> c.expires() > 0).map(c -> (c.expires() - now) / DAY).toList();
    long first = cookies.stream().filter(c -> firstParty.contains(site(c.domain()))).count();
    JsonObject o = new JsonObject();
    o.addProperty("total", cookies.size());
    o.addProperty("sites", bySite.size());
    o.addProperty("first_party", first);
    o.addProperty("third_party", cookies.size() - first);
    o.addProperty("third_party_sites", third.size());
    JsonObject byFirst = new JsonObject();
    for (String s : firstParty) byFirst.addProperty(s, bySite.getOrDefault(s, 0));
    o.add("by_first_party_site", byFirst);
    JsonArray top = new JsonArray();
    third.stream().limit(5).forEach(e -> top.add(e.getKey() + " " + e.getValue()));
    o.add("top_third_party", top);
    o.addProperty("session", cookies.size() - left.size());
    o.addProperty("http_only", cookies.stream().filter(Ck::httpOnly).count());
    o.addProperty("same_site_none", cookies.stream().filter(c -> "None".equals(c.sameSite())).count());
    JsonObject exp = new JsonObject();
    exp.addProperty("under_1_day", left.stream().filter(d -> d < 1).count());
    exp.addProperty("under_30_days", left.stream().filter(d -> d >= 1 && d < 30).count());
    exp.addProperty("under_1_year", left.stream().filter(d -> d >= 30 && d < 365).count());
    exp.addProperty("a_year_or_more", left.stream().filter(d -> d >= 365).count());
    exp.add("max_days", left.isEmpty() ? JsonNull.INSTANCE : new JsonPrimitive(Math.round(Collections.max(left))));
    o.add("expiry", exp);
    o.addProperty("same_key_twice", cookies.size() - cookies.stream().map(Ck::key).distinct().count()); // e.g. Firefox's per-site (partitioned) copies
    return o;
  }

  static List<Ck> fromApi(JsonArray arr) {
    List<Ck> list = new ArrayList<>();
    for (JsonElement e : arr) {
      JsonObject c = e.getAsJsonObject();
      list.add(new Ck(str(c, "name"), str(c, "domain"), str(c, "path"), c.has("expires") ? c.get("expires").getAsDouble() : -1,
          c.has("httpOnly") && c.get("httpOnly").getAsBoolean(), str(c, "sameSite")));
    }
    return list;
  }

  static String sameSite(SameSiteAttribute s) {
    if (s == null) return null;
    return switch (s) { case NONE -> "None"; case LAX -> "Lax"; case STRICT -> "Strict"; };
  }

  static Browser launch(Playwright pw, JsonElement profile, String[] sessionId) throws Exception {
    JsonObject body = new JsonObject();
    body.addProperty("proxy", PROXY);
    body.addProperty("headless", true);
    if (profile != null) body.add("profile", profile);
    for (int attempt = 1; ; attempt++) {
      HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/camoufox/session"))
          .timeout(Duration.ofSeconds(120)).header("x-api-key", KEY).header("content-type", "application/json")
          .POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
      if (res.statusCode() == 200 || res.statusCode() == 201) {
        JsonObject s = JsonParser.parseString(res.body()).getAsJsonObject();
        sessionId[0] = str(s, "sessionId");
        return pw.firefox().connect(str(s, "wsUrl"), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
      }
      if (res.statusCode() != 503 || attempt == 5) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
      sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
    }
  }

  // What one later session sees: the cookies it starts with, then for each warmed site one request for its
  // /robots.txt — the Cookie header that first request carries is what the site gets from this browser, and
  // the plain-text page lets us read the origin's localStorage and IndexedDB with none of the site's scripts run.
  static Look inspect(Playwright pw, JsonElement profile) throws Exception {
    String[] sid = new String[1];
    Browser browser = launch(pw, profile, sid);
    long t = System.currentTimeMillis();
    try {
      // With a profile, its context is browser.contexts().get(0); without one, a fresh context.
      BrowserContext context = profile != null ? browser.contexts().get(0) : browser.newContext();
      Page page = context.pages().isEmpty() ? context.newPage() : context.pages().get(0);
      List<Ck> atStart = context.cookies().stream().map(c -> new Ck(c.name, c.domain, c.path, c.expires == null ? -1 : c.expires,
          Boolean.TRUE.equals(c.httpOnly), sameSite(c.sameSite))).toList();
      JsonArray origins = new JsonArray();
      for (String url : SITES) {
        URI u = URI.create(url);
        String origin = u.getScheme() + "://" + u.getHost();
        Response res = null;
        for (int attempt = 1; attempt <= 3 && res == null; attempt++) {
          try { res = page.navigate(origin + "/robots.txt", new Page.NavigateOptions().setTimeout(60000)); }
          catch (PlaywrightException e) { if (attempt == 3) throw e; sleep(2000); }
        }
        String header = res.request().allHeaders().getOrDefault("cookie", "");
        List<String> names = header.isEmpty() ? List.of() : Arrays.stream(header.split("; ")).map(c -> c.contains("=") ? c.substring(0, c.indexOf('=')) : c).toList();
        JsonObject storage = JsonParser.parseString(GSON.toJson(page.evaluate(
            "async () => ({ local_storage: localStorage.length, indexeddb: (await indexedDB.databases()).length })"))).getAsJsonObject();
        JsonObject o = new JsonObject();
        o.addProperty("origin", origin);
        o.addProperty("status", res.status());
        o.addProperty("cookie_header_cookies", names.size());
        JsonArray first8 = new JsonArray();
        names.stream().limit(8).forEach(first8::add);
        o.add("cookie_header_names", first8);
        for (String k : List.of("local_storage", "indexeddb")) o.addProperty(k, storage.get(k).getAsInt());
        origins.add(o);
        sleep(3000);
      }
      return new Look(sid[0], atStart, origins, secs(System.currentTimeMillis() - t));
    } finally {
      browser.close();
    }
  }

  public static void main(String[] args) throws Exception {
    JsonObject warmup = JsonParser.parseString("""
        {"browser": "camoufox", "duration": 600,
         "behavior": {"scroll": true, "mouse": true, "typing": false, "dwell_s": [4, 10], "pages_per_site": [2, 4]}}""").getAsJsonObject();
    // duration is a safety net: the program stops the job itself. No typing: the warmup would otherwise type into search boxes now and then.
    warmup.addProperty("url", SITES.get(0));
    JsonArray more = new JsonArray();
    SITES.subList(1, SITES.size()).forEach(more::add);
    warmup.add("sites", more);
    warmup.addProperty("profile", NAME);

    JsonObject out = new JsonObject();
    out.addProperty("measured_at", LocalDate.now(ZoneOffset.UTC).toString());
    JsonArray sites = new JsonArray();
    SITES.forEach(sites::add);
    out.add("sites", sites);
    JsonObject shown = new JsonObject();
    for (String k : List.of("url", "sites", "browser", "profile", "duration", "behavior")) shown.add(k, warmup.get(k));
    shown.addProperty("profile", "case90-java-…");
    shown.addProperty("stop_after_s", STOP_AFTER_S);
    out.add("warmup", shown);
    String id = null;
    try {
      // 1. Start the warmup into a new profile, on a sticky US exit.
      long t0 = System.currentTimeMillis();
      JsonObject body = warmup.deepCopy();
      JsonObject launch = new JsonObject();
      launch.addProperty("headless", true);
      launch.addProperty("proxy", PROXY);
      body.add("launch", launch);
      Res start = api("POST", "/v1/functions/warmup", body);
      if (start.status() != 202) throw new RuntimeException("warmup " + start.status() + " " + start.text());
      id = str(start.json(), "job_id");
      shown.addProperty("start_status", start.status());

      // 2. Follow its progress; stop it after STOP_AFTER_S seconds.
      List<JsonObject> samples = new ArrayList<>();
      JsonObject job;
      JsonObject stop = null;
      long stopAt = 0;
      while (true) {
        sleep(5000);
        job = api("GET", "/v1/jobs/" + id, null).json();
        JsonObject p = job.has("progress") && job.get("progress").isJsonObject() ? job.getAsJsonObject("progress").deepCopy() : new JsonObject();
        p.addProperty("s", Math.round((System.currentTimeMillis() - t0) / 1000.0));
        samples.add(p);
        if (ENDED.contains(str(job, "status"))) break;
        if (stop == null && System.currentTimeMillis() - t0 >= STOP_AFTER_S * 1000L) {
          Res r = api("DELETE", "/v1/jobs/" + id, null);
          stop = new JsonObject();
          stop.addProperty("after_seconds", secs(System.currentTimeMillis() - t0));
          stop.addProperty("status", r.status());
          stop.addProperty("reply", str(r.json(), "status"));
          stopAt = System.currentTimeMillis();
        }
        if (System.currentTimeMillis() - t0 > (STOP_AFTER_S + 120) * 1000L) throw new RuntimeException("job still " + str(job, "status"));
      }
      long endedAt = ms(str(job, "ended_at"));
      JsonObject j = new JsonObject();
      j.addProperty("status", str(job, "status"));
      j.add("own_session", job.get("own_session"));
      j.add("runs", job.get("runs"));
      j.add("progress", job.get("progress"));
      j.add("error", job.get("error"));
      j.add("billed_seconds", job.get("billed_seconds"));
      j.addProperty("run_seconds", secs(endedAt - ms(str(job, "started_at"))));
      if (stop != null) stop.addProperty("seconds_to_ended", secs(endedAt - stopAt));
      j.add("stop", stop);
      JsonArray kept = new JsonArray();
      for (int i = 0; i < samples.size(); i++) {
        JsonObject p = samples.get(i);
        if (!p.has("visits")) continue;
        if (i < samples.size() - 1 && p.get("visits").equals(samples.get(i + 1).get("visits"))) continue;
        JsonObject k = new JsonObject();
        for (String f : List.of("s", "visits", "sites", "challenges")) k.add(f, p.get(f));
        kept.add(k);
      }
      j.add("progress_samples", kept);
      out.add("job", j);

      // 3. The visit history: the job's results log every page it browsed.
      List<JsonObject> lines = new ArrayList<>();
      for (JsonElement cursor = new JsonPrimitive(0); !cursor.isJsonNull(); ) {
        JsonObject r = api("GET", "/v1/jobs/" + id + "/results?cursor=" + cursor.getAsString(), null).json();
        for (JsonElement e : r.getAsJsonArray("results")) lines.add(e.getAsJsonObject());
        if (r.get("next_cursor").equals(cursor)) break;
        cursor = r.get("next_cursor");
      }
      List<JsonObject> visits = lines.stream().filter(l -> "visit".equals(str(l, "type"))).toList();
      List<String> firstParty = visits.stream().map(v -> site(URI.create(str(v, "url")).getHost())).distinct().sorted().toList();
      JsonObject vs = new JsonObject();
      vs.addProperty("count", visits.size());
      vs.addProperty("challenged", visits.stream().filter(v -> v.has("challenged") && v.get("challenged").getAsBoolean()).count());
      JsonObject bySite = new JsonObject();
      for (String s : firstParty) bySite.addProperty(s, visits.stream().filter(v -> site(URI.create(str(v, "url")).getHost()).equals(s)).count());
      vs.add("by_site", bySite);
      JsonArray statuses = new JsonArray();
      visits.stream().map(v -> v.get("status").getAsInt()).distinct().sorted().forEach(statuses::add);
      vs.add("statuses", statuses);
      if (visits.isEmpty()) vs.add("dwell_s", JsonNull.INSTANCE);
      else {
        JsonObject d = new JsonObject();
        d.addProperty("min", visits.stream().mapToInt(v -> v.get("dwell_s").getAsInt()).min().getAsInt());
        d.addProperty("max", visits.stream().mapToInt(v -> v.get("dwell_s").getAsInt()).max().getAsInt());
        vs.add("dwell_s", d);
      }
      JsonArray paths = new JsonArray();
      visits.forEach(v -> { URI u = URI.create(str(v, "url")); paths.add(u.getHost() + u.getRawPath()); });
      vs.add("paths", paths);
      vs.addProperty("cookie_lines", lines.stream().filter(l -> "cookies".equals(str(l, "type"))).count());
      out.add("visits", vs);

      // 4. The cookies it left: GET /v1/jobs/{id}/cookies (after the end, the last set it delivered).
      Res ck = api("GET", "/v1/jobs/" + id + "/cookies", null);
      List<Ck> jobCookies = ck.json().has("cookies") ? fromApi(ck.json().getAsJsonArray("cookies")) : List.of();
      JsonObject jc = new JsonObject();
      jc.addProperty("status", ck.status());
      jc.add("live", ck.json().get("live"));
      jc.addProperty("at_vs_end_seconds", secs(ms(str(ck.json(), "at")) - endedAt));
      summarize(jobCookies, firstParty, System.currentTimeMillis() / 1000.0).entrySet().forEach(e -> jc.add(e.getKey(), e.getValue()));
      out.add("job_cookies", jc);

      // 5. The profile, saved after the job's browser exited.
      JsonObject p = null;
      for (int i = 0; i < 60; i++) {
        p = profileInfo();
        if (p != null && p.get("version").getAsInt() >= 1 && !p.get("in_use").getAsBoolean()) break;
        sleep(2000);
      }
      if (p == null) out.add("profile", JsonNull.INSTANCE);
      else {
        JsonObject pr = new JsonObject();
        pr.add("engine", p.get("engine"));
        pr.add("version", p.get("version"));
        pr.add("bytes", p.get("bytes"));
        pr.addProperty("saved_after_end_seconds", secs(ms(str(p, "saved_at")) - endedAt));
        out.add("profile", pr);
      }

      // 6. A later session on the profile (read-only: it sees the profile and doesn't change it), then a fresh one.
      Look later, fresh;
      try (Playwright pw = Playwright.create()) {
        JsonObject ro = new JsonObject();
        ro.addProperty("id", NAME);
        ro.addProperty("mode", "readonly");
        later = inspect(pw, ro);
        fresh = inspect(pw, null);
      }
      Set<String> keys = later.cookiesAtStart().stream().map(Ck::key).collect(Collectors.toSet());
      Set<String> jobKeys = jobCookies.stream().map(Ck::key).collect(Collectors.toSet());
      List<Ck> session = jobCookies.stream().filter(c -> !(c.expires() > 0)).toList();
      JsonObject l = new JsonObject();
      l.add("cookies_at_start", summarize(later.cookiesAtStart(), firstParty, System.currentTimeMillis() / 1000.0));
      l.addProperty("job_cookies_restored", jobKeys.stream().filter(keys::contains).count());
      l.addProperty("job_cookies_distinct", jobKeys.size());
      l.addProperty("session_cookies_restored", session.stream().filter(c -> keys.contains(c.key())).count());
      l.addProperty("session_cookies_in_job", session.size());
      l.addProperty("not_in_job", keys.stream().filter(k -> !jobKeys.contains(k)).count());
      l.add("origins", later.origins());
      l.addProperty("seconds", later.seconds());
      out.add("later", l);
      JsonObject f = new JsonObject();
      f.addProperty("cookies_at_start", fresh.cookiesAtStart().size());
      f.add("origins", fresh.origins());
      f.addProperty("seconds", fresh.seconds());
      out.add("fresh", f);

      // 7. The bill: the warmup's own session and the two later sessions, from the session history.
      String since = URLEncoder.encode(Instant.ofEpochMilli(t0 - 60000).toString(), StandardCharsets.UTF_8);
      List<String> wanted = Arrays.asList(str(job, "session_id"), later.sessionId(), fresh.sessionId());
      JsonArray sessions = new JsonArray();
      for (int i = 0; i < 15; i++) {
        JsonObject r = api("GET", "/v1/me/sessions?from=" + since + "&limit=200", null).json();
        sessions = r.has("sessions") ? r.getAsJsonArray("sessions") : new JsonArray();
        final JsonArray ss = sessions;
        if (wanted.stream().allMatch(w -> ss.asList().stream().anyMatch(x -> w != null && w.equals(sid(x.getAsJsonObject())) && str(x.getAsJsonObject(), "ended_at") != null))) break;
        sleep(2000);
      }
      JsonObject bill = new JsonObject();
      JsonObject w = new JsonObject();
      w.add("billed_seconds", job.get("billed_seconds"));
      w.add("session", bill(sessions, str(job, "session_id")));
      bill.add("warmup", w);
      bill.add("later", bill(sessions, later.sessionId()));
      bill.add("fresh", bill(sessions, fresh.sessionId()));
      out.add("bill", bill);
    } finally {
      // Stop the job if something failed early, then delete the profile once nothing holds it.
      if (id != null && !out.has("job")) api("DELETE", "/v1/jobs/" + id, null);
      for (int i = 0; i < 60; i++) {
        JsonObject info = profileInfo();
        if (info == null || !info.get("in_use").getAsBoolean()) break;
        sleep(2000);
      }
      Res del = api("DELETE", "/v1/me/profiles/" + NAME, null);
      JsonObject c = new JsonObject();
      c.addProperty("delete_status", del.status());
      c.addProperty("deleted", del.status() == 200 && del.json().has("deleted") && del.json().get("deleted").getAsBoolean() && profileInfo() == null);
      out.add("cleanup", c);
    }
    System.out.println(GSON.toJson(out));
  }

  static String sid(JsonObject s) { return str(s, "session_id") != null ? str(s, "session_id") : str(s, "id"); }

  static JsonElement bill(JsonArray sessions, String sessionId) {
    for (JsonElement e : sessions) {
      JsonObject s = e.getAsJsonObject();
      if (sessionId == null || !sessionId.equals(sid(s))) continue;
      JsonObject o = new JsonObject();
      o.add("engine", s.get("engine"));
      o.add("weight", s.get("weight"));
      String ended = str(s, "ended_at");
      if (ended == null) o.add("seconds", JsonNull.INSTANCE);
      else o.addProperty("seconds", secs(ms(ended) - ms(str(s, "started_at"))));
      return o;
    }
    return JsonNull.INSTANCE;
  }
}
```

```csharp
// .NET 9, Microsoft.Playwright 1.60.0
// env: CDPFLEET_API_KEY (a paid plan: jobs and profiles aren't on Community), PROXY_URL_US (a sticky US exit)
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Playwright;

var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
var proxy = Environment.GetEnvironmentVariable("PROXY_URL_US")!;
const string Api = "https://cdpfleet.com";
var name = $"case90-csharp-{Convert.ToHexString(RandomNumberGenerator.GetBytes(4)).ToLowerInvariant()}"; // a profile of its own for each run
// Three ordinary public sites: an encyclopedia, a scraping sandbox, a news front page.
string[] sites = ["https://en.wikipedia.org/wiki/Main_Page", "https://books.toscrape.com/", "https://www.theguardian.com/us"];
var warmup = new JsonObject
{
    ["url"] = sites[0],
    ["sites"] = new JsonArray(sites.Skip(1).Select(s => (JsonNode)s).ToArray()),
    ["browser"] = "camoufox",
    ["profile"] = name,
    ["duration"] = 600, // a safety net: the program stops the job itself
    // No typing: the warmup would otherwise type into search boxes now and then.
    ["behavior"] = new JsonObject { ["scroll"] = true, ["mouse"] = true, ["typing"] = false, ["dwell_s"] = new JsonArray(4, 10), ["pages_per_site"] = new JsonArray(2, 4) },
};
const int StopAfterS = 150;
string[] ended = ["done", "failed", "stopped", "interrupted"];
const double Day = 86400;
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };

async Task<(int Status, JsonObject Json, string Text)> CallApi(HttpMethod method, string path, JsonNode? body = null)
{
    var req = new HttpRequestMessage(method, Api + path);
    req.Headers.Add("x-api-key", key);
    if (body != null) req.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
    var res = await http.SendAsync(req);
    var text = await res.Content.ReadAsStringAsync();
    JsonObject json;
    try { json = JsonNode.Parse(text)!.AsObject(); } catch { json = new JsonObject(); }
    return ((int)res.StatusCode, json, text);
}
string? Str(JsonNode? n) => n is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
double Num(JsonNode? n) => n is JsonValue v && v.TryGetValue<double>(out var d) ? d : 0;
double Secs(double ms) => Math.Round(ms / 100, MidpointRounding.AwayFromZero) / 10;
double Ms(JsonNode? iso) => DateTimeOffset.Parse(Str(iso)!).ToUnixTimeMilliseconds(); // an API timestamp
double NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
JsonNode? Copy(JsonNode? n) => n?.DeepClone();

async Task<JsonObject?> ProfileInfo()
{
    var r = (await CallApi(HttpMethod.Get, "/v1/me/profiles")).Json["profiles"]?.AsArray();
    return r?.FirstOrDefault(p => Str(p!["name"]) == name)?.AsObject();
}

// The site a cookie or a host belongs to: its last two labels (three under co.uk and the like).
string Site(string host)
{
    var l = host.TrimStart('.').Split('.');
    var n = l.Length >= 2 && Regex.IsMatch($"{l[^2]}.{l[^1]}", "^(co|com|org|net|ac|gov)\\.[a-z]{2}$") ? 3 : 2;
    return string.Join(".", l.Skip(Math.Max(0, l.Length - n)));
}

// Cookies by site, first- vs third-party (first = a site the warmup visited as a page), and how long they last.
JsonObject Summarize(List<Ck> cookies, List<string> firstParty, double now)
{
    var bySite = cookies.GroupBy(c => Site(c.Domain)).ToDictionary(g => g.Key, g => g.Count());
    var third = bySite.Where(e => !firstParty.Contains(e.Key)).OrderBy(e => -e.Value).ThenBy(e => e.Key, StringComparer.Ordinal).ToList();
    var left = cookies.Where(c => c.Expires > 0).Select(c => (c.Expires - now) / Day).ToList();
    var first = cookies.Count(c => firstParty.Contains(Site(c.Domain)));
    var byFirst = new JsonObject();
    foreach (var s in firstParty) byFirst[s] = bySite.GetValueOrDefault(s, 0);
    return new JsonObject
    {
        ["total"] = cookies.Count,
        ["sites"] = bySite.Count,
        ["first_party"] = first,
        ["third_party"] = cookies.Count - first,
        ["third_party_sites"] = third.Count,
        ["by_first_party_site"] = byFirst,
        ["top_third_party"] = new JsonArray(third.Take(5).Select(e => (JsonNode)$"{e.Key} {e.Value}").ToArray()),
        ["session"] = cookies.Count - left.Count,
        ["http_only"] = cookies.Count(c => c.HttpOnly),
        ["same_site_none"] = cookies.Count(c => c.SameSite == "None"),
        ["expiry"] = new JsonObject
        {
            ["under_1_day"] = left.Count(d => d < 1),
            ["under_30_days"] = left.Count(d => d >= 1 && d < 30),
            ["under_1_year"] = left.Count(d => d >= 30 && d < 365),
            ["a_year_or_more"] = left.Count(d => d >= 365),
            ["max_days"] = left.Count > 0 ? (JsonNode)Math.Round(left.Max(), MidpointRounding.AwayFromZero) : null,
        },
        ["same_key_twice"] = cookies.Count - cookies.Select(c => c.Key).Distinct().Count(), // e.g. Firefox's per-site (partitioned) copies
    };
}

async Task<(string SessionId, IBrowser Browser)> Launch(IPlaywright pw, JsonNode? profile)
{
    var body = new JsonObject { ["proxy"] = proxy, ["headless"] = true };
    if (profile != null) body["profile"] = profile.DeepClone();
    for (var attempt = 1; ; attempt++)
    {
        var req = new HttpRequestMessage(HttpMethod.Post, "https://starter.cdpfleet.com/camoufox/session");
        req.Headers.Add("x-api-key", key);
        req.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
        var res = await http.SendAsync(req);
        var text = await res.Content.ReadAsStringAsync();
        if (res.IsSuccessStatusCode)
        {
            var s = JsonNode.Parse(text)!;
            var browser = await pw.Firefox.ConnectAsync(Str(s["wsUrl"])!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
            return (Str(s["sessionId"])!, browser);
        }
        if ((int)res.StatusCode != 503 || attempt == 5) throw new Exception($"launch {(int)res.StatusCode} {text}");
        await Task.Delay((int.TryParse(res.Headers.TryGetValues("retry-after", out var v) ? v.First() : "5", out var ra) ? ra : 5) * 1000);
    }
}

// What one later session sees: the cookies it starts with, then for each warmed site one request for its
// /robots.txt — the Cookie header that first request carries is what the site gets from this browser, and
// the plain-text page lets us read the origin's localStorage and IndexedDB with none of the site's scripts run.
async Task<(string SessionId, List<Ck> CookiesAtStart, JsonArray Origins, double Seconds)> Inspect(IPlaywright pw, JsonNode? profile)
{
    var (sessionId, browser) = await Launch(pw, profile);
    var t = NowMs();
    try
    {
        // With a profile, its context is browser.Contexts[0]; without one, a fresh context.
        var context = profile != null ? browser.Contexts[0] : await browser.NewContextAsync();
        var page = context.Pages.Count > 0 ? context.Pages[0] : await context.NewPageAsync();
        var atStart = (await context.CookiesAsync()).Select(c => new Ck(c.Name, c.Domain, c.Path, c.Expires, c.HttpOnly, c.SameSite.ToString())).ToList();
        var origins = new JsonArray();
        foreach (var url in sites)
        {
            var u = new Uri(url);
            var origin = $"{u.Scheme}://{u.Host}";
            IResponse? res = null;
            for (var attempt = 1; attempt <= 3 && res == null; attempt++)
            {
                try { res = await page.GotoAsync($"{origin}/robots.txt", new() { Timeout = 60000 }); }
                catch (PlaywrightException) { if (attempt == 3) throw; await Task.Delay(2000); }
            }
            var headers = await res!.Request.AllHeadersAsync();
            var header = headers.TryGetValue("cookie", out var h) ? h : "";
            var names = header.Length > 0 ? header.Split("; ").Select(c => c.Contains('=') ? c[..c.IndexOf('=')] : c).ToList() : [];
            var storage = await page.EvaluateAsync<JsonElement>("async () => ({ local_storage: localStorage.length, indexeddb: (await indexedDB.databases()).length })");
            origins.Add(new JsonObject
            {
                ["origin"] = origin,
                ["status"] = res.Status,
                ["cookie_header_cookies"] = names.Count,
                ["cookie_header_names"] = new JsonArray(names.Take(8).Select(n => (JsonNode)n).ToArray()),
                ["local_storage"] = storage.GetProperty("local_storage").GetInt32(),
                ["indexeddb"] = storage.GetProperty("indexeddb").GetInt32(),
            });
            await Task.Delay(3000);
        }
        return (sessionId, atStart, origins, Secs(NowMs() - t));
    }
    finally
    {
        await browser.CloseAsync();
    }
}

var shown = (JsonObject)warmup.DeepClone();
shown["profile"] = "case90-csharp-…";
shown["stop_after_s"] = StopAfterS;
var output = new JsonObject
{
    ["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd"),
    ["sites"] = new JsonArray(sites.Select(s => (JsonNode)s).ToArray()),
    ["warmup"] = shown,
};
string? id = null;
try
{
    // 1. Start the warmup into a new profile, on a sticky US exit.
    var t0 = NowMs();
    var body = (JsonObject)warmup.DeepClone();
    body["launch"] = new JsonObject { ["headless"] = true, ["proxy"] = proxy };
    var start = await CallApi(HttpMethod.Post, "/v1/functions/warmup", body);
    if (start.Status != 202) throw new Exception($"warmup {start.Status} {start.Text}");
    id = Str(start.Json["job_id"])!;
    shown["start_status"] = start.Status;

    // 2. Follow its progress; stop it after StopAfterS seconds.
    var samples = new List<JsonObject>();
    JsonObject job;
    JsonObject? stop = null;
    double stopAt = 0;
    while (true)
    {
        await Task.Delay(5000);
        job = (await CallApi(HttpMethod.Get, $"/v1/jobs/{id}")).Json;
        var p = job["progress"] is JsonObject po ? (JsonObject)po.DeepClone() : new JsonObject();
        p["s"] = (long)Math.Round((NowMs() - t0) / 1000);
        samples.Add(p);
        if (ended.Contains(Str(job["status"]))) break;
        if (stop == null && NowMs() - t0 >= StopAfterS * 1000)
        {
            var r = await CallApi(HttpMethod.Delete, $"/v1/jobs/{id}");
            stop = new JsonObject { ["after_seconds"] = Secs(NowMs() - t0), ["status"] = r.Status, ["reply"] = Str(r.Json["status"]) };
            stopAt = NowMs();
        }
        if (NowMs() - t0 > (StopAfterS + 120) * 1000) throw new Exception($"job still {Str(job["status"])}");
    }
    var endedAt = Ms(job["ended_at"]);
    if (stop != null) stop["seconds_to_ended"] = Secs(endedAt - stopAt);
    var kept = new JsonArray();
    for (var i = 0; i < samples.Count; i++)
    {
        var p = samples[i];
        if (p["visits"] == null) continue;
        if (i < samples.Count - 1 && Num(p["visits"]) == Num(samples[i + 1]["visits"])) continue;
        kept.Add(new JsonObject { ["s"] = Copy(p["s"]), ["visits"] = Copy(p["visits"]), ["sites"] = Copy(p["sites"]), ["challenges"] = Copy(p["challenges"]) });
    }
    output["job"] = new JsonObject
    {
        ["status"] = Str(job["status"]), ["own_session"] = Copy(job["own_session"]), ["runs"] = Copy(job["runs"]), ["progress"] = Copy(job["progress"]), ["error"] = Copy(job["error"]),
        ["billed_seconds"] = Copy(job["billed_seconds"]), ["run_seconds"] = Secs(endedAt - Ms(job["started_at"])),
        ["stop"] = stop, ["progress_samples"] = kept,
    };

    // 3. The visit history: the job's results log every page it browsed.
    var lines = new List<JsonNode>();
    for (JsonNode? cursor = 0; cursor != null;)
    {
        var r = (await CallApi(HttpMethod.Get, $"/v1/jobs/{id}/results?cursor={cursor.ToJsonString()}")).Json;
        lines.AddRange(r["results"]!.AsArray().Select(x => x!.DeepClone()));
        var next = r["next_cursor"];
        if (next != null && next.ToJsonString() == cursor.ToJsonString()) break;
        cursor = next?.DeepClone();
    }
    var visits = lines.Where(l => Str(l["type"]) == "visit").ToList();
    var firstParty = visits.Select(v => Site(new Uri(Str(v["url"])!).Host)).Distinct().OrderBy(s => s, StringComparer.Ordinal).ToList();
    var bySiteV = new JsonObject();
    foreach (var s in firstParty) bySiteV[s] = visits.Count(v => Site(new Uri(Str(v["url"])!).Host) == s);
    output["visits"] = new JsonObject
    {
        ["count"] = visits.Count,
        ["challenged"] = visits.Count(v => v["challenged"] is JsonValue c && c.TryGetValue<bool>(out var b) && b),
        ["by_site"] = bySiteV,
        ["statuses"] = new JsonArray(visits.Select(v => (int)Num(v["status"])).Distinct().Order().Select(x => (JsonNode)x).ToArray()),
        ["dwell_s"] = visits.Count > 0 ? new JsonObject { ["min"] = visits.Min(v => Num(v["dwell_s"])), ["max"] = visits.Max(v => Num(v["dwell_s"])) } : null,
        ["paths"] = new JsonArray(visits.Select(v => { var u = new Uri(Str(v["url"])!); return (JsonNode)(u.Host + u.AbsolutePath); }).ToArray()),
        ["cookie_lines"] = lines.Count(l => Str(l["type"]) == "cookies"),
    };

    // 4. The cookies it left: GET /v1/jobs/{id}/cookies (after the end, the last set it delivered).
    var ck = await CallApi(HttpMethod.Get, $"/v1/jobs/{id}/cookies");
    var jobCookies = (ck.Json["cookies"]?.AsArray() ?? []).Select(c => new Ck(Str(c!["name"])!, Str(c["domain"])!, Str(c["path"])!,
        c["expires"] != null ? Num(c["expires"]) : -1, c["httpOnly"] is JsonValue hv && hv.TryGetValue<bool>(out var ho) && ho, Str(c["sameSite"]))).ToList();
    var jc = new JsonObject { ["status"] = ck.Status, ["live"] = Copy(ck.Json["live"]), ["at_vs_end_seconds"] = Secs(Ms(ck.Json["at"]) - endedAt) };
    foreach (var (k, v) in Summarize(jobCookies, firstParty, NowMs() / 1000)) jc[k] = v?.DeepClone();
    output["job_cookies"] = jc;

    // 5. The profile, saved after the job's browser exited.
    JsonObject? prof = null;
    for (var i = 0; i < 60; i++)
    {
        prof = await ProfileInfo();
        if (prof != null && Num(prof["version"]) >= 1 && prof["in_use"]?.GetValue<bool>() == false) break;
        await Task.Delay(2000);
    }
    output["profile"] = prof == null ? null : new JsonObject
    {
        ["engine"] = Copy(prof["engine"]), ["version"] = Copy(prof["version"]), ["bytes"] = Copy(prof["bytes"]),
        ["saved_after_end_seconds"] = Secs(Ms(prof["saved_at"]) - endedAt),
    };

    // 6. A later session on the profile (read-only: it sees the profile and doesn't change it), then a fresh one.
    using var pw = await Playwright.CreateAsync();
    var later = await Inspect(pw, new JsonObject { ["id"] = name, ["mode"] = "readonly" });
    var keys = later.CookiesAtStart.Select(c => c.Key).ToHashSet();
    var jobKeys = jobCookies.Select(c => c.Key).ToHashSet();
    var session = jobCookies.Where(c => !(c.Expires > 0)).ToList();
    output["later"] = new JsonObject
    {
        ["cookies_at_start"] = Summarize(later.CookiesAtStart, firstParty, NowMs() / 1000),
        ["job_cookies_restored"] = jobKeys.Count(keys.Contains),
        ["job_cookies_distinct"] = jobKeys.Count,
        ["session_cookies_restored"] = session.Count(c => keys.Contains(c.Key)),
        ["session_cookies_in_job"] = session.Count,
        ["not_in_job"] = keys.Count(k => !jobKeys.Contains(k)),
        ["origins"] = later.Origins,
        ["seconds"] = later.Seconds,
    };
    var fresh = await Inspect(pw, null);
    output["fresh"] = new JsonObject { ["cookies_at_start"] = fresh.CookiesAtStart.Count, ["origins"] = fresh.Origins, ["seconds"] = fresh.Seconds };

    // 7. The bill: the warmup's own session and the two later sessions, from the session history.
    var since = Uri.EscapeDataString(DateTimeOffset.FromUnixTimeMilliseconds((long)t0 - 60000).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"));
    string?[] wanted = [Str(job["session_id"]), later.SessionId, fresh.SessionId];
    var sessions = new JsonArray();
    string? Sid(JsonNode s) => Str(s["session_id"]) ?? Str(s["id"]);
    for (var i = 0; i < 15; i++)
    {
        sessions = (await CallApi(HttpMethod.Get, $"/v1/me/sessions?from={since}&limit=200")).Json["sessions"]?.AsArray() ?? [];
        if (wanted.All(w => sessions.Any(x => w != null && Sid(x!) == w && Str(x!["ended_at"]) != null))) break;
        await Task.Delay(2000);
    }
    JsonNode? Bill(string? sid)
    {
        var s = sessions.FirstOrDefault(x => sid != null && Sid(x!) == sid);
        if (s == null) return null;
        return new JsonObject { ["engine"] = Copy(s["engine"]), ["weight"] = Copy(s["weight"]), ["seconds"] = Str(s["ended_at"]) != null ? Secs(Ms(s["ended_at"]) - Ms(s["started_at"])) : null };
    }
    output["bill"] = new JsonObject
    {
        ["warmup"] = new JsonObject { ["billed_seconds"] = Copy(job["billed_seconds"]), ["session"] = Bill(Str(job["session_id"])) },
        ["later"] = Bill(later.SessionId),
        ["fresh"] = Bill(fresh.SessionId),
    };
}
finally
{
    // Stop the job if something failed early, then delete the profile once nothing holds it.
    if (id != null && !output.ContainsKey("job")) await CallApi(HttpMethod.Delete, $"/v1/jobs/{id}");
    for (var i = 0; i < 60 && (await ProfileInfo())?["in_use"]?.GetValue<bool>() == true; i++) await Task.Delay(2000);
    var del = await CallApi(HttpMethod.Delete, $"/v1/me/profiles/{name}");
    output["cleanup"] = new JsonObject
    {
        ["delete_status"] = del.Status,
        ["deleted"] = del.Status == 200 && del.Json["deleted"]?.GetValue<bool>() == true && await ProfileInfo() == null,
    };
}
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));

// A cookie as the API and Playwright both describe it.
record Ck(string Name, string Domain, string Path, double Expires, bool HttpOnly, string? SameSite)
{
    public string Key => $"{Name}|{Domain}|{Path}";
}
```

```go
// Go 1.22, github.com/playwright-community/playwright-go v0.6000.0
// env: CDPFLEET_API_KEY (a paid plan: jobs and profiles aren't on Community), PROXY_URL_US (a sticky US exit)
package main

import (
	"bytes"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"math"
	"net/http"
	"net/url"
	"os"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/playwright-community/playwright-go"
)

const api = "https://cdpfleet.com"
const stopAfterS = 150
const day = 86400.0

var key = os.Getenv("CDPFLEET_API_KEY")
var proxy = os.Getenv("PROXY_URL_US")
var name = "case90-go-" + randHex(4) // a profile of its own for each run
// Three ordinary public sites: an encyclopedia, a scraping sandbox, a news front page.
var sites = []string{"https://en.wikipedia.org/wiki/Main_Page", "https://books.toscrape.com/", "https://www.theguardian.com/us"}
var warmup = M{
	"url":      sites[0],
	"sites":    sites[1:],
	"browser":  "camoufox",
	"profile":  name,
	"duration": 600, // a safety net: the program stops the job itself
	// No typing: the warmup would otherwise type into search boxes now and then.
	"behavior": M{"scroll": true, "mouse": true, "typing": false, "dwell_s": []int{4, 10}, "pages_per_site": []int{2, 4}},
}
var ended = map[string]bool{"done": true, "failed": true, "stopped": true, "interrupted": true}
var secondLevel = regexp.MustCompile(`^(co|com|org|net|ac|gov)\.[a-z]{2}$`)

const storageJS = "async () => ({ local_storage: localStorage.length, indexeddb: (await indexedDB.databases()).length })"

type M = map[string]any

// A cookie as the API and Playwright both describe it.
type ck struct {
	Name, Domain, Path, SameSite string
	Expires                      float64
	HttpOnly                     bool
}

func (c ck) key() string { return c.Name + "|" + c.Domain + "|" + c.Path }

func randHex(n int) string { b := make([]byte, n); rand.Read(b); return hex.EncodeToString(b) }

func send(method, u string, body any) (int, []byte, http.Header) {
	var rd io.Reader
	if body != nil {
		b, _ := json.Marshal(body)
		rd = bytes.NewReader(b)
	}
	req, _ := http.NewRequest(method, u, rd)
	req.Header.Set("x-api-key", key)
	if body != nil {
		req.Header.Set("content-type", "application/json")
	}
	res, err := (&http.Client{Timeout: 120 * time.Second}).Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	b, _ := io.ReadAll(res.Body)
	return res.StatusCode, b, res.Header
}

func call(method, path string, body any) (int, M) {
	status, b, _ := send(method, api+path, body)
	var m M
	json.Unmarshal(b, &m)
	return status, m
}

func str(v any) string { s, _ := v.(string); return s }
func num(v any) float64 {
	switch x := v.(type) {
	case float64:
		return x
	case int:
		return float64(x)
	case int64:
		return float64(x)
	}
	return 0
}
func obj(v any) M     { m, _ := v.(M); return m }
func arr(v any) []any { a, _ := v.([]any); return a }
func nowMs() float64  { return float64(time.Now().UnixMilli()) }
func secs(ms float64) float64 { return math.Round(ms/100) / 10 }
func ms(iso any) float64 { // an API timestamp
	t, _ := time.Parse(time.RFC3339Nano, str(iso))
	return float64(t.UnixMilli())
}

func profileInfo() M {
	_, r := call("GET", "/v1/me/profiles", nil)
	for _, p := range arr(r["profiles"]) {
		if str(obj(p)["name"]) == name {
			return obj(p)
		}
	}
	return nil
}

// The site a cookie or a host belongs to: its last two labels (three under co.uk and the like).
func site(host string) string {
	l := strings.Split(strings.TrimPrefix(host, "."), ".")
	n := 2
	if len(l) >= 2 && secondLevel.MatchString(l[len(l)-2]+"."+l[len(l)-1]) {
		n = 3
	}
	if n > len(l) {
		n = len(l)
	}
	return strings.Join(l[len(l)-n:], ".")
}

func contains(list []string, s string) bool {
	for _, x := range list {
		if x == s {
			return true
		}
	}
	return false
}

// Cookies by site, first- vs third-party (first = a site the warmup visited as a page), and how long they last.
func summarize(cookies []ck, firstParty []string, now float64) M {
	bySite := map[string]int{}
	for _, c := range cookies {
		bySite[site(c.Domain)]++
	}
	type kv struct {
		s string
		n int
	}
	third := []kv{}
	for s, n := range bySite {
		if !contains(firstParty, s) {
			third = append(third, kv{s, n})
		}
	}
	sort.Slice(third, func(i, j int) bool {
		if third[i].n != third[j].n {
			return third[i].n > third[j].n
		}
		return third[i].s < third[j].s
	})
	top := []string{}
	for i := 0; i < len(third) && i < 5; i++ {
		top = append(top, fmt.Sprintf("%s %d", third[i].s, third[i].n))
	}
	left := []float64{}
	first, httpOnly, none := 0, 0, 0
	keys := map[string]bool{}
	for _, c := range cookies {
		if c.Expires > 0 {
			left = append(left, (c.Expires-now)/day)
		}
		if contains(firstParty, site(c.Domain)) {
			first++
		}
		if c.HttpOnly {
			httpOnly++
		}
		if c.SameSite == "None" {
			none++
		}
		keys[c.key()] = true
	}
	byFirst := M{}
	for _, s := range firstParty {
		byFirst[s] = bySite[s]
	}
	count := func(f func(float64) bool) int {
		n := 0
		for _, d := range left {
			if f(d) {
				n++
			}
		}
		return n
	}
	var maxDays any
	if len(left) > 0 {
		m := left[0]
		for _, d := range left {
			m = math.Max(m, d)
		}
		maxDays = math.Round(m)
	}
	return M{
		"total": len(cookies), "sites": len(bySite), "first_party": first, "third_party": len(cookies) - first,
		"third_party_sites": len(third), "by_first_party_site": byFirst, "top_third_party": top,
		"session": len(cookies) - len(left), "http_only": httpOnly, "same_site_none": none,
		"expiry": M{
			"under_1_day":    count(func(d float64) bool { return d < 1 }),
			"under_30_days":  count(func(d float64) bool { return d >= 1 && d < 30 }),
			"under_1_year":   count(func(d float64) bool { return d >= 30 && d < 365 }),
			"a_year_or_more": count(func(d float64) bool { return d >= 365 }),
			"max_days":       maxDays,
		},
		"same_key_twice": len(cookies) - len(keys), // e.g. Firefox's per-site (partitioned) copies
	}
}

func launch(pw *playwright.Playwright, profile any) (string, playwright.Browser) {
	body := M{"proxy": proxy, "headless": true}
	if profile != nil {
		body["profile"] = profile
	}
	for attempt := 1; ; attempt++ {
		status, b, h := send("POST", "https://starter.cdpfleet.com/camoufox/session", body)
		if status == 200 || status == 201 {
			var s M
			json.Unmarshal(b, &s)
			browser, err := pw.Firefox.Connect(str(s["wsUrl"]), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
			if err != nil {
				panic(err)
			}
			return str(s["sessionId"]), browser
		}
		if status != 503 || attempt == 5 {
			panic(fmt.Sprintf("launch %d %s", status, b))
		}
		wait, err := strconv.Atoi(h.Get("retry-after"))
		if err != nil {
			wait = 5
		}
		time.Sleep(time.Duration(wait) * time.Second)
	}
}

type look struct {
	sessionID      string
	cookiesAtStart []ck
	origins        []M
	seconds        float64
}

// What one later session sees: the cookies it starts with, then for each warmed site one request for its
// /robots.txt — the Cookie header that first request carries is what the site gets from this browser, and
// the plain-text page lets us read the origin's localStorage and IndexedDB with none of the site's scripts run.
func inspect(pw *playwright.Playwright, profile any) look {
	sessionID, browser := launch(pw, profile)
	defer browser.Close()
	t := nowMs()
	// With a profile, its context is browser.Contexts()[0]; without one, a fresh context.
	var context playwright.BrowserContext
	var err error
	if profile != nil {
		context = browser.Contexts()[0]
	} else if context, err = browser.NewContext(); err != nil {
		panic(err)
	}
	var page playwright.Page
	if pages := context.Pages(); len(pages) > 0 {
		page = pages[0]
	} else if page, err = context.NewPage(); err != nil {
		panic(err)
	}
	raw, err := context.Cookies()
	if err != nil {
		panic(err)
	}
	atStart := []ck{}
	for _, c := range raw {
		ss := ""
		if c.SameSite != nil {
			ss = string(*c.SameSite)
		}
		atStart = append(atStart, ck{c.Name, c.Domain, c.Path, ss, c.Expires, c.HttpOnly})
	}
	origins := []M{}
	for _, u := range sites {
		pu, _ := url.Parse(u)
		origin := pu.Scheme + "://" + pu.Host
		var res playwright.Response
		var err error
		for attempt := 1; attempt <= 3 && res == nil; attempt++ {
			res, err = page.Goto(origin+"/robots.txt", playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
			if err != nil {
				if attempt == 3 {
					panic(err)
				}
				res = nil
				time.Sleep(2 * time.Second)
			}
		}
		headers, _ := res.Request().AllHeaders()
		names := []string{}
		if h := headers["cookie"]; h != "" {
			for _, c := range strings.Split(h, "; ") {
				n, _, _ := strings.Cut(c, "=")
				names = append(names, n)
			}
		}
		first8 := names
		if len(first8) > 8 {
			first8 = first8[:8]
		}
		v, err := page.Evaluate(storageJS)
		if err != nil {
			panic(err)
		}
		storage := obj(v)
		origins = append(origins, M{
			"origin": origin, "status": res.Status(), "cookie_header_cookies": len(names), "cookie_header_names": first8,
			"local_storage": int(num(storage["local_storage"])), "indexeddb": int(num(storage["indexeddb"])),
		})
		time.Sleep(3 * time.Second)
	}
	return look{sessionID, atStart, origins, secs(nowMs() - t)}
}

func main() {
	shown := M{"stop_after_s": stopAfterS}
	for k, v := range warmup {
		shown[k] = v
	}
	shown["profile"] = "case90-go-…"
	out := M{"measured_at": time.Now().UTC().Format("2006-01-02"), "sites": sites, "warmup": shown}
	id := ""
	defer func() {
		// Stop the job if something failed early, then delete the profile once nothing holds it.
		if id != "" && out["job"] == nil {
			call("DELETE", "/v1/jobs/"+id, nil)
		}
		for i := 0; i < 60; i++ {
			if p := profileInfo(); p == nil || p["in_use"] != true {
				break
			}
			time.Sleep(2 * time.Second)
		}
		status, del := call("DELETE", "/v1/me/profiles/"+name, nil)
		out["cleanup"] = M{"delete_status": status, "deleted": status == 200 && del["deleted"] == true && profileInfo() == nil}
		if r := recover(); r != nil {
			panic(r)
		}
		enc := json.NewEncoder(os.Stdout)
		enc.SetEscapeHTML(false)
		enc.SetIndent("", "  ")
		enc.Encode(out)
	}()

	// 1. Start the warmup into a new profile, on a sticky US exit.
	t0 := nowMs()
	body := M{"launch": M{"headless": true, "proxy": proxy}}
	for k, v := range warmup {
		body[k] = v
	}
	status, start := call("POST", "/v1/functions/warmup", body)
	if status != 202 {
		panic(fmt.Sprintf("warmup %d %v", status, start))
	}
	id = str(start["job_id"])
	shown["start_status"] = status

	// 2. Follow its progress; stop it after stopAfterS seconds.
	samples := []M{}
	var job M
	var stop M
	stopAt := 0.0
	for {
		time.Sleep(5 * time.Second)
		_, job = call("GET", "/v1/jobs/"+id, nil)
		p := M{}
		for k, v := range obj(job["progress"]) {
			p[k] = v
		}
		p["s"] = math.Round((nowMs() - t0) / 1000)
		samples = append(samples, p)
		if ended[str(job["status"])] {
			break
		}
		if stop == nil && nowMs()-t0 >= stopAfterS*1000 {
			st, r := call("DELETE", "/v1/jobs/"+id, nil)
			stop = M{"after_seconds": secs(nowMs() - t0), "status": st, "reply": r["status"]}
			stopAt = nowMs()
		}
		if nowMs()-t0 > (stopAfterS+120)*1000 {
			panic("job still " + str(job["status"]))
		}
	}
	endedAt := ms(job["ended_at"])
	var stopOut any
	if stop != nil {
		stop["seconds_to_ended"] = secs(endedAt - stopAt)
		stopOut = stop
	}
	kept := []M{}
	for i, p := range samples {
		if _, ok := p["visits"]; !ok {
			continue
		}
		if i < len(samples)-1 && num(p["visits"]) == num(samples[i+1]["visits"]) {
			continue
		}
		kept = append(kept, M{"s": p["s"], "visits": p["visits"], "sites": p["sites"], "challenges": p["challenges"]})
	}
	out["job"] = M{
		"status": job["status"], "own_session": job["own_session"], "runs": job["runs"], "progress": job["progress"], "error": job["error"],
		"billed_seconds": job["billed_seconds"], "run_seconds": secs(endedAt - ms(job["started_at"])),
		"stop": stopOut, "progress_samples": kept,
	}

	// 3. The visit history: the job's results log every page it browsed.
	lines := []M{}
	var cursor any = 0.0
	for cursor != nil {
		_, r := call("GET", fmt.Sprintf("/v1/jobs/%s/results?cursor=%v", id, cursor), nil)
		for _, l := range arr(r["results"]) {
			lines = append(lines, obj(l))
		}
		if r["next_cursor"] == cursor {
			break
		}
		cursor = r["next_cursor"]
	}
	visits := []M{}
	cookieLines := 0
	for _, l := range lines {
		switch str(l["type"]) {
		case "visit":
			visits = append(visits, l)
		case "cookies":
			cookieLines++
		}
	}
	host := func(v M) string { u, _ := url.Parse(str(v["url"])); return u.Hostname() }
	firstParty := []string{}
	for _, v := range visits {
		if s := site(host(v)); !contains(firstParty, s) {
			firstParty = append(firstParty, s)
		}
	}
	sort.Strings(firstParty)
	bySiteV := M{}
	for _, s := range firstParty {
		n := 0
		for _, v := range visits {
			if site(host(v)) == s {
				n++
			}
		}
		bySiteV[s] = n
	}
	statusSet := map[int]bool{}
	challenged := 0
	paths := []string{}
	var dwell any
	minD, maxD := math.Inf(1), math.Inf(-1)
	for _, v := range visits {
		statusSet[int(num(v["status"]))] = true
		if v["challenged"] == true {
			challenged++
		}
		u, _ := url.Parse(str(v["url"]))
		paths = append(paths, u.Hostname()+u.EscapedPath())
		minD, maxD = math.Min(minD, num(v["dwell_s"])), math.Max(maxD, num(v["dwell_s"]))
	}
	if len(visits) > 0 {
		dwell = M{"min": minD, "max": maxD}
	}
	statuses := []int{}
	for s := range statusSet {
		statuses = append(statuses, s)
	}
	sort.Ints(statuses)
	out["visits"] = M{"count": len(visits), "challenged": challenged, "by_site": bySiteV, "statuses": statuses, "dwell_s": dwell, "paths": paths, "cookie_lines": cookieLines}

	// 4. The cookies it left: GET /v1/jobs/{id}/cookies (after the end, the last set it delivered).
	ckStatus, ckBody := call("GET", "/v1/jobs/"+id+"/cookies", nil)
	jobCookies := []ck{}
	for _, x := range arr(ckBody["cookies"]) {
		c := obj(x)
		exp := -1.0
		if e, ok := c["expires"]; ok {
			exp = num(e)
		}
		jobCookies = append(jobCookies, ck{str(c["name"]), str(c["domain"]), str(c["path"]), str(c["sameSite"]), exp, c["httpOnly"] == true})
	}
	jc := M{"status": ckStatus, "live": ckBody["live"], "at_vs_end_seconds": secs(ms(ckBody["at"]) - endedAt)}
	for k, v := range summarize(jobCookies, firstParty, nowMs()/1000) {
		jc[k] = v
	}
	out["job_cookies"] = jc

	// 5. The profile, saved after the job's browser exited.
	var prof M
	for i := 0; i < 60; i++ {
		prof = profileInfo()
		if prof != nil && num(prof["version"]) >= 1 && prof["in_use"] == false {
			break
		}
		time.Sleep(2 * time.Second)
	}
	if prof != nil {
		out["profile"] = M{"engine": prof["engine"], "version": prof["version"], "bytes": prof["bytes"], "saved_after_end_seconds": secs(ms(prof["saved_at"]) - endedAt)}
	} else {
		out["profile"] = nil
	}

	// 6. A later session on the profile (read-only: it sees the profile and doesn't change it), then a fresh one.
	pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
	if err != nil {
		panic(err)
	}
	defer pw.Stop()
	later := inspect(pw, M{"id": name, "mode": "readonly"})
	keys := map[string]bool{}
	for _, c := range later.cookiesAtStart {
		keys[c.key()] = true
	}
	jobKeys := map[string]bool{}
	sessionIn, sessionRestored := 0, 0
	for _, c := range jobCookies {
		jobKeys[c.key()] = true
		if !(c.Expires > 0) {
			sessionIn++
			if keys[c.key()] {
				sessionRestored++
			}
		}
	}
	restored, notInJob := 0, 0
	for k := range jobKeys {
		if keys[k] {
			restored++
		}
	}
	for k := range keys {
		if !jobKeys[k] {
			notInJob++
		}
	}
	out["later"] = M{
		"cookies_at_start": summarize(later.cookiesAtStart, firstParty, nowMs()/1000), "job_cookies_restored": restored,
		"job_cookies_distinct": len(jobKeys), "session_cookies_restored": sessionRestored, "session_cookies_in_job": sessionIn,
		"not_in_job": notInJob, "origins": later.origins, "seconds": later.seconds,
	}
	fresh := inspect(pw, nil)
	out["fresh"] = M{"cookies_at_start": len(fresh.cookiesAtStart), "origins": fresh.origins, "seconds": fresh.seconds}

	// 7. The bill: the warmup's own session and the two later sessions, from the session history.
	since := url.QueryEscape(time.UnixMilli(int64(t0) - 60000).UTC().Format("2006-01-02T15:04:05.000Z"))
	wanted := []string{str(job["session_id"]), later.sessionID, fresh.sessionID}
	sid := func(s M) string {
		if v := str(s["session_id"]); v != "" {
			return v
		}
		return str(s["id"])
	}
	var sessions []any
	for i := 0; i < 15; i++ {
		_, r := call("GET", "/v1/me/sessions?from="+since+"&limit=200", nil)
		sessions = arr(r["sessions"])
		all := true
		for _, w := range wanted {
			found := false
			for _, x := range sessions {
				if sid(obj(x)) == w && str(obj(x)["ended_at"]) != "" {
					found = true
				}
			}
			all = all && found
		}
		if all {
			break
		}
		time.Sleep(2 * time.Second)
	}
	bill := func(id string) any {
		for _, x := range sessions {
			s := obj(x)
			if sid(s) != id {
				continue
			}
			var seconds any
			if str(s["ended_at"]) != "" {
				seconds = secs(ms(s["ended_at"]) - ms(s["started_at"]))
			}
			return M{"engine": s["engine"], "weight": s["weight"], "seconds": seconds}
		}
		return nil
	}
	out["bill"] = M{
		"warmup": M{"billed_seconds": job["billed_seconds"], "session": bill(str(job["session_id"]))},
		"later":  bill(later.sessionID), "fresh": bill(fresh.sessionID),
	}
}
```

## What we got

| Step | What happened | Measured |
| --- | --- | --- |
| Warmup | 202: Camoufox on en.wikipedia.org, books.toscrape.com, www.theguardian.com, stopped at 152.2 s → stopped 5.7 s later; 14 pages (theguardian.com 4, toscrape.com 4, wikipedia.org 6), 1 challenged, statuses 200 | 2026-11-14 |
| Visit history | en.wikipedia.org/wiki/Main\_Page → en.wikipedia.org/wiki/Wikipedia:Be\_bold → books.toscrape.com/ → books.toscrape.com/catalogue/soumission\_998/index.html → www.theguardian.com/us → www.theguardian.com/food/food → en.wikipedia.org/wiki/Main\_Page → en.wikipedia.org/wiki/Stade\_Sylvio\_Cator → books.toscrape.com/ → books.toscrape.com/catalogue/the-dirty-little-secrets-of-getting-your-dream-job\_994/index.html → www.theguardian.com/us → www.theguardian.com/email-newsletters → en.wikipedia.org/wiki/Main\_Page → en.wikipedia.org/wiki/Special:SpecialPages | 2026-11-14 |
| Job cookies | 216 cookies from 96 sites: first-party 31 (theguardian.com 24, toscrape.com 0, wikipedia.org 7), third-party 185 from 94 sites (top: wikimedia.org 11, qvdt3feo.com 6, contextweb.com 5, pubmatic.com 5, rubiconproject.com 5); 11 session cookies, 49 httpOnly; expiry <1 day 10, <30 days 21, <1 year 132, ≥1 year 42 (max 400 days) | 2026-11-14 |
| Profile | camoufox, version 1, 1552 KB, saved 1.1 s after the job ended | 2026-11-14 |
| Later session (profile, read-only) | started with 216 cookies (31 first-party, 185 third-party): 216 of the job's 216, session cookies 11 of 11 | 2026-11-14 |
| en.wikipedia.org | with the profile: Cookie header 7 (WMF-Last-Access, WMF-Last-Access-Global, WMF-DP, NetworkProbeLimit, …), localStorage 8 keys, IndexedDB 0 databases · fresh session: Cookie header 0, localStorage 0, IndexedDB 0 | 2026-11-14 |
| books.toscrape.com | with the profile: Cookie header 0, localStorage 0 keys, IndexedDB 0 databases · fresh session: Cookie header 0, localStorage 0, IndexedDB 0 | 2026-11-14 |
| www.theguardian.com | with the profile: Cookie header 23 (gu\_client\_ab\_tests, gu\_v2\_mvt\_id, GU\_mvt\_id, bwid, …), localStorage 65 keys, IndexedDB 3 databases · fresh session: Cookie header 0, localStorage 0, IndexedDB 0 | 2026-11-14 |
| Cost | warmup 154 thread-seconds for 157.8 s (own camoufox session, weight 1); later session 13.8 s, fresh session 12.5 s | 2026-11-14 |

From the Node.js run on 2026-11-14. 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)

```
{
  "measured_at": "2026-11-14",
  "sites": [
    "https://en.wikipedia.org/wiki/Main_Page",
    "https://books.toscrape.com/",
    "https://www.theguardian.com/us"
  ],
  "warmup": {
    "url": "https://en.wikipedia.org/wiki/Main_Page",
    "sites": [
      "https://books.toscrape.com/",
      "https://www.theguardian.com/us"
    ],
    "browser": "camoufox",
    "profile": "case90-node-…",
    "duration": 600,
    "behavior": {
      "scroll": true,
      "mouse": true,
      "typing": false,
      "dwell_s": [
        4,
        10
      ],
      "pages_per_site": [
        2,
        4
      ]
    },
    "stop_after_s": 150,
    "start_status": 202
  },
  "job": {
    "status": "stopped",
    "own_session": true,
    "runs": 1,
    "progress": {
      "sites": 3,
      "visits": 13,
      "scrapes": 0,
      "last_url": "https://en.wikipedia.org/wiki/Special:SpecialPages",
      "challenges": 1,
      "cookies_at": "2026-11-14T21:10:32.081Z"
    },
    "error": null,
    "billed_seconds": 154,
    "run_seconds": 157.8,
    "stop": {
      "after_seconds": 152.2,
      "status": 200,
      "reply": "stopping",
      "seconds_to_ended": 5.7
    },
    "progress_samples": [
      {
        "s": 26,
        "visits": 1,
        "sites": 3,
        "challenges": 0
      },
      {
        "s": 41,
        "visits": 3,
        "sites": 3,
        "challenges": 0
      },
      {
        "s": 56,
        "visits": 4,
        "sites": 3,
        "challenges": 0
      },
      {
        "s": 71,
        "visits": 5,
        "sites": 3,
        "challenges": 0
      },
      {
        "s": 86,
        "visits": 7,
        "sites": 3,
        "challenges": 0
      },
      {
        "s": 102,
        "visits": 8,
        "sites": 3,
        "challenges": 0
      },
      {
        "s": 117,
        "visits": 9,
        "sites": 3,
        "challenges": 0
      },
      {
        "s": 147,
        "visits": 11,
        "sites": 3,
        "challenges": 1
      },
      {
        "s": 162,
        "visits": 13,
        "sites": 3,
        "challenges": 1
      }
    ]
  },
  "visits": {
    "count": 14,
    "challenged": 1,
    "by_site": {
      "theguardian.com": 4,
      "toscrape.com": 4,
      "wikipedia.org": 6
    },
    "statuses": [
      200
    ],
    "dwell_s": {
      "min": 0,
      "max": 10
    },
    "paths": [
      "en.wikipedia.org/wiki/Main_Page",
      "en.wikipedia.org/wiki/Wikipedia:Be_bold",
      "books.toscrape.com/",
      "books.toscrape.com/catalogue/soumission_998/index.html",
      "www.theguardian.com/us",
      "www.theguardian.com/food/food",
      "en.wikipedia.org/wiki/Main_Page",
      "en.wikipedia.org/wiki/Stade_Sylvio_Cator",
      "books.toscrape.com/",
      "books.toscrape.com/catalogue/the-dirty-little-secrets-of-getting-your-dream-job_994/index.html",
      "www.theguardian.com/us",
      "www.theguardian.com/email-newsletters",
      "en.wikipedia.org/wiki/Main_Page",
      "en.wikipedia.org/wiki/Special:SpecialPages"
    ],
    "cookie_lines": 1
  },
  "job_cookies": {
    "status": 200,
    "live": false,
    "at_vs_end_seconds": -0.1,
    "total": 216,
    "sites": 96,
    "first_party": 31,
    "third_party": 185,
    "third_party_sites": 94,
    "by_first_party_site": {
      "theguardian.com": 24,
      "toscrape.com": 0,
      "wikipedia.org": 7
    },
    "top_third_party": [
      "wikimedia.org 11",
      "qvdt3feo.com 6",
      "contextweb.com 5",
      "pubmatic.com 5",
      "rubiconproject.com 5"
    ],
    "session": 11,
    "http_only": 49,
    "same_site_none": 209,
    "expiry": {
      "under_1_day": 10,
      "under_30_days": 21,
      "under_1_year": 132,
      "a_year_or_more": 42,
      "max_days": 400
    },
    "same_key_twice": 0
  },
  "profile": {
    "engine": "camoufox",
    "version": 1,
    "bytes": 1588946,
    "saved_after_end_seconds": 1.1
  },
  "later": {
    "cookies_at_start": {
      "total": 216,
      "sites": 96,
      "first_party": 31,
      "third_party": 185,
      "third_party_sites": 94,
      "by_first_party_site": {
        "theguardian.com": 24,
        "toscrape.com": 0,
        "wikipedia.org": 7
      },
      "top_third_party": [
        "wikimedia.org 11",
        "qvdt3feo.com 6",
        "contextweb.com 5",
        "pubmatic.com 5",
        "rubiconproject.com 5"
      ],
      "session": 11,
      "http_only": 49,
      "same_site_none": 209,
      "expiry": {
        "under_1_day": 10,
        "under_30_days": 21,
        "under_1_year": 132,
        "a_year_or_more": 42,
        "max_days": 400
      },
      "same_key_twice": 0
    },
    "job_cookies_restored": 216,
    "job_cookies_distinct": 216,
    "session_cookies_restored": 11,
    "session_cookies_in_job": 11,
    "not_in_job": 0,
    "origins": [
      {
        "origin": "https://en.wikipedia.org",
        "status": 200,
        "cookie_header_cookies": 7,
        "cookie_header_names": [
          "WMF-Last-Access",
          "WMF-Last-Access-Global",
          "WMF-DP",
          "NetworkProbeLimit",
          "WMF-Uniq",
          "GeoIP",
          "enwikimwuser-sessionId"
        ],
        "local_storage": 8,
        "indexeddb": 0
      },
      {
        "origin": "https://books.toscrape.com",
        "status": 404,
        "cookie_header_cookies": 0,
        "cookie_header_names": [],
        "local_storage": 0,
        "indexeddb": 0
      },
      {
        "origin": "https://www.theguardian.com",
        "status": 200,
        "cookie_header_cookies": 23,
        "cookie_header_names": [
          "gu_client_ab_tests",
          "gu_v2_mvt_id",
          "GU_mvt_id",
          "bwid",
          "bwid_withoutSameSiteForIncompatibleClients",
          "_pubcid",
          "_pubcid_cst",
          "usnatUUID"
        ],
        "local_storage": 65,
        "indexeddb": 3
      }
    ],
    "seconds": 13.5
  },
  "fresh": {
    "cookies_at_start": 0,
    "origins": [
      {
        "origin": "https://en.wikipedia.org",
        "status": 200,
        "cookie_header_cookies": 0,
        "cookie_header_names": [],
        "local_storage": 0,
        "indexeddb": 0
      },
      {
        "origin": "https://books.toscrape.com",
        "status": 404,
        "cookie_header_cookies": 0,
        "cookie_header_names": [],
        "local_storage": 0,
        "indexeddb": 0
      },
      {
        "origin": "https://www.theguardian.com",
        "status": 200,
        "cookie_header_cookies": 0,
        "cookie_header_names": [],
        "local_storage": 0,
        "indexeddb": 0
      }
    ],
    "seconds": 12.5
  },
  "bill": {
    "warmup": {
      "billed_seconds": 154,
      "session": {
        "engine": "camoufox",
        "weight": 1,
        "seconds": 154
      }
    },
    "later": {
      "engine": "camoufox",
      "weight": 1,
      "seconds": 13.8
    },
    "fresh": {
      "engine": "camoufox",
      "weight": 1,
      "seconds": 12.5
    }
  },
  "cleanup": {
    "delete_status": 200,
    "deleted": true
  }
}
```

## Takeaways

-   **A short warmup reads real pages.** On 2026-11-14 each of the five runs browsed 12–15 pages in ~150 s — Wikipedia's Main Page and a few articles, books.toscrape.com's front and book pages, the Guardian's US front and a section — switching sites every 2–4 pages, reading 4–10 s each. Every page answered 200 (two runs had one load with no status). Four runs counted no challenge; one counted one (`progress.challenges: 1`) and simply moved on — a warmup never solves anything. `DELETE` stopped the job in 1–6 s.
-   **The visit history lives in the job, not where a page can see it.** Each visit is a result line `{type: "visit", url, status, dwell_s}`, so you get the exact path the warmup took. Browsers don't let pages read their history, so this case doesn't measure it in the profile; what a site can see on a later visit is cookies and storage.
-   **Most of the cookies are not from the sites it visited.** `GET /v1/jobs/{id}/cookies` held 182–221 cookies from 77–96 sites per run. First-party: 24–32 — the Guardian 17–25, Wikipedia 7, books.toscrape.com none. The other 153–192 came from 75–94 third-party sites, nearly all ad and identity services loaded by the Guardian's pages (Wikipedia's own `wikimedia.org` counts as third-party here, 7–11). 96–99 % were `SameSite=None`, 39–55 `httpOnly`, 9–11 session cookies; 10–23 expire within a day, 33–42 last a year or more (up to 400 days). The count swings with the news site's ad auction, not with the warmup.
-   **The profile was saved at once, for Camoufox.** The job created the profile (1.5–1.65 MB) and saved it 0.6–1.4 s after the job ended. A profile belongs to the browser that made it — the later session has to be Camoufox too.
-   **A later session starts exactly where the warmup stopped.** In 5 of 5 runs a read-only session on the profile started with **all** of the job's cookies (182–221 of 182–221, the 9–11 session cookies included), and its first requests sent them: 7 cookies to Wikipedia, 17–23 to the Guardian, none to books.toscrape.com (which sets none). The pages found the sites' storage too — Wikipedia 4–8 localStorage keys, the Guardian 60–66 keys and 2–3 IndexedDB databases. A fresh session without the profile started with none of it. (On 2026-11-11 the profile came back empty because the warmup browsed in a separate, unsaved context; Functions fixed that in 77c21b4, live since 2026-11-14.)
-   **Billed as one Camoufox session for the seconds its browser ran.** 147–154 thread-seconds for a 153–158 s job (the difference is the launch before the browser starts), the same as its own Camoufox session (weight 1) in the session history; a later look at the profile cost 13–16 s and the fresh session 13–17 s.

---
Source: https://cdpfleet.com/docs/cases/what-a-warmup-leaves-behind · Markdown: https://cdpfleet.com/docs/cases/what-a-warmup-leaves-behind.md · All docs for AI tools: https://cdpfleet.com/llms.txt
