# What leaves your runtime

> What each capture level sends to Pyrula Cloud, how it is redacted, and where redaction does not reach.

Source: https://docs.pyrula.io/getting-started/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](https://docs.pyrula.io/getting-started/cloud.md).

## Capture levels

A level ships its class and everything below it:

| Level | Adds | Examples |
|---|---|---|
| `none` | structure | ids, kinds, names, timing, status, error codes, `error_type`, usage counters, a choice interrupt's option labels |
| `lifecycle` (default for `production` keys) | your workflow's data | a 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 text | an LLM step's message, a tool or sub-agent call's input and result, an agent turn's message and reply, failure messages |
| `full` | streams | token deltas, heartbeats, stream-overflow notices |

What Cloud sees, by level:

<!-- capture-table:start -->
| | `none` | `lifecycle` | `io` | `full` |
|---|---|---|---|---|
| Names, ids and timing | yes | yes | yes | yes |
| Counts and usage | yes | yes | yes | yes |
| Error type and code | yes | yes | yes | yes |
| Workflow params, step inputs and results | no | yes | yes | yes |
| An agent turn's message and reply | no | no | yes | yes |
| Error messages | no | no | yes | yes |
| A terminate's requester you named | no | no | yes | yes |
| Tool and model inputs and outputs | no | no | yes | yes |
| Token deltas and heartbeats | no | no | no | yes |
<!-- capture-table:end -->

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

### Who sets it

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 key | Default level |
|---|---|
| `development`, `staging` | `io` |
| `production`, every other | `lifecycle` |

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.

## Redaction

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.

### Your redaction policy

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

```bash
export PYRULA_REDACT_KEYS="tenant_pin,badge_number"
```

Or set a process-wide policy at startup (replaces `PYRULA_REDACT_KEYS`):

```python
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](https://docs.pyrula.io/reference/redaction.md).

### Your own mask callback

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

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

## Error text

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.

## Replay and fork in the console

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

## The outbox

Undelivered events wait in a local spool ([what it holds](https://docs.pyrula.io/getting-started/cloud.md#outbox-contents)).
Nothing is re-sent until the runtime matches the store's binding
([One store per project and environment](https://docs.pyrula.io/getting-started/cloud.md#one-store-per-project-and-environment)).

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