runtime-narrative 1.0.0__tar.gz → 1.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. runtime_narrative-1.0.1/PKG-INFO +1040 -0
  2. runtime_narrative-1.0.1/README.md +984 -0
  3. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/pyproject.toml +2 -2
  4. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/__init__.py +20 -2
  5. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/context.py +5 -0
  6. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/events.py +2 -0
  7. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/middleware.py +6 -1
  8. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/stage.py +6 -1
  9. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/story.py +63 -6
  10. runtime_narrative-1.0.1/runtime_narrative.egg-info/PKG-INFO +1040 -0
  11. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative.egg-info/SOURCES.txt +2 -0
  12. runtime_narrative-1.0.1/tests/test_issues.py +213 -0
  13. runtime_narrative-1.0.0/PKG-INFO +0 -1124
  14. runtime_narrative-1.0.0/README.MD +0 -1068
  15. runtime_narrative-1.0.0/runtime_narrative.egg-info/PKG-INFO +0 -1124
  16. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/LICENSE +0 -0
  17. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/analyzers/__init__.py +0 -0
  18. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/analyzers/anthropic.py +0 -0
  19. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/analyzers/base.py +0 -0
  20. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/analyzers/deduplication.py +0 -0
  21. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/analyzers/ollama.py +0 -0
  22. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/celery.py +0 -0
  23. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/cli.py +0 -0
  24. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/decorators.py +0 -0
  25. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/diagnostics.py +0 -0
  26. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/failure.py +0 -0
  27. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/grpc_interceptor.py +0 -0
  28. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/instrumentation.py +0 -0
  29. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/middleware_django.py +0 -0
  30. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/renderer/__init__.py +0 -0
  31. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/renderer/alert_renderer.py +0 -0
  32. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/renderer/console.py +0 -0
  33. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/renderer/html_renderer.py +0 -0
  34. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/renderer/json_renderer.py +0 -0
  35. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/renderer/otel_log_renderer.py +0 -0
  36. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/renderer/otel_metrics_renderer.py +0 -0
  37. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/renderer/otel_renderer.py +0 -0
  38. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/renderer/persistence_renderer.py +0 -0
  39. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/renderer/prometheus_renderer.py +0 -0
  40. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/task_group.py +0 -0
  41. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative/testing.py +0 -0
  42. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative.egg-info/dependency_links.txt +0 -0
  43. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative.egg-info/entry_points.txt +0 -0
  44. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative.egg-info/requires.txt +0 -0
  45. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/runtime_narrative.egg-info/top_level.txt +0 -0
  46. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/setup.cfg +0 -0
  47. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_alert_renderer.py +0 -0
  48. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_analyzers.py +0 -0
  49. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_anthropic_analyzer.py +0 -0
  50. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_async_renderer.py +0 -0
  51. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_celery.py +0 -0
  52. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_console_renderer.py +0 -0
  53. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_decorators.py +0 -0
  54. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_deduplication.py +0 -0
  55. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_diagnostics.py +0 -0
  56. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_dry_run.py +0 -0
  57. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_failure.py +0 -0
  58. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_grpc_interceptor.py +0 -0
  59. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_html_renderer.py +0 -0
  60. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_instrumentation.py +0 -0
  61. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_instrumentation_phase2.py +0 -0
  62. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_json_renderer.py +0 -0
  63. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_middleware.py +0 -0
  64. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_middleware_django.py +0 -0
  65. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_middleware_propagation.py +0 -0
  66. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_otel_log_renderer.py +0 -0
  67. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_otel_metrics_renderer.py +0 -0
  68. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_otel_renderer.py +0 -0
  69. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_persistence_renderer.py +0 -0
  70. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_prometheus_renderer.py +0 -0
  71. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_redaction_extended.py +0 -0
  72. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_stage.py +0 -0
  73. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_stage_metadata.py +0 -0
  74. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_story.py +0 -0
  75. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_structured_analysis.py +0 -0
  76. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_task_group.py +0 -0
  77. {runtime_narrative-1.0.0 → runtime_narrative-1.0.1}/tests/test_testing_utils.py +0 -0
@@ -0,0 +1,1040 @@
1
+ Metadata-Version: 2.4
2
+ Name: runtime-narrative
3
+ Version: 1.0.1
4
+ Summary: Model execution as human-readable stories with lean/rich failure diagnostics and optional LLM analysis
5
+ Author-email: Shashank Raj <shashank.raj28@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/sraj0501/runtime_narrative
8
+ Project-URL: Repository, https://github.com/sraj0501/runtime_narrative
9
+ Project-URL: Bug Tracker, https://github.com/sraj0501/runtime_narrative/issues
10
+ Keywords: logging,observability,tracing,fastapi,debugging,diagnostics,runtime_narrative
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: System :: Logging
22
+ Classifier: Topic :: System :: Monitoring
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: python-dotenv>=1.2.1
28
+ Provides-Extra: console
29
+ Requires-Dist: typer>=0.9.0; extra == "console"
30
+ Provides-Extra: fastapi
31
+ Requires-Dist: starlette>=0.27.0; extra == "fastapi"
32
+ Provides-Extra: otel
33
+ Requires-Dist: opentelemetry-api>=1.20.0; extra == "otel"
34
+ Requires-Dist: opentelemetry-sdk>=1.20.0; extra == "otel"
35
+ Provides-Extra: prometheus
36
+ Requires-Dist: prometheus-client>=0.19.0; extra == "prometheus"
37
+ Provides-Extra: anthropic
38
+ Requires-Dist: anthropic>=0.25.0; extra == "anthropic"
39
+ Provides-Extra: django
40
+ Requires-Dist: django>=3.2; extra == "django"
41
+ Provides-Extra: celery
42
+ Requires-Dist: celery>=5.0; extra == "celery"
43
+ Provides-Extra: grpc
44
+ Requires-Dist: grpcio>=1.50.0; extra == "grpc"
45
+ Provides-Extra: all
46
+ Requires-Dist: typer>=0.9.0; extra == "all"
47
+ Requires-Dist: starlette>=0.27.0; extra == "all"
48
+ Requires-Dist: opentelemetry-api>=1.20.0; extra == "all"
49
+ Requires-Dist: opentelemetry-sdk>=1.20.0; extra == "all"
50
+ Requires-Dist: prometheus-client>=0.19.0; extra == "all"
51
+ Requires-Dist: anthropic>=0.25.0; extra == "all"
52
+ Requires-Dist: django>=3.2; extra == "all"
53
+ Requires-Dist: celery>=5.0; extra == "all"
54
+ Requires-Dist: grpcio>=1.50.0; extra == "all"
55
+ Dynamic: license-file
56
+
57
+ # runtime-narrative
58
+
59
+ Turn any Python execution into a traceable **story** composed of named **stages**. Get minimal logs when everything works — and surgical, LLM-powered diagnostics the moment something breaks.
60
+
61
+ ```
62
+ ▶ Story started: Import Customers
63
+ ✔ Load CSV 0.012s
64
+ ✔ Validate Data 0.004s
65
+
66
+ ❌ Failure at: Insert Records
67
+
68
+ ValueError: duplicate customer id
69
+ Location: app/db.py:47 insert_row
70
+ Code: raise ValueError("duplicate customer id")
71
+ Chain: ValueError ← sqlite3.IntegrityError
72
+
73
+ ## Exact Why
74
+ A record with the same customer_id already exists (UNIQUE constraint).
75
+
76
+ ## Targeted Fix
77
+ Use INSERT OR IGNORE, or check for an existing row before inserting.
78
+ ```
79
+
80
+ ---
81
+
82
+ ## Contents
83
+
84
+ - [Installation](#installation)
85
+ - [Quick start](#quick-start)
86
+ - [Decorators](#decorators)
87
+ - [Auto-instrumentation](#auto-instrumentation)
88
+ - [Failure diagnostics](#failure-diagnostics)
89
+ - [Failure analyzers](#failure-analyzers)
90
+ - [Renderers](#renderers)
91
+ - [Framework integrations](#framework-integrations)
92
+ - [Async task groups](#async-task-groups)
93
+ - [Persistence and CLI](#persistence-and-cli)
94
+ - [Alert routing](#alert-routing)
95
+ - [Testing utilities](#testing-utilities)
96
+ - [Custom renderers and analyzers](#custom-renderers-and-analyzers)
97
+ - [Environment variables](#environment-variables)
98
+
99
+ ---
100
+
101
+ ## Installation
102
+
103
+ ```bash
104
+ pip install runtime-narrative
105
+ ```
106
+
107
+ Optional extras unlock additional renderers and integrations:
108
+
109
+ | Extra | What it installs |
110
+ |---|---|
111
+ | `console` | `typer` — colored terminal output in `ConsoleRenderer` |
112
+ | `fastapi` | `starlette` — `RuntimeNarrativeMiddleware` |
113
+ | `otel` | `opentelemetry-api`, `opentelemetry-sdk` — `OtelRenderer`, `OtelLogRenderer`, `OtelMetricsRenderer` |
114
+ | `prometheus` | `prometheus-client` — `PrometheusRenderer` |
115
+ | `anthropic` | `anthropic` — `AnthropicFailureAnalyzer` |
116
+ | `django` | `django` — `RuntimeNarrativeDjangoMiddleware` / `SyncMiddleware` |
117
+ | `celery` | `celery` — `NarrativeTask`, `connect_narrative` |
118
+ | `grpc` | `grpcio` — `RuntimeNarrativeInterceptor` / `AsyncInterceptor` |
119
+ | `all` | Everything above |
120
+
121
+ ```bash
122
+ pip install "runtime-narrative[console,fastapi,anthropic]"
123
+ pip install "runtime-narrative[all]"
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Quick start
129
+
130
+ ### Sync
131
+
132
+ ```python
133
+ from runtime_narrative import story, stage
134
+
135
+ with story("Import Customers"):
136
+ with stage("Load CSV"):
137
+ rows = load_csv("customers.csv")
138
+
139
+ with stage("Validate Data"):
140
+ validate(rows)
141
+
142
+ with stage("Insert Records"):
143
+ db.insert(rows)
144
+ ```
145
+
146
+ `ConsoleRenderer` is the default. On failure it prints the exact frame, a source snippet, the exception chain, and a compressed stack summary — no configuration needed.
147
+
148
+ ### Async
149
+
150
+ ```python
151
+ import asyncio
152
+ from runtime_narrative import story, stage
153
+
154
+ async def run():
155
+ async with story("Sync Pipeline"):
156
+ async with stage("Fetch Orders"):
157
+ orders = await fetch_orders()
158
+
159
+ async with stage("Process Orders"):
160
+ await process(orders)
161
+
162
+ asyncio.run(run())
163
+ ```
164
+
165
+ `story` and `stage` are dual sync/async context managers — use `with` or `async with` interchangeably.
166
+
167
+ ### Track progress
168
+
169
+ Declare the total stage count upfront so `progress_percent` is accurate at every stage boundary:
170
+
171
+ ```python
172
+ with story("Import Customers", total_stages=3) as runtime:
173
+ with stage("Load CSV"): ... # 33%
174
+ with stage("Validate"): ... # 66%
175
+ with stage("Insert"): ... # 100%
176
+ ```
177
+
178
+ Or set it dynamically after the story starts:
179
+
180
+ ```python
181
+ with story("Batch Job") as runtime:
182
+ items = fetch_batch()
183
+ runtime.set_total_stages(len(items))
184
+ for item in items:
185
+ with stage(f"Process {item.id}"): ...
186
+ ```
187
+
188
+ ---
189
+
190
+ ## Decorators
191
+
192
+ Wrap functions without restructuring call sites. The library detects `async def` automatically:
193
+
194
+ ```python
195
+ from runtime_narrative import runtime_narrative_story, runtime_narrative_stage
196
+
197
+ @runtime_narrative_stage("Load CSV")
198
+ def load_csv() -> list[str]:
199
+ return open("customers.csv").read().splitlines()
200
+
201
+ @runtime_narrative_stage("Insert Records")
202
+ def insert(rows: list[str]) -> None:
203
+ db.insert_many(rows)
204
+
205
+ @runtime_narrative_story("Import Customers")
206
+ def run() -> None:
207
+ rows = load_csv()
208
+ insert(rows)
209
+ ```
210
+
211
+ `@runtime_narrative_story` accepts the same keyword arguments as `story()`: `renderers`, `failure_analyzer`, `background_analysis`, `diagnostics_config`, `failure_diagnostics`, `allow_rich_in_production`, `app_roots`, `redact_extra`, `total_stages`, `dry_run`.
212
+
213
+ ---
214
+
215
+ ## Auto-instrumentation
216
+
217
+ Instrument an entire class or module without wrapping every function individually.
218
+
219
+ ### `@narrative_class`
220
+
221
+ Every public instance method becomes a stage. Stage names are `ClassName.method_name`:
222
+
223
+ ```python
224
+ from runtime_narrative import narrative_class, no_stage, story
225
+
226
+ @narrative_class
227
+ class OrderService:
228
+ def validate(self, order: dict) -> None: ... # → "OrderService.validate"
229
+ def charge(self, order: dict) -> str: ... # → "OrderService.charge"
230
+ def fulfill(self, order: dict) -> str: ... # → "OrderService.fulfill"
231
+
232
+ @no_stage
233
+ def _log(self, msg: str) -> None: ... # excluded
234
+
235
+ svc = OrderService()
236
+ with story("Process Order", total_stages=3):
237
+ svc.validate(order)
238
+ svc.charge(order)
239
+ svc.fulfill(order)
240
+ ```
241
+
242
+ Skipped by default: names starting with `_`, `@no_stage`-marked methods, `@property`, `@staticmethod`, `@classmethod`, and inherited methods. Opt in to class and static methods explicitly:
243
+
244
+ ```python
245
+ @narrative_class(instrument_classmethods=True, instrument_staticmethods=True)
246
+ class Factory:
247
+ @classmethod
248
+ def create(cls): ... # → "Factory.create"
249
+
250
+ @staticmethod
251
+ def validate(x): ... # → "Factory.validate"
252
+
253
+ @classmethod
254
+ @narrative_stage("Build Widget")
255
+ def build(cls): ... # → "Build Widget" (custom name)
256
+ ```
257
+
258
+ ### `@narrative_stage`
259
+
260
+ Override the stage name on a single method or standalone function. Prevents double-wrapping when used inside `@narrative_class`:
261
+
262
+ ```python
263
+ from runtime_narrative import narrative_class, narrative_stage
264
+
265
+ @narrative_class
266
+ class ReportService:
267
+ @narrative_stage("Generate PDF Report")
268
+ def generate(self, data: dict) -> bytes: ... # custom name
269
+
270
+ def archive(self, report: bytes) -> None: ... # "ReportService.archive" (default)
271
+ ```
272
+
273
+ When `name` is omitted, the function name is title-cased: `validate_order` → `"Validate Order"`.
274
+
275
+ ### `@no_stage`
276
+
277
+ Exclude any method or function from all auto-instrumentation:
278
+
279
+ ```python
280
+ from runtime_narrative import no_stage
281
+
282
+ @no_stage
283
+ def _cache_lookup(key: str): ...
284
+ ```
285
+
286
+ ### `instrument_module`
287
+
288
+ Instrument all public callables in an existing module in-place. Classes get `@narrative_class`; top-level functions are wrapped directly. Imported symbols are not touched:
289
+
290
+ ```python
291
+ import runtime_narrative
292
+ import myapp.services as svc
293
+
294
+ runtime_narrative.instrument_module(svc)
295
+ ```
296
+
297
+ ### `auto_instrument`
298
+
299
+ Register a `sys.meta_path` import hook that instruments every app module on import. Only modules whose source path is under `app_roots` (default: `cwd`) are affected — stdlib and installed packages are unaffected:
300
+
301
+ ```python
302
+ import runtime_narrative
303
+
304
+ finder = runtime_narrative.auto_instrument()
305
+
306
+ # All subsequent imports of app modules are instrumented automatically.
307
+ from myapp.services import OrderService
308
+
309
+ # Stop at any point:
310
+ import sys
311
+ sys.meta_path.remove(finder)
312
+ ```
313
+
314
+ Pin to specific directories:
315
+
316
+ ```python
317
+ runtime_narrative.auto_instrument(app_roots=["/app/src", "/app/workers"])
318
+ ```
319
+
320
+ ---
321
+
322
+ ## Failure diagnostics
323
+
324
+ ### Lean vs rich mode
325
+
326
+ | Mode | What is captured |
327
+ |---|---|
328
+ | `lean` (default) | Primary frame, source snippet (±2 lines), exception chain, compressed stack summary |
329
+ | `rich` | Everything in lean + local variable values for up to 2 frames, with automatic secret redaction |
330
+
331
+ ```bash
332
+ RUNTIME_NARRATIVE_FAILURE_DIAGNOSTICS=rich python app.py
333
+ ```
334
+
335
+ Or per-story:
336
+
337
+ ```python
338
+ with story("Import", failure_diagnostics="rich"):
339
+ ...
340
+ ```
341
+
342
+ ### Production safeguards
343
+
344
+ When `RUNTIME_NARRATIVE_ENV=production`:
345
+ - Tracebacks are capped at 8 000 characters.
346
+ - `rich` mode is silently downgraded to `lean` to prevent local variable leakage in logs.
347
+
348
+ Override the downgrade when needed:
349
+
350
+ ```python
351
+ with story("Debug", failure_diagnostics="rich", allow_rich_in_production=True):
352
+ ...
353
+ ```
354
+
355
+ ### Secret redaction
356
+
357
+ Rich mode automatically redacts local variables whose key names contain any of: `password`, `secret`, `token`, `api_key`, `apikey`, `authorization`, `cookie`, `session`, `credential`.
358
+
359
+ Extend the list with project-specific names:
360
+
361
+ ```python
362
+ with story("Import", failure_diagnostics="rich", redact_extra=["internal_id", "org_key"]):
363
+ ...
364
+ ```
365
+
366
+ For more expressive rules, use `FailureDiagnosticsConfig`:
367
+
368
+ ```python
369
+ from runtime_narrative import FailureDiagnosticsConfig
370
+
371
+ config = FailureDiagnosticsConfig(
372
+ failure_diagnostics="rich",
373
+ redact_patterns=("^internal_.*", r"\bpii\b"), # regex, case-insensitive re.search
374
+ redact_callback=lambda key: key.startswith("priv_"),
375
+ )
376
+
377
+ with story("Import", diagnostics_config=config):
378
+ ...
379
+ ```
380
+
381
+ `redact_callback` exceptions are caught and treated as non-redact. Both `redact_patterns` and `redact_callback` are available on `FailureDiagnosticsConfig` and flow through `merge()`.
382
+
383
+ ### Full `FailureDiagnosticsConfig` reference
384
+
385
+ ```python
386
+ from runtime_narrative import FailureDiagnosticsConfig
387
+
388
+ config = FailureDiagnosticsConfig(
389
+ runtime_environment="production", # "development" | "production"
390
+ failure_diagnostics="lean", # "lean" | "rich"
391
+ allow_rich_in_production=False,
392
+ app_roots=("/app/src",), # paths used for primary frame selection
393
+ redact_extra=("internal_id",), # additional substring matches
394
+ redact_patterns=("^priv_",), # regex patterns (case-insensitive)
395
+ redact_callback=lambda k: k.endswith("_key"),
396
+ max_traceback_chars=12_000, # development cap (None = unlimited)
397
+ production_traceback_cap=8_000,
398
+ max_locals_per_frame=12,
399
+ max_local_value_len=200,
400
+ max_local_depth=2,
401
+ max_frames_with_locals=2,
402
+ snippet_context_lines=2,
403
+ )
404
+
405
+ with story("Import", diagnostics_config=config):
406
+ ...
407
+ ```
408
+
409
+ `FailureDiagnosticsConfig.from_env()` reads the standard environment variables. `FailureDiagnosticsConfig.merge(base, **overrides)` layers per-story overrides on a base config.
410
+
411
+ ---
412
+
413
+ ## Failure analyzers
414
+
415
+ Analyzers receive structured failure data and return a diagnosis string that is attached to `FailureOccurred` and displayed by renderers. All analyzers are optional — if the endpoint is unreachable, your exception propagates normally.
416
+
417
+ ### `OllamaFailureAnalyzer`
418
+
419
+ Calls a local Ollama instance or any `/api/generate`-compatible endpoint:
420
+
421
+ ```python
422
+ from runtime_narrative import OllamaFailureAnalyzer, story
423
+
424
+ analyzer = OllamaFailureAnalyzer(
425
+ model="llama3",
426
+ endpoint="http://127.0.0.1:11434/api/generate", # default
427
+ timeout_seconds=60.0,
428
+ max_context_chars=8000,
429
+ )
430
+
431
+ with story("Import Customers", failure_analyzer=analyzer):
432
+ ...
433
+ ```
434
+
435
+ ### `LLMFailureAnalyzer`
436
+
437
+ Calls any OpenAI-compatible `/v1/chat/completions` endpoint (OpenAI, vLLM, llama.cpp, LM Studio, Ollama OpenAI mode):
438
+
439
+ ```python
440
+ from runtime_narrative import LLMFailureAnalyzer
441
+
442
+ analyzer = LLMFailureAnalyzer(
443
+ model="gpt-4o-mini",
444
+ endpoint="https://api.openai.com/v1/chat/completions",
445
+ api_key="sk-...",
446
+ max_context_chars=8000,
447
+ )
448
+ ```
449
+
450
+ ### `AnthropicFailureAnalyzer` (`[anthropic]` extra)
451
+
452
+ Uses the Anthropic Claude API. Reads `ANTHROPIC_API_KEY` from the environment by default:
453
+
454
+ ```python
455
+ from runtime_narrative import AnthropicFailureAnalyzer
456
+
457
+ analyzer = AnthropicFailureAnalyzer(
458
+ model="claude-haiku-4-5-20251001", # default
459
+ api_key="sk-ant-...", # or set ANTHROPIC_API_KEY
460
+ max_tokens=1024,
461
+ timeout_seconds=30.0,
462
+ )
463
+ ```
464
+
465
+ Both `analyze_failure()` (sync) and `analyze_failure_async()` (async) are implemented. The async path uses `anthropic.AsyncAnthropic` natively — no thread offloading.
466
+
467
+ ### Structured output
468
+
469
+ All analyzers request a JSON response (`exact_why`, `evidence`, `targeted_fix`, `code_changes`) and format it into guaranteed `## Header\ncontent` sections. Falls back to raw text when the model returns non-JSON.
470
+
471
+ ### Context budget
472
+
473
+ Tracebacks are trimmed from the top (keeping the most recent frames) when the prompt would exceed `max_context_chars`. If the budget is exhausted before any traceback fits, a `<traceback omitted>` marker is used:
474
+
475
+ ```python
476
+ analyzer = LLMFailureAnalyzer(model="llama3", max_context_chars=4000)
477
+ ```
478
+
479
+ ### `DeduplicatingAnalyzer`
480
+
481
+ Wraps any analyzer with an LRU cache keyed by `SHA-256(error_type, filename, lineno, exception_chain)`. Prevents redundant LLM calls for the same recurring error. `None` results (timeouts, network errors) are never cached:
482
+
483
+ ```python
484
+ from runtime_narrative import DeduplicatingAnalyzer, AnthropicFailureAnalyzer
485
+
486
+ analyzer = DeduplicatingAnalyzer(
487
+ AnthropicFailureAnalyzer(),
488
+ max_cache_size=256,
489
+ )
490
+ ```
491
+
492
+ Thread-safe; async-aware (delegates to `analyze_failure_async` when available).
493
+
494
+ ### Background analysis
495
+
496
+ `background_analysis=True` emits `FailureOccurred` immediately (with `llm_analysis=None`), then runs the LLM as a background `asyncio.Task`. When the task completes, `LLMAnalysisReady` is emitted:
497
+
498
+ ```python
499
+ async with story(
500
+ "Process Order",
501
+ failure_analyzer=analyzer,
502
+ background_analysis=True,
503
+ ):
504
+ ...
505
+ # Response is not delayed by LLM latency.
506
+ # LLMAnalysisReady fires when analysis is ready.
507
+ ```
508
+
509
+ ### `FailureAnalyzer` protocol
510
+
511
+ All built-in analyzers satisfy the `@runtime_checkable` `FailureAnalyzer` protocol. Use it to type-check custom analyzers:
512
+
513
+ ```python
514
+ from runtime_narrative import FailureAnalyzer
515
+
516
+ assert isinstance(my_analyzer, FailureAnalyzer)
517
+ ```
518
+
519
+ ---
520
+
521
+ ## Renderers
522
+
523
+ A renderer is any object with `handle(self, event: object) -> None` (sync) or `async def handle(self, event: object) -> None` (async). Pass a list to `story()`, middleware, or a decorator. Async renderers are awaited inside `async with story(...)` including for stage events.
524
+
525
+ Renderers never crash a story — if a renderer raises, a warning is printed to stderr and the next renderer continues.
526
+
527
+ ### `ConsoleRenderer` (default)
528
+
529
+ Colored terminal output for local development. Falls back to `print` and ASCII glyphs (`>`, `[ok]`, `[FAIL]`) when `typer` is absent or the terminal is not UTF-8 (e.g. Windows cp1252):
530
+
531
+ ```python
532
+ from runtime_narrative.renderer.console import ConsoleRenderer
533
+
534
+ with story("My Pipeline", renderers=[ConsoleRenderer()]):
535
+ ...
536
+ ```
537
+
538
+ ### `JsonRenderer`
539
+
540
+ One structured JSON object per lifecycle event. Suitable for log aggregators (Datadog, CloudWatch, Loki):
541
+
542
+ ```python
543
+ from runtime_narrative import JsonRenderer
544
+
545
+ with story("My Pipeline", renderers=[JsonRenderer()]):
546
+ ...
547
+
548
+ # Write to a file
549
+ with story("My Pipeline", renderers=[JsonRenderer(output=open("stories.jsonl", "a"))]):
550
+ ...
551
+ ```
552
+
553
+ On failure, `FailureOccurred` carries the full diagnostics payload: exact location, stack frame classifications, source snippet, local variables (rich mode), compressed stack summary, and traceback text.
554
+
555
+ ### `RotatingJsonRenderer`
556
+
557
+ Same as `JsonRenderer` but writes to a rotating log file using `path.1` / `path.2` semantics. No external dependencies:
558
+
559
+ ```python
560
+ from runtime_narrative import RotatingJsonRenderer
561
+
562
+ renderer = RotatingJsonRenderer(
563
+ "stories.jsonl",
564
+ max_bytes=10_485_760, # rotate at 10 MB (default)
565
+ backup_count=5, # keep .1 through .5 (default)
566
+ )
567
+ ```
568
+
569
+ ### `HtmlReportRenderer`
570
+
571
+ Writes a self-contained HTML report on `StoryCompleted`. Includes a story header, per-stage duration bar chart, and a failure detail section with traceback and optional LLM analysis:
572
+
573
+ ```python
574
+ from runtime_narrative import HtmlReportRenderer
575
+
576
+ with story("ETL Run", renderers=[HtmlReportRenderer("report.html", open_browser=True)]):
577
+ ...
578
+ ```
579
+
580
+ `open_browser=True` calls `webbrowser.open()` on the generated file after writing.
581
+
582
+ ### `SqliteStoryRenderer`
583
+
584
+ Persists all six lifecycle events to a SQLite database. No extra dependencies. See [Persistence and CLI](#persistence-and-cli):
585
+
586
+ ```python
587
+ from runtime_narrative import SqliteStoryRenderer
588
+
589
+ with story("Nightly ETL", renderers=[SqliteStoryRenderer("stories.db")]):
590
+ ...
591
+ ```
592
+
593
+ ### `OtelRenderer` (`[otel]` extra)
594
+
595
+ Maps narrative events to OpenTelemetry spans. Each story is a root span; each stage is a child span:
596
+
597
+ ```python
598
+ from runtime_narrative import OtelRenderer
599
+
600
+ renderer = OtelRenderer(
601
+ exclude_stages={"Health Check"}, # never create child spans for these stages
602
+ min_duration_ms=5.0, # suppress spans shorter than 5 ms
603
+ max_attribute_length=8192, # truncate long string attributes (default)
604
+ )
605
+ ```
606
+
607
+ Attributes on failure spans: `error.type`, `error.message`, `code.filepath`, `code.lineno`, `code.function`, `error.stack_trace`, `narrative.exception_chain`. `LLMAnalysisReady` adds a `llm_analysis_ready` span event with `narrative.llm_analysis`.
608
+
609
+ `exclude_stages` stages that fail still mark the root span `ERROR` — only the child span is suppressed.
610
+
611
+ ### `OtelLogRenderer` (`[otel]` extra)
612
+
613
+ Emits all six lifecycle events as OpenTelemetry log records via `opentelemetry._logs`:
614
+
615
+ | Event | Severity |
616
+ |---|---|
617
+ | `StoryStarted`, `StoryCompleted`, `LLMAnalysisReady` | `INFO` |
618
+ | `StageStarted`, `StageCompleted` | `DEBUG` |
619
+ | `FailureOccurred` | `ERROR` with `error.type`, `code.filepath`, `error.stack_trace`, etc. |
620
+
621
+ Automatically correlates `trace_id`/`span_id` from the ambient OTel context so logs link to their enclosing spans:
622
+
623
+ ```python
624
+ from runtime_narrative import OtelLogRenderer
625
+
626
+ renderer = OtelLogRenderer(logger_name="my_app")
627
+ ```
628
+
629
+ ### `OtelMetricsRenderer` (`[otel]` extra)
630
+
631
+ Emits four OTel instruments via the OpenTelemetry Metrics API:
632
+
633
+ | Instrument | Type | Labels |
634
+ |---|---|---|
635
+ | `narrative.stage.duration` | Histogram (s) | `story_name`, `stage_name` |
636
+ | `narrative.story.duration` | Histogram (s) | `story_name`, `success` |
637
+ | `narrative.story.failures` | Counter | `story_name`, `error_type` |
638
+ | `narrative.llm.analysis_latency` | Histogram (s) | `story_name` |
639
+
640
+ `narrative.llm.analysis_latency` measures the time between `FailureOccurred` and `LLMAnalysisReady` (only recorded when `background_analysis=True`):
641
+
642
+ ```python
643
+ from runtime_narrative import OtelMetricsRenderer
644
+
645
+ renderer = OtelMetricsRenderer(meter_name="my_app")
646
+ ```
647
+
648
+ ### `PrometheusRenderer` (`[prometheus]` extra)
649
+
650
+ Exposes four Prometheus metrics:
651
+
652
+ | Metric | Type | Labels |
653
+ |---|---|---|
654
+ | `narrative_story_duration_seconds` | Histogram | `story_name`, `success` |
655
+ | `narrative_stage_duration_seconds` | Histogram | `story_name`, `stage_name` |
656
+ | `narrative_story_failures_total` | Counter | `story_name`, `error_type` |
657
+ | `narrative_story_total` | Counter | `story_name`, `success` |
658
+
659
+ ```python
660
+ from runtime_narrative import PrometheusRenderer
661
+ from prometheus_client import CollectorRegistry, start_http_server
662
+
663
+ registry = CollectorRegistry()
664
+ renderer = PrometheusRenderer(registry=registry)
665
+ start_http_server(8000, registry=registry)
666
+ ```
667
+
668
+ ### Combining renderers
669
+
670
+ ```python
671
+ from runtime_narrative import story, JsonRenderer, SqliteStoryRenderer, OtelRenderer
672
+
673
+ with story("Nightly ETL", renderers=[
674
+ JsonRenderer(),
675
+ SqliteStoryRenderer("stories.db"),
676
+ OtelRenderer(),
677
+ ]):
678
+ ...
679
+ ```
680
+
681
+ ---
682
+
683
+ ## Framework integrations
684
+
685
+ ### FastAPI / Starlette
686
+
687
+ `RuntimeNarrativeMiddleware` wraps every HTTP request in `async with story(...)`. Route handlers only need to declare stages — no `story()` context required in handlers:
688
+
689
+ ```python
690
+ from fastapi import FastAPI
691
+ from runtime_narrative import RuntimeNarrativeMiddleware, JsonRenderer, AnthropicFailureAnalyzer
692
+
693
+ app = FastAPI()
694
+ app.add_middleware(
695
+ RuntimeNarrativeMiddleware,
696
+ renderers=[JsonRenderer()],
697
+ failure_analyzer=AnthropicFailureAnalyzer(),
698
+ runtime_environment="production",
699
+ )
700
+
701
+ @app.post("/orders")
702
+ async def create_order(payload: OrderIn):
703
+ with stage("Validate Input"):
704
+ validate(payload)
705
+ with stage("Persist Order"):
706
+ order = await db.insert(payload)
707
+ return {"id": order.id}
708
+ ```
709
+
710
+ Each request becomes a story named `"METHOD /path"` (e.g. `"POST /orders"`). When `renderers` is not passed, the middleware auto-selects `ConsoleRenderer` on a TTY and `JsonRenderer` otherwise.
711
+
712
+ When `opentelemetry-api` is installed, the middleware automatically extracts incoming W3C `traceparent`/`tracestate` headers so story spans become children of the upstream trace:
713
+
714
+ ```python
715
+ app.add_middleware(
716
+ RuntimeNarrativeMiddleware,
717
+ propagate_trace_context=True, # default; set False to disable
718
+ )
719
+ ```
720
+
721
+ ### Django
722
+
723
+ **ASGI (async):**
724
+
725
+ ```python
726
+ # settings.py
727
+ MIDDLEWARE = [
728
+ "runtime_narrative.middleware_django.RuntimeNarrativeDjangoMiddleware",
729
+ ...
730
+ ]
731
+ ```
732
+
733
+ **WSGI (sync):**
734
+
735
+ ```python
736
+ # settings.py
737
+ MIDDLEWARE = [
738
+ "runtime_narrative.middleware_django.RuntimeNarrativeDjangoSyncMiddleware",
739
+ ...
740
+ ]
741
+ ```
742
+
743
+ Story name is `"METHOD /path"`. Requires `pip install "runtime-narrative[django]"`.
744
+
745
+ ### Celery
746
+
747
+ ```python
748
+ from celery import Celery
749
+ from runtime_narrative import NarrativeTask, connect_narrative, JsonRenderer
750
+
751
+ app = Celery("myapp")
752
+
753
+ # Option A — per task
754
+ @app.task(base=NarrativeTask)
755
+ def process_order(order_id: str) -> None:
756
+ with stage("Validate"): validate(order_id)
757
+ with stage("Charge"): charge(order_id)
758
+
759
+ # Option B — set defaults for all tasks globally
760
+ connect_narrative(
761
+ app,
762
+ renderers=[JsonRenderer()],
763
+ failure_analyzer=AnthropicFailureAnalyzer(),
764
+ )
765
+ ```
766
+
767
+ Story name is `"<task.name> [task_id=<id>]"`. Override options per task by setting `narrative_*` class attributes directly. Requires `pip install "runtime-narrative[celery]"`.
768
+
769
+ ### gRPC
770
+
771
+ ```python
772
+ import grpc
773
+ from runtime_narrative import RuntimeNarrativeAsyncInterceptor, JsonRenderer
774
+
775
+ # Async server
776
+ server = grpc.aio.server(
777
+ interceptors=[RuntimeNarrativeAsyncInterceptor(renderers=[JsonRenderer()])],
778
+ )
779
+
780
+ # Sync server
781
+ from runtime_narrative import RuntimeNarrativeInterceptor
782
+ server = grpc.server(
783
+ thread_pool,
784
+ interceptors=[RuntimeNarrativeInterceptor(renderers=[JsonRenderer()])],
785
+ )
786
+ ```
787
+
788
+ Story name is the full gRPC method path, e.g. `"/mypackage.MyService/MyMethod"`. Requires `pip install "runtime-narrative[grpc]"`.
789
+
790
+ ---
791
+
792
+ ## Async task groups
793
+
794
+ `NarrativeTaskGroup` runs concurrent `asyncio` tasks under a shared story. Tasks inherit the parent story context automatically via `ContextVar` copy, so `stage()` calls inside tasks are tracked normally:
795
+
796
+ ```python
797
+ import asyncio
798
+ from runtime_narrative import story, NarrativeTaskGroup, NarrativeTaskGroupError
799
+
800
+ async def main():
801
+ async with story("Parallel Pipeline"):
802
+ async with NarrativeTaskGroup() as tg:
803
+ tg.create_task(fetch_orders(), name="Fetch Orders")
804
+ tg.create_task(fetch_inventory(), name="Fetch Inventory")
805
+ # waits for both; stages from both appear in the timeline
806
+
807
+ asyncio.run(main())
808
+ ```
809
+
810
+ If tasks fail, `NarrativeTaskGroupError` is raised with `failed_tasks: dict[str, BaseException]`:
811
+
812
+ ```python
813
+ try:
814
+ async with NarrativeTaskGroup() as tg:
815
+ tg.create_task(risky_job(), name="Risky Job")
816
+ except NarrativeTaskGroupError as e:
817
+ for task_name, exc in e.failed_tasks.items():
818
+ print(f"{task_name} failed: {exc}")
819
+ ```
820
+
821
+ No extra dependencies. Python 3.9+.
822
+
823
+ ---
824
+
825
+ ## Persistence and CLI
826
+
827
+ `SqliteStoryRenderer` records all six lifecycle events to a local SQLite database. No extra dependencies:
828
+
829
+ ```python
830
+ from runtime_narrative import story, SqliteStoryRenderer
831
+
832
+ with story("Nightly ETL", renderers=[SqliteStoryRenderer("stories.db")]):
833
+ ...
834
+ ```
835
+
836
+ **Schema:**
837
+
838
+ | Table | Key columns |
839
+ |---|---|
840
+ | `stories` | `story_id`, `name`, `success`, `duration_seconds`, `started_at`, `completed_at` |
841
+ | `stages` | `story_id`, `stage_name`, `stage_index`, `parent_stage_name`, `duration_seconds`, `completed`, `failed` |
842
+ | `failures` | `story_id`, `stage_name`, `error_type`, `error_message`, `filename`, `lineno`, `traceback_text`, `llm_analysis` |
843
+
844
+ `LLMAnalysisReady` back-fills the `llm_analysis` column in `failures` so background analysis results are persisted even when they arrive after `StoryCompleted`.
845
+
846
+ **CLI** (registered as `runtime-narrative`):
847
+
848
+ ```bash
849
+ # List the 10 most recent failures
850
+ runtime-narrative failures --db stories.db
851
+
852
+ # Filter by stage name or story name (LIKE pattern)
853
+ runtime-narrative failures --last 25 --stage "Insert Records"
854
+ runtime-narrative failures --story "Nightly ETL"
855
+
856
+ # Show the full detail for one story
857
+ runtime-narrative story <story_id> --db stories.db
858
+ ```
859
+
860
+ The `--db` flag defaults to `runtime_narrative.db` in the current directory.
861
+
862
+ ---
863
+
864
+ ## Alert routing
865
+
866
+ `AlertRoutingRenderer` fans out `FailureOccurred` events to webhook destinations concurrently. Destination failures are logged to stderr and swallowed — they never crash the story:
867
+
868
+ ```python
869
+ from runtime_narrative import (
870
+ story,
871
+ AlertRoutingRenderer,
872
+ SlackWebhookDestination,
873
+ HttpWebhookDestination,
874
+ )
875
+
876
+ renderer = AlertRoutingRenderer(
877
+ destinations=[
878
+ SlackWebhookDestination("https://hooks.slack.com/services/..."),
879
+ HttpWebhookDestination(
880
+ "https://alerts.example.com/webhook",
881
+ headers={"Authorization": "Bearer ..."},
882
+ timeout=5.0,
883
+ ),
884
+ ],
885
+ only_stories={"Nightly ETL", "Payment Processor"}, # None = all stories
886
+ only_error_types={"ValueError", "TimeoutError"}, # None = all error types
887
+ )
888
+
889
+ async with story("Nightly ETL", renderers=[renderer]):
890
+ ...
891
+ ```
892
+
893
+ `SlackWebhookDestination` sends a Block Kit message with a header, error detail section, and an optional analysis section when `llm_analysis` is present.
894
+
895
+ `HttpWebhookDestination` POSTs a JSON payload containing: `story_id`, `story_name`, `stage_name`, `error_type`, `error_message`, `filename`, `lineno`, `function`, `llm_analysis`, `timestamp`.
896
+
897
+ ---
898
+
899
+ ## `dry_run` mode
900
+
901
+ `dry_run=True` suppresses exceptions raised inside stage bodies, marks all stages completed, and emits `StoryCompleted(success=True)`. Useful for smoke-testing instrumentation wiring without triggering real side effects:
902
+
903
+ ```python
904
+ with story("Nightly ETL", dry_run=True):
905
+ with stage("Load Warehouse"):
906
+ raise IOError("would connect to DB in prod") # suppressed
907
+ with stage("Transform"):
908
+ raise RuntimeError("would run transforms") # suppressed
909
+ # StoryCompleted(success=True) emitted for all stages
910
+ ```
911
+
912
+ ---
913
+
914
+ ## Testing utilities
915
+
916
+ `StoryRecorder` is a dual sync/async context manager that captures story events for assertions. No output is produced:
917
+
918
+ ```python
919
+ from runtime_narrative import stage
920
+ from runtime_narrative.testing import StoryRecorder
921
+
922
+ def test_pipeline_success():
923
+ with StoryRecorder("ETL") as recorder:
924
+ with stage("Load"): rows = [1, 2, 3]
925
+ with stage("Insert"): db.insert(rows)
926
+
927
+ recorder.assert_stages_completed(["Load", "Insert"])
928
+ recorder.assert_no_failure()
929
+ recorder.assert_story_completed(success=True)
930
+
931
+ def test_invalid_input_fails_at_validate():
932
+ import pytest
933
+
934
+ with pytest.raises(ValueError):
935
+ with StoryRecorder("ETL") as recorder:
936
+ with stage("Load"): pass
937
+ with stage("Validate"): raise ValueError("bad schema")
938
+
939
+ recorder.assert_stage_failed("Validate", error_type="ValueError")
940
+ recorder.assert_story_completed(success=False)
941
+ ```
942
+
943
+ Works as `async with StoryRecorder(...)` too. Pass any `story()` kwargs including `dry_run=True`:
944
+
945
+ ```python
946
+ with StoryRecorder("ETL", dry_run=True) as recorder:
947
+ run_pipeline() # side effects suppressed
948
+
949
+ recorder.assert_stages_completed(["Load", "Validate", "Insert"])
950
+ recorder.assert_no_failure()
951
+ ```
952
+
953
+ **Assertion methods:**
954
+
955
+ | Method | What it checks |
956
+ |---|---|
957
+ | `assert_stages_completed(names)` | All named stages appear in `StageCompleted` events |
958
+ | `assert_no_failure()` | No `FailureOccurred` event was emitted |
959
+ | `assert_stage_failed(name, error_type=None)` | A `FailureOccurred` event at that stage name; optionally checks `error_type` |
960
+ | `assert_story_completed(success=None)` | A `StoryCompleted` event was emitted; optionally checks the `success` flag |
961
+
962
+ ---
963
+
964
+ ## Custom renderers and analyzers
965
+
966
+ ### Custom renderer
967
+
968
+ Implement `handle(self, event: object)`. Async renderers (`async def handle`) are awaited inside `async with story(...)`:
969
+
970
+ ```python
971
+ class PagerDutyRenderer:
972
+ async def handle(self, event: object) -> None:
973
+ if type(event).__name__ == "FailureOccurred":
974
+ await pagerduty.trigger(
975
+ summary=f"{event.story_name} failed at {event.stage_name}",
976
+ details={"error": event.error_type, "analysis": event.llm_analysis},
977
+ )
978
+
979
+ async with story("Nightly ETL", renderers=[PagerDutyRenderer()]):
980
+ ...
981
+ ```
982
+
983
+ Six event types are emitted. Key fields on each:
984
+
985
+ | Event | Key fields |
986
+ |---|---|
987
+ | `StoryStarted` | `story_id`, `story_name`, `timestamp` |
988
+ | `StageStarted` | `story_id`, `stage_name`, `timestamp`, `stage_index` (0-based), `parent_stage_name` |
989
+ | `StageCompleted` | `story_id`, `stage_name`, `timestamp`, `duration_seconds`, `stage_index`, `parent_stage_name` |
990
+ | `FailureOccurred` | `story_id`, `story_name`, `stage_name`, `error_type`, `error_message`, `filename`, `lineno`, `function`, `traceback_text`, `exception_chain`, `exact_cause`, `stage_timeline`, `progress_percent`, `llm_analysis`, `diagnostics_mode`, `stack_frames`, `source_snippet`, `compressed_stack_summary`, `locals_by_frame` |
991
+ | `StoryCompleted` | `story_id`, `story_name`, `success`, `progress_percent`, `completed_stages`, `total_stages`, `timestamp` |
992
+ | `LLMAnalysisReady` | `story_id`, `story_name`, `stage_name`, `llm_analysis`, `timestamp` |
993
+
994
+ `parent_stage_name` is `None` for top-level stages and set to the enclosing stage name for nested stages.
995
+
996
+ ### Custom failure analyzer
997
+
998
+ Implement `analyze_failure(...)`. Add `analyze_failure_async(...)` for native async — otherwise the sync method is called via `asyncio.to_thread`:
999
+
1000
+ ```python
1001
+ class MyAnalyzer:
1002
+ async def analyze_failure_async(
1003
+ self,
1004
+ *,
1005
+ story_name: str,
1006
+ stage_name: str,
1007
+ failure, # FailureSummary: .error_type, .error_message, .filename,
1008
+ # .lineno, .function, .source_line,
1009
+ # .traceback_text, .exception_chain
1010
+ stage_timeline: str,
1011
+ progress_percent: int,
1012
+ ) -> str | None:
1013
+ result = await my_llm_client.complete(build_prompt(failure))
1014
+ return result.text
1015
+
1016
+ def analyze_failure(self, *, story_name, stage_name, failure, stage_timeline, progress_percent):
1017
+ # sync fallback used when called from sync story()
1018
+ return requests.post(...).json()["text"]
1019
+
1020
+ with story("Import", failure_analyzer=MyAnalyzer()):
1021
+ ...
1022
+ ```
1023
+
1024
+ ---
1025
+
1026
+ ## Environment variables
1027
+
1028
+ | Variable | Values | Default | Effect |
1029
+ |---|---|---|---|
1030
+ | `RUNTIME_NARRATIVE_ENV` | `development`, `production` | `development` | Production caps tracebacks to 8 000 chars and forces lean mode |
1031
+ | `RUNTIME_NARRATIVE_FAILURE_DIAGNOSTICS` | `lean`, `rich` | `lean` | `rich` captures local variable values at the failing frames. Invalid values raise `ValueError` at story construction. |
1032
+ | `RUNTIME_NARRATIVE_ALLOW_RICH_IN_PRODUCTION` | `1`, `true` | off | Bypass production safeguard; allow rich diagnostics in production |
1033
+ | `RUNTIME_NARRATIVE_MODEL` | model name string | — | Default model for `AnthropicFailureAnalyzer`; also used by example scripts for `OllamaFailureAnalyzer` / `LLMFailureAnalyzer` |
1034
+ | `ANTHROPIC_API_KEY` | API key | — | Required by `AnthropicFailureAnalyzer`; read automatically if `api_key=` is not passed |
1035
+
1036
+ ---
1037
+
1038
+ ## Python compatibility
1039
+
1040
+ Python 3.9 – 3.13. Zero required dependencies beyond `python-dotenv`.