What leaves your runtime
Pyrula defines which run data Pyrula Cloud receives, how the runtime redacts it, and which data remains local.
Your local store is never redacted. It keeps every step’s real arguments and results at every capture level, because replay reads them. Capture levels and redaction only govern what leaves the process.
Capture levels
Section titled “Capture levels”Each field of each event belongs to one class, and a level ships its class and everything below it:
| Level | Adds | Examples |
|---|---|---|
none | structure | ids, kinds, names, timing, status, error codes, usage counters, a choice interrupt’s option labels |
lifecycle (default) | your workflow’s data | run params and metadata, a step’s input and result, emit / signal / interrupt / update payloads, status and error text |
io | model and tool traffic | an LLM step’s message, a tool or sub-agent call’s input and result |
full | streams | token deltas, heartbeats, stream-overflow notices |
- A withheld field keeps its key with the value
[omitted: capture level]. A field that isnullships asnullat every level, since it holds nothing to withhold. - An unclassified field (say, one added in a new release) counts as
full, so it can’t leak at a lower level. noneis the only level that keeps run content out of the cloud. Atlifecyclea step’s own input and result already ship (redacted).- At
nonea run is still visible and a choice interrupt can still be answered.
Who sets it
Section titled “Who sets it”A declaration is a ceiling; everything after it can only lower the level.
@workflow(capture=...)/@agent(capture=...). Acapture=onRunnerorCloudRunReporteracts as the declaration for workflows that have none.PYRULA_CLOUD_CAPTURE: sets the level when nothing is declared, otherwise can only lower it. An invalid value stops the runtime at startup.- Agents only: the dashboard’s
config.update, applied last, lower only.
With none of these set, the level is lifecycle.
Redaction
Section titled “Redaction”Everything that leaves (cloud events, control-command failure acks, OpenTelemetry content and exception text) passes through the same steps, in order:
- Capture level. A withheld field never reaches redaction.
- Secret-shaped keys. Any dict key, at any depth, that contains one of these names
(case-insensitive) is masked, along with its whole value (a nested object included):
api_key,access_key,private_key,ssh_key,session_id,authorization,password,passwd,passphrase,pwd,secret,token,bearer,credential,cookie,signature,otp,jwt,csrf,xsrf,sessid,connect.sid. Compound names also match with.,-, a space or no separator (apikey,api-key). Plural*_tokensusage counters such asinput_tokensare not masked;access_tokens,refresh_tokens,auth_tokens,api_tokensandid_tokensare. A dict key that is itself secret-shaped (a stray API key used as a map key rather than a value) is masked too, keeping colliding keys distinct. ACookie:header’s pairs are checked individually, sosession=...is masked even when it is not the first pair. - Secret-shaped values in text. A credential in a URL (
postgres://user:pw@host/db) or in akey=value/key: value/ JSON / Python-repr assignment whose key is one of the names above, includingAuthorization: Bearer <token>(the scheme word stays). This catches a DSN or token printed into an exception message. - Known token formats, with no key at all: Anthropic, OpenAI, GitHub, GitLab, AWS,
Slack, Google and Stripe keys, JWTs, PEM private keys (including URL-encoded and
lower-case headers) and PuTTY
.ppkkeys. A JWT is always masked as one unit, even when its payload happens to contain another provider’s token shape. - Your policy (below).
- Size caps. A string over 64 KB is cut to a redacted prefix. A dict or list over
that is replaced by
{_truncated, _original_bytes}(identity fields such as an interrupt’swait_idare kept). Free-text detail such as an error message is capped at 2,048 characters.
Your redaction policy
Section titled “Your redaction policy”Extra key names, comma-separated, matched like the built-in ones:
export PYRULA_REDACT_KEYS="tenant_pin,badge_number"Or set a process-wide policy at startup (it replaces PYRULA_REDACT_KEYS):
import re
from pyrula.contracts.redaction import RedactionPolicy, set_redaction_policy
set_redaction_policy( RedactionPolicy( extra_keys=("tenant_pin", "badge_number"), never_redact_keys=("signature",), # exempt a name; secret-shaped values in it are still masked extra_value_patterns=(re.compile(r"ACME-\d{6}"),), ))See the redaction API reference.
Your own mask callback
Section titled “Your own mask callback”For masking that depends on more than a key name or a shape (a customer ID, a field only
secret in combination with another), pass mask. It runs once, after everything above and
before the size caps, on the whole payload that is about to leave:
def mask(data): # `data` has already been through the built-in redaction - add to it, don't rely # on it to hand you the raw value. if isinstance(data, dict) and "customer_email" in data: data = dict(data) data["customer_email"] = "[masked]" return data
set_redaction_policy(RedactionPolicy(mask=mask))If mask raises, the payload is replaced by a fixed marker ([redacted: mask failed])
rather than shipped either raw or half-masked; the exception is logged once per exception
type, without the payload.
Redaction limits
Section titled “Redaction limits”It masks every shape and name listed here and fails closed on what it can’t parse (an unterminated quote, a value split by a size cut). A secret with no recognizable shape (a bespoke token, a plaintext business secret with no key name) still ships. If something must never leave, use a lower capture level, not redaction, and keep secrets out of run arguments.
At full, token deltas are redacted chunk by chunk, so a secret split across chunks can
pass in the live stream; the assembled message in step:done is redacted whole.
Error text
Section titled “Error text”Locally, exception text is kept raw: a step’s or sub-agent call’s error up to 2,561 characters, an agent’s unexpected-error message up to 1,513. That is the egress cap (2,048 and 1,000) plus a margin so redaction can still recognise a secret the stored copy cut in two.
When the runtime declines a cloud control command, the ack carries the runtime’s own refusal message (capped at 2,048), or for an exception from your code only its type name, never its message.
Replay and fork in the console
Section titled “Replay and fork in the console”Replays and forks run on your side from the local store, so they work at every level. What
the level limits is what the dashboard can show: a step’s input needs lifecycle; a tool
or LLM call’s input and result need io.
The outbox
Section titled “The outbox”Events not yet delivered wait in a local spool; see what the outbox holds. The same rule covers what your runtime re-sends from its local store - a crash-recovery sweep, a repair pass, or the end of an attempt: nothing is re-sent until the runtime is matched to the store’s binding, and then it is sent to that project and environment. See One store per project and environment.
OpenTelemetry
Section titled “OpenTelemetry”With pyrula.agents.observability.configure, prompt and completion content on spans is off
unless OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT is set or you pass
capture_content=True. configure_langfuse, configure_langsmith and
configure_datadog turn it on by default (capture_content=False turns it off). Recorded
content, error statuses and exceptions are redacted as above.