"[REDACTED]" — the key is preserved so you can still see which field contained sensitive data, but the value never reaches Zespan servers.
Key-based redaction (
redactKeys / redact_keys) matches field names, not free-form text. It’s applied to the structured data captured from LLM calls — message objects, tool call arguments, and similar JSON-shaped fields. It runs identically, locally, and always-on in both SDKs. Pattern-based PII detection (scanning prose for emails, phone numbers, etc.) is a separate feature, on by default, that replaces matches with keyed tokens — see Pattern-based PII detection below.When redaction runs
Key-based redaction runs inside the SDK, in your process, before an event is added to the send queue — it never depends on a network round trip. Both wrappers apply it to the same fields:prompt_text/completion_text/system_prompt— only whenstorePrompts(store_prompts) istrue(the default)tool_call_args— sent when tool IO capture is on (storeToolIO/store_tool_io, on by default), regardless ofstorePrompts
wrapOpenAI/patch_openai, wrapAnthropic/patch_anthropic, and so on) calls this before the event is queued for the batch flush to /v1/ingest — so a matching key’s value is already "[REDACTED]" by the time it leaves your application.
Configuring redaction on init
Key-based redaction is always on — there’s no flag to disable it — but you control the key list viaredactKeys (TypeScript) / redact_keys (Python) at initialization.
Default redacted keys
If you don’t setredactKeys / redact_keys, both SDKs fall back to the identical built-in list:
How key matching works
Both SDKs implement the same normalization and traversal logic —redact.ts and redact.py are functionally identical:
"password"matchespassword,Password,PASSWORD"api_key"matchesapi_key,apiKey,API_KEY,api-key
Nested objects, depth limit, and cyclic safety
Matching walks nested objects and arrays recursively, not just the top level, so a matching key several levels deep insidetool_call_args or a serialized message array is still redacted.
Two safety limits are built in, and are identical between the two SDKs:
Tool results and arguments in any text form
A tool argument or result often reaches Zespan as text rather than as an object: JSON, a Pythonstr(dict) such as {'user': 'bob', 'password': '...'}, or either cut short at a length limit. Key-based redaction covers all of these forms in both SDKs, and again on Zespan’s servers:
The server applies the built-in key list (password, token, secret, API key, access and refresh tokens, authorization, cookies, credentials and similar) to tool arguments and results even when your SDK is older. On the server, a key that ends in one of these names also matches, for example
PAYMENTS_API_KEY, db_password or aws_secret_access_key (a key ending in just token, such as next_page_token, does not). Your SDK’s redactKeys / redact_keys list keeps matching exact names, ignoring case and punctuation. A value that is not under a matching key, in text that is neither JSON nor a literal, is left to the pattern-based masking described below.
Manual redaction (Python)
For cases where you want to redact a value yourself — before logging it or passing it somewhere outside the SDK’s automatic capture path — Python exposes the same key-based redaction as a standalone function from the package root.required
The value to redact. Dicts and lists are redacted recursively; a JSON-encoded string is parsed, redacted, and re-serialized; any other string is returned unchanged.
None returns "".List[str]
required
Field names to redact, matched with the same case-insensitive, punctuation-stripped normalization described above. This is not automatically the client’s configured
redact_keys — pass it explicitly."[REDACTED]":
This helper is public in both SDKs:
redact_for_storage is re-exported from the Python package root (zespan/__init__.py), and redactForStorage is exported from @zespan/sdk. You can call it standalone to redact a value yourself, or rely on the automatic capture pipeline that applies it for you.Pattern-based PII detection
Key-based redaction only catches values stored under a matching field name. It won’t catch an email address or phone number typed into a user’s message, since that’s free-form content, not a keyed field. Both SDKs cover this withredactPii / redact_pii, which is on by default. It runs locally, in your process, before the event is transmitted, after key-based redaction. Pass redactPii: false (redact_pii=False) to turn it off.
What is replaced
Emails, phone numbers, cards (Luhn-checked), IBANs, SSNs, national ids, IP addresses, street addresses and dates of birth are replaced before the event leaves your process. Secrets,redactKeys and inline-image stripping are always on and unchanged.
The replacement is a keyed token such as [EMAIL_3F9A2C1B], not a fixed tag like [REDACTED_EMAIL]:
- The token is an HMAC of the value, keyed with a secret and the trace id, so it is not reversible.
- Within one trace and under one key, the same value always gives the same token. One key means one SDK process, or every service that shares a
redactionKey(see below).Jane.Doe@x.comandjane.doe@x.comgive one token, and so do+1 415 555 0134and(415) 555-0134. Zespan’s identity and leak checks can therefore compare values that were never sent. - System prompts use one token per key across traces, so identical prompts still deduplicate.
- Each event carries content-free features for its tokens in
metadata.zs_tokens(kind, checksum validity, last four digits of a card or IBAN, and the key id), never a value. Azs_tokenskey your app sets is dropped.
The TypeScript SDK, the Python SDK and the API use the same token format and algorithm, but not the same key: the SDK uses its own key (random per process, or your
redactionKey) and the server uses the server’s key. So a value the SDK replaced and the same value the server replaced get different tokens, and so do tokens from two SDK processes without a shared redactionKey. Zespan’s checks never treat tokens from different keys as different values: they are treated as unknown (not comparable), never as a mismatch.Which fields pattern detection covers
WithredactPii: true (TypeScript) or redact_pii=True (Python), pattern detection runs on more than prompt and completion text. Before an event leaves your process, it also covers:
error_messagetagsvalues andmetadatastring values, including nested ones- Tool call arguments and tool results, and tool definitions
- Planning and rationale text
redactKeys / redact_keys) applies to these fields whether or not pattern detection is on.
If the pattern detector fails, the SDK sends the key-redacted value and logs a single warning per process — in TypeScript, [zespan] PII pattern redaction failed; in Python, a RuntimeWarning. If key-based redaction itself fails for a field, that field is sent as [REDACTION_FAILED] instead of its raw value.
Sharing a key across services
By default each process uses a random key, so tokens compare only inside that process. If one trace crosses services, set the same secret in each of them so equal values give equal tokens:The server also redacts
The Zespan API also redacts the content fields it receives, whatever the SDK did: prompts, completions, system prompts, tool arguments, results and calls, tool definitions, error text, reasoning, rationale, tags and metadata string values (except structural keys such assession_id, kept as sent). An event the SDK handed over unscanned is still tokenised at ingest.
CPU budget
Tokenising runs on the batch flush, not on your call stack. It has a budget of about 10% of one core over a rolling second (ZESPAN_PII_CPU_BUDGET, a fraction; 0 means no limit). Over budget, an event is sent with keys and secrets redacted and its PII left for the server to tokenise. Each span says who did it in metadata.zs_capture.pii:
Cut fields stop at safe boundaries
Text cut to a size limit (tool results, error messages, agent text, tags over 64 characters) is never cut in the middle of a value such as an email address or an IBAN. The cut moves before a value it would split, or back to a separator within the last 64 characters, or drops those 64 characters. A cut field can therefore be up to 256 characters shorter than the cap, and a tag with no separator can become empty.Options
Python also has
pii_redaction_mode: placeholder (default, keyed tokens), mask-all (replace the match with * of the same length) and mask-middle (keep the first and last character). The mask modes keep their masks and send no token features. pii_preset and pii_confidence_threshold exist in Python for cross-SDK compatibility and do not affect matching.
The TypeScript SDK’s own pass uses the built-in tokenizer described above. The
openredaction package is not loaded by default. Only an explicit redactPii: true makes the async helpers (client.redactValueAsync and its siblings, which you call yourself) use it, and only then do the TypeScript-only options piiCategories (its values are openredaction’s, not the Python ids), piiWhitelist, piiPreset, piiRedactionMode, piiConfidenceThreshold, piiIncludeNames, piiIncludeEmails, piiIncludePhones, piiIncludeAddresses and piiCustomPatterns take effect. Detection failures there fail open (the original text passes through) rather than crashing your app.Confirmation hashes
confirmToolCall records a hash of the arguments (never the arguments). The default redactPii does not change it:
- The hash stays a plain SHA-256 of the canonical JSON, unless you set
redactPii: true(redact_pii=True) explicitly, or turn tool IO capture off (storeToolIO: false/store_tool_io=False). - In those two cases it is a keyed hash: it compares only inside one client, and a stored hash cannot be guessed from small arguments.
Cost
Measured on a dev laptop per 32,768-character field, p50, with the pass on (and off): TypeScript, prose 2.3 ms (1.2), chat messages 2.8 ms (1.6), a tool result with PII in every row 4.2 ms (2.1). Python 3.12, on the flush thread, prose 10 to 12 ms (4.5), chat messages 15.5 ms (7.6), PII in every row 29 ms (15.8). The CPU budget bounds the PII part under load.Tool payloads
Tool arguments and results are kept up to 32,768 characters (they were cut at 10,240). The Zespan API accepts the same size. A self-hosted API older than this release cuts them at 10,240 characters and marks the row, so upgrade the API to keep the full 32,768.- PII scanning covers the whole stored payload, not only its start. The 32,768-character cap is applied at a safe boundary (see above).
- A result summary. When a result is cut, the span carries
zs_tool_summaryin its metadata: keys, paths, numbers, id-like values and list counts, at most 4,096 characters, so the shape of a long result stays visible. PII-like keys and card-like numbers are left out. It covers tool input and output only. - No summary when tool IO capture is off. With
storeToolIO: false(store_tool_io=False) the summary is dropped along with the payload.
Tool results from older SDKs or arriving through OpenTelemetry (
/v1/otel) have no SDK marker. A result of 9,984 characters or more without the marker is treated as cut by the checks (an older cut can end a little before 10,240). With capture v2 off, TypeScript SDK 1.6.0 and Python SDK 1.7.0 or later still cut at 10,240 and mark each cut (zs_capture.tres = "truncated"). See Capture coverage.Server-side redaction
Redaction also runs on Zespan’s servers, before anything is stored or handed to automatic evaluation, anomaly detection, or analytics. It is separate from SDK redaction, which runs in your process, and the two work together.Always-on masking
For every project, whatever its settings, Zespan masks these identifiers in prompts, completions, system prompts, reasoning, decision rationale, error messages, and tool call arguments:- Email addresses
- US phone numbers and international phone numbers written with a
+country code - Credit card numbers
- US Social Security numbers
- IBANs (checksum-validated)
- IPv4 and IPv6 addresses
v, ver, version, build, release, sdk) or a product token such as Chrome/120.0.0.0.
Evaluators and judges see the masked text. Scores on content that contains these values can differ from what an unmasked judge would give.
Inline images
Image bytes are not stored in captured text. Current SDKs replace them before sending; the server applies the same rule to text from older SDK versions, before the masking above. Adata:image/...;base64, URL, or base64 that starts like a PNG, JPEG, GIF or WebP file (at least 64 characters), becomes [IMAGE_OMITTED]; the image type stays (data:image/png;base64,[IMAGE_OMITTED]) and the rest of the text and its structure are kept. A long base64 value that is not an image, such as a PDF or a file attachment, is left as it is, and so is a data URL that is not base64. It applies to every project and cannot be turned off.
Same value, same placeholder
Masked values are replaced with a keyed placeholder such as[PHONE_3F9A2C1B], so the same value written in the same trace gets the same placeholder and different values get different ones. Phone numbers are compared by their last 9 digits after a (0) trunk prefix is dropped (+1 415 555 0134 and (415) 555-0134 match, and so do +33 6 12 34 56 78 and 06 12 34 56 78), and dates of birth by the last two digits of the year, the month and the day (1984-03-12, 12/03/1984, 12/03/84 and 12 March 1984 match; a day and month swap also matches). Different values that share these parts get the same placeholder. Placeholders for phone numbers and dates written after this change differ from the ones written before it, and checks compare placeholders within one trace only.
Zespan also stores a few content-free facts next to the placeholders of a span: the kind of value, whether its checksum is valid (cards, IBANs and some national ids) and, for cards and IBANs only, the last 4 characters. It never stores the value itself, and a zs_tokens entry sent by an SDK is ignored. This data is removed with the content when your retention window for prompt content passes.
Project setting: Redact personal data (PII)
Under project Settings → General, turn on Redact personal data (PII) and choose a PII preset — GDPR, CCPA, HIPAA, PCI DSS, Financial services, Education, or SOC 2. With it on, Zespan applies broader detection to all of the fields above and also to tool results, tool definitions, planning steps, delegation reasons, agent descriptions, and string values intags and metadata (including nested ones). The free text stored with guardrail events — the reason and the tool call arguments — goes through the same redaction.
- New data only. The setting applies to data ingested after you turn it on; existing traces aren’t rewritten. It takes effect on the next batch your SDK sends.
- Identifiers are kept. Keys Zespan relies on to link records, such as
session_id, are left as they are. Atagsormetadatakey that is itself an email address becomes[REDACTED_KEY]. - Very large strings over 256 KB are replaced with
[REDACTED_TOO_LARGE]instead of being scanned. - Dataset runs and HTTP target calls get the same redaction.
- Person names are not detected. Name detection flagged too many product, model and place names, so the setting leaves names as they are. Emails, phone numbers, card numbers and the other kinds are still detected. Self-hosted: set
PII_OPENREDACTION_NAMES=onto detect names. - Failures never store the raw value. If redaction of a field fails, that field is stored as
[REDACTION_FAILED]and the rest of the event is kept. If Zespan can’t read your project’s settings, it redacts with the GDPR preset rather than skipping redaction.
What is NOT redacted — your responsibility
- Content typed into free-form fields is only caught by
redactKeysif it sits under a matching key. An email address embedded inside a user’s chat message body is not redacted by key-based redaction — that’s prose, not a keyed field.redactPii/redact_piiaddresses this in both SDKs and is on by default (see above). - Data outside the SDK’s capture path. Redaction only applies to the fields the SDK captures and sends (messages, tool call arguments, system prompts, prompt/completion text). Anything you log, store, or transmit yourself outside of
zespan’s wrappers is untouched by any of this. - Keys not in your configured list. If a sensitive field’s name isn’t in
redactKeys/redact_keys(and doesn’t match a default), its value passes through unredacted. Review the fields your application actually sends to LLMs and make sure your key list covers them. storePrompts: falsedisables capture, not redaction. SettingstorePrompts(store_prompts) tofalsestopsprompt_text/completion_text/system_promptfrom being captured at all — it isn’t a redaction mechanism, and it doesn’t affecttool_call_args, which is always redacted and sent whenever tool IO capture is on.
Capture v2 fields
With capture v2 on, the extra fields it records go through the same redaction, in the same order: converted to a structure, keys replaced, written out, then pattern redaction, and only then cut to the size limit (nothing is cut before it is redacted, and no object is written out with its text representation). Tool results and arguments, the structured messages, the output schema and the agent text followredactKeys / redact_keys exactly as stored prompts do. Tool definitions have default, example and const values removed from their schemas and keep an enum only when every value is short and not secret-shaped, and their descriptions are limited to 500 characters. The new markers hold fixed words and numbers only. The server applies its built-in key list again at ingest to the output schema as well.

