ADR-0128: No bundled trace UI: OTLP to the user's backend¶
Status: Accepted (owner, 2026-10-01). Built on refactor/remove-phoenix-otlp-only.
Relates to: OSS plan R19 (lean, OSI-only core; addendum of 2026-10-01),
docs/architecture/learning-observability.md (D1: learning never depends on tracing),
ADR-0074 (per-agent console, built from session logs).
Context¶
IRIS shipped Arize Phoenix as its trace UI. Since R19 it was the optional [phoenix]
extra (Elastic-2.0, so not in the OSI-only core), with two modes behind IRIS_PHOENIX_*:
- embedded: the Phoenix app launched inside the
iris_apiprocess, with its own web server and SQLite trace store, and with it the stack the extra pulls in (some 60 locked packages only it needs: pandas, SQLAlchemy, strawberry-graphql, boto3, ...; see alsodocs/experiments/2026-09-22-1gib-memory-budget.md). - external:
scripts/start_iris.shranphoenix serveas a managed service and the API exported spans to it over OTLP.
What the user actually needs from IRIS is the spans, not a particular viewer. The
external mode already proved the point: spans went over plain OTLP, and any OTLP backend
could take them. Meanwhile the bundling carried cost everywhere: an extra, its
IRIS_PHOENIX_* settings (one with no production effect), a service in the start script,
a port and a build arg in Compose, a doctor row, a mypy override, a licence exception to
explain, and a second configuration language that duplicated what the standard
OpenTelemetry variables already say.
The web console's Call trace and Sessions screens never depended on it: they are built
from IRIS's own session logs (foundation/observability/trace_builder.py,
session_log.py), not from OpenTelemetry.
Decision¶
- Remove Phoenix from the harness. No
[phoenix]extra, noarize-phoenixdependency, nophoenix_setup.py, no embedded or external mode, noIRIS_PHOENIX_*setting, no Phoenix service instart_iris.sh, Compose or the Dockerfile, and no doctor row. - OpenTelemetry stays core, configured by the standard variables.
foundation/observability/otlp_setup.pybuilds a tracer provider only whenOTEL_EXPORTER_OTLP_TRACES_ENDPOINTorOTEL_EXPORTER_OTLP_ENDPOINTis set. The exporter is constructed with no arguments, so it reads the endpoint, headers, timeout, compression and TLS settings itself.OTEL_EXPORTER_OTLP_(TRACES_)PROTOCOLpickshttp/protobuf(default) orgrpc.Resource.create()readsOTEL_SERVICE_NAMEandOTEL_RESOURCE_ATTRIBUTES; IRIS fillsservice.name=irisonly when neither names one.OTEL_SDK_DISABLED=trueandOTEL_TRACES_EXPORTER=noneturn export off. - No endpoint, nothing. No provider, no exporter, no instrumentation, and
/healthzreportstracing_enabled: falsewithobservability_error: null. - Batched export, logged egress. Spans go through a
BatchSpanProcessor. The exporter is wrapped so that each batch sent is oneiris.egressline naming the host (credentials, query and fragment stripped), like every other outbound call. - IRIS settings only where the standard has none.
IRIS_OTEL_LANGCHAIN_ENABLEDandIRIS_OTEL_HTTPX_ENABLED(default on) choose the auto-instrumented libraries; the standardOTEL_PYTHON_DISABLED_INSTRUMENTATIONSapplies only to the zero-code agent.IRIS_CHANNEL_GATEWAY_TRACING_ENABLEDkeeps gating the gateway.IRIS_OTEL_ENABLEDis removed: its only production caller always passedenabled=True, and the standard off switches cover it. opentelemetry-instrumentation-httpxbecomes a core dependency, so the httpx switch works on a plain install. It follows the contrib instrumentation line of the installed SDK (0.62b1 with SDK 1.41.1).- Kept, because they are plain OpenTelemetry: the span helpers (
maybe_span,maybe_current_span,set_span_attributes,current_trace_ids), the globaliris_harness.llmtracer'sllm.invokespans, audit rows' trace ids, the tracer nameiris.runtime, the OpenInference LangChain instrumentation (Apache-2.0; its attributes are ordinary span attributes), and theopeninference.project.nameresource attribute, now set to the service name (Phoenix files projects by it; every other backend ignores it). arize-phoenixstays on the licence deny list as a guard, so it cannot come back as a dependency unnoticed. Running Phoenix as one's own backend is unaffected.
Alternatives considered¶
- Keep the
[phoenix]extra and the embedded mode. Rejected: it keeps the memory cost, the extra configuration language and the licence exception, for a viewer the user can run themselves in one command. - Keep
IRIS_PHOENIX_ENDPOINTas an alias ofOTEL_EXPORTER_OTLP_ENDPOINT. Rejected: two names for one thing, and the standard name is what every backend's documentation already tells users to set. - Bundle a different viewer (Jaeger all-in-one) in Compose. Rejected for the same reason as Phoenix: the backend is the user's choice, and the guide shows each in a few lines.
Consequences¶
- Positive: a lighter install and API process; one configuration language that every OTLP backend documents; the core install has no source-available dependency to explain.
- Negative:
/healthzand/observability/llm-metricschange shape (phoenix_urlandphoenix_enabledbecomeotlp_endpointandtracing_enabled; the metrics backendkindisotlp). Neither is in the stable API. An existing.envwithIRIS_PHOENIX_*no longer traces: setOTEL_EXPORTER_OTLP_ENDPOINTinstead (for a localphoenix serve,http://127.0.0.1:6006).start_iris.sh --no-phoenixis gone, and a Phoenix it started earlier is no longer stopped by--stop. - Privacy: trace content (LangChain spans include prompts and replies) goes wherever the operator points the endpoint. The guide says so. The variables are not in the settings catalog, so the app cannot change them.
- Reversibility: high. Re-adding a viewer is a Compose service or a guide section; the core needs no change to export to it.