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.53readssys.modules["httpx2"]and assumes it is fully initialized.- If Phoenix's SDK import races a concurrent
ChatOpenAI/ChatDeepSeekconstruction,httpx2can be partially initialized at the momentopenaireads it.
The result is a crash like:
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; withauto_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:
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
¶
Decorator: OpenInference AGENT span. No-op if Phoenix uninitialized.
chain_span
¶
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 |
|---|---|---|---|
|
str
|
Phoenix collector endpoint (HTTP). |
'http://localhost:6006'
|
|
str
|
Phoenix project name for span grouping. |
'default'
|
|
bool
|
Use batch span processor (recommended for production). |
True
|
|
bool
|
Wire OpenInference LangChain instrumentor so LLM and tool calls nest under manual agent/chain spans. |
True
|
|
bool
|
Phoenix SDK verbose logging. |
False
|
|
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 |
|---|---|---|---|
|
str | None
|
Optional tracer name. Defaults to the process-wide tracer
created by |
None
|
phoenix_is_initialized
¶
Return True if init_phoenix has completed successfully.
tool_span
¶
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_initializedagent_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
¶
Decorator: OpenInference AGENT span. No-op if Phoenix uninitialized.
chain_span
¶
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 |
|---|---|---|---|
|
str
|
Phoenix collector endpoint (HTTP). |
'http://localhost:6006'
|
|
str
|
Phoenix project name for span grouping. |
'default'
|
|
bool
|
Use batch span processor (recommended for production). |
True
|
|
bool
|
Wire OpenInference LangChain instrumentor so LLM and tool calls nest under manual agent/chain spans. |
True
|
|
bool
|
Phoenix SDK verbose logging. |
False
|
|
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 |
|---|---|---|---|
|
str | None
|
Optional tracer name. Defaults to the process-wide tracer
created by |
None
|
phoenix_is_initialized
¶
Return True if init_phoenix has completed successfully.
tool_span
¶
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-safephoenix_get_tracer(name=None)— returns OITracer or None if not initializedphoenix_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_phoenixviaasyncio.to_threador at process startup. - If Phoenix is not installed, or
init_phoenixwas never called,phoenix_get_tracerreturns None and the decorators intraceableno-op. - This module only needs
phoenix.otel(register + using_attributes), provided by the slimarize-phoenix-otelpackage. Do NOT add the fullarize-phoenixapp SDK to the agent process: it drags in pydantic-ai-slim → genai-prices → httpx2, which races openai>=2.53's_httpx2helpers ("partially initialized module 'httpx2' ... no attribute 'URL'"). The Phoenix UI/collector runs as its own service (e.g. a docker-composephoenix).
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 |
|---|---|---|---|
|
str
|
Phoenix collector endpoint (HTTP). |
'http://localhost:6006'
|
|
str
|
Phoenix project name for span grouping. |
'default'
|
|
bool
|
Use batch span processor (recommended for production). |
True
|
|
bool
|
Wire OpenInference LangChain instrumentor so LLM and tool calls nest under manual agent/chain spans. |
True
|
|
bool
|
Phoenix SDK verbose logging. |
False
|
|
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 |
|---|---|---|---|
|
str | None
|
Optional tracer name. Defaults to the process-wide tracer
created by |
None
|
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:
- Graceful no-op when Phoenix is not initialized (safe in unit tests).
- OpenInference span kinds (agent / chain / tool) for the Phoenix UI. OpenInference requires lowercase kind strings when passed as str.
- 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)¶
parse_agent_name=True-> theagent_nameparameter's runtime value.default_override-> a fixed literal name.- 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):
...