Multi-provider instrumentation¶
BIJOTEL is provider-agnostic: it seals every OpenTelemetry GenAI span that lands in its TracerProvider, regardless of which LLM SDK produced it. The shape of the integration depends on how many SDKs your application imports.
This page documents the deployment pattern, with the ARA (AI Research Agency) production deploy as the worked example.
TL;DR¶
For every LLM SDK in use, wire its OTel instrumentor in your
application's startup (FastAPI lifespan, Django AppConfig.ready,
Flask before_first_request, etc.) before the first LLM call.
The instrumentor patches the SDK at the import boundary; every call
site downstream then emits spans automatically.
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor
from opentelemetry.instrumentation.openai import OpenAIInstrumentor
from bijotel.processors import HmacChainSpanProcessor, CasSpanProcessor
provider = TracerProvider()
provider.add_span_processor(HmacChainSpanProcessor(
db_path="chain.db",
secret_key=bytes.fromhex(os.environ["BIJOTEL_HMAC_SECRET"]),
))
provider.add_span_processor(CasSpanProcessor(db_path="chain.db"))
trace.set_tracer_provider(provider)
AnthropicInstrumentor().instrument()
OpenAIInstrumentor().instrument()
# add more instrumentors per SDK you import
After this, every client.messages.create() (Anthropic) and
client.chat.completions.create() (OpenAI, including OpenAI-compatible
backends like xAI / DeepSeek / Together) is sealed into the chain.
SDK-to-instrumentor coverage matrix¶
| Provider | Python SDK | OTel instrumentor | One-instrumentor coverage |
|---|---|---|---|
| Anthropic | anthropic |
opentelemetry-instrumentation-anthropic |
— |
| OpenAI | openai |
opentelemetry-instrumentation-openai |
OpenAI + xAI + DeepSeek + Together + Fireworks + Groq + any other OpenAI-compatible service that uses openai.OpenAI(base_url=…) |
Google Gemini (google-genai SDK) |
google-genai |
No OTel instrumentor on PyPI as of 2026-05-26. The older opentelemetry-instrumentation-vertexai is for the legacy vertexai SDK, not google-genai. |
Track upstream or wrap manually (see "Gap workarounds" below). |
| Mistral | mistralai |
opentelemetry-instrumentation-mistral (third-party, not officially supported) |
Same install pattern; not exercised in this matrix. |
| AWS Bedrock | boto3 |
opentelemetry-instrumentation-botocore |
Captures Bedrock calls inside the broader botocore span. |
| Cohere | cohere |
opentelemetry-instrumentation-cohere |
— |
The big leverage point: OpenAIInstrumentor patches the openai
SDK once, and every consumer of openai.OpenAI / openai.AsyncOpenAI
gets spans automatically — regardless of base_url. That covers the
majority of "OpenAI-compatible" providers in the wild without extra
config.
Worked example: ARA (AI Research Agency)¶
ARA is a 7-provider research platform with one LLM call site
(backend/llm/client.py::LLMClient.complete()) and five active
provider methods (_call_anthropic, _call_openai_compat,
_call_google, _call_mistral, plus the shared dispatch).
_call_openai_compat handles OpenAI, xAI, DeepSeek, and Together via
one openai.AsyncOpenAI client with different base_url overrides.
Before (v2.0.x deploy, 2026-05-25)¶
ARA wired only AnthropicInstrumentor in the FastAPI lifespan. Result:
153 sealed chain entries over 24 hours, 100% anthropic. Other 5
providers were running ungated outside the chain.
After (v2.1.0 deploy, 2026-05-26)¶
Added one block to the lifespan after the anthropic init:
try:
from opentelemetry.instrumentation.openai import OpenAIInstrumentor
OpenAIInstrumentor().instrument()
providers_wired.extend(["openai", "xai", "deepseek", "together"])
except ImportError:
logger.warning("opentelemetry-instrumentation-openai not installed")
Added one line to requirements.txt:
After restart, the lifespan log reads:
BIJOTEL: chain=/app/data/bijotel_chain.db,
providers instrumented=anthropic,openai,xai,deepseek,together
(google/mistral: see comments in main.py lifespan)
End-to-end test from inside the container: real OpenAI + xAI calls
sealed correctly, producing chain entries with the expected
gen_ai.request.model and token counts. bijotel verify reported
Chain VALID (155 entries) post-deploy.
Gap workarounds in this build¶
- Google Gemini (
google-genai) — no PyPI instrumentor. ARA's_call_google()calls remain outside the chain in this deploy. Options: - Wait for an upstream
opentelemetry-instrumentation-google-genaipackage. - Wrap the
_call_googlemethod body withtracer.start_as_current_span("google.chat", ...)and set the standardgen_ai.*attributes manually — BIJOTEL's HmacChainSpanProcessor will pick it up. - Use
bijotel.wrap()on the SDK call site for a quick manual bridge. - Mistral (
mistralai) — the SDK is currently absent from the build (PyPI quarantine). ARA'sMISTRAL_SDK_AVAILABLEguard short-circuits_call_mistral; no calls flow through it. Nothing to instrument until the SDK returns.
How to verify in your own deploy¶
# 1. Snapshot chain count
sqlite3 chain.db "SELECT COUNT(*) FROM chain"
# 2. Make a real call through each instrumented SDK
# 3. Re-count + check provider distribution
sqlite3 chain.db "
SELECT json_extract(canonical_body,
'$.attributes.\"gen_ai.provider.name\"') AS provider,
COUNT(*) AS calls
FROM chain
GROUP BY provider
ORDER BY calls DESC
"
# 4. Confirm chain integrity
bijotel verify --db chain.db
Provider names you'll see in gen_ai.provider.name:
| Actual API call | Reported provider |
|---|---|
| Anthropic | anthropic |
OpenAI (api.openai.com) |
openai |
xAI (api.x.ai, OpenAI SDK + base_url) |
openai (SDK-reported) |
| DeepSeek (OpenAI SDK + base_url) | openai |
| Together (OpenAI SDK + base_url) | openai |
gen_ai.provider.name reflects the SDK that emitted the span, not
the actual upstream API. To distinguish OpenAI-proper from xAI, look at
gen_ai.request.model — model names disambiguate (gpt-4o-mini vs
grok-3-mini vs deepseek-chat vs meta-llama/Llama-3-…).
This is a documented behaviour of opentelemetry-instrumentation-openai,
not a BIJOTEL quirk. If you need to log the "logical" provider for
billing or routing analytics, set a span attribute yourself in your
client wrapper before the SDK call.
Rollback¶
If a multi-provider rollout breaks the lifespan and the backend won't start:
# Restore previous main.py and requirements.txt:
cp /path/to/main.py.pre-multi-provider.bak /path/to/main.py
cp /path/to/requirements.txt.pre-multi-provider.bak /path/to/requirements.txt
# Restart:
docker compose restart backend
# or for a full rebuild:
docker compose build backend && docker compose up -d backend
BIJOTEL's lifespan block is wrapped in a try/except that logs the
error and continues — so a broken instrumentor import won't crash the
host application, just produce a degraded chain (missing one
provider). The fail-soft posture is intentional.