Skip to content

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.

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

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.

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.

Open-source serverHosted cloudYour own AWS, or local
Prompts and completionsNever storedStored if the SDK sends themStored if the SDK sends them
How that is enforcedA strict column allowlist. There is no column for content to land in.Client-side controls, scrubbed twice, capped at 100 KiB per fieldSame as hosted, inside infrastructure you own
Tool arguments and resultsNever storedFollow the same rule as contentFollow the same rule as content
Who operates itYouMetergraphYou
Turning content offNothing to turn offcapture_text, globally or per route or per traceSame

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 single-trace detail endpoint is the only 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 mg_ 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.
  • 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.

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.