runtime-narrative 1.2.1__tar.gz → 1.3.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.3.0}/PKG-INFO +24 -1
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/README.md +23 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/pyproject.toml +5 -1
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/__init__.py +3 -1
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/events.py +1 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/middleware.py +5 -1
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/middleware_django.py +9 -2
- runtime_narrative-1.3.0/runtime_narrative/outcome.py +17 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/console.py +11 -1
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/json_renderer.py +1 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/story.py +10 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0/runtime_narrative.egg-info}/PKG-INFO +24 -1
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative.egg-info/SOURCES.txt +2 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_console_renderer.py +36 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_json_renderer.py +17 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_middleware.py +21 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_middleware_django.py +36 -0
- runtime_narrative-1.3.0/tests/test_outcome.py +13 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_story.py +28 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/LICENSE +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/analyzers/__init__.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/analyzers/anthropic.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/analyzers/base.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/analyzers/deduplication.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/analyzers/ollama.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/celery.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/cli.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/context.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/decorators.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/diagnostics.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/failure.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/grpc_interceptor.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/instrumentation.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/logging_bridge.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/__init__.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/alert_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/filter_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/html_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/otel_log_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/otel_metrics_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/otel_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/persistence_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/prometheus_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/stage.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/task_group.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/testing.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative.egg-info/dependency_links.txt +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative.egg-info/entry_points.txt +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative.egg-info/requires.txt +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative.egg-info/top_level.txt +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/setup.cfg +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_alert_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_analyzers.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_anthropic_analyzer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_async_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_celery.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_decorators.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_deduplication.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_diagnostics.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_dry_run.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_failure.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_filter_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_grpc_interceptor.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_html_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_instrumentation.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_instrumentation_phase2.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_issues.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_logging_bridge.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_middleware_propagation.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_otel_log_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_otel_metrics_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_otel_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_persistence_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_prometheus_renderer.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_redaction_extended.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_stage.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_stage_metadata.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_structured_analysis.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/tests/test_task_group.py +0 -0
- {runtime_narrative-1.2.1 → runtime_narrative-1.3.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.3.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,29 @@ 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
|
+
|
|
214
237
|
## Feature tour
|
|
215
238
|
|
|
216
239
|
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.
|
|
@@ -152,6 +152,29 @@ 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
|
+
|
|
155
178
|
## Feature tour
|
|
156
179
|
|
|
157
180
|
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.
|
|
@@ -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.3.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.3.0"
|
|
2
2
|
|
|
3
3
|
from .analyzers import FailureAnalyzer, LLMFailureAnalyzer, OllamaFailureAnalyzer, DeduplicatingAnalyzer
|
|
4
4
|
from .context import has_active_story
|
|
@@ -16,6 +16,7 @@ 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
|
|
21
22
|
from .renderer.json_renderer import JsonRenderer, RotatingJsonRenderer
|
|
@@ -120,6 +121,7 @@ __all__ = [
|
|
|
120
121
|
"LLMAnalysisReady",
|
|
121
122
|
"LogRecorded",
|
|
122
123
|
"NarrativeLogHandler",
|
|
124
|
+
"http_outcome",
|
|
123
125
|
"FailureDiagnosticsConfig",
|
|
124
126
|
"build_enriched_failure",
|
|
125
127
|
"effective_diagnostics_mode",
|
|
@@ -8,6 +8,7 @@ from starlette.requests import Request
|
|
|
8
8
|
from starlette.responses import Response
|
|
9
9
|
|
|
10
10
|
from .diagnostics import FailureDiagnosticsConfig
|
|
11
|
+
from .outcome import http_outcome
|
|
11
12
|
from .story import story
|
|
12
13
|
|
|
13
14
|
try:
|
|
@@ -27,6 +28,8 @@ def _default_middleware_renderers() -> tuple:
|
|
|
27
28
|
return (JsonRenderer(),)
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
|
|
32
|
+
|
|
30
33
|
class RuntimeNarrativeMiddleware(BaseHTTPMiddleware):
|
|
31
34
|
"""
|
|
32
35
|
FastAPI/Starlette middleware that wraps every HTTP request in a runtime_narrative story.
|
|
@@ -107,8 +110,9 @@ class RuntimeNarrativeMiddleware(BaseHTTPMiddleware):
|
|
|
107
110
|
allow_rich_in_production=self._allow_rich_in_production,
|
|
108
111
|
app_roots=self._app_roots,
|
|
109
112
|
redact_extra=self._redact_extra,
|
|
110
|
-
):
|
|
113
|
+
) as runtime:
|
|
111
114
|
response = await call_next(request)
|
|
115
|
+
runtime.set_outcome(http_outcome(response.status_code))
|
|
112
116
|
return response
|
|
113
117
|
finally:
|
|
114
118
|
if token is not None:
|
|
@@ -4,6 +4,7 @@ import sys
|
|
|
4
4
|
from typing import Any, Sequence
|
|
5
5
|
|
|
6
6
|
from .diagnostics import FailureDiagnosticsConfig
|
|
7
|
+
from .outcome import http_outcome
|
|
7
8
|
from .story import story
|
|
8
9
|
|
|
9
10
|
try:
|
|
@@ -65,8 +66,11 @@ class RuntimeNarrativeDjangoMiddleware:
|
|
|
65
66
|
allow_rich_in_production=self._allow_rich_in_production,
|
|
66
67
|
app_roots=self._app_roots,
|
|
67
68
|
redact_extra=self._redact_extra,
|
|
68
|
-
):
|
|
69
|
+
) as runtime:
|
|
69
70
|
response = await self.get_response(request)
|
|
71
|
+
status_code = getattr(response, "status_code", None)
|
|
72
|
+
if status_code is not None:
|
|
73
|
+
runtime.set_outcome(http_outcome(status_code))
|
|
70
74
|
return response
|
|
71
75
|
|
|
72
76
|
|
|
@@ -114,8 +118,11 @@ class RuntimeNarrativeDjangoSyncMiddleware:
|
|
|
114
118
|
allow_rich_in_production=self._allow_rich_in_production,
|
|
115
119
|
app_roots=self._app_roots,
|
|
116
120
|
redact_extra=self._redact_extra,
|
|
117
|
-
):
|
|
121
|
+
) as runtime:
|
|
118
122
|
response = self.get_response(request)
|
|
123
|
+
status_code = getattr(response, "status_code", None)
|
|
124
|
+
if status_code is not None:
|
|
125
|
+
runtime.set_outcome(http_outcome(status_code))
|
|
119
126
|
return response
|
|
120
127
|
|
|
121
128
|
|
|
@@ -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"]
|
|
@@ -425,7 +425,17 @@ class ConsoleRenderer:
|
|
|
425
425
|
self._secho(f"{indent}{tag} ", fg=tag_color, bold=True, nl=False)
|
|
426
426
|
self._secho(f"{self._glyph_arrow} Story ended: ", fg=color, bold=True, nl=False)
|
|
427
427
|
duration = getattr(event, "duration_seconds", 0.0)
|
|
428
|
-
|
|
428
|
+
outcome = getattr(event, "outcome", "")
|
|
429
|
+
if outcome:
|
|
430
|
+
# Outcome-bearing stories (e.g. HTTP requests) get a self-contained
|
|
431
|
+
# summary line: name + state + outcome, replacing a separate access log.
|
|
432
|
+
self._secho(
|
|
433
|
+
f"{event.story_name} - {state} ({outcome}, {duration:.3f}s)",
|
|
434
|
+
fg=value_color,
|
|
435
|
+
bold=True,
|
|
436
|
+
)
|
|
437
|
+
else:
|
|
438
|
+
self._secho(f"{state} ({duration:.3f}s)", fg=value_color, bold=True)
|
|
429
439
|
self._story_base_indent.pop(event.story_id, None)
|
|
430
440
|
self._stage_stacks.pop(event.story_id, None)
|
|
431
441
|
|
{runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/json_renderer.py
RENAMED
|
@@ -129,6 +129,7 @@ class JsonRenderer:
|
|
|
129
129
|
"duration_seconds": getattr(event, "duration_seconds", 0.0),
|
|
130
130
|
"parent_story_id": getattr(event, "parent_story_id", None),
|
|
131
131
|
"root_story_id": getattr(event, "root_story_id", ""),
|
|
132
|
+
"outcome": getattr(event, "outcome", ""),
|
|
132
133
|
})
|
|
133
134
|
|
|
134
135
|
|
|
@@ -30,10 +30,18 @@ class StoryRuntime:
|
|
|
30
30
|
parent_story_id: str | None = None
|
|
31
31
|
root_story_id: str = ""
|
|
32
32
|
failure_analyzer: Any = field(default=None, repr=False)
|
|
33
|
+
outcome: str = ""
|
|
33
34
|
|
|
34
35
|
def set_total_stages(self, n: int) -> None:
|
|
35
36
|
self.declared_total_stages = n
|
|
36
37
|
|
|
38
|
+
def set_outcome(self, outcome: str) -> None:
|
|
39
|
+
"""Attach a short result label (e.g. "200 OK") to the StoryCompleted event.
|
|
40
|
+
|
|
41
|
+
Sync by design: a pure in-memory setter with no I/O.
|
|
42
|
+
"""
|
|
43
|
+
self.outcome = outcome
|
|
44
|
+
|
|
37
45
|
def emit(self, event: object) -> None:
|
|
38
46
|
for renderer in self.renderers:
|
|
39
47
|
try:
|
|
@@ -398,6 +406,7 @@ class story:
|
|
|
398
406
|
duration_seconds=(datetime.now() - self.runtime.started_at).total_seconds(),
|
|
399
407
|
parent_story_id=self.runtime.parent_story_id,
|
|
400
408
|
root_story_id=self.runtime.root_story_id,
|
|
409
|
+
outcome=self.runtime.outcome,
|
|
401
410
|
)
|
|
402
411
|
)
|
|
403
412
|
|
|
@@ -489,6 +498,7 @@ class story:
|
|
|
489
498
|
duration_seconds=(datetime.now() - self.runtime.started_at).total_seconds(),
|
|
490
499
|
parent_story_id=self.runtime.parent_story_id,
|
|
491
500
|
root_story_id=self.runtime.root_story_id,
|
|
501
|
+
outcome=self.runtime.outcome,
|
|
492
502
|
)
|
|
493
503
|
)
|
|
494
504
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runtime-narrative
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.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,29 @@ 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
|
+
|
|
214
237
|
## Feature tour
|
|
215
238
|
|
|
216
239
|
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.
|
|
@@ -14,6 +14,7 @@ runtime_narrative/instrumentation.py
|
|
|
14
14
|
runtime_narrative/logging_bridge.py
|
|
15
15
|
runtime_narrative/middleware.py
|
|
16
16
|
runtime_narrative/middleware_django.py
|
|
17
|
+
runtime_narrative/outcome.py
|
|
17
18
|
runtime_narrative/stage.py
|
|
18
19
|
runtime_narrative/story.py
|
|
19
20
|
runtime_narrative/task_group.py
|
|
@@ -65,6 +66,7 @@ tests/test_middleware_propagation.py
|
|
|
65
66
|
tests/test_otel_log_renderer.py
|
|
66
67
|
tests/test_otel_metrics_renderer.py
|
|
67
68
|
tests/test_otel_renderer.py
|
|
69
|
+
tests/test_outcome.py
|
|
68
70
|
tests/test_persistence_renderer.py
|
|
69
71
|
tests/test_prometheus_renderer.py
|
|
70
72
|
tests/test_redaction_extended.py
|
|
@@ -97,6 +97,42 @@ def test_all_event_types_render_without_typer(monkeypatch, capsys):
|
|
|
97
97
|
assert "SUCCESS" in out
|
|
98
98
|
|
|
99
99
|
|
|
100
|
+
def test_story_completed_with_outcome_renders_combined_line(monkeypatch, capsys):
|
|
101
|
+
monkeypatch.setattr(console_mod, "typer", None)
|
|
102
|
+
ts = datetime(2024, 6, 1)
|
|
103
|
+
r = ConsoleRenderer()
|
|
104
|
+
|
|
105
|
+
r.handle(StoryCompleted(
|
|
106
|
+
story_id="d7678e", story_name="GET /api/call", success=True,
|
|
107
|
+
progress_percent=100, completed_stages=1, total_stages=1, timestamp=ts,
|
|
108
|
+
duration_seconds=0.023, outcome="200 OK",
|
|
109
|
+
))
|
|
110
|
+
|
|
111
|
+
out = capsys.readouterr().out
|
|
112
|
+
# One self-contained line: name, state, and HTTP outcome together.
|
|
113
|
+
line = next(l for l in out.splitlines() if "Story ended" in l)
|
|
114
|
+
assert "GET /api/call" in line
|
|
115
|
+
assert "SUCCESS" in line
|
|
116
|
+
assert "200 OK" in line
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_story_completed_without_outcome_keeps_legacy_line(monkeypatch, capsys):
|
|
120
|
+
monkeypatch.setattr(console_mod, "typer", None)
|
|
121
|
+
ts = datetime(2024, 6, 1)
|
|
122
|
+
r = ConsoleRenderer()
|
|
123
|
+
|
|
124
|
+
r.handle(StoryCompleted(
|
|
125
|
+
story_id="s1", story_name="My Story", success=True,
|
|
126
|
+
progress_percent=100, completed_stages=1, total_stages=1, timestamp=ts,
|
|
127
|
+
duration_seconds=0.023,
|
|
128
|
+
))
|
|
129
|
+
|
|
130
|
+
out = capsys.readouterr().out
|
|
131
|
+
line = next(l for l in out.splitlines() if "Story ended" in l)
|
|
132
|
+
assert "My Story" not in line
|
|
133
|
+
assert "SUCCESS (0.023s)" in line
|
|
134
|
+
|
|
135
|
+
|
|
100
136
|
def test_failure_event_renders_without_typer(monkeypatch, capsys):
|
|
101
137
|
monkeypatch.setattr(console_mod, "typer", None)
|
|
102
138
|
|
|
@@ -151,3 +151,20 @@ def test_json_renderer_story_completed_includes_duration_and_parent_id() -> None
|
|
|
151
151
|
assert data["duration_seconds"] == 0.42
|
|
152
152
|
assert data["parent_story_id"] == "s1"
|
|
153
153
|
assert data["root_story_id"] == "s1"
|
|
154
|
+
assert data["outcome"] == ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_json_renderer_story_completed_includes_outcome() -> None:
|
|
158
|
+
from runtime_narrative.events import StoryCompleted
|
|
159
|
+
|
|
160
|
+
buf = StringIO()
|
|
161
|
+
r = JsonRenderer(output=buf)
|
|
162
|
+
event = StoryCompleted(
|
|
163
|
+
story_id="s1", story_name="GET /api/call", success=True, progress_percent=100,
|
|
164
|
+
completed_stages=1, total_stages=1, timestamp=datetime(2024, 6, 1),
|
|
165
|
+
duration_seconds=0.02, outcome="200 OK",
|
|
166
|
+
)
|
|
167
|
+
r.handle(event)
|
|
168
|
+
buf.seek(0)
|
|
169
|
+
data = json.loads(buf.read())
|
|
170
|
+
assert data["outcome"] == "200 OK"
|
|
@@ -62,3 +62,24 @@ def test_middleware_success_emits_story_completed() -> None:
|
|
|
62
62
|
r = client.get("/ok")
|
|
63
63
|
assert r.status_code == 200
|
|
64
64
|
assert [type(e).__name__ for e in cap.events][-1] == "StoryCompleted"
|
|
65
|
+
completed = cap.events[-1]
|
|
66
|
+
assert completed.outcome == "200 OK"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_middleware_records_error_status_outcome() -> None:
|
|
70
|
+
cap = CapturingRenderer()
|
|
71
|
+
|
|
72
|
+
async def teapot(_request):
|
|
73
|
+
return PlainTextResponse("no", status_code=418)
|
|
74
|
+
|
|
75
|
+
app = Starlette(
|
|
76
|
+
routes=[Route("/tea", endpoint=teapot, methods=["GET"])],
|
|
77
|
+
middleware=[Middleware(RuntimeNarrativeMiddleware, renderers=[cap])],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
client = TestClient(app)
|
|
81
|
+
r = client.get("/tea")
|
|
82
|
+
assert r.status_code == 418
|
|
83
|
+
completed = cap.events[-1]
|
|
84
|
+
assert type(completed).__name__ == "StoryCompleted"
|
|
85
|
+
assert completed.outcome.startswith("418")
|
|
@@ -79,6 +79,42 @@ def test_async_failure_reraises() -> None:
|
|
|
79
79
|
asyncio.run(mw(_FakeDjangoRequest()))
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
class _FakeDjangoResponse:
|
|
83
|
+
status_code = 200
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def _ok_with_status(request):
|
|
87
|
+
return _FakeDjangoResponse()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _sync_ok_with_status(request):
|
|
91
|
+
return _FakeDjangoResponse()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_async_records_status_outcome() -> None:
|
|
95
|
+
renderer = CapturingRenderer()
|
|
96
|
+
mw = _django_mod.RuntimeNarrativeDjangoMiddleware(_ok_with_status, renderers=[renderer])
|
|
97
|
+
asyncio.run(mw(_FakeDjangoRequest()))
|
|
98
|
+
completed = next(e for e in renderer.events if type(e).__name__ == "StoryCompleted")
|
|
99
|
+
assert completed.outcome == "200 OK"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_sync_records_status_outcome() -> None:
|
|
103
|
+
renderer = CapturingRenderer()
|
|
104
|
+
mw = _django_mod.RuntimeNarrativeDjangoSyncMiddleware(_sync_ok_with_status, renderers=[renderer])
|
|
105
|
+
mw(_FakeDjangoRequest())
|
|
106
|
+
completed = next(e for e in renderer.events if type(e).__name__ == "StoryCompleted")
|
|
107
|
+
assert completed.outcome == "200 OK"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_async_response_without_status_leaves_outcome_empty() -> None:
|
|
111
|
+
renderer = CapturingRenderer()
|
|
112
|
+
mw = _django_mod.RuntimeNarrativeDjangoMiddleware(_ok, renderers=[renderer])
|
|
113
|
+
asyncio.run(mw(_FakeDjangoRequest()))
|
|
114
|
+
completed = next(e for e in renderer.events if type(e).__name__ == "StoryCompleted")
|
|
115
|
+
assert completed.outcome == ""
|
|
116
|
+
|
|
117
|
+
|
|
82
118
|
def test_sync_emits_story_events() -> None:
|
|
83
119
|
renderer = CapturingRenderer()
|
|
84
120
|
mw = _django_mod.RuntimeNarrativeDjangoSyncMiddleware(_sync_ok, renderers=[renderer])
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from runtime_narrative.outcome import http_outcome
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_http_outcome_known_codes() -> None:
|
|
7
|
+
assert http_outcome(200) == "200 OK"
|
|
8
|
+
assert http_outcome(404) == "404 Not Found"
|
|
9
|
+
assert http_outcome(500) == "500 Internal Server Error"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_http_outcome_unknown_code_falls_back_to_number() -> None:
|
|
13
|
+
assert http_outcome(599) == "599"
|
|
@@ -273,3 +273,31 @@ def test_background_analysis_emits_llm_ready() -> None:
|
|
|
273
273
|
assert "FailureOccurred" in kinds
|
|
274
274
|
assert "StoryCompleted" in kinds
|
|
275
275
|
assert "LLMAnalysisReady" in kinds
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_sync_story_set_outcome_flows_to_story_completed() -> None:
|
|
279
|
+
cap = CapturingRenderer()
|
|
280
|
+
with story("GET /api/call", renderers=[cap]) as runtime:
|
|
281
|
+
runtime.set_outcome("200 OK")
|
|
282
|
+
completed = next(e for e in cap.events if isinstance(e, StoryCompleted))
|
|
283
|
+
assert completed.outcome == "200 OK"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_async_story_set_outcome_flows_to_story_completed() -> None:
|
|
287
|
+
cap = AsyncCapturingRenderer()
|
|
288
|
+
|
|
289
|
+
async def run() -> None:
|
|
290
|
+
async with story("GET /api/call", renderers=[cap]) as runtime:
|
|
291
|
+
runtime.set_outcome("201 Created")
|
|
292
|
+
|
|
293
|
+
asyncio.run(run())
|
|
294
|
+
completed = next(e for e in cap.events if isinstance(e, StoryCompleted))
|
|
295
|
+
assert completed.outcome == "201 Created"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_story_outcome_defaults_to_empty() -> None:
|
|
299
|
+
cap = CapturingRenderer()
|
|
300
|
+
with story("S", renderers=[cap]):
|
|
301
|
+
pass
|
|
302
|
+
completed = next(e for e in cap.events if isinstance(e, StoryCompleted))
|
|
303
|
+
assert completed.outcome == ""
|
|
File without changes
|
|
File without changes
|
{runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/analyzers/anthropic.py
RENAMED
|
File without changes
|
|
File without changes
|
{runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/analyzers/deduplication.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/alert_renderer.py
RENAMED
|
File without changes
|
{runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/filter_renderer.py
RENAMED
|
File without changes
|
{runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/html_renderer.py
RENAMED
|
File without changes
|
{runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/otel_log_renderer.py
RENAMED
|
File without changes
|
|
File without changes
|
{runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative/renderer/otel_renderer.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{runtime_narrative-1.2.1 → runtime_narrative-1.3.0}/runtime_narrative.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|