Skip to content

observability

Tracing for LangChain / LangGraph apps, with Phoenix (OpenInference) as the first backend. The package is structured as a namespace so other backends (e.g. LangSmith) can be added later without changing the public API.

The problem

As your multi-agent app grows, you need to see what the supervisor delegated, which sub-agent ran, what tools it called, and why a run was slow or failed. Phoenix is a self-hosted tracing solution you can run locally to debug and tune a single agent process or a full deployment. Adding the full Phoenix SDK directly to an agent process, however, introduces a dependency conflict described below.

Dependency footprint of the full arize-phoenix SDK

The full arize-phoenix app SDK has a large dependency footprint. It pulls in a chain of packages — pydantic-ai-slim → genai-prices → httpx2 — that can conflict with a LangChain app running openai>=2.53.

The root cause is an import-ordering race on the httpx2 module:

  • openai>=2.53 reads sys.modules["httpx2"] and assumes it is fully initialized.
  • If Phoenix's SDK import races a concurrent ChatOpenAI / ChatDeepSeek construction, httpx2 can be partially initialized at the moment openai reads it.

The result is a crash like:

AttributeError: partially initialized module 'httpx2' ... no attribute 'URL'

Because the crash depends on import timing, it is not deterministic and appears only under concurrent load. The full SDK also enlarges the dependency tree and the install time, while the agent process itself does not use the Phoenix UI/collector.

How langshark-bites avoids it

langshark-bites depends only on arize-phoenix-otel, the slim OTEL client. It provides exactly the two functions observability needs:

  • phoenix.otel.register(...) — wires the OpenTelemetry exporter to your Phoenix collector.
  • phoenix.otel.using_attributes(metadata=...) — attaches filterable metadata to spans.

The Phoenix UI/collector is meant to run as its own service (e.g. the official Phoenix Docker container), not inside your agent process. Because the agent process depends only on arize-phoenix-otel — which does not import the packages that pull in httpx2 — the import-ordering conflict does not arise.

How this bite helps

init_phoenix is idempotent and thread-safe: call it once per process (ideally at startup, or via asyncio.to_thread if you are under ASGI, since registration scans the SDK and can take seconds). If Phoenix is missing or registration fails, it soft-fails — tracing silently no-ops, so a registration failure does not stop the application.

The span decorators — agent_span, chain_span, tool_span — are the Phoenix equivalent of LangSmith's @traceable. They are thin wrappers over OpenInference that:

  • No-op gracefully when Phoenix is not initialized (safe in unit tests).
  • Tag spans with the correct OpenInference span kind so they render correctly in the Phoenix UI.
  • Attach tags (a plain dict) so spans carry filterable keys.
  • Name spans from a fixed override or the running agent's name.

The span types

OpenInference distinguishes span kinds to structure the trace tree in the Phoenix UI. langshark-bites exposes one decorator per kind:

agent_span — supervisors and sub-agents

Use this for any node that represents an agent: both the supervisor that routes work and the sub-agents/workers it delegates to. In a supervisor/worker graph:

  • The supervisor span wraps the routing decision — @agent_span(default_override="supervisor").
  • Each worker span wraps one sub-agent's execution — @agent_span(parse_agent_name=True).

With parse_agent_name=True, the span is named after the agent_name parameter's runtime value — so run_worker("daily_signal_analysis", ...) traces as daily_signal_analysis, even when the worker is invoked positionally in a loop:

@agent_span(parse_agent_name=True, tags={"as_of": "2026-07-10"})
async def run_worker(agent_name: str, as_of: str, ...):
    ...

The span name comes from a fixed default_override or the running agent's name; tags adds filterable labels. Both are optional — with neither set, the span uses the function's own name.

chain_span — deterministic routing / sequences

Use for a non-agent step that coordinates a sequence of calls — e.g. a router that picks a path, or a brief generator that orchestrates several LLM calls. It is lighter-weight than an agent span and signals "this is orchestration, not a sub-agent."

tool_span — tool/callable invocations

Use for individual tool calls the agent makes (search, DB queries, API calls). This gives you a leaf-level view of what each agent actually did, and lets you spot slow or failing tools.

What topologies it supports

  • Supervisor/worker multi-agent graphs (distinguish supervisor vs. worker agents).
  • Any node that wants a span: chains, tools, retrieval steps.
  • Works alongside langchain-core 's LangGraph runtime; with auto_instrument=True, LangChain's own LLM/tool calls nest under your manual spans automatically.

Example

See examples/observability.py for a runnable example of this bite. Run it with:

uv run python examples/observability.py

API reference

langshark_bites.observability

Observability helpers for LangChain / LangGraph apps.

observability is a namespace for tracing backends. Today it ships a single concrete backend, phoenix, whose public API is re-exported here so applications can import from the top-level observability package:

from langshark_bites.observability import init_phoenix, agent_span

Future backends (e.g. observability.langsmith) can be added alongside phoenix without changing the public API.

agent_span

agent_span(
    fn=None,
    /,
    *,
    default_override=None,
    parse_agent_name=False,
    tags=None,
)

Decorator: OpenInference AGENT span. No-op if Phoenix uninitialized.

chain_span

chain_span(
    fn=None,
    /,
    *,
    default_override=None,
    parse_agent_name=False,
    tags=None,
)

Decorator: OpenInference CHAIN span. No-op if Phoenix uninitialized.

init_phoenix

init_phoenix(
    *,
    endpoint="http://localhost:6006",
    project_name="default",
    batch=True,
    auto_instrument=True,
    verbose=False,
    api_key=None,
)

Register Phoenix OTEL exporter once per process.

Idempotent and thread-safe. Subsequent calls are no-ops and return the existing tracer provider.

Parameters:

Name Type Description Default

endpoint

str

Phoenix collector endpoint (HTTP).

'http://localhost:6006'

project_name

str

Phoenix project name for span grouping.

'default'

batch

bool

Use batch span processor (recommended for production).

True

auto_instrument

bool

Wire OpenInference LangChain instrumentor so LLM and tool calls nest under manual agent/chain spans.

True

verbose

bool

Phoenix SDK verbose logging.

False

api_key

str | None

Optional Phoenix cloud API key.

None

Returns:

Type Description
Any | None

The TracerProvider, or None if registration failed / Phoenix missing.

phoenix_get_tracer

phoenix_get_tracer(name=None)

Return an OITracer, or None if Phoenix is not initialized.

Parameters:

Name Type Description Default

name

str | None

Optional tracer name. Defaults to the process-wide tracer created by init_phoenix.

None

phoenix_is_initialized

phoenix_is_initialized()

Return True if init_phoenix has completed successfully.

tool_span

tool_span(
    fn=None,
    /,
    *,
    default_override=None,
    parse_agent_name=False,
    tags=None,
)

Decorator: OpenInference TOOL span. No-op if Phoenix uninitialized.

phoenix

Phoenix observability: tracer setup + OpenInference span decorators.

Exposes a single, project-agnostic API for wiring Phoenix / OpenInference into a LangChain / LangGraph app without dragging in the full arize-phoenix app SDK (see setup for the bloat rationale).

Public API
  • init_phoenix / phoenix_get_tracer / phoenix_is_initialized
  • agent_span / chain_span / tool_span
Quick start

::

from langshark_bites.observability.phoenix import (
    init_phoenix,
    agent_span,
)

# Once per process (off the event loop if under ASGI):
init_phoenix(endpoint="http://localhost:6006", project_name="my-app")

@agent_span(parse_agent_name=True, tags={"as_of": "2026-07-10"})
async def run_worker(agent_name: str, ...):
    ...

agent_span

agent_span(
    fn=None,
    /,
    *,
    default_override=None,
    parse_agent_name=False,
    tags=None,
)

Decorator: OpenInference AGENT span. No-op if Phoenix uninitialized.

chain_span

chain_span(
    fn=None,
    /,
    *,
    default_override=None,
    parse_agent_name=False,
    tags=None,
)

Decorator: OpenInference CHAIN span. No-op if Phoenix uninitialized.

init_phoenix

init_phoenix(
    *,
    endpoint="http://localhost:6006",
    project_name="default",
    batch=True,
    auto_instrument=True,
    verbose=False,
    api_key=None,
)

Register Phoenix OTEL exporter once per process.

Idempotent and thread-safe. Subsequent calls are no-ops and return the existing tracer provider.

Parameters:

Name Type Description Default
endpoint
str

Phoenix collector endpoint (HTTP).

'http://localhost:6006'
project_name
str

Phoenix project name for span grouping.

'default'
batch
bool

Use batch span processor (recommended for production).

True
auto_instrument
bool

Wire OpenInference LangChain instrumentor so LLM and tool calls nest under manual agent/chain spans.

True
verbose
bool

Phoenix SDK verbose logging.

False
api_key
str | None

Optional Phoenix cloud API key.

None

Returns:

Type Description
Any | None

The TracerProvider, or None if registration failed / Phoenix missing.

phoenix_get_tracer

phoenix_get_tracer(name=None)

Return an OITracer, or None if Phoenix is not initialized.

Parameters:

Name Type Description Default
name
str | None

Optional tracer name. Defaults to the process-wide tracer created by init_phoenix.

None

phoenix_is_initialized

phoenix_is_initialized()

Return True if init_phoenix has completed successfully.

tool_span

tool_span(
    fn=None,
    /,
    *,
    default_override=None,
    parse_agent_name=False,
    tags=None,
)

Decorator: OpenInference TOOL span. No-op if Phoenix uninitialized.

setup

Phoenix / OpenInference tracer registration.

Standalone, extractable module. Zero imports from application code — callers inject endpoint/project via arguments.

Public API
  • init_phoenix(endpoint, project_name, ...) — idempotent, thread-safe
  • phoenix_get_tracer(name=None) — returns OITracer or None if not initialized
  • phoenix_is_initialized() — bool
Design notes
  • Registration is expensive (~seconds of SDK scan when auto_instrument=True) and must NOT run on the ASGI event loop (blockbuster rejects os.mkdir / heavy imports). Callers should invoke init_phoenix via asyncio.to_thread or at process startup.
  • If Phoenix is not installed, or init_phoenix was never called, phoenix_get_tracer returns None and the decorators in traceable no-op.
  • This module only needs phoenix.otel (register + using_attributes), provided by the slim arize-phoenix-otel package. Do NOT add the full arize-phoenix app SDK to the agent process: it drags in pydantic-ai-slim → genai-prices → httpx2, which races openai>=2.53's _httpx2 helpers ("partially initialized module 'httpx2' ... no attribute 'URL'"). The Phoenix UI/collector runs as its own service (e.g. a docker-compose phoenix).
init_phoenix
init_phoenix(
    *,
    endpoint="http://localhost:6006",
    project_name="default",
    batch=True,
    auto_instrument=True,
    verbose=False,
    api_key=None,
)

Register Phoenix OTEL exporter once per process.

Idempotent and thread-safe. Subsequent calls are no-ops and return the existing tracer provider.

Parameters:

Name Type Description Default
endpoint
str

Phoenix collector endpoint (HTTP).

'http://localhost:6006'
project_name
str

Phoenix project name for span grouping.

'default'
batch
bool

Use batch span processor (recommended for production).

True
auto_instrument
bool

Wire OpenInference LangChain instrumentor so LLM and tool calls nest under manual agent/chain spans.

True
verbose
bool

Phoenix SDK verbose logging.

False
api_key
str | None

Optional Phoenix cloud API key.

None

Returns:

Type Description
Any | None

The TracerProvider, or None if registration failed / Phoenix missing.

phoenix_get_tracer
phoenix_get_tracer(name=None)

Return an OITracer, or None if Phoenix is not initialized.

Parameters:

Name Type Description Default
name
str | None

Optional tracer name. Defaults to the process-wide tracer created by init_phoenix.

None
phoenix_is_initialized
phoenix_is_initialized()

Return True if init_phoenix has completed successfully.

traceable

OpenInference-style span decorators for agents, chains, and tools.

Wraps Phoenix OITracer's start_as_current_span with:

  1. Graceful no-op when Phoenix is not initialized (safe in unit tests).
  2. OpenInference span kinds (agent / chain / tool) for the Phoenix UI. OpenInference requires lowercase kind strings when passed as str.
  3. Filterable tags via using_attributes(metadata=...) so spans carry keys you can filter on in Phoenix (as_of, task_id, version, model, ...).
Public API
  • agent_span(fn=None, *, default_override=None, parse_agent_name=False, tags=None)
  • chain_span(...) (same signature)
  • tool_span(...) (same signature)
Span naming precedence (highest wins)
  1. parse_agent_name=True -> the agent_name parameter's runtime value.
  2. default_override -> a fixed literal name.
  3. otherwise -> the wrapped function's name.

Usage::

from langshark_bites.observability.phoenix import agent_span

# Default: span is named after the function.
@agent_span
async def run_worker(agent_name: str):
    ...

# Name each span after the agent that actually ran (worker loop).
@agent_span(parse_agent_name=True, tags={"as_of": "2026-07-10"})
async def run_worker(agent_name: str, as_of: str):
    ...

# Fixed override + filterable tags.
@agent_span(default_override="work", tags={"task_id": "x"})
async def run_worker(agent_name: str):
    ...
agent_span
agent_span(
    fn=None,
    /,
    *,
    default_override=None,
    parse_agent_name=False,
    tags=None,
)

Decorator: OpenInference AGENT span. No-op if Phoenix uninitialized.

chain_span
chain_span(
    fn=None,
    /,
    *,
    default_override=None,
    parse_agent_name=False,
    tags=None,
)

Decorator: OpenInference CHAIN span. No-op if Phoenix uninitialized.

tool_span
tool_span(
    fn=None,
    /,
    *,
    default_override=None,
    parse_agent_name=False,
    tags=None,
)

Decorator: OpenInference TOOL span. No-op if Phoenix uninitialized.