API logging
API logging prints every HTTP call made through Playwright's APIRequestContext (request.get, post, put, patch, delete, head and fetch). It is configured with the apiLog option.
This page describes the line-by-line output, apiLog: { format: 'verbose' }. The default output, one line per call with detail only where a test failed, is described in Compact output & the reporter. Options such as curl, curlFormat, redaction and sink apply to both formats.
Two ways to turn it on
- Fixture (recommended)
- Manual wrapper
Import test and expect from playwright-logger-pro/fixtures. The built-in request fixture is wrapped automatically, and baseURL and extraHTTPHeaders from your Playwright config are passed on to the logger.
import { test, expect } from 'playwright-logger-pro/fixtures';
test('create vegetable', async ({ request }) => {
const res = await request.post('/vegetables', { data: { name: 'Orange', price: 1 } });
expect(res.status()).toBe(201);
});
Configure it project-wide, or per suite or test with test.use():
- TypeScript
- JavaScript
import { defineConfig } from '@playwright/test';
import type { ApiLoggerOptions } from 'playwright-logger-pro/fixtures';
export default defineConfig<ApiLoggerOptions>({
use: {
baseURL: 'http://localhost:3000',
apiLog: { curl: 'on-failure' },
},
});
// @ts-check
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
use: {
baseURL: 'http://localhost:3000',
apiLog: { curl: 'on-failure' },
},
});
test.describe('debugging this flow', () => {
test.use({ apiLog: { curl: true, maxBodyLength: 10000 } });
// ...
});
test.use replaces the whole objecttest.use({ apiLog: { ... } }) replaces the apiLog object from your config rather than merging into it. Repeat the options you still want.
wrapRequestContext() wraps any APIRequestContext, for example one you create in a script or in beforeAll:
import { request } from '@playwright/test';
import { wrapRequestContext } from 'playwright-logger-pro';
const ctx = wrapRequestContext(
await request.newContext({ baseURL: 'https://api.example.com' }),
{ curl: true, baseURL: 'https://api.example.com' },
);
const res = await ctx.get('/users');
The wrapper is a transparent proxy: everything else on APIRequestContext (dispose(), storageState(), ...) keeps working; only the HTTP verb methods gain logging.
In manual mode the logger only knows what you tell it. Pass baseURL to get absolute URLs in error lines and curl, and pass the same extraHTTPHeaders you gave newContext() to have them appear in the logged headers and curl.
Output anatomy
→ POST /vegetables
{
"name": "Orange",
"price": 1
}
← POST http://localhost:3000/vegetables 201 Created (10ms)
content-type: application/json
date: Thu, 29 Oct 2026 09:12:25 GMT
connection: keep-alive
keep-alive: timeout=5
transfer-encoding: chunked
{"id":7,"msg":"Successfully added: Orange"}
curl -X POST 'http://localhost:3000/vegetables' \
-H 'Content-Type: application/json' \
--data-raw '{"name":"Orange","price":1}'
curl.sh: attached to the test report (see "curl.sh" in the HTML report; also saved in this test's output folder)| Line | Meaning |
|---|---|
→ METHOD url | The request. Includes params in the query string. Indented below it: request headers (when the call sets any) and the body. |
← METHOD url STATUS (Nms) | The response, with the original request method and URL, the status, and the time to the response. Below: response headers, then the body. |
✖ METHOD url failed (Nms) error | The request failed without a response (timeout, connection refused, ...). The error is then rethrown unchanged. |
curl ... | The equivalent command, when curl is true (always) or 'on-failure' (status not 2xx, or no response). |
Methods and statuses are colour-coded: GET cyan, POST green, DELETE red; 2xx green, 4xx yellow, 5xx red. The glyphs and the status text carry the same information when colours are off.
→ GET http://127.0.0.1:9/health
✖ GET http://127.0.0.1:9/health failed (4ms) apiRequestContext.get: connect ECONNREFUSED 127.0.0.1:9
curl 'http://127.0.0.1:9/health'
curl.sh: attached to the test report (see "curl.sh" in the HTML report; also saved in this test's output folder)- Turn headers off with
logHeaders: falseand bodies withlogBody: false. - Bodies longer than
maxBodyLength(default 2000 characters) are cut and end with... (N more chars). The cut never splits an emoji or another multi-byte character.Infinitydisables truncation. - Repeated response headers (several
Set-Cookie) are printed as one indented line per value. - Binary request bodies (a
Bufferasdata) are logged as<Buffer N bytes>. Binary responses (non-text content type) as<binary N bytes (type)>. - Control characters in text bodies, such as terminal escape sequences sent by a server, are escaped (
\x1b), so a response cannot inject escape codes into your log. - Responses are logged when they complete. With concurrent requests (
Promise.all) the lines appear in completion order, not call order.
Verbs and fetch
All of get, post, put, patch, delete, head and fetch are logged. fetch accepts a URL string, a URL object or a Playwright Request; the real URL is logged in each case.
Redirects
The logged URL and method are the ones you requested. For a redirected request the curl follows Playwright's rules, which differ from curl -L: a 303 turns any method except GET/HEAD into a GET without a body, a 301/302 turns a POST into a GET, and PUT/PATCH/DELETE keep their method and body on 301/302/307/308.
A redirected GET or HEAD gets -L. Any other redirected request gets one curl per step, with the method, URL and body Playwright actually used (a step that became a GET has no body and no Content-Type/Content-Length, and it stays a bodiless GET on later 307/308 steps). Which headers are forwarded depends on the Playwright version (see below). The steps are read from Playwright's fetch log; if that is not available, a POST with a body is replayed with -L and any other method is replayed without following the redirect, with a comment saying so.
Redirect headers by Playwright version
Which headers Playwright forwards to a redirect target changed between releases. The step curls follow the version that is installed (read from its package.json; when it cannot be found the 1.60+ rules are used). Secrets are redacted in every step as usual.
| Playwright | Cookie you set | Authorization to another origin |
|---|---|---|
| 1.30 – 1.31 | dropped | forwarded |
| 1.32 – 1.59 | forwarded if spelled Cookie, dropped if spelled cookie; the same exact-case rule applies to Content-Type and the other body headers when a redirect becomes a GET | forwarded |
| 1.60+ | dropped | dropped |
Cookies from the cookie jar are never carried over: Playwright re-reads the jar for each step, and they are not in the printed steps. On 1.30 – 1.59 a replayed step curl really does send your Authorization (and, on 1.32 – 1.59, Cookie) to the other origin, because Playwright did; the values in the printed curl are still redacted.
curl output
curl chooses which calls get a curl:
| Value | Calls |
|---|---|
'5xx' | Status >= 500 and network errors. The default with format: 'compact'. |
'4xx+' | Status >= 400 and network errors. |
'always' | Every call. Legacy true is the same. |
'never' | None. Legacy false is the same. The default with format: 'verbose'. |
'on-failure' | Legacy: any status that is not 2xx, and network errors. |
In this verbose format the curl is printed under each call it applies to. Any other value warns once and uses the default.
→ GET /vegetables/99
← GET http://localhost:3000/vegetables/99 404 Not Found (2ms)
{"error":"Vegetable 99 not found"}
curl 'http://localhost:3000/vegetables/99'
curl.sh: attached to the test report (see "curl.sh" in the HTML report; also saved in this test's output folder)What the curl reproduces:
- Method and URL:
GETwith a body uses-X GET;HEADuses--head. - Headers, including the context's
extraHTTPHeadersand (redacted) cookies from the context's cookie jar. The jar is read before the request is sent, so a cookie set by the response itself is not included. - JSON, form and raw bodies as
--data-raw/--data-urlencode. - Multipart as
--form-stringfor fields and-F 'name=@file'for files. A file given as a path is read from that path; a file given as an in-memory buffer is read from its file name in the current directory, as a comment in the curl says. - Binary bodies as
--data-binary '@request-body.bin'with a comment telling you to save the bytes to that file first. maxBodyLengthalso applies to the curl body; a truncated body starts the command with a# request body truncatedcomment.
Colours and single-line output
On the console the curl is coloured like the log lines. curl is bold; the method, the URL, query parameter and header names, JSON keys and strings and # comment lines each have their own colour (table below); a masked *** is bold and reversed (inverse video). A JSON body gets coloured keys and strings only when the body is valid JSON, anything else stays uncoloured. Flags (-X, -H, --data-raw, -F, --form-string, --data-urlencode, --data-binary, -L, --head), the \ continuations, quotes and punctuation keep the terminal's default colour.
The colours are picked to read on both a black and a white terminal background: in the 256-colour palette every one has a WCAG contrast of at least 4.5:1 on both, in the 16-colour palette at least 3:1. They are xterm's default palette indexes, so a theme that redefines the first 16 colours changes the 16-colour variant (the 256-colour cube and greys are fixed). The 256-colour codes (38;5;N) are used when COLORTERM is truecolor or 24bit, TERM contains 256color, or the terminal reports at least 8 bits of colour; otherwise the 16 basic colours. The 16-colour palette has no green that reaches 3:1 on white, so POST and strings are not green there. Contrast of each element, on a black / white background:
| Element | 256 colours (index, colour) | black / white | 16 colours (index, colour) | black / white |
|---|---|---|---|---|
URL, JSON key, -X GET | 63 #5f5fff | 4.57 / 4.60 | 12 #5c5cff | 4.43 / 4.74 |
| Query parameter and header names | 163 #d700af | 4.51 / 4.66 | 5 #cd00cd | 4.48 / 4.69 |
JSON string, -X POST | 29 #00875f | 4.63 / 4.53 | 9 #ff0000 (POST: 5 #cd00cd) | 5.25 / 4.00 (POST: 4.48 / 4.69) |
-X PUT, -X PATCH | 98 #875fd7 | 4.65 / 4.52 | 13 #ff00ff | 6.70 / 3.14 |
-X DELETE | 131 #af5f5f | 4.62 / 4.54 | 1 #cd0000 | 3.60 / 5.84 |
# comments | 243 #767676 | 4.62 / 4.54 | 8 #7f7f7f | 5.24 / 4.00 |
curl, other methods | bold, default foreground | bold, default foreground | ||
Masked *** | bold + inverse | bold + inverse |
npm run contrast in the repository prints these ratios from src/curlPalette.ts, and a unit test fails when one drops below the limits above.
The colours only decorate the text. Remove the ANSI codes and you get exactly the plain command, so what you copy from a terminal that strips them is safe to paste. With colours off the output is the plain command, byte for byte.
use: {
apiLog: {
curl: true,
curlFormat: {
colors: 'auto', // true | false | 'auto' (default)
multiline: false, // one line instead of one option per line
},
},
}
multiline: falsejoins the options with single spaces.#notes (a truncated body, a binary body, the steps of a redirect chain) stay on their own lines above the command, each starting with#.
When the curl is coloured
With colors: 'auto' (the default) the curl is coloured only when all of these hold:
NO_COLORis not set to a non-empty value. It always wins under'auto', even over a top-levelcolors: true.- The process is not on CI: none of
CI,GITHUB_ACTIONS,GITLAB_CI,BUILDKITE,CIRCLECI,TRAVIS,APPVEYOR,TF_BUILD,TEAMCITY_VERSION,JENKINS_URL,BITBUCKET_BUILD_NUMBER,CODEBUILD_BUILD_IDorDRONEis set to a value other than0orfalse. - The output really reaches a terminal:
process.stdoutis a TTY, or the test runner's own stdout is one (see below), orPLAYWRIGHT_FORCE_TTYis set (anything but0/false); andTERMis notdumb. - The resolved
colorsis true and the sink is the default console sink. A customsinkkeeps the curl plain.
FORCE_COLOR does not count: playwright test sets FORCE_COLOR=1 for its workers, but their stdout is a pipe. In verbose output a top-level colors: true alone does not colour the curl in CI or when the output is piped; it still colours the other log lines. In compact output it colours the whole output, the curl block included (Compact output).
Local runs in a terminal need no setting
A worker cannot see the runner's terminal, but the runner can. When a spec file imports playwright-logger-pro (or playwright-logger-pro/fixtures), the import runs in the runner process, which checks whether its own stdout is a real terminal (isatty(1)). If it is, and the runner is not on CI, NO_COLOR is unset, FORCE_COLOR and DEBUG_COLORS are not 0/false and TERM is not dumb, it sets PLP_RUNNER_TTY=<runner pid>:<colour depth> in its environment, which every worker inherits. A worker that finds its parent's pid in the variable counts the output as a terminal, and still applies every other check above (CI, NO_COLOR, TERM=dumb, the resolved colors, the sink). So a plain npx playwright test in a terminal prints a coloured curl, while playwright test | cat, CI and NO_COLOR=1 npx playwright test print a plain one.
Limits:
- The variable is tied to the runner's pid, so any process that is not a direct child of the runner ignores it. A copy inherited by a nested
playwright teststarted from a test, or one you set yourself (PLP_RUNNER_TTY=1), does nothing. To force colours, usePLAYWRIGHT_FORCE_TTY=1orcurlFormat.colors: true. - The package must be imported while the runner loads the spec files (an
importat the top of a spec, or of a fixture file it imports). Arequire()made only inside a test body runs in the worker, too late. This holds on Playwright 1.30, 1.39, 1.48 and the latest, where the runner process loads the spec files. - Only a runner whose stdout is a terminal counts. Output that goes through a pipe (
| tee, an IDE test panel, a CI step) stays plain; usecurlFormat.colors: trueif that output renders colours.
curlFormat: { colors: true } is an explicit override: the curl is coloured whatever NO_COLOR, CI, the terminal, the top-level colors and the sink say. Use it only when you know where the output goes; ANSI codes will end up in CI logs, files and custom sinks. curlFormat: { colors: false } never colours.
npx playwright test # in a terminal: coloured curl
PLAYWRIGHT_FORCE_TTY=1 npx playwright test | tee run.log # you assert the output is a terminal
The curl.sh file
With the test fixture, the curls a test printed are also collected in a script. When the test ends (passed or failed) and at least one curl was produced, the fixture:
- writes
curl.shto the test's output directory; - attaches it to the report as
curl.sh(text/plain); - prints one line through the
sink:curl.sh: attached to the test report (see "curl.sh" in the HTML report; also saved in this test's output folder).
The header of the file is #!/bin/sh, then the spec file and line of the test, relative to the config's rootDir (or the project's testDir), then a generic comment, then the commands one after the other:
#!/bin/sh
# tests/users.spec.ts:42
# redacted curl commands of this test; run with: sh curl.sh
The test title is not written to the file, and the notice names no path. Titles can contain data (a URL with a token, a user name) and Playwright derives the output folder name from them, so neither goes into the file, the attachment or the console line. The location has escape sequences (ANSI, OSC 8, OSC 52) and every control character removed, so it cannot start a command or write to the terminal.
The script is never coloured and uses the same builder and the same redaction lists as the printed curl, so masked values are *** in the file and in the attachment. There is no setting that writes unredacted values: put the real values back before you run it with sh curl.sh.
That is the file of format: 'verbose'. With the default compact format the script is #!/usr/bin/env bash and holds no ***: every masked value is a ${VAR} placeholder named after its key, with a guard (: "${TOKEN:?export TOKEN first}") at the top, and it is attached only for a failed test (or details: 'always'). export the variables, then run bash curl.sh. See The curl.sh replay script. The file follows multiline. Turn it off with curlFormat: { file: false }; it does nothing while curl is false, and with curl: 'on-failure' it only exists if a request failed.
- POSIX shells only. Values are single-quoted for
sh,bashandzsh(also Git Bash and WSL), and lines continue with\(unlesscurlFormat.multilineisfalse). The command does not work as-is in PowerShell orcmd.exe. - Redacted values are sent literally as
***. Put the real value back before replaying. - Settings that are not part of the request, such as
httpCredentials, proxy, client certificates andignoreHTTPSErrors, are not in the curl. - An unparsable URL gives
# could not build curl: invalid URL.
Redaction
Secrets are masked in the logged URL, headers and bodies and in the curl. By default:
- headers
authorization,cookie,set-cookie,x-api-key - query parameters
api_key,apikey,token,access_token,password,secret
Request and response bodies are not masked by default. Opt in per key:
use: { apiLog: { redactBodyKeys: ['password', 'access_token', 'refreshToken'] } }
→ POST /api/session?api_key=***&q=shoes
Authorization: ***
{
"user": "jo",
"password": "***"
}
← POST http://localhost:3000/api/session?api_key=***&q=shoes 401 Unauthorized (2ms)
content-type: application/json
date: Thu, 29 Oct 2026 09:12:25 GMT
connection: keep-alive
keep-alive: timeout=5
transfer-encoding: chunked
{"error":"invalid credentials"}
curl -X POST 'http://localhost:3000/api/session?api_key=***&q=shoes' \
-H 'Authorization: ***' \
-H 'Content-Type: application/json' \
--data-raw '{"user":"jo","password":"***"}'
curl.sh: attached to the test report (see "curl.sh" in the HTML report; also saved in this test's output folder)Custom lists are added to the defaults, never replace them. See Redaction & security for the details and for replaceDefaultRedaction.
Colours
With colors unset, the logger decides like Playwright's reporters, in this order: off for a non-empty NO_COLOR or FORCE_COLOR=0/false; on for any other FORCE_COLOR or DEBUG_COLORS; on for PLAYWRIGHT_FORCE_TTY; on when stdout or stderr is a TTY (unless TERM=dumb). colors: true or false always wins, including over NO_COLOR. See Configuration.
With a custom sink and no explicit colors, colours default to off for every line, so ANSI codes do not end up in a file. Set colors: true to keep them (for example when the sink feeds a CI log that renders colours). The curl has its own, stricter rule: curlFormat.colors.
Redirect the output
import fs from 'node:fs';
const stream = fs.createWriteStream('api.log', { flags: 'a' });
test.use({ apiLog: { colors: false, sink: (line) => stream.write(line + '\n') } });
A sink that throws never fails the request: you get one warning and the lines go to console.log instead.
To keep API lines in the report, attach them yourself from a sink, or use browserLog.includeApiLog (the default), which copies apiLog lines into the browser-log attachment and files. See Reporter & attachments.
Turn it off
test.use({ apiLog: { enabled: false } }); // transparent pass-through
Options with invalid values
An option set to undefined uses its default. An invalid value (maxBodyLength: NaN, curl: 'sometimes', a sink that is not a function, a string where a list is expected) never fails a request: a one-time [playwright-logger-pro] warning is printed and the default is used.