▶ runtime-narrative

See it in action

Every terminal below is real output, captured from the scripts that actually ship in examples/ — nothing here is a mockup. Run any of them yourself with uv run python examples/<name>.py, or click Replay to watch a session again.

Why this changes the game for Python logging

Traditional logging asks you to describe execution one disconnected line at a time — you thread request IDs through function signatures by hand, you scroll a 30-frame traceback to find the one line that's actually yours, and every polling loop floods the log forever. runtime-narrative asks a different question: what is the story of this execution? Wrap it in story()/stage() once, and correlation, diagnostics, and noise control come for free.

Traditional print() / loggingruntime-narrative
Correlating one request across functionsThread a request ID through every function signature and every log call by handNest story() calls — sub-stories auto-link via ContextVar, no threading, no new API
Understanding a failureScroll a 30+ frame traceback mixing your code with framework internalsOne pinpointed frame, a source snippet, and — in rich mode — the exact local values involved
Noisy polling loopsOne log line per iteration, foreverCoalescingRenderer collapses repeats into a single summary line
Explaining "why it broke"You reread the traceback and guessAn optional LLM analyzer turns the traceback into a plain-English cause and a targeted fix
Changing where output goesRewrite your logging callsSwap renderers — same events, any combination of console/JSON/SQLite/OTel/Prometheus/webhooks

1. A wall of traceback vs. one pinpointed line

examples/fastapi_ugly_traceback_demo.py runs the exact same async bug two ways: an asyncio.gather fan-out inside a retry-wrapped pricing engine, called through FastAPI routing, that multiplies a price by a BOGO promo dict instead of a discount factor. Left: no instrumentation. Right: the same call stack, wrapped in story()/stage().

Without runtime-narrative
34 stack frames, 102 lines
Traceback (most recent call last):
File "app/routes/checkout.py", line 78, in bare_checkout
return await checkout_order(cart, promo_code, payment_method)
File "app/order_pipeline.py", line 146, in checkout_order
return await orchestrator.run(cart, promo_code, payment_method)
File "app/order_pipeline.py", line 53, in wrapper
return await fn(*args, **kwargs)
File "app/order_pipeline.py", line 130, in run
total = await self.pricing.apply_discounts(cart, promo_code)
File "app/order_pipeline.py", line 42, in wrapper
raise last_exc from None
File "app/order_pipeline.py", line 91, in apply_discounts
discounts = await asyncio.gather(*[_compute_line_discount(i, promo_code) for i in cart])
... 24 more frames: asyncio.gather task stepping, httpx transport,
starlette routing/middleware, fastapi dependency injection ...
File "app/order_pipeline.py", line 84, in _compute_line_discount
return item["price"] * promo
TypeError: unsupported operand type(s) for *: 'float' and 'dict'
With runtime-narrative
1 pinpointed frame, locals included
[825a79] ▶ Story started: POST /orders/checkout
[825a79] ▶ Stage started: Reserve Inventory
[825a79] ✔ Stage completed: Reserve Inventory (0.000s)
[825a79] ▶ Stage started: Apply Pricing
❌ Failure detected
Story: POST /orders/checkout
Stage: Apply Pricing
Error: TypeError — unsupported operand type(s) for *: 'float' and 'dict'
Location: app/order_pipeline.py:84 _compute_line_discount
Code: return item["price"] * promo
Context:
82 | promo = PROMO_TABLE[promo_code]
83 | await asyncio.sleep(0)
> 84 | return item["price"] * promo
85 |
Stack summary: 9 app frame(s), 16 other/hidden in full stack (25 total)
Locals (rich diagnostics):
item = {'sku': 'SHIRT-001', 'price': 29.99}
promo = {'buy': 1, 'get': 1, 'item_sku': 'SHIRT-001'}
Why (exact):
`item['price']` = 29.99 (float) and `promo` = {'buy': 1, 'get': 1, ...} (dict)
can't be combined with '*' — float and dict are incompatible operand types.
[825a79] ▶ Story ended: FAILED (2.125s)

Both sides hit the identical bug through the identical call stack. Run it yourself: uv run python examples/fastapi_ugly_traceback_demo.py. The Why (exact) line naming item['price'] and promo by name — instead of restating the generic TypeError message — is FailureOccurred.exact_cause; see the Event Reference.


2. Call tracing across functions, for free

Open a story() while another is already active — an API handler calling a DB helper, say — and it automatically becomes a linked sub-story: its own success/failure and duration, tagged with a short ID, indented by nesting depth, colored consistently per story family. No correlation ID to thread through the call, no manual span-linking.

examples/substory_db_call.py
[7a6211] ▶ Story started: POST /orders
[7a6211] ▶ Stage started: Validate Input
[7a6211] ✔ Stage completed: Validate Input (0.020s)
[7a6211] ▶ Stage started: Persist Order
[5752d4] ▶ Story started: DB: INSERT INTO orders ...
[5752d4] ▶ Stage started: Acquire Connection
[5752d4] ✔ Stage completed: Acquire Connection (0.015s)
[5752d4] ▶ Stage started: Execute Query
[5752d4] ✔ Stage completed: Execute Query (0.030s)
[5752d4] ▶ Story ended: SUCCESS (0.046s)
[7a6211] ✔ Stage completed: Persist Order (0.046s)
[7a6211] ▶ Stage started: Notify
[7a6211] ✔ Stage completed: Notify (0.015s)
[7a6211] ▶ Story ended: SUCCESS (0.082s)

[5752d4]'s story inherited [7a6211]'s renderers and diagnostics automatically and shares its root_story_id — this holds up under real concurrency for free, since asyncio.Task copies ContextVar state at creation. Details: Sub-stories & Log Capture.


3. Structured logs and a failure box, from one story()

NarrativeLogHandler folds ordinary logging.warning()/.error() calls into the same event stream as story()/stage() — one timeline instead of two. level_icons prepends any string (typically an emoji) per log level, no custom renderer required, and a failure still gets the full diagnostics box automatically.

examples/colorful_errors_and_emojis.py
▶ Story started: Checkout
▶ Stage started: Validate Cart
🔍 cart contents loaded items=3
ℹ️ cart validated
✔ Stage completed: Validate Cart (0.000s)
▶ Stage started: Apply Discount
⚠️ promo code expired, falling back to full price code=SUMMER25
✔ Stage completed: Apply Discount (0.000s)
▶ Story ended: SUCCESS (0.001s)
▶ Story started: Checkout
▶ Stage started: Reserve Inventory
🔥 stock check failed sku=BOOT-42
❌ Failure detected
Error: InsufficientStockError — only 0 units left for SKU BOOT-42
Location: examples/colorful_errors_and_emojis.py:57
Why (exact): the code explicitly raises InsufficientStockError here.
▶ Story ended: FAILED (0.002s)

The 🔍/ℹ️/⚠️/🔥 icons are three lines of config (ConsoleRenderer(level_icons={...})), not a custom renderer. Color and the story/stage glyphs are on by default. Details: Renderers.


4. No more flooded logs from polling loops

Status-polling loops — waiting on a pipeline, an upload, a job queue — are the classic source of log floods: one line per iteration, sometimes for minutes. CoalescingRenderer wraps any other renderer and collapses a run of identical back-to-back stages into a single summary line once it's clear they're repeating.

Without CoalescingRenderer
45 iterations → 90 lines
▶ Stage started: Check Pipeline Status
✔ Stage completed: Check Pipeline Status (2.010s)
▶ Stage started: Check Pipeline Status
✔ Stage completed: Check Pipeline Status (2.005s)
▶ Stage started: Check Pipeline Status
✔ Stage completed: Check Pipeline Status (1.998s)
... 39 more identical start/complete pairs ...
▶ Stage started: Check Pipeline Status
✔ Stage completed: Check Pipeline Status (2.012s)
With CoalescingRenderer
45 iterations → 5 lines
▶ Stage started: Check Pipeline Status
✔ Stage completed: Check Pipeline Status (2.010s)
▶ Stage started: Check Pipeline Status
✔ Stage completed: Check Pipeline Status (2.005s)
'Check Pipeline Status' repeated 43 more times (45 total) over 90.400s (avg 2.009s/call)

The wrapped renderer still sees the first two occurrences in full — so it's visible that polling started — before collapsing the rest. Machine-consumption renderers like JsonRenderer or SqliteStoryRenderer should stay unwrapped for full fidelity. Details: CoalescingRenderer.

Try it on your own code

None of the above needed a rewrite — every demo is a normal function with story()/stage() wrapped around existing logic. Start with Installation and Quick Start, or browse every runnable script in Examples.