▶ runtime-narrative

Failure Diagnostics

When an exception escapes a story, runtime-narrative builds an EnrichedFailure and emits it as a FailureOccurred event. How much information the failure contains is controlled by FailureDiagnosticsConfig.

9.1 Lean mode (default)

Lean mode adds no overhead beyond reading the traceback. It collects:

  • Primary frame: the innermost app-code frame in the traceback (falling back to innermost non-stdlib, then the leaf frame). The frame's filename, line number, function name, and source line are included.
  • Source snippet: a few lines of source context around the primary frame (snippet_context_lines=2 on each side by default).
  • Compressed stack summary: a one-line count like "2 app frame(s), 5 other/hidden in full stack (7 total)".
  • Exception chain: a human-readable description of any chained __cause__ or __context__ exceptions.
python
from runtime_narrative import story, stage, FailureDiagnosticsConfig

lean_config = FailureDiagnosticsConfig(failure_diagnostics="lean")

try:
    with story("Checkout", diagnostics_config=lean_config):
        with stage("Charge Card"):
            raise ValueError("card declined: insufficient funds")
except ValueError:
    pass

9.2 Rich mode

Rich mode additionally captures local variable values from the primary frame and up to one enclosing frame. This is invaluable for debugging because it shows the exact values in scope at the point of failure.

python
from runtime_narrative import story, stage, FailureDiagnosticsConfig

rich_config = FailureDiagnosticsConfig(failure_diagnostics="rich")

def process_payment(card_number: str, amount: float) -> None:
    retries = 3
    raise ValueError("gateway timeout")

try:
    with story("Checkout", diagnostics_config=rich_config):
        with stage("Charge Card"):
            process_payment("4111111111111111", 99.99)
except ValueError:
    pass
# FailureOccurred.locals_by_frame contains: amount=99.99, retries=3
# card_number is NOT redacted by default — add redact_extra=("card_number",) to redact it

FailureDiagnosticsConfig field reference

FieldDefaultDescription
runtime_environment"development""development" or "production"
failure_diagnostics"lean""lean" or "rich"
allow_rich_in_productionFalseBypass the production safety guard
app_roots()Directories whose frames are labeled "app" for primary frame selection. Defaults to cwd when empty.
redact_extra()Additional key substrings to redact (case-insensitive substring match)
redact_patterns()Regex patterns matched against local variable key names
redact_callbackNoneCallable[[str], bool] — return True to redact the key
max_traceback_chars12_000Truncate traceback text in development at this length
production_traceback_cap8_000Traceback cap applied in production
max_locals_per_frame12Maximum local variables captured per frame
max_local_value_len200Maximum character length for a single variable's repr
max_local_depth2Recursion depth when serializing nested containers
max_frames_with_locals2Number of frames (starting at primary) for which locals are captured
snippet_context_lines2Lines of source before and after the primary line

9.3 Production mode

In production, two safety behaviors are enforced automatically:

  1. The traceback is truncated at production_traceback_cap (default 8 000 characters).
  2. Rich mode is forced to lean unless allow_rich_in_production=True is explicitly set.

Rich diagnostics are forced to lean in production

If runtime_environment="production" and failure_diagnostics="rich" are both set, the effective mode is silently downgraded to lean unless you explicitly pass allow_rich_in_production=True. This exists to stop local variable values — potentially including secrets — from being captured in a production failure path by accident.

python
from runtime_narrative import story, stage, FailureDiagnosticsConfig

prod_config = FailureDiagnosticsConfig(
    runtime_environment="production",
    failure_diagnostics="lean",
)

try:
    with story("Checkout", diagnostics_config=prod_config):
        with stage("Charge Card"):
            raise RuntimeError("connection pool exhausted")
except RuntimeError:
    pass

To use rich mode in production (e.g., a controlled staging environment):

python
prod_rich = FailureDiagnosticsConfig(
    runtime_environment="production",
    failure_diagnostics="rich",
    allow_rich_in_production=True,
)

Production mode can also be set without constructing a full config:

python
with story("Job", runtime_environment="production", failure_diagnostics="lean"):
    ...

9.4 Secret redaction

When rich mode captures local variables, any variable whose name matches a redacted pattern has its value replaced with "<redacted>". The count of redacted keys is reported in FailureOccurred.redaction_removed_keys.

Built-in redacted key substrings

Case-insensitive, substring match: password, secret, token, api_key, apikey, authorization, cookie, session, credential.

redact_extra — additional substrings

python
config = FailureDiagnosticsConfig(
    failure_diagnostics="rich",
    redact_extra=("card_number", "cvv", "ssn"),
)

def pay(card_number: str, cvv: str, amount: float) -> None:
    raise ValueError("declined")

try:
    with story("Payment", diagnostics_config=config):
        with stage("Charge"):
            pay("4111111111111111", "123", 99.99)
except ValueError:
    pass
# card_number → <redacted>, cvv → <redacted>, amount → 99.99

redact_patterns — regular expressions matched against variable names

python
config = FailureDiagnosticsConfig(
    failure_diagnostics="rich",
    redact_patterns=(
        r"pan$",        # matches card_pan, account_pan
        r"^priv_",      # matches priv_key, priv_cert
        r"_hash$",      # matches pw_hash, token_hash
    ),
)

redact_callback — a custom predicate for arbitrary logic

python
config = FailureDiagnosticsConfig(
    failure_diagnostics="rich",
    redact_callback=lambda key: key.endswith("_ref") or key.startswith("internal_"),
)

All three mechanisms compose additively — specifying redact_extra does not disable the built-in list.

9.5 from_env() and environment variable overrides

FailureDiagnosticsConfig.from_env() reads configuration from environment variables:

Environment variableEffect
RUNTIME_NARRATIVE_ENVSets runtime_environment. Values: development (default), production
RUNTIME_NARRATIVE_FAILURE_DIAGNOSTICSSets failure_diagnostics. Values: lean (default), rich
RUNTIME_NARRATIVE_ALLOW_RICH_IN_PRODUCTIONSets allow_rich_in_production. Values: 1, true

When you construct story(...) without passing diagnostics_config, the story internally calls from_env() as the base and applies any kwargs on top of it via merge(). This means you can change diagnostic depth across all stories in a process by setting environment variables — useful for debugging in non-production environments.

bash
# Enable rich diagnostics for a single run without code changes
RUNTIME_NARRATIVE_FAILURE_DIAGNOSTICS=rich python examples/basic.py

9.6 FailureDiagnosticsConfig.merge()

merge() creates a new config by overlaying specified fields onto an existing base config. Fields you do not specify are inherited from the base unchanged.

python
from runtime_narrative import FailureDiagnosticsConfig

base = FailureDiagnosticsConfig.from_env()

# Specialise for a single story
per_story = FailureDiagnosticsConfig.merge(
    base,
    failure_diagnostics="rich",
    redact_extra=("card_number", "cvv"),
)

# Specialise for production
prod = FailureDiagnosticsConfig.merge(
    base,
    runtime_environment="production",
    failure_diagnostics="lean",
)

9.7 Built-in failure analyzers

Failure analyzers attach LLM-generated explanations to FailureOccurred. Pass any analyzer as failure_analyzer= to story(), a decorator, or middleware. See Custom Failure Analyzers to write your own.

OllamaFailureAnalyzer

Uses Ollama's native /api/generate endpoint. A dataclass — pass fields as constructor arguments.

python
from runtime_narrative import OllamaFailureAnalyzer, story, stage

analyzer = OllamaFailureAnalyzer(
    model="llama3",
    endpoint="http://127.0.0.1:11434/api/generate",  # default
    timeout_seconds=30.0,
    max_context_chars=8000,
)

try:
    with story("Pipeline", failure_analyzer=analyzer):
        with stage("Load"):
            raise ValueError("source file missing")
except ValueError:
    pass
FieldDefaultDescription
modelrequiredOllama model name
endpoint"http://127.0.0.1:11434/api/generate"Ollama API endpoint
timeout_seconds12.0HTTP timeout
include_traceback_lines30Lines of traceback sent in the prompt
max_context_chars8000Total prompt character budget; traceback is trimmed to fit

LLMFailureAnalyzer

OpenAI-compatible /v1/chat/completions endpoint. Works with vLLM, llama.cpp, LM Studio, and Ollama in OpenAI mode.

python
from runtime_narrative import LLMFailureAnalyzer

analyzer = LLMFailureAnalyzer(
    model="llama3",
    endpoint="http://localhost:8000/v1/chat/completions",
    timeout_seconds=30.0,
)

Both OllamaFailureAnalyzer and LLMFailureAnalyzer request structured JSON from the model (exact_why, evidence, targeted_fix, code_changes) and format the response into ## Exact Why / ## Evidence / ## Targeted Fix / ## Code Changes sections. If the model returns non-JSON, the raw text is used as a fallback.

AnthropicFailureAnalyzer

Calls the Anthropic API. Requires the [anthropic] extra and ANTHROPIC_API_KEY.

python
from runtime_narrative import AnthropicFailureAnalyzer

analyzer = AnthropicFailureAnalyzer(
    model="claude-haiku-4-5-20251001",  # default; override via RUNTIME_NARRATIVE_MODEL
    api_key=None,                        # reads ANTHROPIC_API_KEY
    timeout_seconds=30.0,
    max_tokens=1024,
)

Provides both analyze_failure (sync, using anthropic.Anthropic) and analyze_failure_async (async, using anthropic.AsyncAnthropic). Response parsing identical to the OpenAI-compatible analyzers.

DeduplicatingAnalyzer

Wraps any analyzer to avoid repeat API calls for the same error site.

python
from runtime_narrative import AnthropicFailureAnalyzer, DeduplicatingAnalyzer

inner = AnthropicFailureAnalyzer()
analyzer = DeduplicatingAnalyzer(inner, max_cache_size=256)

The cache key is a SHA-256 hash of (error_type, filename, lineno, exception_chain). None results (network failures, timeouts) are never cached — the next call retries the model. LRU eviction applies when the cache reaches max_cache_size. Thread-safe. Delegates to inner.analyze_failure_async() when available.

Next step

Read Custom Failure Analyzers to implement your own analyzer against the analyze_failure protocol.