runtime-narrative 0.2.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.
- runtime_narrative-1.0.1/PKG-INFO +1040 -0
- runtime_narrative-1.0.1/README.md +984 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/pyproject.toml +33 -3
- runtime_narrative-1.0.1/runtime_narrative/__init__.py +134 -0
- runtime_narrative-1.0.1/runtime_narrative/analyzers/__init__.py +16 -0
- runtime_narrative-1.0.1/runtime_narrative/analyzers/anthropic.py +154 -0
- runtime_narrative-1.0.1/runtime_narrative/analyzers/base.py +20 -0
- runtime_narrative-1.0.1/runtime_narrative/analyzers/deduplication.py +100 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/runtime_narrative/analyzers/ollama.py +268 -220
- runtime_narrative-1.0.1/runtime_narrative/celery.py +87 -0
- runtime_narrative-1.0.1/runtime_narrative/cli.py +199 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/runtime_narrative/context.py +5 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/runtime_narrative/diagnostics.py +70 -9
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/runtime_narrative/events.py +6 -0
- runtime_narrative-1.0.1/runtime_narrative/grpc_interceptor.py +147 -0
- runtime_narrative-1.0.1/runtime_narrative/instrumentation.py +254 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/runtime_narrative/middleware.py +37 -15
- runtime_narrative-1.0.1/runtime_narrative/middleware_django.py +122 -0
- runtime_narrative-1.0.1/runtime_narrative/renderer/alert_renderer.py +184 -0
- runtime_narrative-1.0.1/runtime_narrative/renderer/html_renderer.py +166 -0
- runtime_narrative-1.0.1/runtime_narrative/renderer/otel_log_renderer.py +134 -0
- runtime_narrative-1.0.1/runtime_narrative/renderer/otel_metrics_renderer.py +93 -0
- runtime_narrative-1.0.1/runtime_narrative/renderer/otel_renderer.py +148 -0
- runtime_narrative-1.0.1/runtime_narrative/renderer/persistence_renderer.py +200 -0
- runtime_narrative-1.0.1/runtime_narrative/renderer/prometheus_renderer.py +100 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/runtime_narrative/stage.py +111 -93
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/runtime_narrative/story.py +83 -7
- runtime_narrative-1.0.1/runtime_narrative/task_group.py +70 -0
- runtime_narrative-1.0.1/runtime_narrative/testing.py +142 -0
- runtime_narrative-1.0.1/runtime_narrative.egg-info/PKG-INFO +1040 -0
- runtime_narrative-1.0.1/runtime_narrative.egg-info/SOURCES.txt +73 -0
- runtime_narrative-1.0.1/runtime_narrative.egg-info/entry_points.txt +2 -0
- runtime_narrative-1.0.1/runtime_narrative.egg-info/requires.txt +37 -0
- runtime_narrative-1.0.1/tests/test_alert_renderer.py +279 -0
- runtime_narrative-1.0.1/tests/test_analyzers.py +133 -0
- runtime_narrative-1.0.1/tests/test_anthropic_analyzer.py +134 -0
- runtime_narrative-1.0.1/tests/test_async_renderer.py +56 -0
- runtime_narrative-1.0.1/tests/test_celery.py +120 -0
- runtime_narrative-1.0.1/tests/test_console_renderer.py +120 -0
- runtime_narrative-1.0.1/tests/test_deduplication.py +145 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/tests/test_diagnostics.py +48 -0
- runtime_narrative-1.0.1/tests/test_dry_run.py +115 -0
- runtime_narrative-1.0.1/tests/test_grpc_interceptor.py +155 -0
- runtime_narrative-1.0.1/tests/test_html_renderer.py +92 -0
- runtime_narrative-1.0.1/tests/test_instrumentation.py +330 -0
- runtime_narrative-1.0.1/tests/test_instrumentation_phase2.py +333 -0
- runtime_narrative-1.0.1/tests/test_issues.py +213 -0
- runtime_narrative-1.0.1/tests/test_json_renderer.py +110 -0
- runtime_narrative-1.0.1/tests/test_middleware_django.py +110 -0
- runtime_narrative-1.0.1/tests/test_middleware_propagation.py +99 -0
- runtime_narrative-1.0.1/tests/test_otel_log_renderer.py +204 -0
- runtime_narrative-1.0.1/tests/test_otel_metrics_renderer.py +201 -0
- runtime_narrative-1.0.1/tests/test_otel_renderer.py +306 -0
- runtime_narrative-1.0.1/tests/test_persistence_renderer.py +368 -0
- runtime_narrative-1.0.1/tests/test_prometheus_renderer.py +193 -0
- runtime_narrative-1.0.1/tests/test_redaction_extended.py +248 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/tests/test_stage.py +13 -0
- runtime_narrative-1.0.1/tests/test_stage_metadata.py +123 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/tests/test_story.py +87 -0
- runtime_narrative-1.0.1/tests/test_structured_analysis.py +186 -0
- runtime_narrative-1.0.1/tests/test_task_group.py +141 -0
- runtime_narrative-1.0.1/tests/test_testing_utils.py +134 -0
- runtime_narrative-0.2.0/PKG-INFO +0 -408
- runtime_narrative-0.2.0/README.MD +0 -373
- runtime_narrative-0.2.0/runtime_narrative/__init__.py +0 -31
- runtime_narrative-0.2.0/runtime_narrative/analyzers/__init__.py +0 -3
- runtime_narrative-0.2.0/runtime_narrative.egg-info/PKG-INFO +0 -408
- runtime_narrative-0.2.0/runtime_narrative.egg-info/SOURCES.txt +0 -30
- runtime_narrative-0.2.0/runtime_narrative.egg-info/requires.txt +0 -10
- runtime_narrative-0.2.0/tests/test_async_renderer.py +0 -28
- runtime_narrative-0.2.0/tests/test_json_renderer.py +0 -50
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/LICENSE +0 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/runtime_narrative/decorators.py +0 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/runtime_narrative/failure.py +0 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/runtime_narrative/renderer/__init__.py +0 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/runtime_narrative/renderer/console.py +0 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/runtime_narrative/renderer/json_renderer.py +0 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/runtime_narrative.egg-info/dependency_links.txt +0 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/runtime_narrative.egg-info/top_level.txt +0 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/setup.cfg +0 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/tests/test_decorators.py +0 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/tests/test_failure.py +0 -0
- {runtime_narrative-0.2.0 → runtime_narrative-1.0.1}/tests/test_middleware.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`.
|