The Capture You Took Is the Only Documentation That Exists

Nobody publishes the state machine of a challenge flow. What exists is a capture: a HAR file with twelve entries, some cookies, and a set of numbers that only make sense if you know which clock produced them. Debugging a challenge means turning that file into a sequence you can assert on, and an assertion is the only thing that survives a vendor shipping a change on a Tuesday.

The snippet below parses a literal HAR, prints the exchange table, and then diffs the run against a stored baseline. The diff is the point. Everything before it is plumbing that you could get wrong quietly. This is the operational half of what Playwright Stealth Pipeline does between runs, and it pairs with the vendor change cadence described in Challenge Vendor Deep Dive and the habit of watching for movement in Keeping Current.

What a HAR Contains and What It Omits

A HAR is log.entries, and each entry has request, response and timings. Requests carry method, url, cookies, headers, query string and postData. Responses carry status, cookies, content metadata and headers. That is enough to answer what was sent, what came back and in what order.

It is not enough to answer why. There is no body for a request the browser reused, no timing for a phase that did not happen, and no comment explaining that the beacon on entry twelve fired because the page was unloading. Those gaps are why a challenge is hard to debug from a capture alone: the absence of a fact and the absence of the action look identical in the file. An instrumented solver is the only thing that closes the gap, which is why the last section of this lesson is about what to log rather than what to capture.

Splitting the Capture Into Phases Before Reading It

Twelve entries read as a wall. Classified by path fragment they read as a story: two page navigations, four challenge exchanges, two API calls, one asset and three third-party requests that have nothing to do with any of it.

The rollup adds the two numbers worth arguing about. The challenge consumed forty percent of wall time across five entries, and the page navigations consumed thirty-eight percent across two. Anything you cut from the non-challenge phases saves less than you would guess, which is a useful thing to learn from a file rather than from an opinion. Classifying before reading also stops the most common analytical mistake, which is attributing a third-party analytics request's latency to the challenge because it happened during the same window.

The Cookie Timeline Is Usually the Whole Answer

Nine of the twelve rows in the timeline print something, and they collapse into four facts: the 403 sets a one-token pre-instrument value, the challenge script carries it, the sensor post both carries and replaces it, and every subsequent navigation carries the replacement.

That is the entire lifecycle in one table. When a flow stops working, the first question is not which request failed, it is which stage stopped writing the cookie. A row that sends but never sets, or sets but never gets consumed, points straight at the break. The same timeline is also how you notice the cookie was written twice in one run, which usually means two widgets rendered and only one of them is being used.

Timing Fields Are Not Additive

Three of twelve entries declare a total that disagrees with the sum of their own phase timings, by twenty-one, fourteen and six milliseconds respectively. The declared total is what a browser UI shows you, and the phase sum is what you want to reason with, because a stored total can come from a different clock than the rows beneath it.

Practically: compute the sum, compare it against the declared value, and log the difference when it is large. A twenty-millisecond skew is unremarkable; a two-hundred-millisecond one means the connection was reused or the entry spans a navigation, and your latency comparisons are measuring the wrong thing. Once you know the skew is there, every later latency comparison should state which of the two numbers it used, or the same number will be argued about twice.

'''Parse a challenge round trip out of a HAR file, then diff it against a baseline.

The HAR below is a literal, constructed here rather than exported, so the parse
runs offline and prints the same table every time. The shape is the one Chrome
DevTools and Playwright's record_har_path produce: log.version, log.entries,
and per entry request / response / timings.

What the parser is actually for is answering three questions quickly: which
requests the challenge made, which cookies each stage set and consumed, and
whether this run differs from the run you stored yesterday.
'''

from urllib.parse import urlsplit

HAR = {"log": {
    "version": "1.2",
    "creator": {"name": "playwright-chromium", "version": "130.0.6723.92"},
    "pages": [{"id": "page_1", "title": "https://shop.example/p/12345",
               "startedDateTime": "2026-10-01T09:14:02.410Z", "pageTimings": {}}],
    "entries": [
        {"startedDateTime": "2026-10-01T09:14:02.412Z", "time": 184,
         "request": {"method": "GET", "url": "https://shop.example/p/12345",
                     "httpVersion": "h2", "cookies": [], "headers": [
                         {"name": "sec-fetch-mode", "value": "navigate"}],
                     "queryString": [], "headersSize": 420, "bodySize": 0},
         "response": {"status": 403, "statusText": "Forbidden", "httpVersion": "h2",
                      "cookies": [{"name": "_abck", "value": "~hw",
                                   "path": "/", "domain": ".shop.example",
                                   "expires": None, "httpOnly": False, "secure": True}],
                      "content": {"size": 2481, "mimeType": "text/html"},
                      "headers": [{"name": "content-type", "value": "text/html"},
                                  {"name": "set-cookie", "value": "_abck=~hw; Path=/; Secure"}],
                      "bodySize": 2481, "_transferSize": 2912},
         "timings": {"blocked": 1, "dns": 12, "connect": 18, "ssl": 21,
                     "send": 1, "wait": 138, "receive": 14}},
        {"startedDateTime": "2026-10-01T09:14:02.596Z", "time": 41,
         "request": {"method": "GET",
                     "url": "https://shop.example/akam/2c1f/8f21/script.js",
                     "httpVersion": "h2", "cookies": [{"name": "_abck", "value": "~hw"}],
                     "headers": [{"name": "sec-fetch-dest", "value": "script"}],
                     "queryString": [], "headersSize": 610, "bodySize": 0},
         "response": {"status": 200, "statusText": "OK", "httpVersion": "h2",
                      "cookies": [], "content": {"size": 41820, "mimeType": "application/javascript"},
                      "headers": [{"name": "content-type", "value": "application/javascript"}],
                      "bodySize": 41820, "_transferSize": 42610},
         "timings": {"blocked": 0, "dns": -1, "connect": -1, "ssl": -1,
                     "send": 1, "wait": 22, "receive": 18}},
        {"startedDateTime": "2026-10-01T09:14:02.637Z", "time": 212,
         "request": {"method": "POST",
                     "url": "https://shop.example/akam/2c1f/8f21/sensor_data",
                     "httpVersion": "h2", "cookies": [{"name": "_abck", "value": "~hw"}],
                     "headers": [{"name": "content-type", "value": "text/plain"}],
                     "queryString": [],
                     "postData": {"mimeType": "text/plain",
                                  "text": "-1_1-aj_indx-1491928374-1_1-session_id-0007f3c9a11"},
                     "headersSize": 640, "bodySize": 812},
         "response": {"status": 200, "statusText": "OK", "httpVersion": "h2",
                      "cookies": [{"name": "_abck", "value": "~-hw~m~4~3b1c9f0a2d~mr~8f21e7d4c0~",
                                   "path": "/", "domain": ".shop.example",
                                   "expires": None, "httpOnly": False, "secure": True}],
                      "content": {"size": 0, "mimeType": "text/plain"},
                      "headers": [{"name": "set-cookie",
                                   "value": "_abck=~-hw~m~4~3b1c9f0a2d~mr~8f21e7d4c0~; Path=/"}],
                      "bodySize": 0, "_transferSize": 412},
         "timings": {"blocked": 0, "dns": -1, "connect": -1, "ssl": -1,
                     "send": 2, "wait": 186, "receive": 24}},
        {"startedDateTime": "2026-10-01T09:14:02.849Z", "time": 18,
         "request": {"method": "GET",
                     "url": "https://shop.example/akam/2c1f/8f21/img/A1B2C3",
                     "httpVersion": "h2", "cookies": [{"name": "_abck", "value": "~-hw~m~4~3b1c9f0a2d~mr~8f21e7d4c0~"}],
                     "headers": [], "queryString": [{"name": "c", "value": "A1B2C3"}],
                     "headersSize": 600, "bodySize": 0},
         "response": {"status": 200, "statusText": "OK", "httpVersion": "h2", "cookies": [],
                      "content": {"size": 0, "mimeType": "image/gif"},
                      "headers": [{"name": "cache-control", "value": "no-store"}],
                      "bodySize": 0, "_transferSize": 288},
         "timings": {"blocked": 0, "dns": -1, "connect": -1, "ssl": -1,
                     "send": 1, "wait": 9, "receive": 8}},
        {"startedDateTime": "2026-10-01T09:14:02.867Z", "time": 96,
         "request": {"method": "POST",
                     "url": "https://shop.example/akam/2c1f/8f21/bm/collect",
                     "httpVersion": "h2", "cookies": [{"name": "_abck", "value": "~-hw~m~4~3b1c9f0a2d~mr~8f21e7d4c0~"}],
                     "headers": [{"name": "content-type", "value": "application/json"}],
                     "queryString": [],
                     "postData": {"mimeType": "application/json",
                                  "text": "{\"d\":\"web\",\"ab\":\"0.42\"}"},
                     "headersSize": 618, "bodySize": 96},
         "response": {"status": 200, "statusText": "OK", "httpVersion": "h2", "cookies": [],
                      "content": {"size": 0, "mimeType": "application/json"},
                      "headers": [], "bodySize": 0, "_transferSize": 204},
         "timings": {"blocked": 0, "dns": -1, "connect": -1, "ssl": -1,
                     "send": 1, "wait": 82, "receive": 13}},
        {"startedDateTime": "2026-10-01T09:14:02.611Z", "time": 33,
         "request": {"method": "GET", "url": "https://tags.metrics.example/g.js",
                     "httpVersion": "h3", "cookies": [], "headers": [],
                     "queryString": [], "headersSize": 380, "bodySize": 0},
         "response": {"status": 200, "statusText": "OK", "httpVersion": "h3", "cookies": [],
                      "content": {"size": 51200, "mimeType": "application/javascript"},
                      "headers": [], "bodySize": 51200, "_transferSize": 51980},
         "timings": {"blocked": 0, "dns": 8, "connect": 5, "ssl": 7,
                     "send": 1, "wait": 14, "receive": 12}},
        {"startedDateTime": "2026-10-01T09:14:02.644Z", "time": 24,
         "request": {"method": "POST", "url": "https://tags.metrics.example/collect",
                     "httpVersion": "h3", "cookies": [], "headers": [],
                     "queryString": [], "headersSize": 402, "bodySize": 44},
         "response": {"status": 204, "statusText": "No Content", "httpVersion": "h3",
                      "cookies": [], "content": {"size": 0, "mimeType": ""},
                      "headers": [], "bodySize": 0, "_transferSize": 120},
         "timings": {"blocked": 0, "dns": -1, "connect": -1, "ssl": -1,
                     "send": 1, "wait": 17, "receive": 6}},
        {"startedDateTime": "2026-10-01T09:14:03.001Z", "time": 171,
         "request": {"method": "GET", "url": "https://shop.example/p/12345",
                     "httpVersion": "h2",
                     "cookies": [{"name": "_abck", "value": "~-hw~m~4~3b1c9f0a2d~mr~8f21e7d4c0~"}],
                     "headers": [{"name": "sec-fetch-mode", "value": "navigate"}],
                     "queryString": [], "headersSize": 432, "bodySize": 0},
         "response": {"status": 200, "statusText": "OK", "httpVersion": "h2", "cookies": [],
                      "content": {"size": 84210, "mimeType": "text/html"},
                      "headers": [{"name": "content-type", "value": "text/html"}],
                      "bodySize": 84210, "_transferSize": 85200},
         "timings": {"blocked": 2, "dns": -1, "connect": -1, "ssl": -1,
                     "send": 1, "wait": 152, "receive": 16}},
        {"startedDateTime": "2026-10-01T09:14:03.172Z", "time": 28,
         "request": {"method": "GET",
                     "url": "https://shop.example/api/v1/reviews?productId=12345",
                     "httpVersion": "h2",
                     "cookies": [{"name": "_abck", "value": "~-hw~m~4~3b1c9f0a2d~mr~8f21e7d4c0~"}],
                     "headers": [{"name": "accept", "value": "application/json"}],
                     "queryString": [{"name": "productId", "value": "12345"}],
                     "headersSize": 588, "bodySize": 0},
         "response": {"status": 200, "statusText": "OK", "httpVersion": "h2", "cookies": [],
                      "content": {"size": 14200, "mimeType": "application/json"},
                      "headers": [], "bodySize": 14200, "_transferSize": 14420},
         "timings": {"blocked": 0, "dns": -1, "connect": -1, "ssl": -1,
                     "send": 1, "wait": 21, "receive": 6}},
        {"startedDateTime": "2026-10-01T09:14:03.318Z", "time": 12,
         "request": {"method": "POST", "url": "https://shop.example/api/v1/cart",
                     "httpVersion": "h2", "cookies": [], "headers": [],
                     "queryString": [], "headersSize": 470, "bodySize": 0},
         "response": {"status": 401, "statusText": "Unauthorized", "httpVersion": "h2",
                      "cookies": [], "content": {"size": 88, "mimeType": "application/json"},
                      "headers": [], "bodySize": 88, "_transferSize": 240},
         "timings": {"blocked": 0, "dns": -1, "connect": -1, "ssl": -1,
                     "send": 1, "wait": 8, "receive": 3}},
        {"startedDateTime": "2026-10-01T09:14:03.196Z", "time": 37,
         "request": {"method": "GET", "url": "https://shop.example/assets/app.a91f.js",
                     "httpVersion": "h2", "cookies": [], "headers": [],
                     "queryString": [], "headersSize": 512, "bodySize": 0},
         "response": {"status": 200, "statusText": "OK", "httpVersion": "h2", "cookies": [],
                      "content": {"size": 241100, "mimeType": "application/javascript"},
                      "headers": [], "bodySize": 241100, "_transferSize": 242400},
         "timings": {"blocked": 0, "dns": -1, "connect": -1, "ssl": -1,
                     "send": 1, "wait": 28, "receive": 8}},
        {"startedDateTime": "2026-10-01T09:14:03.330Z", "time": 61,
         "request": {"method": "GET", "url": "https://cdn.assets.example/hero.avif",
                     "httpVersion": "h3", "cookies": [], "headers": [],
                     "queryString": [], "headersSize": 360, "bodySize": 0},
         "response": {"status": 200, "statusText": "OK", "httpVersion": "h3", "cookies": [],
                      "content": {"size": 142300, "mimeType": "image/avif"},
                      "headers": [], "bodySize": 142300, "_transferSize": 143100},
         "timings": {"blocked": 0, "dns": 6, "connect": 4, "ssl": 6,
                     "send": 1, "wait": 38, "receive": 12}},
        {"startedDateTime": "2026-10-01T09:14:03.902Z", "time": 34,
         "request": {"method": "POST",
                     "url": "https://shop.example/akam/2c1f/8f21/bm/collect",
                     "httpVersion": "h2",
                     "cookies": [{"name": "_abck", "value": "~-hw~m~4~3b1c9f0a2d~mr~8f21e7d4c0~"}],
                     "headers": [{"name": "content-type", "value": "application/json"}],
                     "queryString": [],
                     "postData": {"mimeType": "application/json",
                                  "text": "{\"d\":\"pagehide\",\"ab\":\"0.42\"}"},
                     "headersSize": 618, "bodySize": 74},
         "response": {"status": 200, "statusText": "OK", "httpVersion": "h2", "cookies": [],
                      "content": {"size": 0, "mimeType": "application/json"},
                      "headers": [], "bodySize": 0, "_transferSize": 206},
         "timings": {"blocked": 0, "dns": -1, "connect": -1, "ssl": -1,
                     "send": 1, "wait": 27, "receive": 6}},
    ]}}

# path fragment -> phase, and whether the entry is challenge-relevant at all
PHASES = [
    ("/akam/", "challenge", True),
    ("/api/", "api", False),
    ("/assets/", "asset", False),
    ("/p/", "page", True),
    ("/cart", "api", False),
]
DEFAULT_PHASE = ("other", False)


def phase_of(url):
    path = urlsplit(url).path
    for fragment, phase, relevant in PHASES:
        if fragment in path:
            return phase, relevant
    return DEFAULT_PHASE


def duration(entry):
    '''HAR timings use -1 for a phase that did not apply (a reused connection).'''
    return sum(v for v in entry["timings"].values() if v and v > 0)


entries = HAR["log"]["entries"]
rows = []
for index, entry in enumerate(entries):
    url = urlsplit(entry["request"]["url"])
    phase, relevant = phase_of(entry["request"]["url"])
    rows.append({
        "i": index, "method": entry["request"]["method"], "host": url.netloc,
        "path": url.path + (("?" + url.query) if url.query else ""),
        "status": entry["response"]["status"],
        "mime": (entry["response"]["content"]["mimeType"] or "-").split(";")[0],
        "sent": entry["request"]["bodySize"], "recv": entry["response"]["bodySize"],
        "wire": entry["response"]["_transferSize"], "ms": duration(entry),
        "phase": phase, "relevant": relevant,
        "sent_cookie": [c["name"] for c in entry["request"]["cookies"]],
        "set_cookie": [c["name"] for c in entry["response"]["cookies"]],
        "has_body": "postData" in entry["request"],
        "http": entry["response"]["httpVersion"],
    })

print("har: {} entries, creator {} {}".format(
    len(entries), HAR["log"]["creator"]["name"], HAR["log"]["creator"]["version"]))
print("page: {}".format(HAR["log"]["pages"][0]["title"]))
print()
print("{:<3} {:<4} {:<18} {:<30} {:>3} {:<10} {:>5} {:>6}  {}".format(
    "#", "verb", "host", "path", "st", "phase", "ms", "bytes", "flags"))
print("-" * 100)
for row in rows:
    flags = []
    if row["set_cookie"]:
        flags.append("set:" + ",".join(row["set_cookie"]))
    if row["sent_cookie"]:
        flags.append("send:" + ",".join(row["sent_cookie"]))
    if row["has_body"]:
        flags.append("body")
    if row["status"] >= 400:
        flags.append("err")
    print("{:<3} {:<4} {:<18} {:<30} {:>3} {:<10} {:>5} {:>6}  {}".format(
        row["i"], row["method"], row["host"][:18], row["path"][:30], row["status"],
        row["phase"], row["ms"], row["recv"], " ".join(flags) or "-"))

# entry["time"] is the browser's own total; the phase timings must add up to it
skew = [(row["i"], entries[row["i"]]["time"], row["ms"]) for row in rows
        if abs(entries[row["i"]]["time"] - row["ms"]) > 2]
print()
print("timing consistency: entry[\"time\"] against the sum of its own phase timings")
for index, declared, summed in skew:
    print("  entry {} declares {} ms, its phases sum to {} ms, difference {:+d} ms".format(
        index, declared, summed, declared - summed))
print("{} of {} entries disagree; the phase sum is the one to use, because a".format(
    len(skew), len(rows)))
print("stored total can come from a different clock than the per-phase rows.")

print()
print("phase rollup")
print("{:<10} {:>5} {:>8} {:>9} {:>11}  {}".format(
    "phase", "reqs", "total ms", "share ms", "wire bytes", "challenge relevant"))
print("-" * 76)
wall = sum(r["ms"] for r in rows)
for phase in ("page", "challenge", "api", "asset", "other"):
    subset = [r for r in rows if r["phase"] == phase]
    if not subset:
        continue
    total_ms = sum(r["ms"] for r in subset)
    print("{:<10} {:>5} {:>8} {:>8.1f}% {:>11}  {}".format(
        phase, len(subset), total_ms, 100.0 * total_ms / wall,
        "{:,}".format(sum(r["wire"] for r in subset)),
        "{} of {}".format(sum(1 for r in subset if r["relevant"]), len(subset))))

print()
print("cookie timeline: who writes the cookie, who consumes it")
print("{:<3} {:<4} {:<28} {:<26} {}".format("#", "verb", "path", "action", "value"))
print("-" * 100)
for row in rows:
    for cookie in entries[row["i"]]["response"]["cookies"]:
        print("{:<3} {:<4} {:<28} {:<26} {}".format(
            row["i"], row["method"], row["path"][:28], "SET " + cookie["name"],
            cookie["value"]))
    for cookie in entries[row["i"]]["request"]["cookies"]:
        print("{:<3} {:<4} {:<28} {:<26} {}".format(
            row["i"], row["method"], row["path"][:28], "send " + cookie["name"], "-"))
print()
print("the 403 sets the pre-instrument value, the sensor POST replaces it with the")
print("punctuated status form, and the retry navigation carries the new value.")

print()
print("the challenge exchanges, in the order the page made them")
for row in [r for r in rows if r["relevant"]]:
    print("  {:>2}  {:<4} {:<46} -> {} in {:>3} ms".format(
        row["i"], row["method"], row["path"][:46], row["status"], row["ms"]))

# ------------------------------------------------------------------ regression
# What yesterday's accepted capture recorded. Anything the run does differently
# is a question for a human, not an automatic update.
STORED = {
    0: {"status": 403, "ms": 205},
    1: {"status": 200, "ms": 41},
    2: {"status": 200, "ms": 150},
    3: {"status": 200, "ms": 18},
    4: {"status": 200, "ms": 96},
    7: {"status": 200, "ms": 168},
    8: {"status": 200, "ms": 31},
    13: {"path": "/akam/2c1f/8f21/sensor_data", "status": 200, "ms": 194},
}
TOLERANCE_MS = 60

print()
print("capture-to-diff against the stored baseline, tolerance {} ms".format(TOLERANCE_MS))
print("{:<3} {:<30} {:>5} {:>5} {:>8} {:>8}  {}".format(
    "#", "path", "st", "base", "ms", "base", "verdict"))
print("-" * 86)
findings = []
for index in sorted(STORED):
    stored = STORED[index]
    if index >= len(rows):
        findings.append("entry {} {} was in the baseline and did not happen".format(
            index, stored["path"]))
        print("{:<3} {:<30} {:>5} {:>5} {:>8} {:>8}  {}".format(
            index, stored["path"][:30], "-", stored["status"], "-", stored["ms"],
            "MISSING"))
        continue
    row = rows[index]
    delta = row["ms"] - stored["ms"]
    if row["status"] != stored["status"]:
        verdict = "STATUS {} not {}".format(row["status"], stored["status"])
        findings.append("entry {} {} returned {}, baseline says {}".format(
            index, row["path"], row["status"], stored["status"]))
    elif abs(delta) > TOLERANCE_MS:
        verdict = "TIMING {:+d} ms".format(delta)
        findings.append("entry {} {} moved {:+d} ms, tolerance is {}".format(
            index, row["path"], delta, TOLERANCE_MS))
    else:
        verdict = "ok (delta {:+d})".format(delta)
    print("{:<3} {:<30} {:>5} {:>5} {:>8} {:>8}  {}".format(
        index, row["path"][:30], row["status"], stored["status"],
        row["ms"], stored["ms"], verdict))

for row in rows:
    if row["i"] in STORED or not row["relevant"]:
        continue
    findings.append("entry {} {} is new and was not reviewed".format(row["i"], row["path"]))
    print("{:<3} {:<30} {:>5} {:>5} {:>8} {:>8}  {}".format(
        row["i"], row["path"][:30], row["status"], "-", row["ms"], "-", "NEW"))

print()
print("{} finding(s)".format(len(findings)))
for line in findings:
    print("  " + line)
print("verdict: {}".format("capture matches the baseline" if not findings
                           else "do not accept yet; read the findings first"))

print()
print("accepting the run means storing it, and storing means picking the keys")
print("deliberately rather than copying the whole capture:")
for row in rows:
    if not row["relevant"]:
        continue
    print('    {}: {{"status": {}, "ms": {}, "body": {}}},'.format(
        row["i"], row["status"], row["ms"], row["sent"]))
print("an entry that carries a request body is keyed by its size as well, because")
print("a sensor POST that returns 200 with a shorter body is still a change.")
har: 13 entries, creator playwright-chromium 130.0.6723.92
page: https://shop.example/p/12345

#   verb host               path                            st phase         ms  bytes  flags
----------------------------------------------------------------------------------------------------
0   GET  shop.example       /p/12345                       403 page         205   2481  set:_abck err
1   GET  shop.example       /akam/2c1f/8f21/script.js      200 challenge     41  41820  send:_abck
2   POST shop.example       /akam/2c1f/8f21/sensor_data    200 challenge    212      0  set:_abck send:_abck body
3   GET  shop.example       /akam/2c1f/8f21/img/A1B2C3     200 challenge     18      0  send:_abck
4   POST shop.example       /akam/2c1f/8f21/bm/collect     200 challenge     96      0  send:_abck body
5   GET  tags.metrics.examp /g.js                          200 other         47  51200  -
6   POST tags.metrics.examp /collect                       204 other         24      0  -
7   GET  shop.example       /p/12345                       200 page         171  84210  send:_abck
8   GET  shop.example       /api/v1/reviews?productId=1234 200 api           28  14200  send:_abck
9   POST shop.example       /api/v1/cart                   401 api           12     88  err
10  GET  shop.example       /assets/app.a91f.js            200 asset         37 241100  -
11  GET  cdn.assets.example /hero.avif                     200 other         67 142300  -
12  POST shop.example       /akam/2c1f/8f21/bm/collect     200 challenge     34      0  send:_abck body

timing consistency: entry["time"] against the sum of its own phase timings
  entry 0 declares 184 ms, its phases sum to 205 ms, difference -21 ms
  entry 5 declares 33 ms, its phases sum to 47 ms, difference -14 ms
  entry 11 declares 61 ms, its phases sum to 67 ms, difference -6 ms
3 of 13 entries disagree; the phase sum is the one to use, because a
stored total can come from a different clock than the per-phase rows.

phase rollup
phase       reqs total ms  share ms  wire bytes  challenge relevant
----------------------------------------------------------------------------
page           2      376     37.9%      88,112  2 of 2
challenge      5      401     40.4%      43,720  5 of 5
api            2       40      4.0%      14,660  0 of 2
asset          1       37      3.7%     242,400  0 of 1
other          3      138     13.9%     195,200  0 of 3

cookie timeline: who writes the cookie, who consumes it
#   verb path                         action                     value
----------------------------------------------------------------------------------------------------
0   GET  /p/12345                     SET _abck                  ~hw
1   GET  /akam/2c1f/8f21/script.js    send _abck                 -
2   POST /akam/2c1f/8f21/sensor_data  SET _abck                  ~-hw~m~4~3b1c9f0a2d~mr~8f21e7d4c0~
2   POST /akam/2c1f/8f21/sensor_data  send _abck                 -
3   GET  /akam/2c1f/8f21/img/A1B2C3   send _abck                 -
4   POST /akam/2c1f/8f21/bm/collect   send _abck                 -
7   GET  /p/12345                     send _abck                 -
8   GET  /api/v1/reviews?productId=12 send _abck                 -
12  POST /akam/2c1f/8f21/bm/collect   send _abck                 -

the 403 sets the pre-instrument value, the sensor POST replaces it with the
punctuated status form, and the retry navigation carries the new value.

the challenge exchanges, in the order the page made them
   0  GET  /p/12345                                       -> 403 in 205 ms
   1  GET  /akam/2c1f/8f21/script.js                      -> 200 in  41 ms
   2  POST /akam/2c1f/8f21/sensor_data                    -> 200 in 212 ms
   3  GET  /akam/2c1f/8f21/img/A1B2C3                     -> 200 in  18 ms
   4  POST /akam/2c1f/8f21/bm/collect                     -> 200 in  96 ms
   7  GET  /p/12345                                       -> 200 in 171 ms
  12  POST /akam/2c1f/8f21/bm/collect                     -> 200 in  34 ms

capture-to-diff against the stored baseline, tolerance 60 ms
#   path                              st  base       ms     base  verdict
--------------------------------------------------------------------------------------
0   /p/12345                         403   403      205      205  ok (delta +0)
1   /akam/2c1f/8f21/script.js        200   200       41       41  ok (delta +0)
2   /akam/2c1f/8f21/sensor_data      200   200      212      150  TIMING +62 ms
3   /akam/2c1f/8f21/img/A1B2C3       200   200       18       18  ok (delta +0)
4   /akam/2c1f/8f21/bm/collect       200   200       96       96  ok (delta +0)
7   /p/12345                         200   200      171      168  ok (delta +3)
8   /api/v1/reviews?productId=1234   200   200       28       31  ok (delta -3)
13  /akam/2c1f/8f21/sensor_data        -   200        -      194  MISSING
12  /akam/2c1f/8f21/bm/collect       200     -       34        -  NEW

3 finding(s)
  entry 2 /akam/2c1f/8f21/sensor_data moved +62 ms, tolerance is 60
  entry 13 /akam/2c1f/8f21/sensor_data was in the baseline and did not happen
  entry 12 /akam/2c1f/8f21/bm/collect is new and was not reviewed
verdict: do not accept yet; read the findings first

accepting the run means storing it, and storing means picking the keys
deliberately rather than copying the whole capture:
    0: {"status": 403, "ms": 205, "body": 0},
    1: {"status": 200, "ms": 41, "body": 0},
    2: {"status": 200, "ms": 212, "body": 812},
    3: {"status": 200, "ms": 18, "body": 0},
    4: {"status": 200, "ms": 96, "body": 96},
    7: {"status": 200, "ms": 171, "body": 0},
    12: {"status": 200, "ms": 34, "body": 74},
an entry that carries a request body is keyed by its size as well, because
a sensor POST that returns 200 with a shorter body is still a change.

Capture-to-Diff as a Regression Test

The baseline stores what the last accepted capture did, per entry index, as status, milliseconds and request body size. The diff then reports three kinds of finding, and in the run below it reports all three.

An entry that was in the baseline and did not happen. An entry that is new and was never reviewed. And an entry whose status changed or whose timing moved outside tolerance. The sensor post in this capture took sixty-two milliseconds longer than the baseline, two milliseconds past the tolerance, and that is precisely the kind of change that is invisible in a spot check and obvious in a diff.

Accepting the run is a separate, deliberate act. Store the keys you chose rather than copying the whole capture, include the body size for any entry that posts data, and read the findings before you update anything. A diff that auto-accepts is worse than no diff at all, because it turns every vendor change into a silent update and leaves you with no record of when the flow moved.

Instrumenting the Solver, Not the Browser

The capture tells you what the browser sent. It does not tell you what your solver believed it was solving, and the gap between those two is where most intermittent failures live.

Log the link you were given, the algorithm you chose, the budget you set, the nonce you found and the verification verdict you got back. Four fields, per solve, at the moment each one is known rather than reconstructed afterwards. A link that changes between two solves explains a session reset before the capture shows it. A budget that was halved explains an abort. A nonce that is always the first candidate means the difficulty dropped, and a verdict that disagrees with the capture means the two records were taken from different runs. When a capture shows a 200 that your code treated as a rejection, the answer is in those four fields and nowhere else. The same fields let you line a solver failure up against the capture's sensor post, which is the payload that Akamai Bot Manager: Sensor Data and _abck walks through field by field.

Checklist

  • Classify entries by phase before reading any of them.
  • Build the cookie timeline; it usually identifies the break.
  • Sum the phase timings and diff against the declared total.
  • Store a baseline of statuses, timings and body sizes per entry index.
  • Report missing, new and changed entries separately, with a stated tolerance.
  • Read findings before accepting a run, and store only the keys you reviewed.
  • Log link, algorithm, budget, nonce and verdict on every solve, at the moment each value is known.
  • Keep the capture and the solver log for the same run, so a mismatch can be explained without repeating the run under different conditions.
  • Record the branch URL and build identifier beside each capture, so two baselines are never compared across a deploy.
  • Treat a new challenge-relevant entry as a review item, not as noise; the beacon that fires on page unload is the one most often dismissed by hand.

The Legitimate Route

Recorded traffic is the normal tool for debugging a system you own, and the same file is what you hand a vendor when you need them to confirm a change. For a site you are only authorised to test, capture inside the scope you agreed, and ask for a test key rather than a production capture: the difference is usually one email and removes the need to argue about which requests were in bounds. Storing a capture of someone else's authenticated session, on the other hand, is not a debugging aid, and no diff makes it one. The same test applies here: if you would not hand the capture to the site owner and call the work authorised testing, the file is evidence of something else.