Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

What leaves your runtime

Your local store is never redacted; replay reads its real arguments and results at every capture level. Capture levels and redaction govern only what leaves the process for Pyrula Cloud.

A level ships its class and everything below it:

LevelAddsExamples
nonestructureids, kinds, names, timing, status, error codes, error_type, usage counters, a choice interrupt’s option labels
lifecycle (default for production keys)your workflow’s dataa workflow’s params, metadata and result, a step’s input and result, emit / signal / interrupt / update payloads
io (default for development and staging keys)model and tool traffic, failure textan LLM step’s message, a tool or sub-agent call’s input and result, an agent turn’s message and reply, failure messages
fullstreamstoken deltas, heartbeats, stream-overflow notices

What Cloud sees, by level:

nonelifecycleiofull
Names, ids and timingyesyesyesyes
Counts and usageyesyesyesyes
Error type and codeyesyesyesyes
Workflow params, step inputs and resultsnoyesyesyes
An agent turn’s message and replynonoyesyes
Error messagesnonoyesyes
A terminate’s requester you namednonoyesyes
Tool and model inputs and outputsnonoyesyes
Token deltas and heartbeatsnononoyes
  • Error messages below io ship three exceptions: a framework explanation (a budget’s cap and spend, a blocked tool’s name and reason), a ControlRefused ack, and an identifier-shaped value under a structural key of a structured detail (reason, error).
  • An agent turn’s input and result stay on your runtime below io; declare @agent(capture="io") to see them in Cloud. A @workflow that returns a model’s output ships it from lifecycle as its result.
  • A withheld field keeps its key with the value [omitted: capture level]. A null stays null.
  • An unclassified field counts as full, so a new field can’t leak at a lower level.
  • none is the only level that keeps run content out of Cloud. A run stays visible and a choice interrupt can still be answered.
  • input_fingerprint, branch_options and owner on run:init never leave at any level, full included.
  • A failure’s message (run:error.detail, a step’s error* fields, agent:call_error.error, run:update_failed.detail) ships at io and above after redaction, cut to 2048 characters. Redaction catches token and key patterns, not a plain password quoted in the text, so keep secrets out of exception text. The local journal keeps the whole message.
  • Below io a failure ships its code and error_type (the class name, Outer.Inner; a name that is not a dotted identifier ships as Exception).
  • A terminate’s or parent-caused cancel’s reason follows the failure rule. Who asked ships at every level only as a runtime requester: cloud, parent, api or cli. A requester you pass to terminate(requested_by=...), or the Cloud user id on a dashboard terminate, ships at io and above.
  • A Kafka-bound run’s run:init names the source record (topic, partition, offset, timestamp), never its key or header values.
  • A tool call held for approval ships its reason, tool name and call id at every level; its arguments (tool_input on run:interrupted) ship at io and above.
  • Identifiers you choose ship at every level: a run id you pass, a workflow or agent name, an agent turn’s thread_id. Use opaque ids, not an email address or a name.

A declaration is a ceiling; later settings can only lower it.

  1. @workflow(capture=...) / @agent(capture=...). A capture= on Runner or CloudRunReporter acts as the declaration for workflows that have none.
  2. PYRULA_CLOUD_CAPTURE sets the level when nothing is declared, otherwise can only lower it. An invalid value stops the runtime at startup.
  3. Agents only: the dashboard’s config.update, applied last.

With none of these set, Cloud’s default for the key’s environment applies:

Environment of the keyDefault level
development, stagingio
production, every otherlifecycle

Cloud reports the default at registration. The connector waits at most 3 seconds for it (not at all when a level is declared or PYRULA_CLOUD_CAPTURE is set). Without an answer the level is lifecycle, and a late answer applies only to events built afterward. pyrula dev prints the effective level and where it came from.

Everything that leaves (Cloud events, control-command failure acks, OpenTelemetry content and exception text) passes through these steps in order:

  1. Capture level. A withheld field never reaches redaction.
  2. Secret-shaped keys. A dict key at any depth containing one of these names (case-insensitive) is masked with its whole value: 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 match with ., -, a space or no separator. Usage counters such as input_tokens are not masked; access_tokens and refresh_tokens are.
  3. Secret-shaped values in text. Credentials in URLs (postgres://user:pw@host/db) and in key=value, key: value, JSON or repr assignments using those names, including Authorization: Bearer <token>.
  4. Known token formats, with no key: Anthropic, OpenAI, GitHub, GitLab, AWS, Slack, Google and Stripe keys, JWTs, PEM private keys and PuTTY .ppk keys.
  5. Your policy (below).
  6. 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}. Free-text detail such as an error message is capped at 2,048 characters.

Redaction fails closed on what it can’t parse. A secret with no recognizable shape, such as a bespoke token or a plaintext business secret under an innocent key, still ships; keep it out of run arguments or use a lower capture level. At full, a secret split across token deltas can pass in the live stream, while the assembled step:done message is redacted whole.

Extra key names, matched like the built-in ones:

Terminal window
export PYRULA_REDACT_KEYS="tenant_pin,badge_number"

Or set a process-wide policy at startup (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.

mask runs once on the whole payload about to leave, after everything above and before the size caps:

def mask(data):
# `data` has already been through the built-in redaction.
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 [redacted: mask failed]. redaction_hook on AppOptions or RunRouterConfig is separate: it runs on what an HTTP client receives, never on events sent to Cloud.

A declined control command’s ack carries the runtime’s own refusal message (capped at 2,048 characters), or for an exception from your code only its type name.

A Kafka dead-letter record’s pyrula.error.message header is [code] ErrorType below io; at io and above it is the failure text, redacted and cut to 2,048 bytes. Records from pyrula loggers have the same shapes masked in the message and traceback. A formatter that renders exc_info itself rather than through exc_text bypasses that.

Replays and forks run on your side from the local store, so they work at every level. The level only limits what the dashboard shows: a step’s input needs lifecycle; a tool or LLM call’s input and result need io.

Undelivered events wait in a local spool (what it holds). Nothing is re-sent until the runtime matches the store’s binding (One store per project and environment).

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. Recorded content, error statuses and exceptions are redacted as above.

View this page as Markdown