Request Capture
Storing request and response bodies per scope: what is stored, how it is switched on, how long it stays.
Request Capture stores THREE bodies for a request that passes through the gateway: the ORIGINAL body the client sent, the FINAL body the gateway sent upstream, and the model's RESPONSE. The reason both request forms are kept is the DIFFERENCE between them: the system prompt, second brain memory, skills and guardrails injection is visible exactly there. That difference is what answers "why did the model answer like that" — with only one body you see the request, but not what the gateway added to it.
#The three bodies
| Body | When it is taken |
|---|---|
| `request_original` | At the proxy entrance, before ANY injection. It is frozen by serialising it to text immediately — the steps below replace `data` wholesale, so holding a reference would store the final body under the "original" label. |
| `request_final` | Immediately before it is sent upstream — that is, AFTER the system prompt, skills, second brain, guardrails and judge sanitize. |
| `response_body` | The parsed body on a non-streaming response. On a stream the chunks are teed as they pass and reassembled after the stream ENDS, then written — so the stream itself is never delayed. |
Capture is not limited to chat endpoints: endpoints such as embeddings that pass through the proxy are captured the same way. When a row is expanded in the panel, the default tab is the "Changes" diff rather than two blobs side by side — that difference is the whole reason both request forms are stored.
#Where it is switched on — scope
Capture is switched on per scope. The vocabulary is the same one used across the panel (System Prompts, Skills, Judge): `global`, `user`, `workspace`, `node`, `group`, `model`, `mapping`. Each configuration carries its own retention, its own per-body limit, and which of the three bodies it stores.
System Prompts stack EVERY matching record; here there is nothing to stack. A row is either written to ClickHouse or it is not, and if it is written it carries exactly one `retention_days` — a body cannot live for both 30 and 365 days. So the matches are reduced to a single winner: **the narrowest match wins**.
| Narrowness order | What it covers |
|---|---|
| `mapping` → `model` | A single mapping or a single real model (narrowest). |
| `node` → `group` | The traffic of one node or one model group. |
| `workspace` → `user` | One workspace, or ALL of that user's traffic. |
| `global` | Everything, without distinction (broadest). |
If nothing matches, the answer is off — that is also the behaviour of an empty table. A configuration that is enabled but has all three body flags off stores nothing, and the panel warns about exactly that. The configurations are cached, but every create/edit/delete invalidates the cache immediately: a capture you switch off stops on the next request, not minutes later.
#Retention
Each configuration carries a retention between **1 and 3650 days**; the default is 30. The row carries that number in its own column, so different scopes live for different durations in the same table. `0` and "unlimited" are not offered.
The defence is deliberately layered, and both layers point the same way — TOWARD KEEPING THE DATA. The API REJECTS an out-of-range value rather than clamping it: a value clamped in silence would lie to the admin, who would believe "kept for 3650 days" while having typed 36500. The schema is the LAST line, and it works on rows the API never saw: for a row inserted by hand in SQL, or written before this rule existed, `retention_days` is clamped at the ceiling and a stray `0` is read as "the longest life", not "delete now". That is not a contradiction but a division of labour: the API refuses the input, and the schema refuses to turn what it could not refuse into lost data.
#Truncation
Every configuration has a per-body byte limit; the default is 128 KB, selectable between 16 KB and 256 KB in the panel — the largest preset below the API ceiling of roughly 341 KB, which is itself derived from the write path's 1 MiB budget per queued record. A body over the limit is **truncated and FLAGGED — never dropped silently**. In the panel a truncated body carries a "Truncated" badge; a body shown without that badge would be trusted further than it should be.
If all three bodies end up empty, no ClickHouse row is written at all: it would cost TTL, disk and panel noise while answering no question — the request's metadata already sits in the activity row in Postgres.
#What is NOT stored
The schema has NO header column; no request or response header is stored — that is the strongest masking available. Credential-shaped fields INSIDE the body (`authorization`, `api_key`, `cookie`, `access_token` and the like) are replaced with `***`, and proxy-internal `_`-prefixed keys are dropped entirely.
The MCP agent loop, judge evaluation, second brain distiller and content-router classifier are not captured. The reason is CONSENT, not noise: these calls authenticate as the service identity, so a user's own "do not capture me" row would not match them — storing them would leave a capture the user believes is off running under the admin name. Their content already sits inside the outer request's captured bodies.
The captured row deliberately leaves `cost_usd` empty. Cost is computed on the activity row in Postgres together with the price catalog; computing it a second time would create a place where two numbers could quietly drift apart.
There is no sampling rate: all matching traffic is captured. Adding sampling later is easy; bringing the past back is impossible.
#Where the data lives
Bodies are not MOVED out of Postgres, they are placed NEXT to it. Billing and plan limits stay transactional in `user_activity_logs` and this feature does not touch them; the bodies plus a few denormalised fields for analysis go to ClickHouse. `request_uid` joins the two.
- request_logs (ClickHouse)
- The three bodies (`ZSTD`-compressed), the truncation flags, `retention_days`, and which scope made the decision. `MergeTree`, partitioned by month, with the TTL read from the row's own retention column.
- user_activity_logs (Postgres)
- The billing and usage record — UNCHANGED by this feature.
- request_uid
- The link between the two stores; the panel fetches the bodies by this key when a row is expanded.
- CLICKHOUSE_URL / _USER / _PASSWORD / _DB
- The store's address and credentials; the compose default database is `manifold_logs`.
The write path never slows a request down or fails one: all it does on the request side is a single `rpush` onto a BOUNDED (32 MiB) Redis queue, and a background batch writer sends it to ClickHouse (roughly every 2 seconds, over HTTP `JSONEachRow`). If ClickHouse goes down the batch is put back and retried with exponential backoff; if the queue's byte budget fills up the OLDEST records are dropped and counted — silent unbounded growth would kill Redis.