runtime-narrative 1.2.1__tar.gz → 1.5.0__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.2.1/runtime_narrative.egg-info → runtime_narrative-1.5.0}/PKG-INFO +76 -2
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/README.md +75 -1
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/pyproject.toml +5 -1
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/__init__.py +5 -1
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/celery.py +2 -10
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/decorators.py +6 -2
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/events.py +3 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/grpc_interceptor.py +2 -10
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/middleware.py +7 -12
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/middleware_django.py +12 -13
- runtime_narrative-1.5.0/runtime_narrative/outcome.py +17 -0
- runtime_narrative-1.5.0/runtime_narrative/renderer/coalescing_renderer.py +179 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/console.py +75 -20
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/json_renderer.py +3 -0
- runtime_narrative-1.5.0/runtime_narrative/renderer_defaults.py +73 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/stage.py +13 -2
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/story.py +26 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0/runtime_narrative.egg-info}/PKG-INFO +76 -2
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/SOURCES.txt +7 -0
- runtime_narrative-1.5.0/tests/test_coalescing_renderer.py +198 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_console_renderer.py +187 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_json_renderer.py +45 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_middleware.py +21 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_middleware_django.py +36 -0
- runtime_narrative-1.5.0/tests/test_module_capture.py +103 -0
- runtime_narrative-1.5.0/tests/test_outcome.py +13 -0
- runtime_narrative-1.5.0/tests/test_renderer_defaults.py +132 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_story.py +28 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/LICENSE +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/__init__.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/anthropic.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/base.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/deduplication.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/ollama.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/cli.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/context.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/diagnostics.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/failure.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/instrumentation.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/logging_bridge.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/__init__.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/alert_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/filter_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/html_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/otel_log_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/otel_metrics_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/otel_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/persistence_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/prometheus_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/task_group.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/testing.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/dependency_links.txt +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/entry_points.txt +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/requires.txt +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/top_level.txt +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/setup.cfg +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_alert_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_analyzers.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_anthropic_analyzer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_async_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_celery.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_decorators.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_deduplication.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_diagnostics.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_dry_run.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_failure.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_filter_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_grpc_interceptor.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_html_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_instrumentation.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_instrumentation_phase2.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_issues.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_logging_bridge.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_middleware_propagation.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_otel_log_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_otel_metrics_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_otel_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_persistence_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_prometheus_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_redaction_extended.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_stage.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_stage_metadata.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_structured_analysis.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_task_group.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_testing_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runtime-narrative
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Model execution as human-readable stories with lean/rich failure diagnostics and optional LLM analysis
|
|
5
5
|
Author-email: Shashank Raj <shashank.raj28@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -211,6 +211,78 @@ Run: `uv run python examples/logging_bridge.py`, `uv run python examples/structu
|
|
|
211
211
|
|
|
212
212
|
---
|
|
213
213
|
|
|
214
|
+
## Story outcomes: fold the access log into the story line
|
|
215
|
+
|
|
216
|
+
`StoryCompleted` carries an `outcome` label. The FastAPI/Starlette and Django middlewares set it automatically from the response status, and `ConsoleRenderer` then renders a single self-contained line per request — story name, result, and HTTP status together:
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
[d7678e] ▶ Story started: GET /api/call
|
|
220
|
+
[d7678e] ▶ Story ended: GET /api/call - SUCCESS (200 OK, 0.023s)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Since the story line now carries everything the server access log would print, you can silence the duplicate line — e.g. `uvicorn.run(app, access_log=False)`.
|
|
224
|
+
|
|
225
|
+
Outside middleware, set an outcome on any story yourself:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
from runtime_narrative import http_outcome, story
|
|
229
|
+
|
|
230
|
+
with story("GET /api/call") as runtime:
|
|
231
|
+
...
|
|
232
|
+
runtime.set_outcome(http_outcome(200)) # or any short label, e.g. "3 rows"
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Rich logs to a file, and no more flooded polling loops
|
|
238
|
+
|
|
239
|
+
`ConsoleRenderer` writes its colored, human-readable output to any file-like object, not just the terminal — so the same troubleshooting-friendly narrative can land in a log file alongside the structured JSON stream:
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
with open("narrative.log.txt", "a", encoding="utf-8") as log_file:
|
|
243
|
+
with story("Import Pipeline", renderers=[ConsoleRenderer(output=log_file), JsonRenderer("narrative.log")]):
|
|
244
|
+
...
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Every auto-instrumentation entry point (FastAPI/Starlette middleware, Django middleware, Celery, gRPC interceptors) picks this up automatically via two environment variables, with no code changes:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
RUNTIME_NARRATIVE_RICH_LOG_FILE=/var/log/app/narrative.log.txt # rich console output also goes here
|
|
251
|
+
RUNTIME_NARRATIVE_RICH_LOG_CONSOLE=0 # optional: stop echoing it to the terminal too
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Long-running, poll-heavy stages (status checks every couple of seconds during a big upload or pipeline run) no longer flood the log either. `CoalescingRenderer` wraps any other renderer and collapses a run of identical back-to-back stages into one summary line — total call count and total time, not one line per poll:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
from runtime_narrative import ConsoleRenderer, CoalescingRenderer
|
|
258
|
+
|
|
259
|
+
with story("Process Upload", renderers=[CoalescingRenderer(ConsoleRenderer())]):
|
|
260
|
+
for _ in range(45):
|
|
261
|
+
with stage("Check Pipeline Status"):
|
|
262
|
+
poll()
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
```
|
|
266
|
+
[abcdef] ▶ Stage started: Check Pipeline Status
|
|
267
|
+
[abcdef] ✔ Stage completed: Check Pipeline Status (2.010s)
|
|
268
|
+
[abcdef] ▶ Stage started: Check Pipeline Status
|
|
269
|
+
[abcdef] ✔ Stage completed: Check Pipeline Status (2.005s)
|
|
270
|
+
[abcdef] 'Check Pipeline Status' repeated 43 more times (45 total) over 90.400s (avg 2.009s/call)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Every `ConsoleRenderer` line also carries a `YYYY-MM-DD HH:MM:SS.mmm` timestamp, and the module that opened a story or stage — auto-detected with a single cheap frame lookup, no stack walking — shown once on `Story started` and again only when a stage transitions to a different module, so multi-module workflows stay traceable without repeating the tag on every line:
|
|
274
|
+
|
|
275
|
+
```
|
|
276
|
+
2026-07-03 12:03:41.208 [abcdef] ▶ Story started: Process Upload (app.routes.upload)
|
|
277
|
+
2026-07-03 12:03:41.209 [abcdef] ▶ Stage started: Validate Input (app.validators)
|
|
278
|
+
2026-07-03 12:03:41.210 [abcdef] ✔ Stage completed: Validate Input (0.001s)
|
|
279
|
+
2026-07-03 12:03:41.211 [abcdef] ▶ Stage started: Insert Record (app.db)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Run: `uv run python examples/colorful_errors_and_emojis.py` — full reference: [WIKI §10.1](WIKI.md#101-consolerenderer), [§10.1b](WIKI.md#101b-coalescingrenderer)
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
214
286
|
## Feature tour
|
|
215
287
|
|
|
216
288
|
Everything below works the same way in every context (sync/async, decorators, auto-instrumentation, any framework middleware). One line each here; full detail and every parameter in the Wiki.
|
|
@@ -221,7 +293,7 @@ Everything below works the same way in every context (sync/async, decorators, au
|
|
|
221
293
|
| Auto-instrumentation | `@narrative_class`, `@no_stage`, `instrument_module()`, `auto_instrument()` — instrument classes/modules with zero call-site changes | [WIKI §8](WIKI.md#8-auto-instrumentation) |
|
|
222
294
|
| Failure diagnostics | Lean/rich modes, production traceback caps, secret redaction, `FailureDiagnosticsConfig` | [WIKI §9](WIKI.md#9-failure-diagnostics) |
|
|
223
295
|
| Failure analyzers | `OllamaFailureAnalyzer`, `LLMFailureAnalyzer`, `AnthropicFailureAnalyzer`, `DeduplicatingAnalyzer`, `background_analysis=True` | [WIKI §9](WIKI.md#9-failure-diagnostics), [§16](WIKI.md#16-background-analysis) |
|
|
224
|
-
| Renderers | `ConsoleRenderer
|
|
296
|
+
| Renderers | `ConsoleRenderer` (optional file `output=`), `JsonRenderer`/`RotatingJsonRenderer`, `HtmlReportRenderer`, `SqliteStoryRenderer`, `OtelRenderer`/`OtelLogRenderer`/`OtelMetricsRenderer`, `PrometheusRenderer`, `AlertRoutingRenderer`, `FilteredRenderer`, `CoalescingRenderer` | [WIKI §10](WIKI.md#10-renderers) |
|
|
225
297
|
| Framework integrations | FastAPI/Starlette middleware, Django ASGI/WSGI middleware, Celery task base class, gRPC interceptors | [WIKI §11](WIKI.md#11-framework-integrations) |
|
|
226
298
|
| Async task groups | `NarrativeTaskGroup` — concurrent `asyncio` tasks under one shared story | [WIKI §12](WIKI.md#12-async-task-groups) |
|
|
227
299
|
| Persistence & CLI | `SqliteStoryRenderer` + `runtime-narrative failures` / `runtime-narrative story <id>` | [WIKI §13](WIKI.md#13-sqlite-persistence-and-cli) |
|
|
@@ -302,6 +374,8 @@ Every script under `examples/` is runnable as-is: `uv run python examples/<name>
|
|
|
302
374
|
| `RUNTIME_NARRATIVE_ALLOW_RICH_IN_PRODUCTION` | `1`, `true` | off | Bypass production safeguard; allow rich diagnostics in production |
|
|
303
375
|
| `RUNTIME_NARRATIVE_MODEL` | model name string | — | Default model for `AnthropicFailureAnalyzer`; also used by example scripts for `OllamaFailureAnalyzer` / `LLMFailureAnalyzer` |
|
|
304
376
|
| `ANTHROPIC_API_KEY` | API key | — | Required by `AnthropicFailureAnalyzer`; read automatically if `api_key=` is not passed |
|
|
377
|
+
| `RUNTIME_NARRATIVE_RICH_LOG_FILE` | file path | — | Adds a file-backed `ConsoleRenderer` writing rich, human-readable output to this path, on top of whichever renderer TTY detection selects. Read by every auto-instrumentation entry point (FastAPI/Starlette, Django, Celery, gRPC) when `renderers=` is omitted |
|
|
378
|
+
| `RUNTIME_NARRATIVE_RICH_LOG_CONSOLE` | `1`, `0` | `1` | With `RUNTIME_NARRATIVE_RICH_LOG_FILE` set and stdout a TTY, `0` suppresses the terminal copy so the narrative goes to the file only |
|
|
305
379
|
|
|
306
380
|
---
|
|
307
381
|
|
|
@@ -152,6 +152,78 @@ Run: `uv run python examples/logging_bridge.py`, `uv run python examples/structu
|
|
|
152
152
|
|
|
153
153
|
---
|
|
154
154
|
|
|
155
|
+
## Story outcomes: fold the access log into the story line
|
|
156
|
+
|
|
157
|
+
`StoryCompleted` carries an `outcome` label. The FastAPI/Starlette and Django middlewares set it automatically from the response status, and `ConsoleRenderer` then renders a single self-contained line per request — story name, result, and HTTP status together:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
[d7678e] ▶ Story started: GET /api/call
|
|
161
|
+
[d7678e] ▶ Story ended: GET /api/call - SUCCESS (200 OK, 0.023s)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Since the story line now carries everything the server access log would print, you can silence the duplicate line — e.g. `uvicorn.run(app, access_log=False)`.
|
|
165
|
+
|
|
166
|
+
Outside middleware, set an outcome on any story yourself:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from runtime_narrative import http_outcome, story
|
|
170
|
+
|
|
171
|
+
with story("GET /api/call") as runtime:
|
|
172
|
+
...
|
|
173
|
+
runtime.set_outcome(http_outcome(200)) # or any short label, e.g. "3 rows"
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Rich logs to a file, and no more flooded polling loops
|
|
179
|
+
|
|
180
|
+
`ConsoleRenderer` writes its colored, human-readable output to any file-like object, not just the terminal — so the same troubleshooting-friendly narrative can land in a log file alongside the structured JSON stream:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
with open("narrative.log.txt", "a", encoding="utf-8") as log_file:
|
|
184
|
+
with story("Import Pipeline", renderers=[ConsoleRenderer(output=log_file), JsonRenderer("narrative.log")]):
|
|
185
|
+
...
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Every auto-instrumentation entry point (FastAPI/Starlette middleware, Django middleware, Celery, gRPC interceptors) picks this up automatically via two environment variables, with no code changes:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
RUNTIME_NARRATIVE_RICH_LOG_FILE=/var/log/app/narrative.log.txt # rich console output also goes here
|
|
192
|
+
RUNTIME_NARRATIVE_RICH_LOG_CONSOLE=0 # optional: stop echoing it to the terminal too
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Long-running, poll-heavy stages (status checks every couple of seconds during a big upload or pipeline run) no longer flood the log either. `CoalescingRenderer` wraps any other renderer and collapses a run of identical back-to-back stages into one summary line — total call count and total time, not one line per poll:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from runtime_narrative import ConsoleRenderer, CoalescingRenderer
|
|
199
|
+
|
|
200
|
+
with story("Process Upload", renderers=[CoalescingRenderer(ConsoleRenderer())]):
|
|
201
|
+
for _ in range(45):
|
|
202
|
+
with stage("Check Pipeline Status"):
|
|
203
|
+
poll()
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
[abcdef] ▶ Stage started: Check Pipeline Status
|
|
208
|
+
[abcdef] ✔ Stage completed: Check Pipeline Status (2.010s)
|
|
209
|
+
[abcdef] ▶ Stage started: Check Pipeline Status
|
|
210
|
+
[abcdef] ✔ Stage completed: Check Pipeline Status (2.005s)
|
|
211
|
+
[abcdef] 'Check Pipeline Status' repeated 43 more times (45 total) over 90.400s (avg 2.009s/call)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Every `ConsoleRenderer` line also carries a `YYYY-MM-DD HH:MM:SS.mmm` timestamp, and the module that opened a story or stage — auto-detected with a single cheap frame lookup, no stack walking — shown once on `Story started` and again only when a stage transitions to a different module, so multi-module workflows stay traceable without repeating the tag on every line:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
2026-07-03 12:03:41.208 [abcdef] ▶ Story started: Process Upload (app.routes.upload)
|
|
218
|
+
2026-07-03 12:03:41.209 [abcdef] ▶ Stage started: Validate Input (app.validators)
|
|
219
|
+
2026-07-03 12:03:41.210 [abcdef] ✔ Stage completed: Validate Input (0.001s)
|
|
220
|
+
2026-07-03 12:03:41.211 [abcdef] ▶ Stage started: Insert Record (app.db)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Run: `uv run python examples/colorful_errors_and_emojis.py` — full reference: [WIKI §10.1](WIKI.md#101-consolerenderer), [§10.1b](WIKI.md#101b-coalescingrenderer)
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
155
227
|
## Feature tour
|
|
156
228
|
|
|
157
229
|
Everything below works the same way in every context (sync/async, decorators, auto-instrumentation, any framework middleware). One line each here; full detail and every parameter in the Wiki.
|
|
@@ -162,7 +234,7 @@ Everything below works the same way in every context (sync/async, decorators, au
|
|
|
162
234
|
| Auto-instrumentation | `@narrative_class`, `@no_stage`, `instrument_module()`, `auto_instrument()` — instrument classes/modules with zero call-site changes | [WIKI §8](WIKI.md#8-auto-instrumentation) |
|
|
163
235
|
| Failure diagnostics | Lean/rich modes, production traceback caps, secret redaction, `FailureDiagnosticsConfig` | [WIKI §9](WIKI.md#9-failure-diagnostics) |
|
|
164
236
|
| Failure analyzers | `OllamaFailureAnalyzer`, `LLMFailureAnalyzer`, `AnthropicFailureAnalyzer`, `DeduplicatingAnalyzer`, `background_analysis=True` | [WIKI §9](WIKI.md#9-failure-diagnostics), [§16](WIKI.md#16-background-analysis) |
|
|
165
|
-
| Renderers | `ConsoleRenderer
|
|
237
|
+
| Renderers | `ConsoleRenderer` (optional file `output=`), `JsonRenderer`/`RotatingJsonRenderer`, `HtmlReportRenderer`, `SqliteStoryRenderer`, `OtelRenderer`/`OtelLogRenderer`/`OtelMetricsRenderer`, `PrometheusRenderer`, `AlertRoutingRenderer`, `FilteredRenderer`, `CoalescingRenderer` | [WIKI §10](WIKI.md#10-renderers) |
|
|
166
238
|
| Framework integrations | FastAPI/Starlette middleware, Django ASGI/WSGI middleware, Celery task base class, gRPC interceptors | [WIKI §11](WIKI.md#11-framework-integrations) |
|
|
167
239
|
| Async task groups | `NarrativeTaskGroup` — concurrent `asyncio` tasks under one shared story | [WIKI §12](WIKI.md#12-async-task-groups) |
|
|
168
240
|
| Persistence & CLI | `SqliteStoryRenderer` + `runtime-narrative failures` / `runtime-narrative story <id>` | [WIKI §13](WIKI.md#13-sqlite-persistence-and-cli) |
|
|
@@ -243,6 +315,8 @@ Every script under `examples/` is runnable as-is: `uv run python examples/<name>
|
|
|
243
315
|
| `RUNTIME_NARRATIVE_ALLOW_RICH_IN_PRODUCTION` | `1`, `true` | off | Bypass production safeguard; allow rich diagnostics in production |
|
|
244
316
|
| `RUNTIME_NARRATIVE_MODEL` | model name string | — | Default model for `AnthropicFailureAnalyzer`; also used by example scripts for `OllamaFailureAnalyzer` / `LLMFailureAnalyzer` |
|
|
245
317
|
| `ANTHROPIC_API_KEY` | API key | — | Required by `AnthropicFailureAnalyzer`; read automatically if `api_key=` is not passed |
|
|
318
|
+
| `RUNTIME_NARRATIVE_RICH_LOG_FILE` | file path | — | Adds a file-backed `ConsoleRenderer` writing rich, human-readable output to this path, on top of whichever renderer TTY detection selects. Read by every auto-instrumentation entry point (FastAPI/Starlette, Django, Celery, gRPC) when `renderers=` is omitted |
|
|
319
|
+
| `RUNTIME_NARRATIVE_RICH_LOG_CONSOLE` | `1`, `0` | `1` | With `RUNTIME_NARRATIVE_RICH_LOG_FILE` set and stdout a TTY, `0` suppresses the terminal copy so the narrative goes to the file only |
|
|
246
320
|
|
|
247
321
|
---
|
|
248
322
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "runtime-narrative"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.5.0"
|
|
8
8
|
description = "Model execution as human-readable stories with lean/rich failure diagnostics and optional LLM analysis"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -84,13 +84,17 @@ packages = ["runtime_narrative", "runtime_narrative.renderer", "runtime_narrativ
|
|
|
84
84
|
[dependency-groups]
|
|
85
85
|
dev = [
|
|
86
86
|
"build>=1.4.0",
|
|
87
|
+
"fastapi>=0.100.0",
|
|
87
88
|
"httpx>=0.27.0",
|
|
88
89
|
"opentelemetry-api>=1.20.0",
|
|
89
90
|
"opentelemetry-sdk>=1.20.0",
|
|
90
91
|
"prometheus-client>=0.19.0",
|
|
92
|
+
"pydantic>=2.0.0",
|
|
91
93
|
"pytest>=8.0.0",
|
|
92
94
|
"starlette>=0.27.0",
|
|
93
95
|
"twine>=6.2.0",
|
|
96
|
+
"typer>=0.9.0",
|
|
97
|
+
"uvicorn>=0.23.0",
|
|
94
98
|
]
|
|
95
99
|
|
|
96
100
|
[tool.pytest.ini_options]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
__version__ = "1.
|
|
1
|
+
__version__ = "1.5.0"
|
|
2
2
|
|
|
3
3
|
from .analyzers import FailureAnalyzer, LLMFailureAnalyzer, OllamaFailureAnalyzer, DeduplicatingAnalyzer
|
|
4
4
|
from .context import has_active_story
|
|
@@ -16,8 +16,10 @@ from .events import (
|
|
|
16
16
|
)
|
|
17
17
|
from .instrumentation import auto_instrument, instrument_module, narrative_class, narrative_stage, no_stage
|
|
18
18
|
from .logging_bridge import NarrativeLogHandler
|
|
19
|
+
from .outcome import http_outcome
|
|
19
20
|
from .renderer.console import ConsoleRenderer
|
|
20
21
|
from .renderer.filter_renderer import FilteredRenderer
|
|
22
|
+
from .renderer.coalescing_renderer import CoalescingRenderer
|
|
21
23
|
from .renderer.json_renderer import JsonRenderer, RotatingJsonRenderer
|
|
22
24
|
from .stage import stage
|
|
23
25
|
from .story import story, StoryRuntime
|
|
@@ -105,6 +107,7 @@ __all__ = [
|
|
|
105
107
|
"RuntimeNarrativeMiddleware",
|
|
106
108
|
"ConsoleRenderer",
|
|
107
109
|
"FilteredRenderer",
|
|
110
|
+
"CoalescingRenderer",
|
|
108
111
|
"JsonRenderer",
|
|
109
112
|
"RotatingJsonRenderer",
|
|
110
113
|
"OtelRenderer",
|
|
@@ -120,6 +123,7 @@ __all__ = [
|
|
|
120
123
|
"LLMAnalysisReady",
|
|
121
124
|
"LogRecorded",
|
|
122
125
|
"NarrativeLogHandler",
|
|
126
|
+
"http_outcome",
|
|
123
127
|
"FailureDiagnosticsConfig",
|
|
124
128
|
"build_enriched_failure",
|
|
125
129
|
"effective_diagnostics_mode",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import sys
|
|
4
3
|
from typing import Any, Sequence
|
|
5
4
|
|
|
5
|
+
from .renderer_defaults import default_renderers
|
|
6
6
|
from .story import story
|
|
7
7
|
|
|
8
8
|
try:
|
|
@@ -15,14 +15,6 @@ except ImportError:
|
|
|
15
15
|
_TaskBase = object
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def _default_renderers() -> tuple:
|
|
19
|
-
if getattr(sys.stdout, "isatty", lambda: False)():
|
|
20
|
-
from .renderer.console import ConsoleRenderer
|
|
21
|
-
return (ConsoleRenderer(),)
|
|
22
|
-
from .renderer.json_renderer import JsonRenderer
|
|
23
|
-
return (JsonRenderer(),)
|
|
24
|
-
|
|
25
|
-
|
|
26
18
|
class NarrativeTask(_TaskBase): # type: ignore[valid-type,misc]
|
|
27
19
|
abstract = True
|
|
28
20
|
|
|
@@ -47,7 +39,7 @@ class NarrativeTask(_TaskBase): # type: ignore[valid-type,misc]
|
|
|
47
39
|
request = getattr(self, "request", None)
|
|
48
40
|
task_id = getattr(request, "id", None) or "unknown"
|
|
49
41
|
story_name = f"{self.name} [task_id={task_id}]"
|
|
50
|
-
renderers = self.narrative_renderers if self.narrative_renderers is not None else
|
|
42
|
+
renderers = self.narrative_renderers if self.narrative_renderers is not None else default_renderers()
|
|
51
43
|
with story(
|
|
52
44
|
story_name,
|
|
53
45
|
renderers=renderers,
|
|
@@ -41,6 +41,10 @@ def runtime_narrative_story(
|
|
|
41
41
|
"failure_diagnostics": failure_diagnostics,
|
|
42
42
|
"allow_rich_in_production": allow_rich_in_production,
|
|
43
43
|
"app_roots": app_roots,
|
|
44
|
+
# Without this, story() would auto-detect the caller's frame as
|
|
45
|
+
# this decorator's own wrapper closure, reporting every decorated
|
|
46
|
+
# story as "runtime_narrative.decorators" instead of func's module.
|
|
47
|
+
"module": func.__module__,
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
if inspect.iscoroutinefunction(func):
|
|
@@ -72,14 +76,14 @@ def runtime_narrative_stage(name: str | None = None) -> Callable[[F], F]:
|
|
|
72
76
|
|
|
73
77
|
@wraps(func)
|
|
74
78
|
async def async_wrapper(*args: Any, **kwargs: Any):
|
|
75
|
-
async with stage(stage_name):
|
|
79
|
+
async with stage(stage_name, module=func.__module__):
|
|
76
80
|
return await func(*args, **kwargs)
|
|
77
81
|
|
|
78
82
|
return async_wrapper # type: ignore[return-value]
|
|
79
83
|
|
|
80
84
|
@wraps(func)
|
|
81
85
|
def sync_wrapper(*args: Any, **kwargs: Any):
|
|
82
|
-
with stage(stage_name):
|
|
86
|
+
with stage(stage_name, module=func.__module__):
|
|
83
87
|
return func(*args, **kwargs)
|
|
84
88
|
|
|
85
89
|
return sync_wrapper # type: ignore[return-value]
|
|
@@ -12,6 +12,7 @@ class StoryStarted:
|
|
|
12
12
|
timestamp: datetime
|
|
13
13
|
parent_story_id: str | None = None
|
|
14
14
|
root_story_id: str = ""
|
|
15
|
+
module: str = ""
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
@dataclass
|
|
@@ -23,6 +24,7 @@ class StageStarted:
|
|
|
23
24
|
parent_stage_name: str | None = None
|
|
24
25
|
story_name: str = ""
|
|
25
26
|
root_story_id: str = ""
|
|
27
|
+
module: str = ""
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
@dataclass
|
|
@@ -82,6 +84,7 @@ class StoryCompleted:
|
|
|
82
84
|
duration_seconds: float = 0.0
|
|
83
85
|
parent_story_id: str | None = None
|
|
84
86
|
root_story_id: str = ""
|
|
87
|
+
outcome: str = ""
|
|
85
88
|
|
|
86
89
|
|
|
87
90
|
@dataclass
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import sys
|
|
4
3
|
from typing import Any, Callable, Sequence
|
|
5
4
|
|
|
5
|
+
from .renderer_defaults import default_renderers
|
|
6
6
|
from .story import story
|
|
7
7
|
|
|
8
8
|
try:
|
|
@@ -24,17 +24,9 @@ except ImportError:
|
|
|
24
24
|
_AsyncBase = object
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
def _default_renderers() -> tuple:
|
|
28
|
-
if getattr(sys.stdout, "isatty", lambda: False)():
|
|
29
|
-
from .renderer.console import ConsoleRenderer
|
|
30
|
-
return (ConsoleRenderer(),)
|
|
31
|
-
from .renderer.json_renderer import JsonRenderer
|
|
32
|
-
return (JsonRenderer(),)
|
|
33
|
-
|
|
34
|
-
|
|
35
27
|
def _story_kwargs(interceptor: Any) -> dict:
|
|
36
28
|
return {
|
|
37
|
-
"renderers": interceptor._renderers or
|
|
29
|
+
"renderers": interceptor._renderers or default_renderers(),
|
|
38
30
|
"failure_analyzer": interceptor._failure_analyzer,
|
|
39
31
|
"diagnostics_config": interceptor._diagnostics_config,
|
|
40
32
|
"runtime_environment": interceptor._runtime_environment,
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import sys
|
|
4
3
|
from typing import Any, Callable, Sequence
|
|
5
4
|
|
|
6
5
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
@@ -8,6 +7,8 @@ from starlette.requests import Request
|
|
|
8
7
|
from starlette.responses import Response
|
|
9
8
|
|
|
10
9
|
from .diagnostics import FailureDiagnosticsConfig
|
|
10
|
+
from .outcome import http_outcome
|
|
11
|
+
from .renderer_defaults import default_renderers
|
|
11
12
|
from .story import story
|
|
12
13
|
|
|
13
14
|
try:
|
|
@@ -18,15 +19,6 @@ except ImportError:
|
|
|
18
19
|
_OTEL_PROPAGATION_AVAILABLE = False
|
|
19
20
|
|
|
20
21
|
|
|
21
|
-
def _default_middleware_renderers() -> tuple:
|
|
22
|
-
"""Return ConsoleRenderer when attached to a real terminal, JsonRenderer otherwise."""
|
|
23
|
-
if getattr(sys.stdout, "isatty", lambda: False)():
|
|
24
|
-
from .renderer.console import ConsoleRenderer
|
|
25
|
-
return (ConsoleRenderer(),)
|
|
26
|
-
from .renderer.json_renderer import JsonRenderer
|
|
27
|
-
return (JsonRenderer(),)
|
|
28
|
-
|
|
29
|
-
|
|
30
22
|
class RuntimeNarrativeMiddleware(BaseHTTPMiddleware):
|
|
31
23
|
"""
|
|
32
24
|
FastAPI/Starlette middleware that wraps every HTTP request in a runtime_narrative story.
|
|
@@ -37,6 +29,8 @@ class RuntimeNarrativeMiddleware(BaseHTTPMiddleware):
|
|
|
37
29
|
When no ``renderers`` are passed, the middleware auto-selects:
|
|
38
30
|
- ``ConsoleRenderer`` if ``sys.stdout`` is a real TTY (local dev server)
|
|
39
31
|
- ``JsonRenderer`` otherwise (production, Docker, CI — any non-interactive environment)
|
|
32
|
+
- Set ``RUNTIME_NARRATIVE_RICH_LOG_FILE=<path>`` to also (or instead, combined with
|
|
33
|
+
``RUNTIME_NARRATIVE_RICH_LOG_CONSOLE=0``) write the human-readable narrative to a file.
|
|
40
34
|
|
|
41
35
|
Usage::
|
|
42
36
|
|
|
@@ -76,7 +70,7 @@ class RuntimeNarrativeMiddleware(BaseHTTPMiddleware):
|
|
|
76
70
|
skip_if: Callable[[Request], bool] | None = None,
|
|
77
71
|
):
|
|
78
72
|
super().__init__(app)
|
|
79
|
-
self._renderers = tuple(renderers) if renderers is not None else
|
|
73
|
+
self._renderers = tuple(renderers) if renderers is not None else default_renderers()
|
|
80
74
|
self._failure_analyzer = failure_analyzer
|
|
81
75
|
self._diagnostics_config = diagnostics_config
|
|
82
76
|
self._runtime_environment = runtime_environment
|
|
@@ -107,8 +101,9 @@ class RuntimeNarrativeMiddleware(BaseHTTPMiddleware):
|
|
|
107
101
|
allow_rich_in_production=self._allow_rich_in_production,
|
|
108
102
|
app_roots=self._app_roots,
|
|
109
103
|
redact_extra=self._redact_extra,
|
|
110
|
-
):
|
|
104
|
+
) as runtime:
|
|
111
105
|
response = await call_next(request)
|
|
106
|
+
runtime.set_outcome(http_outcome(response.status_code))
|
|
112
107
|
return response
|
|
113
108
|
finally:
|
|
114
109
|
if token is not None:
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import sys
|
|
4
3
|
from typing import Any, Sequence
|
|
5
4
|
|
|
6
5
|
from .diagnostics import FailureDiagnosticsConfig
|
|
6
|
+
from .outcome import http_outcome
|
|
7
|
+
from .renderer_defaults import default_renderers
|
|
7
8
|
from .story import story
|
|
8
9
|
|
|
9
10
|
try:
|
|
@@ -13,14 +14,6 @@ except ImportError:
|
|
|
13
14
|
_DJANGO_AVAILABLE = False
|
|
14
15
|
|
|
15
16
|
|
|
16
|
-
def _default_renderers() -> tuple:
|
|
17
|
-
if getattr(sys.stdout, "isatty", lambda: False)():
|
|
18
|
-
from .renderer.console import ConsoleRenderer
|
|
19
|
-
return (ConsoleRenderer(),)
|
|
20
|
-
from .renderer.json_renderer import JsonRenderer
|
|
21
|
-
return (JsonRenderer(),)
|
|
22
|
-
|
|
23
|
-
|
|
24
17
|
class RuntimeNarrativeDjangoMiddleware:
|
|
25
18
|
async_capable = True
|
|
26
19
|
sync_capable = False
|
|
@@ -44,7 +37,7 @@ class RuntimeNarrativeDjangoMiddleware:
|
|
|
44
37
|
"Install it with: pip install django"
|
|
45
38
|
)
|
|
46
39
|
self.get_response = get_response
|
|
47
|
-
self._renderers = tuple(renderers) if renderers is not None else
|
|
40
|
+
self._renderers = tuple(renderers) if renderers is not None else default_renderers()
|
|
48
41
|
self._failure_analyzer = failure_analyzer
|
|
49
42
|
self._diagnostics_config = diagnostics_config
|
|
50
43
|
self._runtime_environment = runtime_environment
|
|
@@ -65,8 +58,11 @@ class RuntimeNarrativeDjangoMiddleware:
|
|
|
65
58
|
allow_rich_in_production=self._allow_rich_in_production,
|
|
66
59
|
app_roots=self._app_roots,
|
|
67
60
|
redact_extra=self._redact_extra,
|
|
68
|
-
):
|
|
61
|
+
) as runtime:
|
|
69
62
|
response = await self.get_response(request)
|
|
63
|
+
status_code = getattr(response, "status_code", None)
|
|
64
|
+
if status_code is not None:
|
|
65
|
+
runtime.set_outcome(http_outcome(status_code))
|
|
70
66
|
return response
|
|
71
67
|
|
|
72
68
|
|
|
@@ -93,7 +89,7 @@ class RuntimeNarrativeDjangoSyncMiddleware:
|
|
|
93
89
|
"Install it with: pip install django"
|
|
94
90
|
)
|
|
95
91
|
self.get_response = get_response
|
|
96
|
-
self._renderers = tuple(renderers) if renderers is not None else
|
|
92
|
+
self._renderers = tuple(renderers) if renderers is not None else default_renderers()
|
|
97
93
|
self._failure_analyzer = failure_analyzer
|
|
98
94
|
self._diagnostics_config = diagnostics_config
|
|
99
95
|
self._runtime_environment = runtime_environment
|
|
@@ -114,8 +110,11 @@ class RuntimeNarrativeDjangoSyncMiddleware:
|
|
|
114
110
|
allow_rich_in_production=self._allow_rich_in_production,
|
|
115
111
|
app_roots=self._app_roots,
|
|
116
112
|
redact_extra=self._redact_extra,
|
|
117
|
-
):
|
|
113
|
+
) as runtime:
|
|
118
114
|
response = self.get_response(request)
|
|
115
|
+
status_code = getattr(response, "status_code", None)
|
|
116
|
+
if status_code is not None:
|
|
117
|
+
runtime.set_outcome(http_outcome(status_code))
|
|
119
118
|
return response
|
|
120
119
|
|
|
121
120
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from http import HTTPStatus
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def http_outcome(status_code: int) -> str:
|
|
7
|
+
"""Format an HTTP status code as a "200 OK"-style story outcome label.
|
|
8
|
+
|
|
9
|
+
Sync by design: pure data transformation with no I/O.
|
|
10
|
+
"""
|
|
11
|
+
try:
|
|
12
|
+
return f"{status_code} {HTTPStatus(status_code).phrase}"
|
|
13
|
+
except ValueError:
|
|
14
|
+
return str(status_code)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ["http_outcome"]
|