Skip to content

observability: auto-wire Phoenix from env + fix OpenInference span rendering - #135

Open
whatever wants to merge 4 commits into
mainfrom
matt/phoenix-wire-correctly
Open

observability: auto-wire Phoenix from env + fix OpenInference span rendering#135
whatever wants to merge 4 commits into
mainfrom
matt/phoenix-wire-correctly

Conversation

@whatever

@whatever whatever commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the Phoenix OTLP tracing that shipped in #131. The observability module
existed but was inert end-to-end — nothing called `connect()`, the OpenInference
attribute names didn't match Phoenix's classifier, the OTel bridge hard-coded
`SpanKind.INTERNAL`, and `EventBus.emit` was called on the class rather than
the singleton so `COST_UPDATE` handlers never fired.

After this PR: exporting `PHOENIX_ENDPOINT` and `PHOENIX_PROJECT` and running
any `clearwing` subcommand (CLI, webui, machine-fd, or sourcehunt) sends real
LLM spans to Phoenix with the correct model / tokens / provider / latency.

Four reviewable commits, one logical change each:

  • 5ae6f16 `observability: auto-bootstrap Phoenix wiring from env` — new
    `ObservabilityIntegration.bootstrap_from_env()` singleton factory; wired
    into `clearwing.main()` and `create_app()` (with shutdown flush hook).
  • e1a511f `observability: use OpenInference semantic conventions on LLM
    spans` — rename attributes to `openinference.span.kind`, `llm.model_name`,
    `llm.token_count.prompt|completion|total|cached` per the OpenInference spec.
  • 5b275e2 `observability: derive Phoenix SpanKind from
    openinference.span.kind` — map LLM/TOOL → CLIENT, CHAIN/AGENT → INTERNAL
    instead of hard-coding INTERNAL.
  • 48af566 `observability: enrich COST_UPDATE payload + emit from
    sourcehunt` — add `elapsed_ms` / `provider` / `cached_tokens` to the
    payload; fix `EventBus.emit` to use the singleton; wall-clock timing
    around both LLM call sites; sourcehunt hunter now emits COST_UPDATE too.

Test plan

  • `uv run pytest tests/test_observability.py tests/test_telemetry.py -q` — 74 pass
  • `uv run pytest -q` — 2943 pass, 3 skipped (baseline)
  • Smoke: `docker run -p 6006:6006 arizephoenix/phoenix:latest`, export
    `PHOENIX_ENDPOINT=http://127.0.0.1:6006 PHOENIX_PROJECT=clearwing-dev`,
    run a small `clearwing operate` invocation, confirm `llm_call` spans
    show up in Phoenix UI with correct model / tokens / provider / duration.

Follow-ups (deferred)

  • Hunt / operate CHAIN parent spans that wrap per-call LLM spans — needs new
    `EventType` values and touches busy code paths; deferred.
  • Fix the thread-local parent-span stack in `tracer.py:_active_spans`
    (async concurrency bug — same-thread asyncio tasks clobber each other's
    parent). Only matters once CHAIN spans land and concurrent hunts share a
    Tracer.

The ObservabilityIntegration existed but was inert — nothing ever called
`.connect()`, so PHOENIX_ENDPOINT / PHOENIX_PROJECT had no effect and
no LLM spans ever reached the collector.

Add `ObservabilityIntegration.bootstrap_from_env()` as a singleton
factory that idempotently instantiates + connects when both env vars
are set (returns None otherwise). Wire it into every process entry
point:

- `clearwing.main()` covers all CLI subcommands, including
  `clearwing operate --machine-fd 3` used by embedding hosts.
- `create_app()` covers the webui/FastAPI server and registers a
  shutdown hook so the OTel batch processor flushes on graceful exit.

Safe to call from multiple entry points concurrently; the classvar
`_singleton` guard makes the call idempotent. `disconnect()` releases
the singleton so a fresh bootstrap can rebuild — primarily useful for
tests.
The synthetic ``llm_call`` span was rendered as a generic "internal"
span in the Arize Phoenix UI because the attribute names didn't match
Phoenix's OpenInference-based classifier.

Switch to the documented OpenInference conventions:

  - openinference.span.kind      = "LLM"    (was: span.kind = "llm")
  - llm.model_name               (was: llm.model)
  - llm.token_count.prompt       (was: llm.token_count.input)
  - llm.token_count.completion   (was: llm.token_count.output)
  - llm.token_count.total        (new — sum)
  - llm.token_count.cached       (unchanged)
  - llm.provider, llm.cost_usd   (unchanged)

Reference:
https://github.com/Arize-ai/openinference/blob/main/spec/semantic_conventions.md

Also coerce int-typed token counts up front so downstream span serializers
(OTel expects concrete ints/floats) don't have to re-parse.
``_to_readable_span`` hard-coded ``SpanKind.INTERNAL`` on every export,
so even after the LLM span carried the correct OpenInference
``openinference.span.kind = "LLM"`` attribute, the OTel envelope
Phoenix received still classified it as an internal generic span.

Introduce ``_span_kind_from_openinference`` that maps the attribute to
the most fitting OTel kind:

  - LLM / TOOL          → CLIENT   (outbound call semantics)
  - CHAIN / AGENT / *   → INTERNAL (orchestration)

The attribute itself is preserved on the exported span — Phoenix keys
off both the string and the OTel enum.
Two related fixes so Phoenix LLM spans surface every real LLM call
with useful attributes:

1. Enrich the ``COST_UPDATE`` payload emitted from
   ``CostTracker.record_llm_call`` with ``cached_tokens``,
   ``elapsed_ms``, and ``provider``. Adds keyword-only ``elapsed_ms``
   and ``provider`` args (backward-compatible — legacy callers still
   work and get safe defaults).

2. Fix a latent bug: ``EventBus.emit`` was called on the class, not an
   instance, so ``event_type`` was silently binding to ``self`` and
   the ``COST_UPDATE`` handler never fired. Route through
   ``EventBus()`` (the singleton) instead.

3. Call ``CostTracker().record_llm_call`` from the sourcehunt hunter's
   per-turn LLM loop as well. It previously called
   ``_estimate_cost_usd`` (a local wrapper around
   ``CostTracker.estimate_cost``) that never touched the singleton, so
   Phoenix saw operator LLM spans but no hunter ones. The local
   ``total_cost_usd`` tally is preserved unchanged — the additional
   call only surfaces telemetry.

4. Measure wall-clock latency around both LLM call sites so the
   backdated Phoenix span duration reflects real API latency.

Tests: cover the new payload fields, the OpenInference attribute
names on the emitted span, the ``_span_kind_from_openinference``
mapping, and ``bootstrap_from_env`` singleton semantics.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant