Skip to main content

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.

Compact is the default since 0.4.0

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​

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.

tests/vegetables.spec.ts
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():

playwright.config.ts
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' },
},
});
test.describe('debugging this flow', () => {
test.use({ apiLog: { curl: true, maxBodyLength: 10000 } });
// ...
});
test.use replaces the whole object

test.use({ apiLog: { ... } }) replaces the apiLog object from your config rather than merging into it. Repeat the options you still want.

Output anatomy​

POST with curl: true
→ 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)
LineMeaning
→ METHOD urlThe 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) errorThe 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.

A request that fails without a response
→ 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: false and bodies with logBody: 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. Infinity disables truncation.
  • Repeated response headers (several Set-Cookie) are printed as one indented line per value.
  • Binary request bodies (a Buffer as data) 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.

PlaywrightCookie you setAuthorization to another origin
1.30 – 1.31droppedforwarded
1.32 – 1.59forwarded 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 GETforwarded
1.60+droppeddropped

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:

ValueCalls
'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.

curl: 'on-failure', logHeaders: false
→ 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: GET with a body uses -X GET; HEAD uses --head.
  • Headers, including the context's extraHTTPHeaders and (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-string for 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.
  • maxBodyLength also applies to the curl body; a truncated body starts the command with a # request body truncated comment.

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:

Element256 colours (index, colour)black / white16 colours (index, colour)black / white
URL, JSON key, -X GET63 #5f5fff4.57 / 4.6012 #5c5cff4.43 / 4.74
Query parameter and header names163 #d700af4.51 / 4.665 #cd00cd4.48 / 4.69
JSON string, -X POST29 #00875f4.63 / 4.539 #ff0000 (POST: 5 #cd00cd)5.25 / 4.00 (POST: 4.48 / 4.69)
-X PUT, -X PATCH98 #875fd74.65 / 4.5213 #ff00ff6.70 / 3.14
-X DELETE131 #af5f5f4.62 / 4.541 #cd00003.60 / 5.84
# comments243 #7676764.62 / 4.548 #7f7f7f5.24 / 4.00
curl, other methodsbold, default foregroundbold, default foreground
Masked ***bold + inversebold + 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: false joins 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:

  1. NO_COLOR is not set to a non-empty value. It always wins under 'auto', even over a top-level colors: true.
  2. 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_ID or DRONE is set to a value other than 0 or false.
  3. The output really reaches a terminal: process.stdout is a TTY, or the test runner's own stdout is one (see below), or PLAYWRIGHT_FORCE_TTY is set (anything but 0/false); and TERM is not dumb.
  4. The resolved colors is true and the sink is the default console sink. A custom sink keeps 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 test started from a test, or one you set yourself (PLP_RUNNER_TTY=1), does nothing. To force colours, use PLAYWRIGHT_FORCE_TTY=1 or curlFormat.colors: true.
  • The package must be imported while the runner loads the spec files (an import at the top of a spec, or of a fixture file it imports). A require() 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; use curlFormat.colors: true if that output renders colours.
Explicit override

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:

  1. writes curl.sh to the test's output directory;
  2. attaches it to the report as curl.sh (text/plain);
  3. 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.

curl limitations
  • POSIX shells only. Values are single-quoted for sh, bash and zsh (also Git Bash and WSL), and lines continue with \ (unless curlFormat.multiline is false). The command does not work as-is in PowerShell or cmd.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 and ignoreHTTPSErrors, 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'] } }
Authorization, api_key and a body key masked
→ 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.