Debugging Challenges with Recorded Traffic
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.