runtime-narrative 1.3.0__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.3.0/runtime_narrative.egg-info → runtime_narrative-1.5.0}/PKG-INFO +53 -2
  2. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/README.md +52 -1
  3. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/pyproject.toml +1 -1
  4. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/__init__.py +3 -1
  5. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/celery.py +2 -10
  6. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/decorators.py +6 -2
  7. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/events.py +2 -0
  8. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/grpc_interceptor.py +2 -10
  9. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/middleware.py +4 -13
  10. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/middleware_django.py +3 -11
  11. runtime_narrative-1.5.0/runtime_narrative/renderer/coalescing_renderer.py +179 -0
  12. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/console.py +64 -19
  13. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/json_renderer.py +2 -0
  14. runtime_narrative-1.5.0/runtime_narrative/renderer_defaults.py +73 -0
  15. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/stage.py +13 -2
  16. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/story.py +16 -0
  17. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0/runtime_narrative.egg-info}/PKG-INFO +53 -2
  18. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/SOURCES.txt +5 -0
  19. runtime_narrative-1.5.0/tests/test_coalescing_renderer.py +198 -0
  20. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_console_renderer.py +151 -0
  21. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_json_renderer.py +28 -0
  22. runtime_narrative-1.5.0/tests/test_module_capture.py +103 -0
  23. runtime_narrative-1.5.0/tests/test_renderer_defaults.py +132 -0
  24. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/LICENSE +0 -0
  25. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/__init__.py +0 -0
  26. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/anthropic.py +0 -0
  27. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/base.py +0 -0
  28. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/deduplication.py +0 -0
  29. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/analyzers/ollama.py +0 -0
  30. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/cli.py +0 -0
  31. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/context.py +0 -0
  32. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/diagnostics.py +0 -0
  33. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/failure.py +0 -0
  34. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/instrumentation.py +0 -0
  35. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/logging_bridge.py +0 -0
  36. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/outcome.py +0 -0
  37. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/__init__.py +0 -0
  38. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/alert_renderer.py +0 -0
  39. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/filter_renderer.py +0 -0
  40. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/html_renderer.py +0 -0
  41. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/otel_log_renderer.py +0 -0
  42. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/otel_metrics_renderer.py +0 -0
  43. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/otel_renderer.py +0 -0
  44. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/persistence_renderer.py +0 -0
  45. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/renderer/prometheus_renderer.py +0 -0
  46. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/task_group.py +0 -0
  47. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative/testing.py +0 -0
  48. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/dependency_links.txt +0 -0
  49. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/entry_points.txt +0 -0
  50. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/requires.txt +0 -0
  51. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/runtime_narrative.egg-info/top_level.txt +0 -0
  52. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/setup.cfg +0 -0
  53. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_alert_renderer.py +0 -0
  54. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_analyzers.py +0 -0
  55. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_anthropic_analyzer.py +0 -0
  56. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_async_renderer.py +0 -0
  57. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_celery.py +0 -0
  58. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_decorators.py +0 -0
  59. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_deduplication.py +0 -0
  60. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_diagnostics.py +0 -0
  61. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_dry_run.py +0 -0
  62. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_failure.py +0 -0
  63. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_filter_renderer.py +0 -0
  64. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_grpc_interceptor.py +0 -0
  65. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_html_renderer.py +0 -0
  66. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_instrumentation.py +0 -0
  67. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_instrumentation_phase2.py +0 -0
  68. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_issues.py +0 -0
  69. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_logging_bridge.py +0 -0
  70. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_middleware.py +0 -0
  71. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_middleware_django.py +0 -0
  72. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_middleware_propagation.py +0 -0
  73. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_otel_log_renderer.py +0 -0
  74. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_otel_metrics_renderer.py +0 -0
  75. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_otel_renderer.py +0 -0
  76. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_outcome.py +0 -0
  77. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_persistence_renderer.py +0 -0
  78. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_prometheus_renderer.py +0 -0
  79. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_redaction_extended.py +0 -0
  80. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_stage.py +0 -0
  81. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_stage_metadata.py +0 -0
  82. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_story.py +0 -0
  83. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_structured_analysis.py +0 -0
  84. {runtime_narrative-1.3.0 → runtime_narrative-1.5.0}/tests/test_task_group.py +0 -0
  85. {runtime_narrative-1.3.0 → 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.0
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
@@ -234,6 +234,55 @@ with story("GET /api/call") as runtime:
234
234
 
235
235
  ---
236
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
+
237
286
  ## Feature tour
238
287
 
239
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.
@@ -244,7 +293,7 @@ Everything below works the same way in every context (sync/async, decorators, au
244
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) |
245
294
  | Failure diagnostics | Lean/rich modes, production traceback caps, secret redaction, `FailureDiagnosticsConfig` | [WIKI §9](WIKI.md#9-failure-diagnostics) |
246
295
  | Failure analyzers | `OllamaFailureAnalyzer`, `LLMFailureAnalyzer`, `AnthropicFailureAnalyzer`, `DeduplicatingAnalyzer`, `background_analysis=True` | [WIKI §9](WIKI.md#9-failure-diagnostics), [§16](WIKI.md#16-background-analysis) |
247
- | 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) |
248
297
  | Framework integrations | FastAPI/Starlette middleware, Django ASGI/WSGI middleware, Celery task base class, gRPC interceptors | [WIKI §11](WIKI.md#11-framework-integrations) |
249
298
  | Async task groups | `NarrativeTaskGroup` — concurrent `asyncio` tasks under one shared story | [WIKI §12](WIKI.md#12-async-task-groups) |
250
299
  | Persistence & CLI | `SqliteStoryRenderer` + `runtime-narrative failures` / `runtime-narrative story <id>` | [WIKI §13](WIKI.md#13-sqlite-persistence-and-cli) |
@@ -325,6 +374,8 @@ Every script under `examples/` is runnable as-is: `uv run python examples/<name>
325
374
  | `RUNTIME_NARRATIVE_ALLOW_RICH_IN_PRODUCTION` | `1`, `true` | off | Bypass production safeguard; allow rich diagnostics in production |
326
375
  | `RUNTIME_NARRATIVE_MODEL` | model name string | — | Default model for `AnthropicFailureAnalyzer`; also used by example scripts for `OllamaFailureAnalyzer` / `LLMFailureAnalyzer` |
327
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 |
328
379
 
329
380
  ---
330
381
 
@@ -175,6 +175,55 @@ with story("GET /api/call") as runtime:
175
175
 
176
176
  ---
177
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
+
178
227
  ## Feature tour
179
228
 
180
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.
@@ -185,7 +234,7 @@ Everything below works the same way in every context (sync/async, decorators, au
185
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) |
186
235
  | Failure diagnostics | Lean/rich modes, production traceback caps, secret redaction, `FailureDiagnosticsConfig` | [WIKI §9](WIKI.md#9-failure-diagnostics) |
187
236
  | Failure analyzers | `OllamaFailureAnalyzer`, `LLMFailureAnalyzer`, `AnthropicFailureAnalyzer`, `DeduplicatingAnalyzer`, `background_analysis=True` | [WIKI §9](WIKI.md#9-failure-diagnostics), [§16](WIKI.md#16-background-analysis) |
188
- | 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) |
189
238
  | Framework integrations | FastAPI/Starlette middleware, Django ASGI/WSGI middleware, Celery task base class, gRPC interceptors | [WIKI §11](WIKI.md#11-framework-integrations) |
190
239
  | Async task groups | `NarrativeTaskGroup` — concurrent `asyncio` tasks under one shared story | [WIKI §12](WIKI.md#12-async-task-groups) |
191
240
  | Persistence & CLI | `SqliteStoryRenderer` + `runtime-narrative failures` / `runtime-narrative story <id>` | [WIKI §13](WIKI.md#13-sqlite-persistence-and-cli) |
@@ -266,6 +315,8 @@ Every script under `examples/` is runnable as-is: `uv run python examples/<name>
266
315
  | `RUNTIME_NARRATIVE_ALLOW_RICH_IN_PRODUCTION` | `1`, `true` | off | Bypass production safeguard; allow rich diagnostics in production |
267
316
  | `RUNTIME_NARRATIVE_MODEL` | model name string | — | Default model for `AnthropicFailureAnalyzer`; also used by example scripts for `OllamaFailureAnalyzer` / `LLMFailureAnalyzer` |
268
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 |
269
320
 
270
321
  ---
271
322
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "runtime-narrative"
7
- version = "1.3.0"
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"
@@ -1,4 +1,4 @@
1
- __version__ = "1.3.0"
1
+ __version__ = "1.5.0"
2
2
 
3
3
  from .analyzers import FailureAnalyzer, LLMFailureAnalyzer, OllamaFailureAnalyzer, DeduplicatingAnalyzer
4
4
  from .context import has_active_story
@@ -19,6 +19,7 @@ from .logging_bridge import NarrativeLogHandler
19
19
  from .outcome import http_outcome
20
20
  from .renderer.console import ConsoleRenderer
21
21
  from .renderer.filter_renderer import FilteredRenderer
22
+ from .renderer.coalescing_renderer import CoalescingRenderer
22
23
  from .renderer.json_renderer import JsonRenderer, RotatingJsonRenderer
23
24
  from .stage import stage
24
25
  from .story import story, StoryRuntime
@@ -106,6 +107,7 @@ __all__ = [
106
107
  "RuntimeNarrativeMiddleware",
107
108
  "ConsoleRenderer",
108
109
  "FilteredRenderer",
110
+ "CoalescingRenderer",
109
111
  "JsonRenderer",
110
112
  "RotatingJsonRenderer",
111
113
  "OtelRenderer",
@@ -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
@@ -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
@@ -9,6 +8,7 @@ from starlette.responses import Response
9
8
 
10
9
  from .diagnostics import FailureDiagnosticsConfig
11
10
  from .outcome import http_outcome
11
+ from .renderer_defaults import default_renderers
12
12
  from .story import story
13
13
 
14
14
  try:
@@ -19,17 +19,6 @@ except ImportError:
19
19
  _OTEL_PROPAGATION_AVAILABLE = False
20
20
 
21
21
 
22
- def _default_middleware_renderers() -> tuple:
23
- """Return ConsoleRenderer when attached to a real terminal, JsonRenderer otherwise."""
24
- if getattr(sys.stdout, "isatty", lambda: False)():
25
- from .renderer.console import ConsoleRenderer
26
- return (ConsoleRenderer(),)
27
- from .renderer.json_renderer import JsonRenderer
28
- return (JsonRenderer(),)
29
-
30
-
31
-
32
-
33
22
  class RuntimeNarrativeMiddleware(BaseHTTPMiddleware):
34
23
  """
35
24
  FastAPI/Starlette middleware that wraps every HTTP request in a runtime_narrative story.
@@ -40,6 +29,8 @@ class RuntimeNarrativeMiddleware(BaseHTTPMiddleware):
40
29
  When no ``renderers`` are passed, the middleware auto-selects:
41
30
  - ``ConsoleRenderer`` if ``sys.stdout`` is a real TTY (local dev server)
42
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.
43
34
 
44
35
  Usage::
45
36
 
@@ -79,7 +70,7 @@ class RuntimeNarrativeMiddleware(BaseHTTPMiddleware):
79
70
  skip_if: Callable[[Request], bool] | None = None,
80
71
  ):
81
72
  super().__init__(app)
82
- 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()
83
74
  self._failure_analyzer = failure_analyzer
84
75
  self._diagnostics_config = diagnostics_config
85
76
  self._runtime_environment = runtime_environment
@@ -1,10 +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
7
6
  from .outcome import http_outcome
7
+ from .renderer_defaults import default_renderers
8
8
  from .story import story
9
9
 
10
10
  try:
@@ -14,14 +14,6 @@ except ImportError:
14
14
  _DJANGO_AVAILABLE = False
15
15
 
16
16
 
17
- def _default_renderers() -> tuple:
18
- if getattr(sys.stdout, "isatty", lambda: False)():
19
- from .renderer.console import ConsoleRenderer
20
- return (ConsoleRenderer(),)
21
- from .renderer.json_renderer import JsonRenderer
22
- return (JsonRenderer(),)
23
-
24
-
25
17
  class RuntimeNarrativeDjangoMiddleware:
26
18
  async_capable = True
27
19
  sync_capable = False
@@ -45,7 +37,7 @@ class RuntimeNarrativeDjangoMiddleware:
45
37
  "Install it with: pip install django"
46
38
  )
47
39
  self.get_response = get_response
48
- 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()
49
41
  self._failure_analyzer = failure_analyzer
50
42
  self._diagnostics_config = diagnostics_config
51
43
  self._runtime_environment = runtime_environment
@@ -97,7 +89,7 @@ class RuntimeNarrativeDjangoSyncMiddleware:
97
89
  "Install it with: pip install django"
98
90
  )
99
91
  self.get_response = get_response
100
- 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()
101
93
  self._failure_analyzer = failure_analyzer
102
94
  self._diagnostics_config = diagnostics_config
103
95
  self._runtime_environment = runtime_environment
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from typing import Any
5
+
6
+ from ..events import LogRecorded
7
+
8
+
9
+ class CoalescingRenderer:
10
+ """Wraps another renderer, collapsing a run of identical back-to-back stages --
11
+ e.g. a "poll status every 2s until done" loop -- into a single summary line
12
+ instead of flooding the log with one StageStarted/StageCompleted pair per
13
+ iteration.
14
+
15
+ The wrapped renderer still sees the first `threshold` occurrences of a
16
+ repeated (story_id, stage_name) pair in full, so the pattern is visible as
17
+ it starts. From the next occurrence on, StageStarted/StageCompleted for
18
+ that stage are suppressed and accumulated instead of forwarded. As soon as
19
+ a different event arrives for that story (a different stage, a failure, or
20
+ the story completing), the run is flushed as one LogRecorded summary line
21
+ -- reporting the total call count and total time spent in that stage --
22
+ forwarded through the wrapped renderer before the triggering event.
23
+
24
+ Only wrap the human-facing renderer(s) with this (typically ConsoleRenderer).
25
+ Renderers meant for full-fidelity machine consumption -- JsonRenderer,
26
+ SqliteStoryRenderer, OtelRenderer, and friends -- should keep receiving
27
+ every real event and should NOT be wrapped.
28
+
29
+ Example -- a status-polling loop inside one long-running story::
30
+
31
+ with story("Process Upload", renderers=[CoalescingRenderer(ConsoleRenderer())]):
32
+ with stage("Upload File"):
33
+ ...
34
+ while not done:
35
+ with stage("Check Pipeline Status"):
36
+ done = check_status()
37
+ time.sleep(2)
38
+
39
+ Only the first two (default `threshold`) status checks print a
40
+ StageStarted/StageCompleted pair; the rest are folded into one line like::
41
+
42
+ 'Check Pipeline Status' repeated 41 more times (43 total) over 84.200s (avg 1.958s/call)
43
+
44
+ Limitations: a run is keyed on (story_id, stage_name) alone, without regard
45
+ to nesting -- a differently named stage nested *inside* the repeated one
46
+ (e.g. a "Parse Response" sub-stage on every poll) counts as "a different
47
+ stage" and ends the run early. Keep the polling loop's stage as a single,
48
+ unnested stage per iteration to get full coalescing. Also, if the stage
49
+ that's mid-run raises before completing, that final in-flight call's
50
+ duration is not included in the summary total (though it is still counted).
51
+ """
52
+
53
+ def __init__(self, renderer: Any, *, threshold: int = 2) -> None:
54
+ if threshold < 1:
55
+ raise ValueError("threshold must be >= 1")
56
+ self._renderer = renderer
57
+ self._threshold = threshold
58
+ self._runs: dict[str, dict[str, Any]] = {}
59
+ if inspect.iscoroutinefunction(getattr(renderer, "handle", None)):
60
+ self.handle = self._handle_async
61
+ else:
62
+ self.handle = self._handle_sync
63
+
64
+ # ------------------------------------------------------------------
65
+ # Shared run-tracking logic (pure, no I/O).
66
+ # ------------------------------------------------------------------
67
+
68
+ def _track_stage_started(self, event: Any) -> tuple[Any | None, bool]:
69
+ """Returns (summary_event_to_flush_first_or_None, should_forward_this_event)."""
70
+ story_id = event.story_id
71
+ stage_name = event.stage_name
72
+ run = self._runs.get(story_id)
73
+
74
+ if run is not None and run["stage_name"] == stage_name:
75
+ run["count"] += 1
76
+ return None, run["count"] <= self._threshold
77
+
78
+ summary = self._pop_summary(story_id)
79
+ self._runs[story_id] = {
80
+ "stage_name": stage_name,
81
+ "count": 1,
82
+ "total_duration": 0.0,
83
+ "first_started_at": event.timestamp,
84
+ "story_name": event.story_name,
85
+ "root_story_id": event.root_story_id or story_id,
86
+ }
87
+ return summary, True
88
+
89
+ def _track_stage_completed(self, event: Any) -> tuple[Any | None, bool]:
90
+ story_id = event.story_id
91
+ run = self._runs.get(story_id)
92
+ if run is None or run["stage_name"] != event.stage_name:
93
+ # Unbalanced (shouldn't normally happen) -- forward as-is.
94
+ return None, True
95
+ run["total_duration"] += event.duration_seconds
96
+ return None, run["count"] <= self._threshold
97
+
98
+ def _pop_summary(self, story_id: str) -> Any | None:
99
+ run = self._runs.pop(story_id, None)
100
+ if run is None or run["count"] <= self._threshold:
101
+ return None
102
+ suppressed = run["count"] - self._threshold
103
+ avg = run["total_duration"] / run["count"] if run["count"] else 0.0
104
+ return LogRecorded(
105
+ story_id=story_id,
106
+ story_name=run["story_name"],
107
+ root_story_id=run["root_story_id"],
108
+ stage_name=run["stage_name"],
109
+ level="INFO",
110
+ logger_name="runtime_narrative.coalesce",
111
+ message=(
112
+ f"{run['stage_name']!r} repeated {suppressed} more time"
113
+ f"{'s' if suppressed != 1 else ''} ({run['count']} total) "
114
+ f"over {run['total_duration']:.3f}s (avg {avg:.3f}s/call)"
115
+ ),
116
+ timestamp=run["first_started_at"],
117
+ )
118
+
119
+ def _flush(self, story_id: str) -> Any | None:
120
+ return self._pop_summary(story_id)
121
+
122
+ # ------------------------------------------------------------------
123
+ # Sync / async dispatch
124
+ # ------------------------------------------------------------------
125
+
126
+ def _handle_sync(self, event: object) -> None:
127
+ event_name = event.__class__.__name__
128
+ story_id = getattr(event, "story_id", None)
129
+
130
+ if event_name == "StageStarted":
131
+ summary, forward = self._track_stage_started(event)
132
+ if summary is not None:
133
+ self._renderer.handle(summary)
134
+ if forward:
135
+ self._renderer.handle(event)
136
+ return
137
+
138
+ if event_name == "StageCompleted":
139
+ summary, forward = self._track_stage_completed(event)
140
+ if summary is not None:
141
+ self._renderer.handle(summary)
142
+ if forward:
143
+ self._renderer.handle(event)
144
+ return
145
+
146
+ if story_id is not None:
147
+ summary = self._flush(story_id)
148
+ if summary is not None:
149
+ self._renderer.handle(summary)
150
+ self._renderer.handle(event)
151
+
152
+ async def _handle_async(self, event: object) -> None:
153
+ event_name = event.__class__.__name__
154
+ story_id = getattr(event, "story_id", None)
155
+
156
+ if event_name == "StageStarted":
157
+ summary, forward = self._track_stage_started(event)
158
+ if summary is not None:
159
+ await self._renderer.handle(summary)
160
+ if forward:
161
+ await self._renderer.handle(event)
162
+ return
163
+
164
+ if event_name == "StageCompleted":
165
+ summary, forward = self._track_stage_completed(event)
166
+ if summary is not None:
167
+ await self._renderer.handle(summary)
168
+ if forward:
169
+ await self._renderer.handle(event)
170
+ return
171
+
172
+ if story_id is not None:
173
+ summary = self._flush(story_id)
174
+ if summary is not None:
175
+ await self._renderer.handle(summary)
176
+ await self._renderer.handle(event)
177
+
178
+
179
+ __all__ = ["CoalescingRenderer"]