Content and privacy
Two different things get captured, and conflating them is how privacy questions get answered badly.
Metadata is the shape of a call: when it happened, which model, how many tokens, how long it took, whether it failed, which function and route it belongs to, your tags. Every deployment captures this, and none of it contains your users’ text.
Content is the prompt and the completion: the request you sent and the response you got. This is what makes replay and quality analysis possible, and it is the only part of Metergraph that raises a real privacy question.
This page is about content.
The stages content passes through
Section titled “The stages content passes through”Content is handled at three separate points, and each one does something different. The numbers are different too, which is why a single number is never the right answer.
1. In your process, before anything is sent
Section titled “1. In your process, before anything is sent”The SDK does four things in order, all inside your own process:
- Structural scrub. The request object is walked recursively and any key
whose name is credential-shaped is dropped before serialization:
api-key,api_key,apikey,authorization,client_secret,cookie,headers,id_token,password,proxy-authorization,refresh_token,secret,set-cookie,token,x-api-key. Note thatheadersgoes wholesale, so a provider key in a custom header never reaches the serializer. - Serialize. The scrubbed request, and a normalized envelope of the response, are each serialized to a JSON string.
- Your
redacthook, if you supplied one. See below. - Truncate each field to
text_max_bytes, appending\n<metergraph:truncated>when it bites.
text_max_bytes defaults to 1 MiB (1,048,576 bytes) per field. It is
clamped at the bottom only, at 1 byte. There is no upper clamp, so you can raise
it as far as you like, and the ingest body limits become the real ceiling.
2. At the ingest API, before anything is durable
Section titled “2. At the ingest API, before anything is durable”The server does not trust the client to have scrubbed correctly.
- A row that declares
content_opted_in: falsehas its request and response fields removed here, before the batch is written anywhere. Its tool calls are reduced tocall_id,name,statusandidempotency. - Every other row has its content re-parsed and scrubbed against the same credential-shaped key list. The second pass is not decoration: it is what makes the guarantee independent of which SDK version you are running.
Only then is the sanitized batch written to object storage, and only then does
the request return 202.
3. In the worker, before Postgres
Section titled “3. In the worker, before Postgres”Each stored content field is truncated to 100 KiB of UTF-8, with the same
<metergraph:truncated> marker, and the row records content_captured and
content_truncated as fields of their own so a truncation is never mistaken for
a failure.
The number that matters to you
Section titled “The number that matters to you”The controls you have
Section titled “The controls you have”Turn content off entirely
Section titled “Turn content off entirely”capture_text=false sends metadata only. Everything about cost attribution keeps
working.
# process-wide, in codemetergraph.init(capture_text=False)# process-wide, by environmentexport METERGRAPH_CAPTURE_TEXT=0// process-wide, in codemg.init({ captureText: false });# process-wide, by environmentexport METERGRAPH_CAPTURE_TEXT=0Turn it off for one surface
Section titled “Turn it off for one surface”route() and trace() both take a capture_text override, so you can keep
content on everywhere except the one place that handles regulated data.
with metergraph.route("patient-intake", capture_text=False): client.chat.completions.create(...) # metadata only// options are the third argument, after the callbackawait mg.route("patient-intake", () => client.chat.completions.create({ ... }), { captureText: false });The scoped value wins when it is set. When it is not set, the process-wide value
applies. There is no way for a scope to turn content back on against a
process-wide false by accident, because the check is an explicit
“is this scope’s value set?”, not a truthiness test.
Rewrite content on the way out
Section titled “Rewrite content on the way out”redact is a hook you supply. It runs in your process, on your machine, before
anything is sent, and it is the only mechanism that can remove something the
structural scrub cannot recognize: a national ID number inside a message body, a
customer name, an internal hostname.
import re
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
def redact(text: str, kind: str) -> str: # kind is "request" or "response" return EMAIL.sub("<email>", text)
metergraph.init(redact=redact)mg.init({ redact: (text: string, kind: "request" | "response") => text.replace(/[\w.+-]+@[\w-]+\.[\w.]+/g, "<email>"),});Three things about it are easy to get wrong.
- It receives the serialized JSON string for a whole field, not one message
at a time.
textis the entire scrubbed request document or the entire response envelope. Write patterns that are safe against JSON structure, and do not assume you are looking at prose. - It runs before truncation. Whatever it returns is what gets measured
against
text_max_bytes. - It fails closed. If your hook raises, the field is replaced with the
literal string
<redaction-failed>and none of the original text is sent. That is the right default, and it means a bug in your regex costs you observability rather than costing you a leak. Watch for that marker in the dashboard.
There is no server-side switch
Section titled “There is no server-side switch”This is worth stating plainly, because people look for one. The workspace-level content flag that once existed is deprecated and is forced to enabled by a database trigger. Hosted content capture has no plan gate and no workspace gate.
Every control over content is in your process. That is a deliberate design choice: the decision lives with the people who can read the code that handles the data, not with whoever happens to hold the dashboard admin seat. It also means turning content off is a deploy, not a click.
What each deployment can see
Section titled “What each deployment can see”| Open-source server | Hosted cloud | Your own AWS, or local | |
|---|---|---|---|
| Prompts and completions | Never stored | Stored if the SDK sends them | Stored if the SDK sends them |
| How that is enforced | A strict column allowlist. There is no column for content to land in. | Client-side controls, scrubbed twice, capped at 100 KiB per field | Same as hosted, inside infrastructure you own |
| Tool arguments and results | Never stored | Follow the same rule as content | Follow the same rule as content |
| Who operates it | You | Metergraph | You |
| Turning content off | Nothing to turn off | capture_text, globally or per route or per trace | Same |
The open-source guarantee is structural rather than a promise. Its ingest path projects every incoming row through a fixed column allowlist, so content is discarded even when the SDK is configured to send it, and a sentinel test in the repository asserts that a marker string placed in a request never reaches the database. That holds whether or not you trust the operator, which is a stronger claim than any policy document.
Who can read stored content
Section titled “Who can read stored content”- List and summary responses are content-free. Nothing that aggregates carries prompts or completions.
- The single-trace detail endpoint is the only content-bearing read. It
responds
Cache-Control: no-storeand accepts exactly two things: a same-workspace dashboard session, or a bearer token carrying thereadscope, which only the operator CLI can mint. Every workspace role can use it, includingviewer: content is workspace-visible, not role-gated. - Ingest keys cannot read it. An
mg_key carries theingestscope only. A leaked ingest key lets someone write junk into your workspace. It does not let them read your prompts back out. - The MCP and agent surface cannot read it. The read-only agent surface returns grouped metadata and never returns prompts, outputs, tool arguments or the content-bearing detail response. A coding agent with Metergraph access cannot exfiltrate your users’ text through it.
- Cross-workspace sessions are rejected on every content path.
Content follows the workspace metadata retention period, 90 days by default. The raw ingest objects have their own, separate lifecycle. A workspace can be configured for zero raw retention, which permanently removes every version of the raw object once normalized processing is done; that is an operator setting rather than a dashboard toggle, so ask if you need it.
What metadata-only costs you
Section titled “What metadata-only costs you”This is the honest trade, and it is not symmetric.
You keep everything about cost. Spend by route, by function, by model, by environment. Token counts including cache reads, cache writes and reasoning tokens. Latency and time to first token. Error rates and error types. Sessions and traces. The template hash, so calls still cluster by prompt shape. Alert detectors for runaway spend, cost drift, failure spikes, retry loops, latency drift and template change all run on metadata and keep working.
You lose analysis. Replay works by re-sending the request you actually made to a candidate model and grading its answer against the answer your system actually produced. With content off there is no request to re-send and no answer to grade against. There is nothing partial to fall back on: the analysis has no evidence at all for that workload. You also lose the ability to inspect a trace in the dashboard when you are debugging, and you cannot build an evaluation dataset from captured traffic.
So metadata-only tells you precisely what you are spending and gives you no opinion about whether you could spend less. If that is the right trade for a particular surface, scope it to that surface with a route-level override rather than turning it off everywhere.