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=2on 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.
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.
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
| Field | Default | Description |
|---|---|---|
runtime_environment | "development" | "development" or "production" |
failure_diagnostics | "lean" | "lean" or "rich" |
allow_rich_in_production | False | Bypass 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_callback | None | Callable[[str], bool] — return True to redact the key |
max_traceback_chars | 12_000 | Truncate traceback text in development at this length |
production_traceback_cap | 8_000 | Traceback cap applied in production |
max_locals_per_frame | 12 | Maximum local variables captured per frame |
max_local_value_len | 200 | Maximum character length for a single variable's repr |
max_local_depth | 2 | Recursion depth when serializing nested containers |
max_frames_with_locals | 2 | Number of frames (starting at primary) for which locals are captured |
snippet_context_lines | 2 | Lines of source before and after the primary line |
9.3 Production mode
In production, two safety behaviors are enforced automatically:
- The traceback is truncated at
production_traceback_cap(default 8 000 characters). - Rich mode is forced to lean unless
allow_rich_in_production=Trueis 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.
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):
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:
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
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
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
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 variable | Effect |
|---|---|
RUNTIME_NARRATIVE_ENV | Sets runtime_environment. Values: development (default), production |
RUNTIME_NARRATIVE_FAILURE_DIAGNOSTICS | Sets failure_diagnostics. Values: lean (default), rich |
RUNTIME_NARRATIVE_ALLOW_RICH_IN_PRODUCTION | Sets 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.
# 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.
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.
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
| Field | Default | Description |
|---|---|---|
model | required | Ollama model name |
endpoint | "http://127.0.0.1:11434/api/generate" | Ollama API endpoint |
timeout_seconds | 12.0 | HTTP timeout |
include_traceback_lines | 30 | Lines of traceback sent in the prompt |
max_context_chars | 8000 | Total 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.
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.
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.
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.