A screenshot of a failed request tells the next person almost nothing. A HAR capture tells them everything except which of the four hundred entries matters and how to replay it.
There are two ways this handoff goes wrong: a message that says "it returns 500, here is a picture", and a 12 MB archive with no note attached. The second version gets fixed faster, but only if the person receiving it can turn the archive back into a command. That conversion is mechanical, and the mechanical part breaks in ways that look like product bugs.
What a HAR file actually contains
A HAR is JSON. The spec defines log.entries, and each entry holds one request and its response:
log.entries[n].request method, url, httpVersion, headers[], cookies[],
queryString[], postData{ mimeType, text, params[] }
log.entries[n].response status, statusText, headers[], content{ size,
mimeType, text, encoding }, redirectURL
log.entries[n].timings blocked, dns, connect, send, wait, receive, ssl
log.entries[n].time total milliseconds
Two things in there are worth more than the rest. request.postData.text is the exact body that was sent, byte for byte, which is usually what a screenshot cannot show. response.content carries the bytes that came back, and when those bytes are not text the entry sets content.encoding to base64 so the archive stays valid JSON.
Capture the small window, not the whole tab
The archive is only as useful as the session you recorded it in, and a browsing session holds your cookies for everything you visited.
Open a fresh window, log in, reproduce the failure once, stop. Then export from the Network panel. In Chrome, since version 130, the export is sanitized by default: Cookie, Set-Cookie and Authorization headers are dropped unless you turn on Settings > Preferences > Network > "Allow to generate HAR with sensitive data" and pick the matching export option. That default is good for your secrets and bad for reproduction, because a request replayed without its auth header returns 401 and the bug report becomes a mystery again. Export the sensitive version on your own machine, then redact the specific values before it leaves your laptop. The steps further down do that part.
Firefox exports the same full log from its own Network panel. Either way, the file includes response bodies, which is the part that matters.
Find the one entry
Do not read the archive. Query it. This prints every entry that failed, with its index, which is the only number you need from here on:
python3 - <<'PY'
import json
har = json.load(open("capture.har"))
for i, e in enumerate(har["log"]["entries"]):
if e["response"]["status"] >= 400:
print(i, e["response"]["status"], e["request"]["method"],
e["request"]["url"][:110], e["time"], "ms")
PY
If the failing call returned 200 with a wrong body, filter on the URL path instead of the status. Slow calls are the third case, and the wait timing is the one that names them: timings.wait is time to first byte, timings.receive is the rest.
Convert it, with the fields a naive converter forgets
Copying the request as a dictionary loses duplicate headers and picks up fields the client must set itself. This converter drops those and keeps everything else:
import json, shlex, sys
DROP = {"content-length", "transfer-encoding", "connection", "host"}
def to_curl(entry):
req = entry["request"]
chunks = [f"-X {req['method'].upper()}", shlex.quote(req["url"])]
for header in req.get("headers", []):
name = header["name"]
if name.startswith(":") or name.lower() in DROP:
continue
chunks.append("-H " + shlex.quote(f"{name}: {header['value']}"))
post = req.get("postData") or {}
if post.get("text") is not None:
flag = "--data-binary" if post.get("mimeType", "").startswith("multipart/") else "--data-raw"
chunks.append(flag + " " + shlex.quote(post["text"]))
chunks.append("--compressed")
return "curl " + " \\\n ".join(chunks)
har = json.load(open(sys.argv[1]))
print(to_curl(har["log"]["entries"][int(sys.argv[2])]))
Run against a POST entry captured from httpbin.org/anything, it prints:
curl -X POST \
https://httpbin.org/anything \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer sk_test_...' \
-H 'Accept: application/json' \
--data-raw '{"order_id": "ord_1042", "refund": true}' \
--compressed
That command replayed the request and returned the same JSON body, 537 bytes, with the server echoing the exact payload. The four decisions in the script are the ones that turn a broken replay into a working one.
Content-Length is gone. The archive records the length the browser sent, and once you change the body, or the shell rewrites it, the header lies. A server waiting for 99 bytes that never arrive holds the connection until curl gives up (exit 28 over HTTP/1.1), and over HTTP/2 curl aborts the stream (exit 92). Neither error mentions the header that caused it.
Pseudo-headers are gone. Entries from HTTP/2 sites carry :authority, :method, :path and :scheme. They are protocol instructions rather than headers, and curl maps or discards them depending on the target protocol, so a command that behaves locally can behave differently on a colleague's machine. Anything starting with a colon is not a header.
Duplicates survive because headers are a list. Building a dict collapses repeated Accept or Cookie values and silently changes what the server sees.
--compressed is added because browsers send Accept-Encoding and expect a compressed response. Without it the body may arrive as bytes that look like garbage in your terminal.
Redact before you paste it into a ticket
Sanitizing by hand is how tokens end up in issue trackers. Walk the headers and replace values instead:
import json
SECRET = {"authorization", "cookie", "set-cookie", "x-api-key"}
har = json.load(open("capture.har"))
for entry in har["log"]["entries"]:
for header in entry["request"].get("headers", []) + entry["response"].get("headers", []):
if header["name"].lower() in SECRET:
header["value"] = "REDACTED"
json.dump(har, open("capture.redacted.har", "w"), indent=2)
Keep the header names. A maintainer needs to know an Authorization header was present and roughly what shape it had, not its value. Redact query strings too, since analytics and API keys travel there often, and check postData.text for a password or a session token before attaching the file.
When the body is not text
If content.encoding is base64, the response is binary and the text field is an encoded blob. Decode it to a real file rather than reading base64 out loud:
import base64, json
har = json.load(open("capture.har"))
content = har["log"]["entries"][0]["response"]["content"]
if content.get("encoding") == "base64":
open("payload.bin", "wb").write(base64.b64decode(content["text"]))
The round trip is worth checking once on a known file. Decoding a 482,855 byte PNG back from its base64 text produced an identical hash. For one-off decoding without a terminal, Base64 to file does the same thing in the browser and guesses the MIME type and filename.
Multipart uploads are the other shape worth knowing. The entry still holds postData.text with the full boundary and parts, so the --data-binary branch in the converter reproduces the request as long as you keep the Content-Type header that carries the boundary. Do not strip it.
GraphQL requests are just POSTs
A GraphQL call in a HAR looks like an ordinary POST to /graphql with a JSON body holding query, variables and operationName. Replaying it is the same command. What usually goes wrong is that people read the operationName and hand-write a smaller query, which changes the response. Keep the captured query, including the fields you do not recognise, or the response will differ for a reason that has nothing to do with the bug. If you are composing one from scratch rather than replaying one, GraphQL to cURL builds the POST body for you.
If you would rather not run a script
Convert HAR to cURL takes the archive, or a single entry copied out of it, and prints cURL commands in the browser, which is handy when the archive is already on someone else's machine. For reading the archive itself, JSON formatter collapses a 12 MB file into something you can navigate. Those pages process the input in the browser rather than uploading it, which matters when a redacted capture is still sensitive.
What goes in the report
The archive, redacted. The one-line cURL, replayed on your machine with the status and the response time you got. And one sentence naming what you expected instead. Three items, and the person reading it can reproduce the failure in under a minute without asking you a question.
The archive is not the report. The command is the report. The file is the evidence behind it.
Top comments (0)