Skip to content

Add opt-in raw HTTP request/response logging - #93

Open
jcheng5 wants to merge 13 commits into
mainfrom
raw-http-logging
Open

Add opt-in raw HTTP request/response logging#93
jcheng5 wants to merge 13 commits into
mainfrom
raw-http-logging

Conversation

@jcheng5

@jcheng5 jcheng5 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Adds a fetch wrapper (raw-http-logging.ts) that captures byte-faithful .http request/response pairs for wire-level debugging of provider rejections — the AI SDK-structured JsonRequestLogger view in the monorepo shows the SDK's interpretation of a call, not the actual bytes on the wire, which is exactly what you need when a provider returns a 400 you can't explain.

Behavior

  • Opt-in and dynamic. Logging is active while the configured raw-http/ directory exists — late binding, checked per chat call, so you can mkdir mid-session to enable capture without a restart. PA_RAW_HTTP_LOG_DIR turns it on unconditionally.
  • Redaction. Credential-bearing header values are redacted; bodies are always byte-for-byte.
  • Never breaks a request. All logging errors are swallowed.
  • Bundle-safe. node:fs is looked up lazily via process.getBuiltinModule rather than a static node: import, so the module can be bundled into the Positron webview frontend (which shares chunks with the model clients). Outside Node, logging simply disables itself.

Wired into every AI SDK-backed model client — OpenAI, Anthropic, DeepSeek, Gemini, Vertex, Ollama, OpenRouter, Posit AI, Snowflake, Bedrock — composing with each client's existing custom-fetch wrappers rather than replacing them.

Consumer

Needed by posit-dev/assistant#2015, which pins this branch and adds the per-deployment raw-http/ log directories plus the rawHttpLogging.md memory-bank doc. That PR can't merge until this one does.

🤖 Generated with Claude Code

@wch

wch commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Some feedback from /assistant-review. I'll fix and push the changes.

  • critical (correctness)packages/ai-provider-bridge/src/model-clients/DeepSeekClient.ts:73: wrapping each existing custom fetch places the logger outside request/auth/retry/response middleware, so it records SDK input rather than wire input and transformed output rather than wire output. DeepSeek omits injected reasoning_effort, Posit AI logs before final auth headers (PositAiClient.ts:191), and Snowflake hides physical retries and logs post-normalized responses (SnowflakeClient.ts:313); make these wrappers typed middleware over a delegate and compose SDK → transform/auth/retry → raw logger → global fetch, with the logger inside retry loops so every physical call gets a pair. Add composition tests that assert the logged body/header bytes include final mutations and that raw SSE is captured before compatibility rewrites.

  • critical (correctness)packages/ai-provider-bridge/src/model-clients/raw-http-logging.ts:321: eagerly draining response.clone() means SDK cancellation no longer cancels the underlying response while the logging branch is active, so an aborted generation can continue consuming provider output and the faster branch can buffer without bound. The request-side tee() at line 167 has the same independent-drain problem, and lines 325–329 discard all partial response bytes on a read error despite the documented partial-body guarantee. Replace tee-and-drain with a cancellation-aware pass-through that records chunks as the real consumer pulls them, forwards cancellation, and writes accumulated bytes plus an error/cancel marker; cover cancellation, slow-consumer backpressure, and a stream that emits a chunk before failing.

  • important (correctness)packages/ai-provider-bridge/src/model-clients/raw-http-logging.ts:168: request-stream capture mutates the caller's RequestInit after tee() has locked the original body. If the object is frozen or the assignment otherwise throws, the catch suppresses the logging error but the underlying fetch still receives the now-locked original stream and fails because logging touched it. Copy the init first and pass the copied init through capture and the underlying fetch; add a frozen-init regression test.

  • important (security)packages/ai-provider-bridge/src/model-clients/raw-http-logging.ts:58: credential redaction misses Cookie and Set-Cookie, so reusable session credentials from enterprise gateways are persisted verbatim. Include cookie headers (and other explicitly supported credential forms) in the predicate while retaining the rate-limit-token negative case in tests.

  • important (correctness)packages/ai-provider-bridge/src/model-clients/GeminiGenerateContentClient.ts:278: Databricks routes google-generative requests through this separate AI-SDK-backed client, but it never installs raw logging even though the feature claims every AI SDK client is covered. Wire both API-key and bearer modes through the corrected middleware composition, placing logging beneath the bearer rewrite.

  • important (correctness)packages/ai-provider-bridge/src/model-clients/raw-http-logging.ts:92: envDirCreated is global and not tied to a path, so deleting the override directory or changing PA_RAW_HTTP_LOG_DIR after its first use permanently leaves logging pointed at a nonexistent, non-recreated directory. Delete the flag and call idempotent mkdirSync(envDir, { recursive: true }) whenever resolving an environment override; this is both the fix and a simplification.

  • important (correctness)packages/ai-provider-bridge/src/model-clients/raw-http-logging.ts:252: filenames use only millisecond time plus a process-local sequence, while multiple Assistant/RStudio/TUI processes can write to the same configured directory. Same-millisecond calls with matching provider/model/sequence overwrite one another because writeFile is non-exclusive; include a process-independent nonce such as randomUUID() or use exclusive create-and-retry.

  • minor (correctness)packages/ai-provider-bridge/src/model-clients/raw-http-logging.ts:259: URL and URL-string calls without init.method are sent by Fetch as GET but logged as POST. Default to GET, with Request.method and init.method overriding it, and cover all three input forms.

  • minor (simplification)packages/ai-provider-bridge/src/model-clients/__tests__/raw-http-logging.test.ts:83: every asynchronous case waits a fixed 500 ms even when files are already present. Replace this with a condition-based waitForPair helper so the suite finishes immediately on success and remains reliable on slower CI hosts.

@wch
wch force-pushed the raw-http-logging branch from e26f47a to 4ceff73 Compare August 30, 2026 02:32
@wch

wch commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Another round of review after the fixes:

  • critical (correctness)packages/ai-provider-bridge/src/model-clients/OpenAIClient.ts:106: several OpenAI-based providers change a request immediately before sending it and clean up the response immediately after receiving it, but the logger currently sits outside both steps. For example, the compatibility fetch can change an outgoing body from "max_tokens":128 to "max_completion_tokens":128; the provider receives the second form, while the log records the first. In the other direction, a provider can return "arguments":"", the compatibility fetch changes it to "arguments":"{}", and the log records the cleaned-up value rather than what the provider actually returned. This affects generic OpenAI-compatible, Foundry, Databricks, and unauthenticated OpenAI-compatible routes, defeating the purpose of wire-level logging. Change OpenAIClient so its custom fetch receives the lower-level fetch it should call; then place the logger around that lower-level fetch, as the corrected Snowflake path already does. Add an OpenAIClient integration test that checks both the final outgoing request and the original incoming response.

  • important (correctness)packages/ai-provider-bridge/src/model-clients/raw-http-logging.ts:216: bodyful Request inputs still use Request.clone().arrayBuffer(), which tees and eagerly drains a second branch; cancelling or slowly consuming the network branch therefore does not stop logging and can buffer the full upload. Return a rewritten input that wraps Request.body with the same recording pass-through used for init.body, and add cancellation/backpressure coverage for a bodyful Request (the current Request test at raw-http-logging.test.ts:281 has no body).

  • important (correctness)packages/ai-provider-bridge/src/model-clients/raw-http-logging.ts:280: replacing every bodyful network response with new Response(...) loses observable fetch metadata (url, redirected, and type) and changes the headers guard, so enabling diagnostics does not preserve the fetch contract even when body bytes are unchanged. Preserve the original response metadata/guards in the wrapper and test them, or keep this helper internal and explicitly narrow its contract to callers that only consume status, headers, and body.

  • minor (correctness)packages/ai-provider-bridge/src/model-clients/raw-http-logging.ts:69: truncating randomUUID() to eight hex characters makes collisions unlikely rather than making the filename component process-unique as documented; because line 307 still writes non-exclusively, a collision can overwrite another process's pair. Keep the full UUID or use exclusive create-and-retry.

  • minor (tests)packages/ai-provider-bridge/src/model-clients/__tests__/raw-http-logging.test.ts:96: waitForFiles treats a nonempty final-path file as complete even though writeFile may still be in progress, so the immediate full-body assertions can race partial output. Publish logs with temp-file-plus-rename so existence means completion, or give tests a completion seam rather than polling only for nonzero length; the composition suite's pairsComplete has the same race.

jcheng5 and others added 12 commits August 29, 2026 23:49
A fetch wrapper (raw-http-logging.ts) that captures byte-faithful .http
request/response pairs for wire-level debugging of provider rejections,
unlike the AI SDK-structured JsonRequestLogger view.

Opt-in and dynamic: active while the configured raw-http/ directory
exists (late binding, checked per chat call — mkdir mid-session to
enable), or always-on via PA_RAW_HTTP_LOG_DIR. Credential-bearing header
values are redacted; bodies are always byte-for-byte. All logging errors
are swallowed so capture can never break a request.

Wired into every AI SDK-backed model client (OpenAI, Anthropic, DeepSeek,
Gemini, Vertex, Ollama, OpenRouter, Posit AI, Snowflake, Bedrock),
composing with existing custom-fetch wrappers.
Look up node:fs lazily via process.getBuiltinModule instead of static
node: imports so the module can be bundled into the Positron webview
frontend (which shares chunks with the model clients). Outside Node,
logging is simply disabled.
The logger previously wrapped each client's custom fetch, so it recorded
SDK input rather than wire input: DeepSeek logs omitted the injected
reasoning_effort, Posit AI logs preceded the final auth headers, and
Snowflake logs hid session-refresh retries and captured post-normalized
responses.

Middleware now takes a delegate fetch and composes as
SDK -> transform/auth/retry -> raw logger -> global fetch, so every
physical call is logged with its final mutations, raw SSE is captured
before compatibility rewrites, and each attempt of a retry gets its own
request/response pair.
The previous tee-and-drain capture changed behavior of the wrapped
fetch: eagerly draining response.clone() meant SDK cancellation no
longer cancelled the underlying response (an aborted generation kept
consuming provider output), the faster tee branch could buffer without
bound, partial response bytes were discarded on a read error despite
the documented partial-body guarantee, and request capture mutated the
caller's RequestInit after tee() had locked the original body —
breaking the request outright if the init was frozen.

Both request and response bodies now flow through a recording
pass-through: chunks are recorded as the real consumer pulls them,
cancellation is forwarded to the source, and the log file is written
on completion, error, or cancellation with whatever bytes arrived plus
a marker. Request capture copies the init instead of mutating it.
- Redact Cookie and Set-Cookie headers; they carry reusable session
  credentials from enterprise gateways.
- Install raw logging in GeminiGenerateContentClient (Databricks
  google-generative route), beneath the bearer rewrite in bearer mode.
- Resolve the env-var directory with an idempotent mkdir on every call,
  so a deleted override directory is recreated and env-var changes are
  picked up (drops the stale envDirCreated flag).
- Include a process-unique nonce in log file names so concurrent
  Assistant/RStudio/TUI processes sharing a directory cannot overwrite
  each other's files.
- Log GET (the Fetch default) for calls without an explicit method
  instead of POST.
- Replace fixed 500ms test settles with condition-based waits.
OpenAIClient composed its fetch as SDK -> raw logger -> customFetch ->
network, so the generic OpenAI-compatible, Foundry, and Databricks
routes (and the empty-key auth stripper) logged request bodies and
headers before the compat middleware mutated them and responses after
its SSE rewrite.

customFetch is now a factory receiving the wire fetch (the raw-HTTP-
logging wrapper when active, otherwise the global fetch), composing
SDK -> custom middleware -> raw logger -> global fetch — the same
ordering the Snowflake client already used. New composition tests cover
the customFetch path (post-transform request, raw SSE) and the
empty-key auth strip.
- Bodyful Request inputs now use the recording pass-through via a
  rewritten Request instead of clone().arrayBuffer(), so cancelling or
  slowly consuming the upload propagates to the source and the log
  branch cannot buffer the full upload.
- The wrapped response preserves url, redirected, and type (shadowed
  onto the wrapper, which the Response constructor cannot set); the
  narrowed headers-guard contract is documented on withRawHttpLogging.
- The filename nonce is a full UUID, making the process-uniqueness the
  comment claims actual rather than likely.
- Log files are published with temp-file-plus-rename, so an existing
  .http file always holds complete contents; test helpers now poll for
  existence instead of racing in-progress writes.
A bodyful Request that cannot be rewritten with a recording body
(keepalive/no-cors, or a failed rewrite) previously logged an empty
body, which reads as "the SDK sent no body" when debugging wire
problems. Write a [body omitted: ...] marker instead, matching the
response side's [error: ...] convention.
Record stream terminal state as a discriminated union instead of an
optional error value: the Streams API permits controller.error() with
no argument, so a stream can fail with an undefined rejection, which
an optional error field cannot distinguish from a clean completion.
Such failures now log an [error: ...] marker on both the request and
response sides, with regression coverage.

Also consolidate the synthetic fetch-rejection response on
formatErrorMarker, rewrite the typed-array snapshot test to assert
the log matches the bytes the delegate sent, and point internal
OpenAI-compatible composition docs at
createOpenAICompatibleFetchMiddleware.
@wch
wch force-pushed the raw-http-logging branch from dc7e6b8 to b7a86db Compare August 30, 2026 04:49
@wch

wch commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Another round after previous fixes:

  • critical (correctness)packages/node/src/platform/services/NodeModelService.ts:209: every NodeModelService construction overwrites configureRawHttpLogging()'s single process-global directory. Standalone first registers ~/.posit/assistant/logs/raw-http, but each client connection calls createDefaultNodeServices() at packages/standalone/src/server/createSessionServices.ts:121, whose otherwise-unused model resets the directory to the legacy RStudio path; desktop, Connect, and Canvas inherit that path, so their documented opt-in directory stops working after the first connection. Configure this process-owned feature once in each host bootstrap, and remove the full-service factory call from the session factories: they consume only NodeErrorReporter and NodeMessagingService, so constructing those directly deletes the unused auth/storage/model graph and prevents future constructor side effects. Add a construction-order regression test.

  • critical (correctness)scripts/request-log-viewer/public/breakdown.js:37: shape detection handles OpenAI Chat Completions (messages) and Google GenerateContent (contents), but current OpenAI Responses and Gemini Interactions requests both use input (plus system_instruction for Gemini). OpenAIClient selects Responses at packages/ai-lib/packages/ai-provider-bridge/src/model-clients/OpenAIClient.ts:123, and GeminiClient always selects Interactions at line 358, so these real logged requests fall through to generic: the size bar assigns everything to “Other” and readable mode shows no system prompt, tools, or conversation. Normalize both active wire shapes and add production-shaped fixtures; the current tests cover only the older formats.

  • important (security)scripts/request-log-viewer/server.ts:271: binding to 127.0.0.1 prevents direct LAN access, but the unauthenticated server accepts arbitrary Host values, leaving prompts and tool results readable through DNS rebinding. Reject requests whose host is not the expected loopback host/port, or put an unguessable per-launch capability token in the opened URL and require it on every static/API request.

  • important (security)packages/ai-lib/packages/ai-provider-bridge/src/model-clients/raw-http-logging.ts:112: the environment-selected directory is created with default permissions, and writeLogFile() at line 431 creates files with the process umask. Typical Unix defaults yield 0755 directories and 0644 files even though bodies are deliberately unredacted and can contain prompts, source, tool output, and credentials. Create owned directories with 0700 and temporary files with 0600 (rename preserves the mode).

  • important (correctness)scripts/request-log-viewer/public/app.js:553: poll() returns unless it sees a new base filename, so it never reconciles an existing request that gains its response, a partially written file that becomes valid, or deleted pairs. This is reachable because JsonRequestLogger writes request and response sequentially under one base; compare request/response presence and sizes on every poll, replace the list even without new bases, and refetch the selected pair when its signature changes.

  • important (correctness)packages/positron/src/requestLogs.ts:32: all directory-read failures become “No request logs found,” every response open/display failure is treated as a missing response, and the command handler at line 83 discards the request-side promise so those failures become unobserved rejections. Return openLatestRequestLog() from the command, suppress only expected ENOENT cases, and surface permission/editor failures through the normal command error path.

  • minor (convention)packages/positron/package.json:70: the globally active keybinding has no when clause even though the command is titled and documented as a developer surface. Hiding it from the palette does not disable the shortcut; gate it consistently with the neighboring developer commands using config.assistant.devMode && posit-assistant.enabled.

  • minor (clarity)memory-bank/requestLogViewer.md:32: the Positron guidance points to extension globalStorage, but JsonRequestLogger and the new command both use getGlobalStorageBasePath(), so the correct directory is the viewer's existing default, ~/.posit/assistant/logs/request-logs. The architecture section at line 47 also still names the removed breakdown.test.mjs; update both statements to match the implementation.

The env-selected directory was created with default permissions and
log files with the process umask — typically 0755/0644 — even though
bodies are deliberately unredacted and can contain prompts, source,
tool output, and credentials. Directories are now created 0700 and
temp files 0600 (rename preserves the mode).
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.

2 participants