Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """ | |
| 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) | |