Skip to content

Frameworks

aftersight captures through three paths, in this order of preference:

  1. OpenTelemetry spans. Anything already emitting them is captured with no code change.
  2. stdlib logging. Warnings and exceptions from frameworks that only log.
  3. The explicit API. For code that does neither.

Most frameworks land in the first path. The rest of this page is per framework, and each section carries a complete starter you can save to one file and run. They also live in examples/starters/ in the repository.

Check what you are already getting

Run once, then count the event types that landed:

aftersight run python my_agent.py
jq -r '.type' .runs/latest/trace.jsonl | sort | uniq -c

If you see only run.start and run.end, nothing was captured automatically and you want an instrumentor or the explicit API. If you see llm.prompt, tool.call and agent.start, you are done.

Passing framework= to start() makes this check unnecessary: a framework whose instrumentor is missing is reported on stderr as the run begins, rather than found later in an empty trace.

aftersight: openai-agents will not be traced, its instrumentor is not installed: pip install "aftersight[openai-agents]"

pydantic-ai

Emits OpenTelemetry natively, so Agent.instrument_all() is the only extra line. Use Agent(..., instrument=True) instead to record one agent.

pydantic_ai_starter.py
"""aftersight + pydantic-ai, in one file.

    pip install aftersight pydantic-ai
    export OPENAI_API_KEY=sk-...
    python pydantic_ai_starter.py

pydantic-ai emits OpenTelemetry natively, so `instrument_all()` is the only
extra line. Every model call, tool call and token count lands in the run
folder printed at the end.
"""

import aftersight
from pydantic_ai import Agent


def main() -> None:
    with aftersight.start(framework="pydantic-ai", model="gpt-4o-mini") as run:
        Agent.instrument_all()

        agent = Agent("openai:gpt-4o-mini",
                      system_prompt="Answer in one short sentence.")
        result = agent.run_sync("Why is a failed agent run hard to debug?")
        print(result.output)

    print(f"\nread the run: {run.dir}/outline.md")


if __name__ == "__main__":
    main()

LangGraph and LangChain

aftersight.start() activates any OpenInference instrumentor it finds installed, and says which ones on stderr:

aftersight: instrumented langchain

LangChain agents run on LangGraph, so one instrumentor covers both.

langchain_starter.py
"""aftersight + LangChain agents, which run on LangGraph, in one file.

    pip install "aftersight[langchain]" langchain langchain-openai
    export OPENAI_API_KEY=sk-...
    python langchain_starter.py

The OpenInference instrumentor is activated by `aftersight.start()` when it is
installed, so the graph steps, model calls and tool calls all arrive as spans.
"""

import aftersight
from langchain.agents import create_agent
from langchain.tools import tool


@tool
def word_count(text: str) -> int:
    """Count the words in a piece of text."""
    return len(text.split())


def main() -> None:
    with aftersight.start(framework="langchain", model="gpt-4o-mini") as run:
        agent = create_agent(
            "openai:gpt-4o-mini",
            tools=[word_count],
            system_prompt="Use your tools instead of guessing.",
        )
        question = "How many words are in 'the quick brown fox'?"
        result = agent.invoke({"messages": [{"role": "user", "content": question}]})
        print(result["messages"][-1].content)

    print(f"\nread the run: {run.dir}/outline.md")


if __name__ == "__main__":
    main()

agno

pip install "aftersight[agno]"

Auto-activated the same way.

OpenAI Agents SDK

Auto-activated the same way, from the [openai-agents] extra.

openai_agents_starter.py
"""aftersight + the OpenAI Agents SDK, in one file.

    pip install "aftersight[openai-agents]" openai-agents
    export OPENAI_API_KEY=sk-...
    python openai_agents_starter.py

`aftersight.start()` switches on any OpenInference instrumentor it finds
installed, so the agent loop and its tool calls are captured without a line
of framework-specific code. The `[openai-agents]` extra above is what puts
that instrumentor there; without it the run records no llm or tool calls and
`start()` says so on stderr. Nothing is installed at runtime on your behalf.
"""

import aftersight
from agents import Agent, Runner, function_tool


@function_tool
def word_count(text: str) -> int:
    """Count the words in a piece of text."""
    return len(text.split())


def main() -> None:
    with aftersight.start(framework="openai-agents", model="gpt-4o-mini") as run:
        agent = Agent(
            name="counter",
            model="gpt-4o-mini",
            instructions="Use your tools instead of guessing.",
            tools=[word_count],
        )
        result = Runner.run_sync(agent, "How many words are in 'the quick brown fox'?")
        print(result.final_output)

    print(f"\nread the run: {run.dir}/outline.md")


if __name__ == "__main__":
    main()

Other auto-activated instrumentors

Install the one you need and start() will find it:

Framework Install
CrewAI pip install "aftersight[crewai]"
LlamaIndex pip install "aftersight[llama-index]"
smolagents pip install "aftersight[smolagents]"

Nothing is installed on your behalf. Only packages already present are switched on.

Traceloop and OpenLLMetry

These emit gen_ai.* semantic convention attributes, which aftersight reads directly. No instrumentor needed on our side, just start both.

Claude Agent SDK

The SDK does not emit in-process OpenTelemetry spans today, so use the explicit API. A thin wrapper is enough, and the SDK hands back its own cost and turn count to put on the span.

claude_agent_sdk_starter.py
"""aftersight + the Claude Agent SDK, in one file.

    npm install -g @anthropic-ai/claude-code
    pip install aftersight claude-agent-sdk
    export ANTHROPIC_API_KEY=sk-ant-...
    python claude_agent_sdk_starter.py

The SDK emits no in-process OpenTelemetry spans today, so this wraps the query
in an explicit span and copies the cost and turn count the SDK reports back
into it. If a future version emits `gen_ai.*` spans, they are picked up
automatically and the wrapper becomes redundant.
"""

import asyncio

import aftersight
from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock, query


async def ask(prompt: str) -> str:
    with aftersight.span("claude", kind="agent") as s:
        parts: list[str] = []
        async for message in query(prompt=prompt):
            if isinstance(message, AssistantMessage):
                parts += [b.text for b in message.content if isinstance(b, TextBlock)]
            elif isinstance(message, ResultMessage):
                s.set(cost_usd=message.total_cost_usd, turns=message.num_turns)
        s.output = "".join(parts)
        return s.output


def main() -> None:
    with aftersight.start(framework="claude-agent-sdk") as run:
        print(asyncio.run(ask("In one sentence, what is in this directory?")))

    print(f"\nread the run: {run.dir}/outline.md")


if __name__ == "__main__":
    main()

If a future version does emit gen_ai.* spans, they are picked up automatically and this wrapper becomes redundant.

OpenHands

OpenHands logs heavily and the logging bridge captures WARNING and above as log and error events, which covers failure diagnosis. Lower the threshold to logging.INFO for the full narrative, at the cost of volume, and wrap the loop you care about to give the trace some structure.

openhands_starter.py
"""aftersight + the OpenHands SDK, in one file.

    pip install aftersight openhands-sdk openhands-tools
    export LLM_API_KEY=sk-...
    python openhands_starter.py

OpenHands reports its progress through stdlib logging rather than
OpenTelemetry, so aftersight's logging bridge carries the narrative.
`log_level=logging.INFO` keeps the whole story instead of only the warnings
and errors captured by default, at the cost of a much larger trace.
"""

import logging
import os

import aftersight
from openhands.sdk import LLM, Agent, Conversation
from pydantic import SecretStr


def main() -> None:
    with aftersight.start(framework="openhands", model="gpt-4o-mini",
                          log_level=logging.INFO) as run:
        llm = LLM(model="gpt-4o-mini", usage_id="starter",
                  api_key=SecretStr(os.environ["LLM_API_KEY"]))
        agent = Agent(llm=llm, tools=[])

        with aftersight.span("openhands", kind="agent"):
            conversation = Conversation(agent=agent, workspace="./workspace")
            conversation.send_message("Write hello.txt containing the word hello.")
            conversation.run()

    print(f"\nread the run: {run.dir}/outline.md")


if __name__ == "__main__":
    main()

Anything else

The explicit API works with any code:

import aftersight

aftersight.start(framework="my-harness", model="claude-sonnet-5")

with aftersight.span("planner"):
    with aftersight.span("search", kind="tool", args={"q": q}) as s:
        s.output = search(q)

See the Python API for the full surface.

Coexisting with an existing OpenTelemetry setup

aftersight never replaces a tracer provider. If your app already exports to Logfire, Phoenix, Langfuse or an OTLP collector, it keeps doing so and gets a run folder as well. If no provider is configured, one is installed.

Because a tracer provider cannot have a processor removed, calling start() a second time in one process re-points the processor that is already installed rather than adding another. Without that, every event after the second call would be written twice.

Attribute dialects

Three are read, which is why frameworks this package has never heard of tend to work:

Dialect Prompt Completion Tokens Model
OTel gen_ai gen_ai.prompt, gen_ai.prompt.{i}.content gen_ai.completion, gen_ai.completion.{i}.content gen_ai.usage.input_tokens gen_ai.request.model
OpenInference llm.input_messages.{i}.message.content llm.output_messages.{i}.message.content llm.token_count.prompt llm.model_name
Generic input.value output.value

Span kind comes from openinference.span.kind, gen_ai.operation.name or traceloop.span.kind, and falls back to inspecting which attributes are present. Cost is read from gen_ai.usage.cost or llm.cost.total when the framework reports it.