Skip to content

Observability Basics

Tulip ships an in-process pub/sub EventBus that publishes typed StreamEvents for every meaningful step of execution: agent thinking, tool calls, model completions, token usage, multi-agent fan-outs, checkpoints — all under one canonical event_type per component. For a customer-support bot that is the ticket timeline: an ordered, replayable record of every action the agent took on a case, ready for a QA review or a customer dispute.

Telemetry is opt-in. Code that never enters a run_context pays one ContextVar.get() per emission site — no bus, no events, no allocations.

Pipeline::

with run_context() as rid:        ← activates emission; generates run_id
     │
     │  agent.run_sync(…)
     │      │
     │      ├─ agent.think         ← one per ReAct iteration
     │      ├─ agent.tool.started  ┐ span_id ties the pair
     │      ├─ agent.tool.completed┘
     │      ├─ agent.tokens.used   ← per model call (cost meter)
     │      └─ agent.terminate
     │
     └─ bus.subscribe(rid)         ← history replay + live stream
  • Run an Agent with no telemetry (the SDK-default path).
  • Wrap the same ticket reply in run_context() and subscribe to the bus.
  • The canonical events: agent.think, agent.tool.started/completed, agent.tokens.used, agent.terminate.
  • Read the per-run history buffer after the fact (replay semantics for late subscribers — reconstruct the ticket timeline after the run).

Run it (defaults to the bundled mock model; set TULIP_MODEL_PROVIDER to openai / anthropic for a live model):

python examples/notebook_59_observability_basics.py

Offline:

TULIP_MODEL_PROVIDER=mock python examples/notebook_59_observability_basics.py

Source

#!/usr/bin/env python3
# Copyright 2026 Tulip Labs
# SPDX-License-Identifier: Apache-2.0

"""Notebook 59: Support timeline — every step a ticket bot takes, on the record.

When a support agent handles a customer ticket, you want to see — later,
on a QA review or when a customer disputes an outcome — exactly what it
did. Tulip ships an in-process pub/sub EventBus that publishes typed
StreamEvents for every meaningful step of execution: agent thinking, tool
calls, model completions, token usage, multi-agent fan-outs, checkpoints
— all under one canonical event_type per component. That stream is the
Timeline: the support desk's typed, ordered, replayable record of every
action an agent took on a ticket.

A complete action timeline is what lets a supervisor reconstruct, after
the fact, whether the bot followed policy before it refunded an order or
closed a case.

Telemetry is opt-in. Code that never enters a run_context pays one
ContextVar.get() per emission site — no bus, no events, no
allocations.

- Run an Agent with no telemetry: the SDK-default path.
- Wrap the same ticket reply in run_context() and subscribe to the bus.
- The canonical events: agent.think, agent.tool.started/completed,
  agent.tokens.used, agent.terminate.
- Read the per-run history buffer after the fact (replay semantics for
  late subscribers — i.e. reconstruct the ticket timeline after the run).

Run it
    # Default: the bundled mock model (set TULIP_MODEL_PROVIDER for a live provider)
    python examples/notebook_59_observability_basics.py

    # Offline / no credentials:
    TULIP_MODEL_PROVIDER=mock python examples/notebook_59_observability_basics.py
"""

from __future__ import annotations

import asyncio

from config import get_model

from tulip.agent import Agent
from tulip.observability import get_event_bus, run_context


# Part 1: no telemetry. The bus singleton never gets built.


async def part1_no_telemetry() -> None:
    """No run_context: every emit short-circuits before allocating anything."""
    print("\n--- Part 1: no run_context — bus stays uninstantiated ---")

    agent = Agent(model=get_model(), max_iterations=2)
    result = agent.run_sync(
        "In one sentence, summarize ticket CS-1042: customer says their order arrived damaged."
    )
    print("agent reply:", result.message[:120])

    # Probe the module-level singleton to prove it was never built.
    from tulip.observability import event_bus as _bus_mod

    assert _bus_mod._event_bus is None, (
        "running an Agent without run_context must not construct the bus"
    )
    print("bus singleton is None — zero allocations spent on telemetry")


# Part 2: opt in by wrapping the ticket reply in run_context(); subscribe
# to the bus and watch every action the agent takes land on the timeline.


async def part2_subscribe() -> None:
    """Same Agent.run, this time inside a run_context — the timeline is live."""
    print("\n--- Part 2: run_context active — subscribe to the ticket timeline ---")

    agent = Agent(model=get_model(), max_iterations=2)
    seen: list[str] = []

    async with run_context() as rid:
        bus = get_event_bus()

        async def consumer() -> None:
            async for ev in bus.subscribe(rid):
                seen.append(ev.event_type)
                if ev.event_type == "agent.terminate":
                    return

        consumer_task = asyncio.create_task(consumer())
        await asyncio.sleep(0)  # let the subscriber register

        # The contextvar set by run_context() means the @_bus_bridge
        # decorator on Agent.run forwards every yielded TulipEvent to
        # the bus automatically — nothing the agent does goes unrecorded.
        result = await asyncio.to_thread(agent.run_sync, "Reply with the single word: resolved")
        print("agent reply:", result.message[:120])

        await asyncio.wait_for(consumer_task, timeout=10.0)
        # Closing the stream ends any other subscribers cleanly.
        await bus.close_stream(rid)

    print(f"timeline events recorded ({len(seen)}):")
    for e in seen:
        print(f"  - {e}")


# Part 3: late subscribers see the whole run replayed from history
# (capped at 500 events x 200 retained runs) — a QA review the next day
# reads the same timeline the live agent console saw.


async def part3_history_replay() -> None:
    """Subscribe after the run finished; the history deque replays the ticket."""
    print("\n--- Part 3: late subscriber — after-the-fact QA replay ---")

    agent = Agent(model=get_model(), max_iterations=2)

    async with run_context() as rid:
        bus = get_event_bus()
        # Run first; close the stream; subscribe second.
        await asyncio.to_thread(agent.run_sync, "Reply: acknowledged")
        await bus.close_stream(rid)

        replayed: list[str] = []
        async for ev in bus.subscribe(rid):
            replayed.append(ev.event_type)

    print(f"replayed {len(replayed)} events from history (after the run finished)")
    for e in replayed[:10]:
        print(f"  - {e}")
    if len(replayed) > 10:
        print(f"  ... +{len(replayed) - 10} more")


async def main() -> None:
    await part1_no_telemetry()
    await part2_subscribe()
    await part3_history_replay()


if __name__ == "__main__":
    asyncio.run(main())