Skip to content

feat: add --show-request, --show-response, --curl, --show-secrets flags#23

Merged
joalves merged 29 commits intomainfrom
feature/show-request-response
Apr 29, 2026
Merged

feat: add --show-request, --show-response, --curl, --show-secrets flags#23
joalves merged 29 commits intomainfrom
feature/show-request-response

Conversation

@joalves
Copy link
Copy Markdown
Collaborator

@joalves joalves commented Apr 29, 2026

Summary

  • Adds four global flags that write API traffic to stderr for debugging:
    • --show-request — outgoing requests as HTTP-style blocks
    • --show-response — responses (success and error) as HTTP-style blocks
    • --curl — outgoing requests as runnable curl commands
    • --show-secrets — opt out of Authorization redaction (default is Api-Key *** / Bearer ***, scheme prefix preserved)
  • Pretty-prints + syntax-highlights JSON bodies and HTTP headers when stderr is a TTY (chalk + cli-highlight); a forced Chalk instance keeps coloring deterministic regardless of stdout vs stderr routing.
  • Response interceptor is registered before the existing OAuth-refresh and error-translation interceptors, so axios-retry retries and 401 → onExpired → retry flows emit one log entry per attempt.
  • Existing -v / --verbose flag is unchanged.

Example

$ abs --show-request --show-response apps list
→ GET https://demo.absmartly.com/v1/applications?items=100&page=1
  Accept: application/json
  Content-Type: application/json
  Authorization: Api-Key ***
  User-Agent: absmartly-cli/1.1.0

← 200 OK (173ms)
  content-type: application/json; charset=utf-8
  …
  {
    "applications": [ … ]
  }
$ abs --curl experiments get 42
$ curl -X GET 'https://demo.absmartly.com/v1/experiments/42' \
  -H 'Accept: application/json' \
  -H 'Authorization: Api-Key ***' \
  …

Test plan

  • bun run test:run — 2214 passed / 4 skipped / 0 failed (added 40 new tests across request-logger.test.ts and axios-adapter.test.ts)
  • bun run lint — clean
  • bun run typecheck — clean
  • Smoke tested against the test-1 profile: --show-request, --show-response, --curl, --show-secrets behave as designed; redaction is on by default; 4xx responses are dumped before the existing error path runs.
  • Reviewer: try abs --show-request --curl <any-command> in a TTY to confirm the colored output.
  • Reviewer: confirm --show-secrets is the only path that reveals the API key/JWT.

Summary by CodeRabbit

  • New Features

    • New CLI flags to log outgoing requests and incoming responses, render curl, reveal secrets, suppress bodies/headers-only, and show status-only; clients and download flows honour these flags, include elapsed times and suppress repeated request noise.
  • Tests

    • Extensive test suites covering formatting, redaction, suppression, retries, error/network logging and colour/TTY behaviour.
  • Chores

    • .gitignore updated to exclude worktree directories.

joalves added 3 commits April 29, 2026 21:48
Adds four global flags that write API traffic to stderr for debugging:

- --show-request: outgoing requests as HTTP-style blocks
- --show-response: responses (success and error) as HTTP-style blocks
- --curl: outgoing requests as curl commands
- --show-secrets: opt out of redacting Authorization (default: redacted
  to "Api-Key ***" / "Bearer ***", keeping the scheme prefix)

When stderr is a TTY and --no-color is not set, JSON bodies and HTTP
headers are syntax-highlighted via cli-highlight + chalk. The Chalk
instance is forced to level 1 so coloring is gated on the caller's
boolean rather than chalk's own TTY heuristics, which keeps output
deterministic when stdout and stderr are routed differently.

The new response interceptor is registered before the existing OAuth
refresh and error-translation interceptors, so retries (axios-retry,
401 -> refresh -> retry) emit one log line per attempt.

The existing -v/--verbose flag is unchanged.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 29, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds request/response logging and formatting across the HTTP client and download utilities. Introduces a new request-logger module (HTTP and curl formatters, redaction, suppression notices, error formats). Extends the Axios HTTP client to emit configurable stderr logs, fingerprint and suppress repeated requests, record elapsed time, and support curl rendering and secret disclosure. Propagates new global/CLI flags (showRequest, showResponse, curl, showSecrets, headersOnly, statusOnly, colorDisabled/noColor) through helpers and download code. Adds extensive tests and updates .gitignore.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I nibble logs and stitch each request with care,
Arrows, curls and redacted secrets tucked in my lair,
Suppressed repeats hush while timers softly chime,
Responses bloom with colour — one hop at a time,
Debug carrots found, I thump a happy rhyme. 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarises the main changes—adding four global CLI flags (--show-request, --show-response, --curl, --show-secrets) for API traffic debugging.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/show-request-response

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/api/request-logger.ts`:
- Around line 184-190: The generated curl lines currently interpolate raw ${url}
and header values ${v} into single-quoted shell strings (in the lines.push that
builds the curl command and inside the headerEntries loop), which breaks when
those values contain single quotes; add a small helper (e.g.,
escapeSingleQuotesForShell) that replaces every single-quote in a string with
the shell-safe sequence ('\'') (the standard '\'' escape) and use that helper to
escape the URL and header name/value before inserting into the template strings
so the resulting single-quoted curl command is safe to paste into a shell.

In `@src/lib/utils/api-helper.ts`:
- Around line 85-90: The noColor flag is currently forwarded from
options.noColor (which is derived from !process.stdout.isTTY) and can wrongly
disable colors when stderr is a TTY; update the construction that sets
showRequest/showResponse/curl/showSecrets/noColor so that noColor defaults to
options.noColor ?? !process.stderr.isTTY (or otherwise prefer an explicit
options.noColor when provided) instead of relying on stdout.isTTY; change any
upstream derivation to use stderr.isTTY for logging output to stderr and ensure
functions that emit logs use this noColor value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 01a6958b-5639-4aa1-81db-ef109a497dea

📥 Commits

Reviewing files that changed from the base of the PR and between 7327c80 and e625750.

📒 Files selected for processing (9)
  • .gitignore
  • src/index.ts
  • src/lib/api/axios-adapter.test.ts
  • src/lib/api/axios-adapter.ts
  • src/lib/api/client.ts
  • src/lib/api/request-logger.test.ts
  • src/lib/api/request-logger.ts
  • src/lib/utils/api-helper.test.ts
  • src/lib/utils/api-helper.ts

Comment thread src/lib/api/request-logger.ts Outdated
Comment thread src/lib/utils/api-helper.ts
joalves added 12 commits April 29, 2026 22:30
Previously --show-response wrote response headers and bodies to stderr
verbatim, so:
- Set-Cookie / Cookie / X-Api-Key / Proxy-Authorization in responses
  were emitted in cleartext
- response bodies for endpoints that mint secrets (POST .../api_keys
  returning {key}, PUT .../reset-password returning {password}, OAuth
  token endpoints returning JWTs) were emitted in cleartext

Both now redact by default. --show-secrets opts out, mirroring the
request-side behaviour.

Sensitive headers redact to *** (Cookie/Set-Cookie/X-Api-Key) or to
"<scheme> ***" (Authorization, Proxy-Authorization). Sensitive body
field names (key, password, token, secret, access_token, refresh_token,
api_key, apikey) are matched case-insensitively and walked recursively
through nested objects and arrays. Non-string values for those fields
pass through unchanged so that null/0/false don't become "***".

Also hardens redactHeaders against non-string sensitive values: a
malformed Authorization that isn't a string now redacts to *** instead
of throwing TypeError on .indexOf.
If an upstream request interceptor (or future custom interceptor) throws
a plain Error or rejects with a non-Axios value, the response error
handler used to silently skip it because the only branch was guarded by
axios.isAxiosError. With --show-response on, this would leave the user
staring at a request line with no response line.

Adds a generic-error fallback that prints '← (non-HTTP error) <msg>' so
the user can tell they're looking at a synthetic failure rather than a
real HTTP response.
extractHeaders previously kept only string/number/boolean header values
and silently dropped everything else, which meant Set-Cookie (returned
by Node's HTTP layer as string[]) never appeared under --show-response.
A user debugging cookies would see headers that look complete but
silently omit the very ones they want.

Joins arrays with ", " (so the user sees all values) and surfaces
unexpected object values as "[unprintable: <type>]" rather than
dropping them. Sensitive multi-value headers (Set-Cookie/Cookie) are
fully redacted to *** by the existing redaction pass after joining,
without leaking individual values.
Previously a Content-Type: application/json response whose body failed
to parse was silently emitted as raw text, indistinguishable from a
properly formatted response. Now prepends a dim marker line so the user
can tell the body did not parse.

Also drops the dead 'pretty' parameter from formatJsonBody (only one
caller, hard-coded true).
…y duplication

The same eight-field shape was inlined in three places: the
AxiosHttpClient constructor, the createAxiosHttpClient factory, and
ClientOptions in client.ts. Adding a field meant editing all three with
no compiler-enforced link between them.

Define once in axios-adapter.ts and reuse. ClientOptions becomes a
type alias to keep its public name.
GlobalOptions.noColor previously folded together two distinct ideas:
1. The user's explicit intent (--no-color flag), and
2. Whether stdout is a TTY.

Stdout-bound consumers (the formatter) want the conflated value.
Stderr-bound consumers (--show-request / --show-response) want only the
user intent — they make their own TTY decision against stderr in
axios-adapter.

Adds GlobalOptions.colorDisabled holding only the user-intent variant
(--no-color OR NO_COLOR env). getAPIClientFromOptions now forwards
colorDisabled as the noColor option to the API client, so a user with
piped stdout but TTY stderr still gets colored diagnostics by default.

Adds NO_COLOR env support (https://no-color.org) to the user-intent
side. Chalk honors NO_COLOR on its auto-detect path, but the request
logger uses a forced Chalk instance that doesn't, so we check the env
explicitly here.
- AxiosHeaders.toJSON() branch in extractHeaders (production hits this
  via axios; previous tests only used plain object headers).
- OAuth refresh + --show-response: confirms both the original 401 and
  the retry response are logged, no double-counting.
- Stderr-TTY override branches: ANSI emitted when TTY + noColor false;
  suppressed when noColor true; suppressed when stderr is not a TTY.
… checks

The four status-code coloring tests asserted exact escape-code bytes
(\x1b[32m / \x1b[33m / \x1b[31m / a 5xx bold-red regex). Chalk has
historically changed how it composes nested styles (e.g. merging
\x1b[1;31m vs separate \x1b[1m\x1b[31m). A future chalk update would
break all four despite identical visible output.

Now strip ANSI and assert the visible status text is correct, plus
ANSI presence overall. The 2xx test additionally compares its colored
sequence against 4xx to ensure the bands produce different coloring
without coupling to specific bytes.
Polling loops (e.g. abs experiments export --wait) hit the same URL
every couple of seconds, and --show-request faithfully printed each
iteration, drowning the actual interesting events in noise.

The client now fingerprints each outgoing request (method + URL +
params + body + Authorization + axios-retry retryCount) and on a match
to the previous fingerprint:
- skips the request log
- skips the matching response log
- counts the suppression

When a different request comes through, a single line is emitted:
  (N identical requests suppressed)
before the new request log.

Authorization is included in the fingerprint so OAuth refresh retries
(token changes) are still logged distinctly. retryCount is included so
axios-retry attempts are logged distinctly. Polling stays suppressed
because all three (URL, auth, retry) match across iterations.
abs experiments export --download invokes downloadFile, which uses raw
http.get rather than the AxiosHttpClient — so neither --show-request
nor --show-response captured the actual download GET (or any
intermediate redirect-resolution requests). The user only saw the
polling traffic, then the download finished without a logged request.

Threads showRequest/showResponse/curl/showSecrets/noColor through
DownloadOptions and emits synthetic axios-shaped log entries from
inside downloadFile using the same formatters as the axios path.
Redirect-resolution GETs and Range-resume GETs are also logged.

The downloaded body itself is not echoed (it's potentially gigabytes).
Only headers and status are captured.
- S1: drop dead `original` capture and stale comment in captureStderr
  test helper. spy.mockRestore() already restores the real write.
- S2: trim the chalk-instance comment to one line. The Chalk({level:1})
  + visible per-call gating is self-documenting.
- S3: add a one-line comment explaining why showResponse is in the
  request-interceptor gate (it stamps startTime).
- S5: shell-escape single quotes in URL and header values too, not just
  the body. URLs with apostrophes are exotic but possible.
- S9: mark constructor-stored option fields readonly.

Also picks up prettier-driven reflow on touched files.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/lib/utils/download.ts (1)

128-142: Consider logging network failures in download mode for parity.

httpGet logs request/response, but req.on('error', reject) bypasses debug output. With --show-response, this leaves a visibility gap exactly when troubleshooting is needed most.

🔧 Proposed direction
 import {
   formatRequestHTTP,
   formatRequestCurl,
   formatResponseHTTP,
+  formatNetworkError,
   type FormatOptions,
 } from '../api/request-logger.js';
@@
-    req.on('error', reject);
+    req.on('error', (err) => {
+      if (logger?.showResponse) {
+        process.stderr.write(
+          formatNetworkError(err as never, Date.now() - start, logger.formatOpts) + '\n'
+        );
+      }
+      reject(err);
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/utils/download.ts` around lines 128 - 142, The httpGet function
currently attaches req.on('error', reject) which skips any debug logging; update
httpGet so the 'error' event handler logs the network failure (using the same
logger used by logRequest/logResponse) including the error message and elapsed
time before rejecting the promise. Locate the httpGet function and the
req.on('error', ...) line and replace the direct reject with a handler that
calls the logger (e.g., logger?.debug or an existing logResponse/logError
helper) with a clear message and the error object, then rejects; ensure this
only runs when a logger is present and preserves the original reject behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/api/axios-adapter.ts`:
- Around line 312-314: The request interceptor currently does
JSON.stringify(config.params) and JSON.stringify(config.data) directly which can
throw for non-serialisable values and abort the request; wrap these
serialisations in try/catch inside the axios request interceptor (the code
setting const params and const body) and on error fall back to a safe string
(e.g. empty string or a best-effort alternative like util.inspect/JSON.stringify
with a replacer) so that serialization failures do not throw and the request
proceeds, and ensure the catch includes a debug log mentioning
config.params/config.data to aid debugging.

In `@src/lib/api/request-logger.ts`:
- Around line 72-83: redactBody currently only replaces sensitive keys when the
value is a non-empty string, so objects/arrays/numbers under sensitive keys
remain unredacted; update the logic in redactBody (referencing
SENSITIVE_BODY_FIELDS, REDACTED) so that if a lowercased key is in
SENSITIVE_BODY_FIELDS it is replaced with REDACTED regardless of the value type,
otherwise continue the existing recursive handling (including Array.isArray and
recursive redactBody calls), and keep the initial showSecrets/primitive
short-circuit as-is.
- Around line 167-183: The logging code calls JSON.stringify directly (notably
in formatJsonBody and where redactBody output is serialized), which will throw
on circular or non-serialisable values and break requests; add a small safe
serializer (e.g., safeStringify or safeSerialize) that catches errors and
handles circular refs (or returns a clear fallback string like "(unserializable
value)" plus a brief diagnostic) and use it everywhere JSON.stringify is called
in this module (replace uses inside formatJsonBody and the places the review
mentions). Ensure redactBody continues to redact but feed its result through
safeStringify before logging so the logging path never throws; keep the
serializer single-responsibility and reuse it across formatJsonBody, redactBody
callers, and other direct JSON.stringify sites.

---

Nitpick comments:
In `@src/lib/utils/download.ts`:
- Around line 128-142: The httpGet function currently attaches req.on('error',
reject) which skips any debug logging; update httpGet so the 'error' event
handler logs the network failure (using the same logger used by
logRequest/logResponse) including the error message and elapsed time before
rejecting the promise. Locate the httpGet function and the req.on('error', ...)
line and replace the direct reject with a handler that calls the logger (e.g.,
logger?.debug or an existing logResponse/logError helper) with a clear message
and the error object, then rejects; ensure this only runs when a logger is
present and preserves the original reject behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aefabea9-85a9-4143-8f7b-48b4ebb6c640

📥 Commits

Reviewing files that changed from the base of the PR and between e625750 and ba5ee79.

📒 Files selected for processing (8)
  • src/commands/experiments/export.ts
  • src/lib/api/axios-adapter.test.ts
  • src/lib/api/axios-adapter.ts
  • src/lib/api/client.ts
  • src/lib/api/request-logger.test.ts
  • src/lib/api/request-logger.ts
  • src/lib/utils/api-helper.ts
  • src/lib/utils/download.ts
✅ Files skipped from review due to trivial changes (1)
  • src/lib/api/request-logger.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/lib/api/client.ts
  • src/lib/utils/api-helper.ts

Comment thread src/lib/api/axios-adapter.ts Outdated
Comment thread src/lib/api/request-logger.ts
Comment thread src/lib/api/request-logger.ts
The previous suppression compared each request against the immediately
previous one. Real polling loops alternate between several URLs (e.g.
GET /export_configs/N then GET /.../export_histories), so consecutive
fingerprints never matched and every iteration printed.

Track the full set of fingerprints we've already printed in this
client. A request whose fingerprint is in the set is suppressed
regardless of whether it followed an exact repeat. The first new
fingerprint after a run of suppressed requests still flushes a
"(N identical requests suppressed)" summary line.

Adds a test covering the alternating A,B,A,B,... pattern. Existing
back-to-back-identical and "different params" tests still pass.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/api/axios-adapter.test.ts`:
- Around line 229-236: The helper withStderrTTY currently runs the callback
synchronously so its finally block restores process.stderr.isTTY before an async
callback has finished; make withStderrTTY async and change its signature to
accept a () => Promise<T>, capture original = process.stderr.isTTY, set the
override, then await fn() inside the try and restore the original in finally so
the TTY override remains active while the callback (and constructors like
AxiosHttpClient used inside it) execute; keep Object.defineProperty usage and
configurable: true.

In `@src/lib/api/axios-adapter.ts`:
- Around line 186-245: The request-suppression logic currently sets
config.metadata.suppressed and causes the response interceptor
(client.interceptors.response.use) to skip logging those responses; instead keep
suppression scoped to request/curl emission by only running the
fingerprint/suppression branch when this.showRequest || this.curl (or move that
block inside that conditional) and store a request-only flag (e.g.,
config.metadata.requestSuppressed) rather than config.metadata.suppressed; then
remove the early "if (response.config.metadata?.suppressed) return response" and
the error-branch checks that reject/log-skip based on suppressed so response
logging (formatResponseHTTP/formatNetworkError/formatGenericError) always runs
regardless of request suppression, while suppression notices and suppressedCount
remain tied only to request/curl output (formatSuppressionNotice,
seenFingerprints, suppressedCount, fingerprint).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: db91643a-2441-435b-8875-73b7fe9a6db2

📥 Commits

Reviewing files that changed from the base of the PR and between ba5ee79 and f22df82.

📒 Files selected for processing (2)
  • src/lib/api/axios-adapter.test.ts
  • src/lib/api/axios-adapter.ts

Comment thread src/lib/api/axios-adapter.test.ts Outdated
Comment thread src/lib/api/axios-adapter.ts
joalves added 2 commits April 29, 2026 23:18
Useful when responses are large (e.g. paginated lists, export-history
status payloads) but you only need the URL, status, and headers to
debug auth, caching, or cookie-handling issues.

- HTTP-block format: omits the body section entirely; status line and
  headers still printed.
- curl format: drops the -d flag (the resulting curl is no longer a
  faithful reproduction, by design — the user opted in to bodyless).

Threads through GlobalOptions -> AxiosHttpClientOptions -> FormatOptions
(as `omitBody`) plus DownloadLoggingOptions, so it covers axios traffic
and the raw http.get traffic from `experiments export --download`.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (4)
src/lib/api/axios-adapter.ts (2)

188-205: ⚠️ Potential issue | 🟠 Major

Request suppression is incorrectly suppressing response logs.

metadata.suppressed is set in the request path and then used to skip response logging. This can make --show-response miss changing server states for repeated requests.

🔧 Proposed fix
-        const fp = this.fingerprint(config);
-        if (this.seenFingerprints.has(fp)) {
-          this.suppressedCount++;
-          config.metadata.suppressed = true;
-          return config;
-        }
-        if (this.suppressedCount > 0) {
-          process.stderr.write(
-            formatSuppressionNotice(this.suppressedCount, this.formatOpts.color) + '\n'
-          );
-          this.suppressedCount = 0;
-        }
-        this.seenFingerprints.add(fp);
+        if (this.showRequest || this.curl) {
+          const fp = this.fingerprint(config);
+          if (this.seenFingerprints.has(fp)) {
+            this.suppressedCount++;
+            return config;
+          }
+          if (this.suppressedCount > 0) {
+            process.stderr.write(
+              formatSuppressionNotice(this.suppressedCount, this.formatOpts.color) + '\n'
+            );
+            this.suppressedCount = 0;
+          }
+          this.seenFingerprints.add(fp);
+        }
@@
-          if (response.config.metadata?.suppressed) return response;
           const start = response.config.metadata?.startTime ?? Date.now();
@@
-            if (error.config?.metadata?.suppressed) return Promise.reject(error);
             const start = error.config?.metadata?.startTime ?? Date.now();

Also applies to: 224-233

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/api/axios-adapter.ts` around lines 188 - 205, The request interceptor
is marking config.metadata.suppressed which the response interceptor then uses
to skip response logging, causing --show-response to miss updates; change the
metadata flag to be explicit for the request side (e.g.,
config.metadata.suppressedRequest or suppressedRequestFingerprint) and only use
that flag to suppress printing the repeated request in the request interceptor,
then update the response interceptor logic (the code around the response logging
checks, currently referencing metadata.suppressed at lines ~224-233) to not skip
response logs based on that flag — instead use a separate response suppression
flag if you truly need response suppression. Ensure you update references to
fingerprint(), this.seenFingerprints, and this.suppressedCount handling so only
requests are suppressed while responses remain logged when --show-response is
enabled.

316-317: ⚠️ Potential issue | 🟠 Major

Fingerprint serialisation can throw and prevent request dispatch.

Lines 316-317 can throw for circular structures/BigInt/functions, and this happens before the request is sent.

🔧 Proposed fix
+  private safeFingerprintPart(value: unknown): string {
+    if (value === undefined || value === null) return '';
+    try {
+      return JSON.stringify(value);
+    } catch {
+      return '[unserialisable]';
+    }
+  }
+
   private fingerprint(config: {
@@
-    const params = config.params ? JSON.stringify(config.params) : '';
-    const body = config.data ? JSON.stringify(config.data) : '';
+    const params = this.safeFingerprintPart(config.params);
+    const body = this.safeFingerprintPart(config.data);
#!/bin/bash
set -euo pipefail

echo "Fingerprint and direct serialisation in axios adapter:"
rg -n "fingerprint\\(|JSON\\.stringify\\(config\\.params\\)|JSON\\.stringify\\(config\\.data\\)" src/lib/api/axios-adapter.ts -C3
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/api/axios-adapter.ts` around lines 316 - 317, The JSON.stringify
calls for config.params and config.data can throw (circular refs, BigInt,
functions) and currently may abort the request; wrap the serialisation in
try/catch when computing the local variables params and body (the const params
and const body in the axios adapter) and on error fall back to a safe string
(e.g., '' or a small marker like '<unserializable>') or use a non-throwing
safeSerialize helper; ensure you update the code that references params/body to
use these safe values so request dispatch is not prevented.
src/lib/api/request-logger.ts (2)

80-85: ⚠️ Potential issue | 🟠 Major

Sensitive body keys can still leak when values are non-string.

Line 81 only redacts when the sensitive field value is a non-empty string. If token/api_key/etc. is an object, array, number, or boolean, it is logged unredacted.

🔧 Proposed fix
   for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
-    if (SENSITIVE_BODY_FIELDS.has(k.toLowerCase()) && typeof v === 'string' && v !== '') {
-      out[k] = REDACTED;
-    } else {
-      out[k] = redactBody(v, showSecrets);
-    }
+    if (SENSITIVE_BODY_FIELDS.has(k.toLowerCase())) {
+      out[k] = REDACTED;
+      continue;
+    }
+    out[k] = redactBody(v, showSecrets);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/api/request-logger.ts` around lines 80 - 85, The loop that applies
redaction in request-logger uses a type-restricted check so keys in
SENSITIVE_BODY_FIELDS only get redacted when the value is a non-empty string;
change the logic in the for (...) block (the code referencing
SENSITIVE_BODY_FIELDS, out, and redactBody) to redact any matching sensitive key
regardless of the value type (e.g., remove the "typeof v === 'string' && v !==
''" requirement) by setting out[k] = REDACTED for
SENSITIVE_BODY_FIELDS.has(k.toLowerCase()), and otherwise call redactBody(v,
showSecrets) as before.

171-184: ⚠️ Potential issue | 🟠 Major

Unsafe JSON serialisation in logging path can break requests.

Lines 183, 197, and 252 call JSON.stringify directly on unknown input. If serialisation throws, logging changes runtime behaviour and can abort the request.

🔧 Proposed fix
+function safeStringify(value: unknown, space?: number): string {
+  try {
+    return JSON.stringify(value, null, space);
+  } catch {
+    return '[unserialisable value]';
+  }
+}
+
 function formatJsonBody(data: unknown, color: boolean): string {
   let json: string;
@@
   } else {
-    json = JSON.stringify(data, null, 2);
+    json = safeStringify(data, 2);
   }
@@
-  return typeof redacted === 'string' ? redacted : JSON.stringify(redacted);
+  return typeof redacted === 'string' ? redacted : safeStringify(redacted);
 }
@@
-    const body = typeof redacted === 'string' ? redacted : JSON.stringify(redacted);
+    const body = typeof redacted === 'string' ? redacted : safeStringify(redacted);
#!/bin/bash
set -euo pipefail

echo "Direct JSON.stringify call-sites in request logger:"
rg -n "JSON\\.stringify\\(" src/lib/api/request-logger.ts -C2

echo
echo "Safe serialiser helper presence:"
rg -n "safeStringify|safeSerial" src/lib/api/request-logger.ts

Also applies to: 197-197, 250-253

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/api/request-logger.ts` around lines 171 - 184, The logger calls
JSON.stringify on unknown input which can throw and abort requests; add a small
safeStringify(data: unknown, spacing?: number): string helper that wraps
JSON.stringify in try/catch and returns a sensible fallback (e.g. a dimmed
"(unserializable value)" tag or the raw toString) on error, then replace all
direct JSON.stringify usages in formatJsonBody and the other logger call-sites
(the three spots flagged where JSON.stringify is used) to call
safeStringify(...) instead so logging cannot throw and affect request handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/lib/api/axios-adapter.ts`:
- Around line 188-205: The request interceptor is marking
config.metadata.suppressed which the response interceptor then uses to skip
response logging, causing --show-response to miss updates; change the metadata
flag to be explicit for the request side (e.g.,
config.metadata.suppressedRequest or suppressedRequestFingerprint) and only use
that flag to suppress printing the repeated request in the request interceptor,
then update the response interceptor logic (the code around the response logging
checks, currently referencing metadata.suppressed at lines ~224-233) to not skip
response logs based on that flag — instead use a separate response suppression
flag if you truly need response suppression. Ensure you update references to
fingerprint(), this.seenFingerprints, and this.suppressedCount handling so only
requests are suppressed while responses remain logged when --show-response is
enabled.
- Around line 316-317: The JSON.stringify calls for config.params and
config.data can throw (circular refs, BigInt, functions) and currently may abort
the request; wrap the serialisation in try/catch when computing the local
variables params and body (the const params and const body in the axios adapter)
and on error fall back to a safe string (e.g., '' or a small marker like
'<unserializable>') or use a non-throwing safeSerialize helper; ensure you
update the code that references params/body to use these safe values so request
dispatch is not prevented.

In `@src/lib/api/request-logger.ts`:
- Around line 80-85: The loop that applies redaction in request-logger uses a
type-restricted check so keys in SENSITIVE_BODY_FIELDS only get redacted when
the value is a non-empty string; change the logic in the for (...) block (the
code referencing SENSITIVE_BODY_FIELDS, out, and redactBody) to redact any
matching sensitive key regardless of the value type (e.g., remove the "typeof v
=== 'string' && v !== ''" requirement) by setting out[k] = REDACTED for
SENSITIVE_BODY_FIELDS.has(k.toLowerCase()), and otherwise call redactBody(v,
showSecrets) as before.
- Around line 171-184: The logger calls JSON.stringify on unknown input which
can throw and abort requests; add a small safeStringify(data: unknown, spacing?:
number): string helper that wraps JSON.stringify in try/catch and returns a
sensible fallback (e.g. a dimmed "(unserializable value)" tag or the raw
toString) on error, then replace all direct JSON.stringify usages in
formatJsonBody and the other logger call-sites (the three spots flagged where
JSON.stringify is used) to call safeStringify(...) instead so logging cannot
throw and affect request handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3ff05643-0b65-4a56-8513-c9de34465391

📥 Commits

Reviewing files that changed from the base of the PR and between f22df82 and 8e3694c.

📒 Files selected for processing (7)
  • src/commands/experiments/export.ts
  • src/index.ts
  • src/lib/api/axios-adapter.ts
  • src/lib/api/request-logger.test.ts
  • src/lib/api/request-logger.ts
  • src/lib/utils/api-helper.ts
  • src/lib/utils/download.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/index.ts
  • src/commands/experiments/export.ts
  • src/lib/utils/download.ts
  • src/lib/utils/api-helper.ts

joalves added 6 commits April 29, 2026 23:31
Previously redactBody only replaced string values. A request body like
{ token: { value: "leaked-jwt" } } would recurse into the inner object
and emit `value: "leaked-jwt"` because `value` is not a sensitive key.
Numbers/arrays/objects under the same sensitive key set (token, key,
password, secret, access_token, refresh_token, api_key, apikey) all
escaped redaction.

Now any value under a sensitive key is replaced with *** unconditionally.
The presence of the key alone is enough — the type doesn't matter, and
even null/0/false are masked since "the key is present" is itself a
signal worth concealing.

Updates the existing test (which previously asserted the lax behaviour)
to lock in the new contract with object, array, number, and null cases.
…ponses

The previous logic stamped config.metadata.suppressed when a duplicate
request fingerprint was seen, and the response interceptor used that
flag to skip logging. Two problems with that:

1. State transitions vanish from --show-response. Polling fires the same
   request URLs, but the response payload changes (status: "running" ->
   "complete"). Suppressing both directions hid exactly the part the
   user is debugging.
2. Running --show-response *alone* still ran the suppression branch and
   could emit "(N requests suppressed)" notices despite no request
   output ever being printed — confusing.

Fix: move the fingerprint+suppression block inside the
`if (this.showRequest || this.curl)` branch. Drop the
metadata.suppressed flag entirely. The response interceptor now logs
every response when --show-response is on, regardless of whether the
matching request was suppressed for emission.

Adds a test that locks in the new behaviour: --show-response alone
prints no "→" lines and no "suppressed" notice. The combined-flags test
now also asserts every poll response is logged.
JSON.stringify on an unknown payload could throw — circular refs,
BigInts, anything else V8 rejects — and that throw landed inside the
request interceptor or the response formatter, breaking the request or
the response log purely because debug logging was enabled.

- New safeStringify() helper in request-logger wraps JSON.stringify and
  returns '"[unserialisable]"' on failure. Used by formatJsonBody (for
  object payloads), formatBodyHTTP (non-JSON branch), and formatRequestCurl
  (curl -d body).
- AxiosHttpClient.fingerprint() also goes through safeStringify, so a
  pathological config doesn't crash the suppression path.
- redactBody now tracks visited objects via a WeakSet so a circular
  reference no longer recurses to a stack overflow before stringify is
  reached. Cycles render as "[circular]".

Adds tests for both safeStringify directly and a circular-body request
path end-to-end.
The synchronous finally restored process.stderr.isTTY before the async
callback's promise had settled. The tests happened to pass because
AxiosHttpClient reads isTTY in its constructor (synchronous prefix of
the callback) and caches color into formatOpts. But any future test
that reads isTTY after an await would silently see the original value.

Make the helper async and await the callback inside try, so the
override stays active for the entire callback duration.
When responses carry many headers (CORS, CSP, cache, cf-ray, etc.) and
the user only wants to confirm the status code, --show-response is too
noisy. --status-only collapses the response to a single line:

  ← 200 OK (175ms)

Composes with --show-response (and --show-request, which is unaffected:
requests have no "status" — use --headers-only on the request side).
Threads through GlobalOptions / AxiosHttpClientOptions / FormatOptions
(as `statusOnly`) and DownloadLoggingOptions, so it covers axios traffic
and the raw http.get traffic from `experiments export --download`.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/utils/download.ts`:
- Around line 57-92: The request/response logging in logRequest and logResponse
currently passes raw URLs and headers to the shared formatters and can leak
presigned redirect credentials; sanitize the URL query string and the Location
response header before calling
formatRequestHTTP/formatRequestCurl/formatResponseHTTP unless logger.showSecrets
is true (e.g., replace query or entire sensitive value with a fixed "[REDACTED]"
token), ensure the sanitized URL is used for both HTTP and curl formatting and
the sanitized headers are used for response formatting, and add/adjust tests to
assert that a 302 Location with a presigned query is redacted by default but
shown when showSecrets is enabled.
- Around line 132-145: The httpGet function currently rejects on network errors
but doesn't log them, so update the req.on('error') handler in httpGet to log
the failure (including the URL and the error message/stack) before rejecting;
use the same ResolvedLogger used by logRequest/logResponse (e.g., call
logger.error or fall back to console.error/stderr when logger is absent) so the
`--show-response` debug path shows DNS/TLS/socket/redirect failures as well as
successful responses.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 70efd143-9c79-467c-a4a0-897af8d8be82

📥 Commits

Reviewing files that changed from the base of the PR and between 8e3694c and eaea7bd.

📒 Files selected for processing (8)
  • src/commands/experiments/export.ts
  • src/index.ts
  • src/lib/api/axios-adapter.test.ts
  • src/lib/api/axios-adapter.ts
  • src/lib/api/request-logger.test.ts
  • src/lib/api/request-logger.ts
  • src/lib/utils/api-helper.ts
  • src/lib/utils/download.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/lib/utils/api-helper.ts
  • src/lib/api/request-logger.test.ts
  • src/lib/api/request-logger.ts

Comment thread src/lib/utils/download.ts
Comment thread src/lib/utils/download.ts Outdated
joalves added 5 commits April 29, 2026 23:49
…n README

The previous run showed `--show-request --headers-only --status-only`
producing only request output: --status-only had no effect because the
user hadn't also enabled --show-response. Since "status" is a
response-only concept, --status-only now auto-enables --show-response
in getGlobalOptions so the flag does what its name suggests on its own.

Also adds a README section documenting all six debug flags, the
default redaction behaviour, the suppression-on-polling logic, and a
few example invocations.
The previous behaviour (per CR2 review) kept every response visible
when the matching request was deduped. In practice this defeats the
purpose of suppression for typical --show-response usage: polling
loops that fire the same request every 2s now print the response on
every iteration, drowning out the actual interesting events.

Restore the original pair-suppression model: when a request's
fingerprint matches a previous one, both the request emission AND the
response emission are skipped, and the "(N identical requests
suppressed)" notice flushes when the next distinct request arrives.

Trade-off acknowledged: state changes that show up only in the body
of an otherwise-identical request will be hidden. The user can vary
the request, run a separate invocation, or accept the noise loss.
This was an explicit user call.

Updates two tests to lock in the new behaviour:
- showRequest+showResponse with 3 identical polls + 1 done: now 2
  responses (was 4).
- showResponse alone: now suppresses repeats too and emits a
  suppression notice (was: every response logged, no notice).
CR5: --show-request / --curl / --show-response could leak live download
credentials. The export-download flow follows a 302 redirect to object
storage with a presigned query string (X-Amz-Signature, X-Amz-Credential,
etc.); the redirect target URL went straight into the formatters and
the 302 Location response header was emitted verbatim.

  * New redactUrl() helper: when the query string contains any of a
    known set of presigned-credential param names (AWS X-Amz-*, GCP
    X-Goog-*, signature, sig, token, auth, access_token), the entire
    query is replaced with '?[redacted-presigned]'. Whole-query
    (not just the matched key) because the surrounding params are
    part of the signed bundle and a partial reveal can still
    attribute the URL.
  * formatRequestHTTP / formatRequestCurl now run the URL through
    redactUrl before emission.
  * redactHeaders treats Location and Content-Location as URL-bearing
    and runs them through redactUrl too. --show-secrets disables
    both redactions.

CR6: req.on('error') in the raw http.get download path used to reject
silently. DNS / TLS / socket failures and redirect loops never produced
an HTTP response and stayed invisible under --show-response. Now logs
via formatGenericError, matching the axios path's behaviour for
network-level errors.

Adds 7 new tests (presigned URL detection, Location-header redaction,
request-URL redaction).
@joalves joalves added this pull request to the merge queue Apr 29, 2026
Merged via the queue into main with commit 8e9de8c Apr 29, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant