open-navigator / api /telemetry.py
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
5.76 kB
"""
OpenTelemetry bootstrap for the Open Navigator FastAPI service.
Per the CLAUDE.md observability spec: instrument the app at startup via
``FastAPIInstrumentor``, wrap discrete operations (DB queries, external calls,
enrichment) in spans, and export to an OTLP collector β€” falling back to a
console exporter in development.
Usage (once, at app construction):
from api.telemetry import setup_telemetry, tracer
setup_telemetry(app)
with tracer.start_as_current_span("operation-name") as span:
span.set_attribute("key", value)
Exporter selection is driven by env:
- If ``OTEL_EXPORTER_OTLP_ENDPOINT`` (or ``OTEL_EXPORTER_OTLP_TRACES_ENDPOINT``)
is set, traces are batched and exported over OTLP/HTTP to that collector.
- Else if ``OTEL_CONSOLE_EXPORT`` is truthy, spans are printed to the console via
a *batched* processor (off the request path) β€” opt-in because it's noisy.
- Otherwise (the local/dev default) tracing stays active but no exporter is
attached, so the terminal stays clean and request handling isn't slowed by
blocking span export.
``setup_telemetry`` is idempotent: calling it more than once is a no-op.
"""
from __future__ import annotations
import os
from loguru import logger
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import (
BatchSpanProcessor,
ConsoleSpanExporter,
)
# Canonical service identity surfaced on every span / trace.
SERVICE_NAME = "open-navigator-api"
# Module-level tracer β€” import this wherever you need to open spans
# (e.g. the search dispatcher and per-type DB queries).
tracer = trace.get_tracer(__name__)
# Guard so repeated startup hooks (uvicorn reload, multiple app imports) don't
# stack duplicate TracerProviders / processors.
_INSTRUMENTED = False
def _resolve_otlp_endpoint() -> str | None:
"""Return the configured OTLP endpoint, or None for dev/console mode.
Honors the standard OTEL env vars so a collector can be wired in purely
through the environment with no code change.
"""
return (
os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
or None
)
def _console_export_enabled() -> bool:
"""Whether to print spans to the console (opt-in, off by default).
The console exporter is useful for eyeballing traces in dev, but it's noisy
(a JSON blob per span) and β€” with a synchronous processor β€” does blocking
stdout I/O on the asyncio event loop, which stalls request handling. So it's
now opt-in: set ``OTEL_CONSOLE_EXPORT=1`` to turn it on, and even then we
batch it off the request path.
"""
return os.getenv("OTEL_CONSOLE_EXPORT", "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
def setup_telemetry(app) -> None:
"""Configure tracing and instrument the FastAPI ``app``.
Safe to call exactly once at startup; subsequent calls are ignored. With no
OTLP endpoint configured it cleanly uses the console exporter (dev default)
and never attempts a network connection.
"""
global _INSTRUMENTED
if _INSTRUMENTED:
logger.debug("OpenTelemetry already initialized; skipping re-instrument")
return
resource = Resource.create(
{
"service.name": SERVICE_NAME,
"service.version": getattr(app, "version", "0.0.0"),
}
)
provider = TracerProvider(resource=resource)
endpoint = _resolve_otlp_endpoint()
if endpoint:
# Production / collector mode: batch spans off the request path so the
# exporter never blocks request handling.
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
OTLPSpanExporter,
)
provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))
)
logger.info(
"πŸ“‘ OpenTelemetry: exporting traces via OTLP/HTTP to {} (service={})",
endpoint,
SERVICE_NAME,
)
elif _console_export_enabled():
# Opt-in dev console exporter. BatchSpanProcessor (not Simple) so span
# serialization + stdout writes happen on a background thread instead of
# blocking the event loop when each span ends.
provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
logger.info(
"πŸ–₯️ OpenTelemetry: console span exporter enabled via "
"OTEL_CONSOLE_EXPORT (service={}). Spans are batched off the request "
"path.",
SERVICE_NAME,
)
else:
# Quiet dev default: tracing context is still active (spans are created
# and parented correctly) but nothing is exported, so the terminal stays
# clean and request handling isn't slowed by span export. Set
# OTEL_EXPORTER_OTLP_ENDPOINT to ship traces, or OTEL_CONSOLE_EXPORT=1 to
# print them locally.
logger.info(
"πŸ”‡ OpenTelemetry: no exporter configured β€” tracing active but spans "
"not exported (service={}). Set OTEL_EXPORTER_OTLP_ENDPOINT to ship "
"traces, or OTEL_CONSOLE_EXPORT=1 to print them.",
SERVICE_NAME,
)
trace.set_tracer_provider(provider)
# Instrument the FastAPI app (adds a server span per request, propagates
# context, and parents the manual spans we open in the search path).
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
FastAPIInstrumentor.instrument_app(app)
_INSTRUMENTED = True
logger.success("βœ… OpenTelemetry instrumentation enabled for {}", SERVICE_NAME)