Skip to content

Content and privacy

Metergraph captures metadata and, when enabled, content. They have different controls and retention behavior.

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, the completion and caller-authored tool schemas: the request you sent, the response you got and the declarations available to the model. This is what makes replay and quality analysis possible, and it is the only part of Metergraph that raises a real privacy question.

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:

  1. 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 that headers goes wholesale, so a provider key in a custom header never reaches the serializer.
  2. Serialize. The scrubbed request, and a normalized envelope of the response, are each serialized to a JSON string.
  3. Your redact hook, if you supplied one. See below.
  4. 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: false has its request and response fields and declared tool schemas removed here, before the batch is written anywhere. Its tool calls are reduced to call_id, name, status and idempotency.
  • 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.

Each field up to 500 KiB stays inline. Larger fields up to the 8 MiB body limit are scrubbed and stored whole in a gzip object under the tenant’s content/ prefix, with a 4 KiB leading excerpt kept inline. Only content above the body limit receives the <metergraph:truncated> marker. The row records content_captured and content_truncated as fields of their own so a legacy truncation is never mistaken for a failure.

Older documentation said fields were truncated to 500 KiB. That was the previous inline behavior; it is now the threshold for moving scrubbed content to the overflow object.

capture_text=false sends metadata only. Everything about cost attribution keeps working.

# process-wide, in code
metergraph.init(capture_text=False)
# process-wide, by environment
export METERGRAPH_CAPTURE_TEXT=0

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

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.

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)

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. text is 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.

The old workspace-level content flag is deprecated and forced to enabled by a database trigger. Hosted content capture has no plan gate or 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.

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 Same as hosted, inside infrastructure you own
Tool arguments, results and declared schemas 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.

  • List and summary responses are content-free. Nothing that aggregates carries prompts or completions.
  • The dashboard single-trace detail endpoint is a content-bearing read. It responds Cache-Control: no-store and accepts exactly two things: a same-workspace dashboard session, or a bearer token carrying the read scope, which only the operator CLI can mint. Every workspace role can use it, including viewer: content is workspace-visible, not role-gated.
  • Ingest keys cannot read it. An ingest key carries the ingest scope only. A leaked ingest key lets someone write junk into your workspace. It does not let them read your prompts back out.
  • Agent investigation defaults to content-free metadata. New coding-agent connections get the Metadata role (agent:metadata), which cannot read retained content. A connection with the Debug role (agent:read) can also use separate retained-content debugging and report-evidence tools when the deployment exposes them. agent:replay is a separate scope, granted only with agent:read, that can send approved replay input to a configured provider. BYOC core and OSS withhold trace content and replay. Discover capabilities and confirm content intent before using these tools. See Connect a coding agent.
  • 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.

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.