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

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.

Each field of each event belongs to one class, and a level ships its class and everything below it:

LevelAddsExamples
nonestructureids, kinds, names, timing, status, error codes, usage counters, a choice interrupt’s option labels
lifecycle (default)your workflow’s datarun params and metadata, a step’s input and result, emit / signal / interrupt / update payloads, status and error text
iomodel and tool traffican LLM step’s message, a tool or sub-agent call’s input and result
fullstreamstoken deltas, heartbeats, stream-overflow notices
  • A withheld field keeps its key with the value [omitted: capture level]. A field that is null ships as null at 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.
  • none is the only level that keeps run content out of the cloud. At lifecycle a step’s own input and result already ship (redacted).
  • At none a run is still visible and a choice interrupt can still be answered.

A declaration is a ceiling; everything after it can only lower the level.

  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, lower only.

With none of these set, the level is lifecycle.

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

  1. Capture level. A withheld field never reaches redaction.
  2. 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 *_tokens usage counters such as input_tokens are not masked; access_tokens, refresh_tokens, auth_tokens, api_tokens and id_tokens are. 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. A Cookie: header’s pairs are checked individually, so session=... is masked even when it is not the first pair.
  3. Secret-shaped values in text. A credential in a URL (postgres://user:pw@host/db) or in a key=value / key: value / JSON / Python-repr assignment whose key is one of the names above, including Authorization: Bearer <token> (the scheme word stays). This catches a DSN or token printed into an exception message.
  4. 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 .ppk keys. A JWT is always masked as one unit, even when its payload happens to contain another provider’s token shape.
  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} (identity fields such as an interrupt’s wait_id are kept). Free-text detail such as an error message is capped at 2,048 characters.

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

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

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.

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.

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.

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.

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.

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.