Metadata-Version: 2.5
Name: mainwave
Version: 0.1.0rc10
Summary: Python SDK for the Mainwave AI observability platform
Project-URL: Homepage, https://mainwave.com
Project-URL: Repository, https://github.com/mainwaveai/mainwave
Project-URL: Issues, https://github.com/mainwaveai/mainwave/issues
Author: Mainwave AI
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: agents,gen_ai,llm,mainwave,observability,opentelemetry,tracing
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Monitoring
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: opentelemetry-api>=1.27
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27
Requires-Dist: opentelemetry-sdk>=1.27
Provides-Extra: all
Requires-Dist: langchain-core<2,>=1.3; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-bedrock<0.63,>=0.62; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-crewai<0.63,>=0.62; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-genai-anthropic<2,>=1.2b0; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-genai-langchain<1.3,>=1.2b0; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-genai-openai<2,>=1.2b0; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-google-genai<2,>=1.2b0; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-groq<0.63,>=0.62; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-litellm<0.63,>=0.62; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-llamaindex<0.63,>=0.62; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-mistralai<0.63,>=0.62; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-vertexai<0.63,>=0.62; extra == 'all'
Provides-Extra: anthropic
Requires-Dist: opentelemetry-instrumentation-genai-anthropic<2,>=1.2b0; extra == 'anthropic'
Provides-Extra: bedrock
Requires-Dist: opentelemetry-instrumentation-bedrock<0.63,>=0.62; extra == 'bedrock'
Provides-Extra: conformance
Requires-Dist: anthropic<2,>=0.84; extra == 'conformance'
Requires-Dist: openai<4,>=2.8; extra == 'conformance'
Provides-Extra: crewai
Requires-Dist: opentelemetry-instrumentation-crewai<0.63,>=0.62; extra == 'crewai'
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: pyyaml>=6; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: google-genai
Requires-Dist: opentelemetry-instrumentation-google-genai<2,>=1.2b0; extra == 'google-genai'
Provides-Extra: groq
Requires-Dist: opentelemetry-instrumentation-groq<0.63,>=0.62; extra == 'groq'
Provides-Extra: langchain
Requires-Dist: langchain-core<2,>=1.3; extra == 'langchain'
Requires-Dist: opentelemetry-instrumentation-genai-langchain<1.3,>=1.2b0; extra == 'langchain'
Provides-Extra: litellm
Requires-Dist: opentelemetry-instrumentation-litellm<0.63,>=0.62; extra == 'litellm'
Provides-Extra: llama-index
Requires-Dist: opentelemetry-instrumentation-llamaindex<0.63,>=0.62; extra == 'llama-index'
Provides-Extra: mistralai
Requires-Dist: opentelemetry-instrumentation-mistralai<0.63,>=0.62; extra == 'mistralai'
Provides-Extra: openai
Requires-Dist: opentelemetry-instrumentation-genai-openai<2,>=1.2b0; extra == 'openai'
Provides-Extra: vertexai
Requires-Dist: opentelemetry-instrumentation-vertexai<0.63,>=0.62; extra == 'vertexai'
Description-Content-Type: text/markdown

# mainwave

Python SDK for the [Mainwave AI](https://mainwave.com) observability platform.

## Install

```bash
pip install "mainwave[openai]"        # one platform
pip install "mainwave[langchain]"     # LangChain + LangGraph, captured automatically
pip install "mainwave[all]"           # everything (kick-the-tires only)
```

Per-platform pinning is recommended in production.

## Quickstart

```python
import mainwave

mainwave.init(
    api_key="...",  # or MAINWAVE_API_KEY
    use_case_key="invoice-workflow",  # or MAINWAVE_USE_CASE_KEY — a stable name; the SDK derives the use-case UUID
    collector_url="https://collector.mainwave.com",  # default
)

with mainwave.run("INV-12345", metadata={"customer": "acme"}):
    # ... do work; auto-instrumented LLM calls land under this run
    ...

mainwave.flush()  # before exit
```

`init()` auto-wires every supported instrumentor that's already importable, so
the snippet above needs no `instrument()` call. Reach for `instrument()` only
to wire a framework imported *after* `init()` ran (lazy import, CLI subcommand,
FastAPI lifespan) — see [Public API](#public-api).

`mainwave.run(...)` is optional — without it, every OTel trace becomes its
own logical run. Wrap explicitly when one logical operation spans multiple
traces (loops, fan-out, multiple LLM invocations per request); the integration
guide wraps every agent execution for exactly this reason.

## Naming your use case (`use_case_key` vs `use_case_id`)

`use_case_id` means you bring the UUID (created in the Mainwave app);
`use_case_key` means you bring a stable name and the SDK derives the UUID
deterministically — same key, same identity, across runs, restarts, and
machines. Passing both raises. The first run under a new key surfaces in the
app under **Unassigned Agents**, named by the key, ready to adopt in one click.

Resolution, innermost wins:

1. explicit `use_case_id` — anywhere
2. `use_case_key=` on `run()` / `bind_identity()` (top-level roots)
3. `use_case_key=` on `init()`, or `MAINWAVE_USE_CASE_KEY`
4. *(default)* an `agent_name` bound at a run root → `f"{service_name}/{agent_name}"` — one use case per agent
5. *(default)* `service_name` — one use case per deployable

Treat the key like a database key: renaming it forks the use case. Keys are
case-sensitive and trimmed; `mainwave.use_case_id_for("my-key")` (or
`agent_name="…"`) predicts the derived UUID for scripts and CI. Set
`ucid_namespace=` / `MAINWAVE_UCID_NAMESPACE` to your org's issued namespace
(shown in the app) so derived identities survive API-key rotation — without
it, derivation seeds on the API key and rotating the key moves every derived
identity (the SDK warns at init).

## Not seeing data?

The SDK never raises into your app, so a misconfigured endpoint or key fails
quietly. Turn on its logger to see what's happening:

```python
import logging

logging.getLogger("mainwave").setLevel(logging.INFO)  # add a handler if you have none
```

At `INFO` it logs the first successful export, which instrumentors wired, and a
`WARNING` for any framework that didn't. `mainwave.flush()` returns `False` on a
failed drain — check it before a hard exit. Confirm `collector_url` and the API
key, and that the framework's extra is installed (`pip install "mainwave[openai]"`).

## Process exit and signals

`init()` registers an `atexit` hook that flushes buffered spans on normal
interpreter shutdown. It also installs a `SIGTERM` handler (main thread only) so
containers stopped with `SIGTERM` still flush before exit. The handler chains:
it flushes, then hands control back to whatever `SIGTERM` disposition was in
place before `init()` ran — if your app had its own handler, it runs after the
flush and owns the exit; if `SIGTERM` was default, the process exits with the
conventional `128+signum` status; if it was ignored, the SDK stays out of the
way. Repeat `init()` calls don't re-install, so the SDK never chains to itself.

Set `MAINWAVE_DISABLE_SIGNAL_HANDLER=1` to skip installing the `SIGTERM` handler
entirely (the `atexit` flush still registers) — use this if your process manages
`SIGTERM` itself and doesn't want the SDK touching it.

## Public API

- `mainwave.init(...)` — set up the SDK once, at process start.
- `mainwave.instrument(name=None)` — re-wire instrumentors after late imports.
- `mainwave.run(...)` / `mainwave.arun(...)` / `mainwave.start_run(...)` — open a logical run (sync CM, async CM, imperative).
- `mainwave.span(name, attrs=...)` — escape hatch for manual spans.
- `mainwave.flush(timeout=30.0)` / `mainwave.aflush(timeout=30.0)` — drain buffers.
- `mainwave.testing` — `reset()` + `install_in_memory_provider()` for tests.

## LangChain users

`init()` wires LangChain / LangGraph capture automatically when
`mainwave[langchain]` is installed — nothing is passed to `.invoke()` /
`.stream()`. The registry entry `langchain` points at
`mainwave.langchain.LangChainInstrumentor`, the SDK's subclass of the official
`opentelemetry-instrumentation-genai-langchain` package: upstream owns span
lifecycle and structure (`invoke_agent` / `invoke_workflow` / `chat {model}` /
`execute_tool` / retrieval spans), and the subclass adds graph-node spans,
corrected chat content and tokens, and reasoning. Opt out with
`init(skip_instrumentors=["langchain"])`.

## Several agents in one process

Each agent reported as its own use case is the default: bind the agent's name
at its run root and the per-agent identity derives automatically —

```python
with mainwave.run("INV-12345", agent_name="divergence"):
    ...  # lands in the "your-service/divergence" use case

# or around a LangChain call directly, with no enclosing run():
with mainwave.bind_identity(agent_name="divergence"):
    graph.invoke(payload)  # a LangChain ROOT run starting here derives the
    # per-agent default from the bound name
```

To group several agents under ONE use case instead, bind the identity at the
outermost root you own and leave the inner surfaces identity-free — they
inherit:

```python
with mainwave.run("INV-12345", use_case_key="invoice-workflow", agent_name="extractor"):
    ...  # every agent inside shares the invoice-workflow use case
```

An explicit `use_case_id=` / `use_case_key=` bound in a nested
`bind_identity()` (or a nested `run()`) wins over an enclosing root's binding
(a deliberately multi-use-case run). Omit identity args to inherit; an empty
value is ignored rather than applied. If your process identity is an explicit
`use_case_id`, agent names stay a display axis and never move telemetry.

## Running inside a service that already uses OpenTelemetry

If your process already has its own OpenTelemetry setup (FastAPI/DB
auto-instrumentation, background-job spans, …), the SDK coexists with it. It
attaches to your existing `TracerProvider` rather than replacing it, and it
exports a trace to Mainwave **only when that trace involves an LLM call** — your
service's own infrastructure traces stay out of Mainwave. When a trace *does*
contain an LLM call, it's sent in full, so the surrounding request span that
triggered the call is preserved and correlated. This mirrors how Langfuse
filters export on a shared provider; you don't have to isolate the SDK or wire a
separate provider.

## Redacting sensitive data

Give the SDK rules and it replaces every match in a span's content before the
span leaves your process. You can turn on built-in rule packs, write your own
regular-expression rules, or pass a masking function. Without any of them,
nothing changes.

```python
import mainwave

mainwave.init(
    redaction={
        "packs": ["secrets", "pii.contact"],
        "blocked_values": [{"name": "account", "pattern": r"ACCT-[0-9]{8}"}],
        "allowed_values": [r"[A-Za-z0-9._%+-]+@example\.com"],
    },
)
```

With these rules, the prompt `Mail jo@acme.com, cc ops@example.com, key
sk-A1b2C3d4E5A1b2C3d4E5XYZ, account ACCT-12345678` is exported as `Mail
[REDACTED:pii.contact.email], cc ops@example.com, key
[REDACTED:secrets.openai_api_key], account [REDACTED:account]`, and the span
carries `mainwave.redaction.count = 3`.

The same rules as a JSON file (the same shape as the dict) work through an
environment variable, so a deployment can change them without a code change:

```bash
export MAINWAVE_REDACTION_FILE=/etc/mainwave/redaction.json
```

When both are set, the `init(redaction=...)` argument wins, the file is not
read, and the SDK logs one warning saying so. The masking function (`mask=`) is
passed in code only.

### Rule set

Every key is optional. Any other key is an error.

| Key | Value | Effect |
|---|---|---|
| `packs` | list of pack names | Turns on built-in rule packs (below). None are on by default. |
| `blocked_values` | list of rules | Every non-overlapping match, left to right, is replaced with `[REDACTED:<name>]` (`[REDACTED]` for an unnamed rule). Rules run in the order listed, after any packs; a later rule scans the output of an earlier one. |
| `allowed_values` | list of rules | A pack or `blocked_values` match is kept when one of these matches **the whole matched text** (anchored at both ends; `$` is the end of that text). A rule that matches only part of it does not keep it. |
| `blocked_key_patterns` | list of rules | Searched in the attribute **key**. On a hit the whole string value is replaced (each element of a string array). Applied before value rules; a replaced value is not scanned again. A value that is JSON gets the token as a JSON string, so it stays valid JSON. |
| `ignored_keys` | list of strings | Attribute keys that are never scanned (exact match). |
| `ignored_key_patterns` | list of pattern strings | A key matching one is never scanned. |
| `summary` | `"info"` (default) or `"silent"` | With `info`, a span with at least one replacement carries `mainwave.redaction.count`, the number of replacements across everything scanned on that span. Spans with none do not carry the key. `silent` never adds it. |

A rule is a pattern string, or `{"name": ..., "pattern": ..., "case_insensitive": true}`;
`name` and `case_insensitive` are optional. Each replacement counts 1. The field
names follow the OpenTelemetry Collector's `redactionprocessor`, so existing
patterns carry over once they fit the grammar below.

### Rule packs

Each pack is a set of tuned patterns. A pack only runs when `packs` lists it,
and its rules are named `<pack>.<rule>`. Packs always run in one fixed order,
whatever order you list them in: `secrets`, `pii.contact`, `pii.us_gov_id`,
`pii.payment_card` (so a card rule never reads the digits inside a Slack token
before the `secrets` pack has replaced the token). Pack rules run before `blocked_values`
and obey `allowed_values`, `ignored_keys`, `ignored_key_patterns` and
`blocked_key_patterns` like any other rule. The patterns favour precision over
recall: they miss unusual formats rather than redact ordinary text. Every
quantifier in them is bounded, so a pattern cannot run away on a long input.

| Pack | Redacts |
|---|---|
| `secrets` | AWS access key ids, GitHub tokens (classic and fine-grained), Anthropic and OpenAI API keys (including `sk-proj-`, `sk-svcacct-`, `sk-admin-` and OpenRouter-style `sk-or-v1-` keys), Slack tokens (`xoxa-`, `xoxb-`, `xoxc-`, `xoxd-`, `xoxp-`, `xoxr-`, `xoxs-` and `xapp-`), PEM private-key blocks, JWTs |
| `pii.contact` | Email addresses (also written `jo%40acme.com`, with an apostrophe in the name); North American phone numbers (`(415) 555-2671`, `415-555-2671`, `415.555.2671`, `+1 415 555 2671`) and international E.164 numbers, written contiguous or with single spaces, dots or hyphens (`+44 20 7183 8750`, `+44.20.7183.8750`) |
| `pii.us_gov_id` | US Social Security numbers written `123-45-6789`, excluding the area numbers 000, 666 and 900-999 that no SSN uses |
| `pii.payment_card` | Card numbers, first digit 2-6, 13-19 digits, contiguous or grouped with spaces, dots or hyphens: rule `card_number_grouped_19` for the 4-4-4-4-3 grouping, rule `card_number` for the rest. A match is replaced only when it passes the Luhn checksum, so a long number that is not a card number stays |

Trade-offs to know before turning one on:

- Length bounds: tokens and keys longer than 255 or 256 characters, a PEM body
  over 8,192 characters, a PEM header of more than 4 words or with a word over
  16 letters, and JWT segments over 1,024 (header) or 8,192 are not matched at
  all, or only up to the bound.
- `pii.contact` also matches addresses such as `git@github.com`. The E.164 rule
  treats `+` plus 8-15 digits as a phone number, so it also matches `+2024-01-15`
  and other signed decimals, coordinates and IP-like numbers
  (`+40.7127753-074.0059728`, `+1.23456789`, `+192.168.100.200`), because `.` and
  `-` may separate digits. A phone number followed by one space and more digits
  absorbs them up to 15 digits in total.
- `pii.us_gov_id` matches any `123-45-6789` shaped group.
- About 1 in 10 random digit strings starting 2-6 passes the Luhn check, so
  `pii.payment_card` can redact an order number. A 16-digit card followed by a
  3-digit number can pass as one 19-digit match. Card groupings 4-6-6, 4-4-8,
  8-8 and 4-12 are not covered.
- `pii.contact` matches image names such as `image@2x.png`, and does not match
  parenthesized international numbers (`+44 (0)20 7183 8750`) or numbers with a
  `00` prefix.
- Only string attribute values are scanned. An integer, float or bytes attribute
  is sent as is, so a card number stored as an integer is not redacted (numbers
  inside a JSON string attribute are).
- The prefixed token rules match even when the token is glued to other text (after
  a literal `\n`, `%0A` or `_`); the bare `sk-` and `AKIA` rules do not. The
  scoped `sk-` rule matches only the prefixes `proj`, `svcacct`, `admin`, `None`
  and `or-v1`, so ordinary words (`risk-management-...`, `flask-api-...`) are left
  alone and a key with another prefix is not matched. A word ending in `sk`
  followed by a listed prefix and a 20+ character body still matches
  (`flask-admin-...`, `task-admin-...`, `desk-proj-...`), so benign text can be
  over-redacted, but nothing leaks. A GitHub token ending in `_` and the tail of
  an encrypted JWE are not matched.
- The AWS key rule matches AWS's published example keys but not the 40-character
  secret access key.

### Pattern grammar

Patterns use one grammar, shared by the Python and Node SDKs, so one rules file
redacts identically in both. The SDK checks
every pattern against an allowlist when `init()` loads it and then translates it
for Python's `re`; your text is never handed to the regex engine as written.
Anything outside the grammar is rejected, naming the rule.

| Element | Allowed |
|---|---|
| Literals | Any character except `\ ^ $ . \| ? * + ( ) [ ] { }`; write those as `\` plus the character |
| Escapes | `\d \D \w \W \s \S \b \B`, `\t \n \r \f \v`, `\xHH` (exactly two hex digits), and `\` plus one of `\ ^ $ . \| ? * + ( ) [ ] { } / -` |
| Groups | `(...)` and `(?:...)`, both non-capturing; `\|` for alternatives (an alternative may be empty) |
| Quantifiers | `* + ? {n} {n,} {n,m}` (counts up to 65535, leading zeros accepted), each with an optional lazy `?` |
| Classes | `[...]` and `[^...]` with literals, ranges (`a-z`, `\x00-\x1f`) and the escapes above except `\b` and `\B`; a `-` is a literal only first or last (or written `\-`) |
| Case-insensitive | `case_insensitive: true` folds ASCII letters only; the rule cannot contain a non-ASCII character |

What the elements mean, which is not always what your regex engine does:

| Element | Meaning |
|---|---|
| `.` | Any character except `\n`, `\r`, U+2028 and U+2029 |
| `\d`, `\w` | `[0-9]`, `[0-9A-Za-z_]`: ASCII only |
| `\s` | `[\t\n\v\f\r ]`: ASCII only, so a non-breaking space is not `\s` (and is matched by `\S`) |
| `\b` | A boundary between an ASCII word character and anything else, including the ends of the text |
| `^`, `$` | The start and the end of the text. `$` does not match before a trailing newline, and there is no multiline mode |
| Characters | A character is one code point, so an emoji is one character to `.` and to `{n}` |

Rejected, because the engines disagree or the construct is unsafe: lookahead and
lookbehind, inline flags (`(?i)`; use `case_insensitive`), named, atomic and
comment groups, backreferences, `\A \Z \z \p{..} \u`, possessive and stacked
quantifiers (`a*+`, `a**`), a quantifier on an anchor or on a group that can be
empty (`(a*)*`), a stray `{`, `}` or `]`, and in a class a `]` first, an
unescaped `[`, `&&`, `--` and `~~`, a reversed range, a range ending in an
escape, and a stray `-`. Write a literal `&&` or `~~` in a class as `\x26\x26` or
`\x7e\x7e`, and a literal `--` as `\-\-`.

A pattern that can match without consuming a character (`a*`, `\b`, `^`, `(?:ab)?`)
is rejected: it has nothing to replace.

Five cost limits cap a pattern and reject the classic exponential forms when
`init()` loads it:

| Limit | Rejected | Allowed |
|---|---|---|
| At most 4096 characters | a 4097-character pattern | 4096 |
| Groups nest at most 32 deep | 33 nested groups | 32 |
| A quantifier inside a quantified group that can repeat (maximum above 1) must be bounded | `(?:a+)+`, `(?:x+y){2}` | `(?:a+)?b` (a `?` group repeats at most once), `a(?:\.[a-z]{1,63}){0,8}` |
| An unbounded quantifier (`*`, `+`, `{n,}`) over a group needs an operand of one fixed length (minimum length equals maximum length) | `(?:a?a)+x`, `(?:a\|aa)+`, `(?:a\|ab)*c`, `(?:ab?)+c`, `(?:a{1,2})+b` | `(?:ab)+c`, `(?:a\|b)+c`, `(?:ab{2})+`, `a+` |
| Along any chain of nested quantifiers, the maximums multiply to at most 65535 | `(?:a{1,65535}){1,65535}b` | `a(?:\.[a-z]{1,63}){0,8}` (504) |

Unbounded quantifiers at the top level (`a+b`) stay allowed, and so do
alternatives of the same length that overlap (`(?:a|a)*c`).

### What is scanned

| Scanned | Not scanned |
|---|---|
| Span name (as plain text) | Event names |
| String attribute values, including prompts, completions, tool arguments and tool results | Integers, floats and booleans stored as attribute values, including every token count |
| Each string in a string-array attribute | Non-string array elements |
| String values of events on the span (including exception events) | Object keys inside JSON |
| String values of span link attributes | The literals `true`, `false` and `null` inside JSON |
| String values of resource attributes (`host.name`, `deployment.environment.name`, ...) | |
| The span status description (as plain text) | |
| Every string and number value inside an attribute that holds JSON | |

**JSON in place.** An attribute that is one strict JSON text (an object or an
array, RFC 8259, with only JSON whitespace around it) is redacted in place. The
SDK scans each string and number value on its own, so a rule never matches
across two values, and rewrites only the values that change: key order,
duplicate keys, whitespace, number spelling and untouched escapes (`é`,
`\/`) stay exactly as they were. A changed string is written back as a JSON
string; a changed number becomes a string, so a card number stored as the JSON
number `4111111111111111` is exported as `"[REDACTED:...]"`. Text that is not
strictly valid JSON (a trailing comma, `NaN`, single quotes, a raw tab, text
after the value, a non-breaking-space pad) is scanned as one plain string.
Nesting depth is not limited.

Resource, link and event attributes follow the same rules and exemptions as span
attributes; the span name and status description have no key, so key rules and
key exemptions never apply to them. The replacement count is written to the
span's own attributes, including replacements made in its resource, links,
events and status description.

### Attributes that are never scanned

Identity and accounting attributes are exempt, and no rule, pack or masking
function can change that: a rule that matches them leaves them as they were, and
`ignored_keys` only adds to the list. The exempt keys are those the platform reads
back verbatim as identifiers, enums and references, including `mainwave.ucid`,
`mainwave.run_id`, `mainwave.user_email`, `user.email`, `gen_ai.agent.name`,
`gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.tool.name`,
`gen_ai.tool.call.id`, `gen_ai.conversation.id`, `service.name` and
`mainwave.redaction.count`. An email address stored under `mainwave.user_email`
or `user.email` is therefore sent as is. Model names (`gen_ai.request.model`) and
all other attributes are scanned.

### Masking function

For anything a pattern cannot express, pass a function. It receives each scanned
string and the attribute key it came from, and returns the replacement:

```python
import hashlib


def mask(value: str, key: str) -> str:
    if key == "customer.note":
        return "[note withheld]"
    if "internal-host" in value:
        return value.replace(
            "internal-host", "host-" + hashlib.sha256(value.encode()).hexdigest()[:8]
        )
    return value


mainwave.init(mask=mask)  # works with or without `redaction=`
```

| Behavior | Detail |
|---|---|
| When it runs | Once per value, after the packs and `blocked_values` ran on it. It receives their output. |
| What it receives | String attribute values and each element of a string array (key = the attribute key), in span, event, link and resource attributes. For a JSON attribute, each value token: the decoded text of every string and the exact text of every number, as a string (key = the attribute key). The span name (key `"span.name"`) and the status description (key `"span.status.description"`). |
| Empty strings | It is called for them too: an empty attribute value, array element, JSON string, span name or status description. |
| When it is not called | Exempt or ignored keys; a value a `blocked_key_patterns` rule already replaced whole; event names, object keys and literals inside JSON, integers, floats and booleans. |
| Its output | Used as is; rules do not scan it again. A result that differs from what it received adds 1 to `mainwave.redaction.count`. In a JSON attribute a value is rewritten when the final text differs from the original, and is written back as a JSON string. |
| Failure | A return value that is not a string, or an exception, drops that span. |

A masking function with no `redaction` rules still runs on every span.

### Order of operations and size limits

Redaction runs after the SDK normalizes a span and before it shortens long
values, so a secret that straddles a length cut is replaced whole and no part
of it is exported. The length limits still apply to the redacted text. Three
places cut strings before the exporter would:

- **The LangChain and CrewAI integrations** shorten very long strings (8192
  characters by default) when they capture them. With rules configured they end
  that cut before any match it would split, so the exporter still sees the whole
  secret; a cut text can therefore be a little shorter than the limit. The
  check looks only at a window around the cut: as wide as the rule's longest
  possible match, so its cost does not grow with the string. A user rule with an
  unbounded quantifier at the top level (`*`, `+`, `{n,}`) gets a fixed window of
  16,384 characters each side (every pack rule is bounded and uses its own),
  and a match longer than that can still be split. Give user rules bounded
  quantifiers. A masking function sees the text after that cut.
- **OpenTelemetry's attribute value length limit** (`OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT`,
  `OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT`) cuts a value when it is recorded.
  When `init()` owns the provider and redaction or a masking function is
  configured, the SDK lifts that limit from the provider and applies the same
  limit itself, after redaction, to the attributes the span was recorded with
  and to event and link attributes. The result is what the provider would have
  produced, except that a match on the boundary is replaced whole.
- **A provider you own** (the SDK sharing your `TracerProvider`) is not the
  SDK's to change. If a value length limit is set there and redaction is on,
  `init()` logs one warning: the provider cuts values before redaction, so a
  secret straddling the cut can leave a prefix no rule matches. Remove the limit
  or let the SDK own the provider.

### Pattern cost

The grammar's cost limits are the only static cost rules. Beyond them your
patterns are your responsibility, and they run synchronously with no timeout: on
the exporter thread when a span is exported, and on the request thread for the
LangChain and CrewAI capture-time cut. A slow pattern stalls that thread.

- Adjacent overlapping quantifiers are accepted and cost O(n^(k+1)) for `k` of
  them: `\w*\w*\w*\w*\w*\w*x` on 64 `a`s with no `x` takes about 4 s in Python
  (in Python, about 0.4 s with five `\w*` and 0.04 s with four).
- A bounded quantifier over a variable-length group (different-length
  alternatives, an optional member or a nested quantifier) and bounded nesting
  are accepted and can be exponential on a run of `a`s with no `x`:
  `(?:a|aa){1,1000}x` takes 4.6 s in Python (6.9 s in Node) on 38 characters,
  and `(?:a?a){1,40}x` 2.0 s in Python (2.9 s in Node) on 36. The fixed-length
  rule only constrains unbounded quantifiers.
- A top-level `a+b` is quadratic on a long input of `a`s.

Prefer bounded quantifiers (`{1,64}` rather than `+`) and test patterns against
long inputs. The built-in packs are bounded and tested at 256 KiB per input
(the slowest, the PEM rule on repeated headers, takes about 0.5 s in Python).

### Failure behavior

| Situation | What happens |
|---|---|
| Rules file missing, unreadable or not valid JSON; unknown key; unknown or repeated pack; a pattern outside the grammar or that cannot match a character; non-boolean `case_insensitive`; bad `summary`; `mask` not callable | `init()` raises `MainwaveConfigError` naming the file, key, pack or rule. The SDK exports nothing. This also applies when the SDK is disabled. |
| Redacting a span raises, or the masking function raises or returns a non-string | That span is dropped and not exported. One `ERROR` line on the `mainwave` logger per distinct failure (rule and exception type), with no span content. Other spans export normally. |
| No rules, no packs and no masking function (or only `allowed_values` / `ignored_*` / `summary`) | No redaction stage runs. Exported spans are identical to a run without `redaction`. |
| A second `init()` with different rules, packs or a different masking function | Raises `MainwaveReinitConflict`, like any other changed setting. |

When the SDK shares a `TracerProvider` with your own OpenTelemetry setup, only
the export to Mainwave is redacted. Exporters you configured yourself receive
the original, unredacted spans.
