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.
Files changed (85) hide show
  1. {runtime_narrative-1.2.1/runtime_narrative.egg-info → runtime_narrative-1.5.0}/PKG-INFO +76 -2
  2. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/README.md +75 -1
  3. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/pyproject.toml +5 -1
  4. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/__init__.py +5 -1
  5. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/celery.py +2 -10
  6. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/decorators.py +6 -2
  7. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/events.py +3 -0
  8. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/grpc_interceptor.py +2 -10
  9. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/middleware.py +7 -12
  10. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/middleware_django.py +12 -13
  11. runtime_narrative-1.5.0/runtime_narrative/outcome.py +17 -0
  12. runtime_narrative-1.5.0/runtime_narrative/renderer/coalescing_renderer.py +179 -0
  13. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/console.py +75 -20
  14. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/json_renderer.py +3 -0
  15. runtime_narrative-1.5.0/runtime_narrative/renderer_defaults.py +73 -0
  16. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/stage.py +13 -2
  17. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/story.py +26 -0
  18. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0/runtime_narrative.egg-info}/PKG-INFO +76 -2
  19. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/SOURCES.txt +7 -0
  20. runtime_narrative-1.5.0/tests/test_coalescing_renderer.py +198 -0
  21. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_console_renderer.py +187 -0
  22. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_json_renderer.py +45 -0
  23. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_middleware.py +21 -0
  24. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_middleware_django.py +36 -0
  25. runtime_narrative-1.5.0/tests/test_module_capture.py +103 -0
  26. runtime_narrative-1.5.0/tests/test_outcome.py +13 -0
  27. runtime_narrative-1.5.0/tests/test_renderer_defaults.py +132 -0
  28. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_story.py +28 -0
  29. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/LICENSE +0 -0
  30. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/__init__.py +0 -0
  31. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/anthropic.py +0 -0
  32. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/base.py +0 -0
  33. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/deduplication.py +0 -0
  34. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/ollama.py +0 -0
  35. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/cli.py +0 -0
  36. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/context.py +0 -0
  37. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/diagnostics.py +0 -0
  38. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/failure.py +0 -0
  39. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/instrumentation.py +0 -0
  40. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/logging_bridge.py +0 -0
  41. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/__init__.py +0 -0
  42. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/alert_renderer.py +0 -0
  43. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/filter_renderer.py +0 -0
  44. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/html_renderer.py +0 -0
  45. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/otel_log_renderer.py +0 -0
  46. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/otel_metrics_renderer.py +0 -0
  47. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/otel_renderer.py +0 -0
  48. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/persistence_renderer.py +0 -0
  49. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/renderer/prometheus_renderer.py +0 -0
  50. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/task_group.py +0 -0
  51. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative/testing.py +0 -0
  52. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/dependency_links.txt +0 -0
  53. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/entry_points.txt +0 -0
  54. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/requires.txt +0 -0
  55. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/top_level.txt +0 -0
  56. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/setup.cfg +0 -0
  57. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_alert_renderer.py +0 -0
  58. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_analyzers.py +0 -0
  59. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_anthropic_analyzer.py +0 -0
  60. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_async_renderer.py +0 -0
  61. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_celery.py +0 -0
  62. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_decorators.py +0 -0
  63. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_deduplication.py +0 -0
  64. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_diagnostics.py +0 -0
  65. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_dry_run.py +0 -0
  66. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_failure.py +0 -0
  67. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_filter_renderer.py +0 -0
  68. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_grpc_interceptor.py +0 -0
  69. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_html_renderer.py +0 -0
  70. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_instrumentation.py +0 -0
  71. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_instrumentation_phase2.py +0 -0
  72. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_issues.py +0 -0
  73. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_logging_bridge.py +0 -0
  74. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_middleware_propagation.py +0 -0
  75. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_otel_log_renderer.py +0 -0
  76. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_otel_metrics_renderer.py +0 -0
  77. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_otel_renderer.py +0 -0
  78. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_persistence_renderer.py +0 -0
  79. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_prometheus_renderer.py +0 -0
  80. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_redaction_extended.py +0 -0
  81. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_stage.py +0 -0
  82. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_stage_metadata.py +0 -0
  83. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_structured_analysis.py +0 -0
  84. {runtime_narrative-1.2.1 → runtime_narrative-1.5.0}/tests/test_task_group.py +0 -0
  85. {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.2.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`, `JsonRenderer`/`RotatingJsonRenderer`, `HtmlReportRenderer`, `SqliteStoryRenderer`, `OtelRenderer`/`OtelLogRenderer`/`OtelMetricsRenderer`, `PrometheusRenderer`, `AlertRoutingRenderer`, `FilteredRenderer` | [WIKI §10](WIKI.md#10-renderers) |
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`, `JsonRenderer`/`RotatingJsonRenderer`, `HtmlReportRenderer`, `SqliteStoryRenderer`, `OtelRenderer`/`OtelLogRenderer`/`OtelMetricsRenderer`, `PrometheusRenderer`, `AlertRoutingRenderer`, `FilteredRenderer` | [WIKI §10](WIKI.md#10-renderers) |
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.2.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.2.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 _default_renderers()
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 _default_renderers(),
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 _default_middleware_renderers()
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 _default_renderers()
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 _default_renderers()
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"]