Skip to content

proxy,ui-svelte: add request/response capturing#508

Merged
mostlygeek merged 10 commits intomainfrom
body-capture-464
Feb 7, 2026
Merged

proxy,ui-svelte: add request/response capturing#508
mostlygeek merged 10 commits intomainfrom
body-capture-464

Conversation

@mostlygeek
Copy link
Owner

@mostlygeek mostlygeek commented Feb 5, 2026

Add saving request and response headers and bodies that go through llama-swap in memory.

  • captureBuffer added to configuration. Captures are enabled by default.
  • 5MB of memory is allocated for req/response captures in a ring buffer. Setting captureBuffer to 0 will disable captures.
  • UI elements to view captured data added to Activity page. Includes some
    QOL features like json formatting and recombining SSE chat streams
  • capture saving is done at the byte level and has minimal impact on llama-swap performance

Fixes #464
Ref #503

Screenshot:

image

Summary by CodeRabbit

  • New Features

    • New configuration flag captureBuffer (default: 5) to control MB used for request/response captures; set to 0 to disable.
    • Captures store request/response headers and bodies with sensitive-header redaction; total capture storage is capped with FIFO eviction.
    • Activity table adds a Capture column with a View button that opens a modal to inspect captures (renders images, JSON, text).
    • New API endpoint to fetch captures by ID on demand.
  • Documentation

    • Example config updated with comments about the new flag and memory impact.

- ReqRespCapture keeps the request and response headers and body.
- add an API endpoint to possibly fetch the capture record
- add config option to enable captures
@coderabbitai
Copy link

coderabbitai bot commented Feb 5, 2026

Caution

Review failed

The pull request is closed.

Walkthrough

Adds a numeric captureBuffer config option and Config field; implements in-memory request/response capture storage (size accounting, FIFO eviction, redaction) in the metrics monitor; exposes a GET /api/captures/:id endpoint; adds UI types, client call, modal component and Activity integration; and updates tests accordingly.

Changes

Cohort / File(s) Summary
Config & Initialization
config-schema.json, config.example.yaml, proxy/config/config.go, proxy/proxymanager.go, proxy/config/config_posix_test.go, proxy/config/config_windows_test.go
Add integer captureBuffer / CaptureBuffer (MB) config and default (schema default 5, example sets 15); surface CaptureBuffer on Config and pass it into metrics monitor constructor.
Metrics Monitor Core & Tests
proxy/metrics_monitor.go, proxy/metrics_monitor_test.go
Add ReqRespCapture type and HasCapture on TokenMetrics; track captures with size accounting, FIFO eviction, addCapture/getCaptureByID; change newMetricsMonitor signature to accept capture buffer MB and make addMetrics return metric ID; add redaction logic and comprehensive tests for capture behavior.
Proxy API
proxy/proxymanager_api.go, proxy/proxymanager.go
Add GET /api/captures/:id handler (apiGetCapture) to fetch captures by numeric ID; update proxymanager to pass CaptureBuffer into metrics monitor initialization.
UI Types, Client & Components
ui-svelte/src/lib/types.ts, ui-svelte/src/stores/api.ts, ui-svelte/src/components/CaptureDialog.svelte, ui-svelte/src/routes/Activity.svelte
Add ReqRespCapture type and has_capture flag on Metrics; add getCapture(id) API client; add CaptureDialog Svelte component and Activity UI wiring (view button, loading state, modal).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.52% 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
Title check ✅ Passed The title 'proxy,ui-svelte: add request/response capturing' clearly and concisely describes the main change: adding request/response capture functionality across proxy and UI components.
Linked Issues check ✅ Passed The PR fully addresses issue #464 by implementing request/response capture functionality with configurable enable/disable, sensitive header redaction, and UI viewing capability for debugging.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing request/response capture: configuration, metrics monitoring, capture storage, API endpoint, and UI components for viewing captures.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch body-capture-464

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

coderabbitai[bot]

This comment was marked as outdated.

Remove overflow-hidden and h-full that caused Safari to compute
dialog height as 0px, making content invisible despite backdrop
showing.
Copy link

@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: 1

🤖 Fix all issues with AI agents
In `@ui-svelte/src/components/CaptureDialog.svelte`:
- Around line 26-33: The decodeBody function uses atob which yields a Latin-1
string and corrupts multi-byte UTF-8; change decodeBody (function decodeBody) to
base64-decode into a byte array and then decode with TextDecoder('utf-8') to
reconstruct UTF-8 characters reliably, e.g., call atob(body) to get the binary
string, convert each char to its code unit into a Uint8Array, pass that to new
TextDecoder().decode(...), and keep the existing try/catch to fallback to
returning the original body on any error.

Redact Authorization, Proxy-Authorization, Cookie, Set-Cookie, and
X-Api-Key headers to prevent credential leakage in stored captures.
Copy link

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
proxy/metrics_monitor.go (1)

214-217: ⚠️ Potential issue | 🟠 Major

Non-200 responses are never captured — this undermines the core debugging use-case.

Issue #464 was specifically about debugging a 401 error from a peer. The current implementation returns early for any non-OK status without storing a capture, which means the exact scenario that motivated this feature won't produce captures.

Consider moving the capture-storage logic before this early return (or into a defer), so that request/response data for error responses is also captured. At minimum, the request headers/body and the error status code would be valuable for debugging.

🤖 Fix all issues with AI agents
In `@proxy/metrics_monitor_test.go`:
- Around line 988-1027: The test TestMetricsMonitor_WrapHandler_Capture is
missing an assertion that the recorded metric's HasCapture flag is true; after
calling mm.wrapHandler and retrieving metrics via mm.getMetrics() (and before
using metricID to fetch the capture with mm.getCaptureByID), add an assertion
that metrics[0].HasCapture == true to ensure the metric indicates a stored
capture (use the same metrics slice produced by getMetrics and the metric at
index 0).
🧹 Nitpick comments (3)
proxy/metrics_monitor.go (1)

268-283: RespBody stores decompressed content, not raw bytes on the wire.

Line 282 uses body, which has already been through decompressBody (line 236). This is likely fine for debugging (readable JSON), but worth noting in the struct or API docs so consumers don't expect wire-format content.

proxy/metrics_monitor_test.go (2)

944-986: Rename to TestMetricsMonitor_RedactHeaders to match established convention.

Every other test function in this file follows the TestMetricsMonitor_* pattern. TestRedactHeaders breaks consistency. As per coding guidelines, test names should follow the Test<Component>_<test name> convention.

Proposed fix
-func TestRedactHeaders(t *testing.T) {
+func TestMetricsMonitor_RedactHeaders(t *testing.T) {

Based on learnings: "Follow test naming conventions like TestProxyManager_<test name>, TestProcessGroup_<test name>, etc."


1029-1057: Missing assertion that HasCapture is false when captures are disabled.

Symmetric to the enabled case, this would confirm the flag stays false when captures are off.

Proposed fix
         // Metrics should still be recorded
         metrics := mm.getMetrics()
         assert.Equal(t, 1, len(metrics))
+        assert.False(t, metrics[0].HasCapture)
 
         // But no capture

Comment on lines +988 to +1027
func TestMetricsMonitor_WrapHandler_Capture(t *testing.T) {
t.Run("captures request and response when enabled", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10, true)

requestBody := `{"model": "test", "prompt": "hello"}`
responseBody := `{"usage": {"prompt_tokens": 100, "completion_tokens": 50}}`

nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Custom", "header-value")
w.WriteHeader(http.StatusOK)
w.Write([]byte(responseBody))
return nil
}

req := httptest.NewRequest("POST", "/test", bytes.NewBufferString(requestBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(rec)

err := mm.wrapHandler("test-model", ginCtx.Writer, req, nextHandler)
assert.NoError(t, err)

// Check metric was recorded
metrics := mm.getMetrics()
assert.Equal(t, 1, len(metrics))
metricID := metrics[0].ID

// Check capture was stored with same ID
capture := mm.getCaptureByID(metricID)
assert.NotNil(t, capture)
assert.Equal(t, metricID, capture.ID)
assert.Equal(t, []byte(requestBody), capture.ReqBody)
assert.Equal(t, []byte(responseBody), capture.RespBody)
assert.Equal(t, "application/json", capture.ReqHeaders["Content-Type"])
assert.Equal(t, "[REDACTED]", capture.ReqHeaders["Authorization"])
assert.Equal(t, "application/json", capture.RespHeaders["Content-Type"])
assert.Equal(t, "header-value", capture.RespHeaders["X-Custom"])
})
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing assertion on HasCapture flag.

This test verifies capture storage but doesn't assert that metrics[0].HasCapture == true, which is the field that tells the UI whether a capture link should be shown. Consider adding the assertion to prevent regressions on that flag.

Proposed fix
         // Check metric was recorded
         metrics := mm.getMetrics()
         assert.Equal(t, 1, len(metrics))
+        assert.True(t, metrics[0].HasCapture)
         metricID := metrics[0].ID
🤖 Prompt for AI Agents
In `@proxy/metrics_monitor_test.go` around lines 988 - 1027, The test
TestMetricsMonitor_WrapHandler_Capture is missing an assertion that the recorded
metric's HasCapture flag is true; after calling mm.wrapHandler and retrieving
metrics via mm.getMetrics() (and before using metricID to fetch the capture with
mm.getCaptureByID), add an assertion that metrics[0].HasCapture == true to
ensure the metric indicates a stored capture (use the same metrics slice
produced by getMetrics and the metric at index 0).

Use TextDecoder instead of raw atob() to properly handle multi-byte
UTF-8 characters like CJK text and emoji in request/response bodies.
Remove Content-Encoding header from captured response headers since
the stored body is already decompressed. Change redactHeaders to not
return a value since it mutates in-place.
Copy link

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
proxy/metrics_monitor.go (1)

214-217: ⚠️ Potential issue | 🟠 Major

Non-200 responses are silently skipped — defeats the stated debugging goal.

The linked issue #464 specifically describes needing to debug a 401 response from an upstream peer. However, wrapHandler returns early on line 214–217 when recorder.Status() != http.StatusOK, meaning no capture is ever stored for 4xx/5xx responses. This makes the capture feature unable to help with the exact scenario that motivated it.

Consider storing the capture (request + response headers/body) even when the status is non-200, since those are often the most valuable for debugging.

🤖 Fix all issues with AI agents
In `@proxy/metrics_monitor.go`:
- Around line 182-189: The code uses io.ReadAll(request.Body) without limits
(reqBody), risking OOM; switch to reading via
io.ReadAll(io.LimitReader(request.Body, int64(mp.maxCaptureSize)+1)) to cap the
read, then if len(reqBody) > mp.maxCaptureSize treat the capture as too large
(drop capture or flag it) but still reconstruct the full request.Body for
downstream by chaining the already-read bytes back with the remaining stream
(e.g., io.NopCloser(io.MultiReader(bytes.NewReader(reqBody), request.Body))).
Update the block around reqBody, request.Body and use mp.maxCaptureSize,
io.LimitReader, io.NopCloser and bytes.NewBuffer (or bytes.NewReader)
accordingly.
🧹 Nitpick comments (2)
proxy/metrics_monitor.go (1)

126-134: FIFO eviction re-slices from the front repeatedly — minor memory leak.

mp.captureOrder = mp.captureOrder[1:] on line 129 advances the slice start but the underlying array retains references to evicted IDs and never shrinks. Over a long-running process with many captures this is a slow leak of the backing array. It's bounded by the number of captures stored over the lifetime, so for typical usage it's negligible, but worth being aware of.

One lightweight fix: periodically compact, or use a ring-buffer / container/list instead.

proxy/metrics_monitor_test.go (1)

944-986: TestRedactHeaders — consider aligning name with the test naming convention.

The project convention is Test<Type>_<name>. Since redactHeaders is a standalone helper closely tied to metricsMonitor, a name like TestMetricsMonitor_RedactHeaders would be more consistent with the rest of this file. As per coding guidelines, follow test naming conventions like TestProxyManager_<test name>, TestProcessGroup_<test name>, etc.

Comment on lines +182 to +189
if request.Body != nil {
var err error
reqBody, err = io.ReadAll(request.Body)
if err != nil {
return fmt.Errorf("failed to read request body for capture: %w", err)
}
request.Body.Close()
request.Body = io.NopCloser(bytes.NewBuffer(reqBody))
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Unbounded io.ReadAll on the request body when captures are enabled.

There's no size limit on io.ReadAll(request.Body) at line 184. A large upload or a malformed client could cause excessive memory allocation before the capture-size check at line 286. Consider capping the read with io.LimitReader:

Suggested mitigation
-			reqBody, err = io.ReadAll(request.Body)
+			reqBody, err = io.ReadAll(io.LimitReader(request.Body, int64(mp.maxCaptureSize)+1))

After reading, you can check len(reqBody) > mp.maxCaptureSize and decide whether to discard the capture early (and still reconstruct the full body for the downstream handler by chaining the remaining unread bytes back).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if request.Body != nil {
var err error
reqBody, err = io.ReadAll(request.Body)
if err != nil {
return fmt.Errorf("failed to read request body for capture: %w", err)
}
request.Body.Close()
request.Body = io.NopCloser(bytes.NewBuffer(reqBody))
if request.Body != nil {
var err error
reqBody, err = io.ReadAll(io.LimitReader(request.Body, int64(mp.maxCaptureSize)+1))
if err != nil {
return fmt.Errorf("failed to read request body for capture: %w", err)
}
request.Body.Close()
request.Body = io.NopCloser(bytes.NewBuffer(reqBody))
🤖 Prompt for AI Agents
In `@proxy/metrics_monitor.go` around lines 182 - 189, The code uses
io.ReadAll(request.Body) without limits (reqBody), risking OOM; switch to
reading via io.ReadAll(io.LimitReader(request.Body, int64(mp.maxCaptureSize)+1))
to cap the read, then if len(reqBody) > mp.maxCaptureSize treat the capture as
too large (drop capture or flag it) but still reconstruct the full request.Body
for downstream by chaining the already-read bytes back with the remaining stream
(e.g., io.NopCloser(io.MultiReader(bytes.NewReader(reqBody), request.Body))).
Update the block around reqBody, request.Body and use mp.maxCaptureSize,
io.LimitReader, io.NopCloser and bytes.NewBuffer (or bytes.NewReader)
accordingly.

@mostlygeek mostlygeek marked this pull request as draft February 6, 2026 18:10
Change config from a boolean toggle to a size-based setting in MB.
CaptureBuffer: 0 disables captures, >0 sets the buffer size.

- default captureBuffer is 5 (MB)
- remove DefaultMaxCaptureSize constant
- derive enableCaptures and maxCaptureSize from captureBuffer
- update config schema and example config
@mostlygeek mostlygeek marked this pull request as ready for review February 7, 2026 23:39
@mostlygeek mostlygeek merged commit b5fde8e into main Feb 7, 2026
4 of 5 checks passed
@mostlygeek mostlygeek deleted the body-capture-464 branch February 7, 2026 23:40
@h3po
Copy link

h3po commented Feb 11, 2026

@mostlygeek Thanks for the new api. I could use an api/captures/i endpoint that returns the available capture ids to use it to export logs as discussed in #503

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.

Feature request: Request body logging

2 participants