"""OpenTelemetry tracing + Prometheus metrics (架构文档 §15, TD-24). The three dashboards required by §15 (业务漏斗 / AI 成本 / 任务健康) are built from the metrics declared here plus the ``ob_ai_call`` table. """ from __future__ import annotations from typing import TYPE_CHECKING, Any from prometheus_client import CollectorRegistry, Counter, Histogram if TYPE_CHECKING: # pragma: no cover from fastapi import FastAPI from packages.shared.config import Settings REGISTRY = CollectorRegistry(auto_describe=True) HTTP_REQUESTS = Counter( "meida_http_requests_total", "HTTP requests handled by the API", labelnames=("method", "route", "status"), registry=REGISTRY, ) HTTP_LATENCY = Histogram( "meida_http_request_duration_seconds", "HTTP request latency", labelnames=("method", "route"), buckets=(0.01, 0.05, 0.1, 0.2, 0.3, 0.5, 1.0, 2.0, 5.0, 8.0, 15.0), registry=REGISTRY, ) # --- AI 成本看板 --------------------------------------------------------- LLM_CALLS = Counter( "meida_llm_calls_total", "LLM provider calls (one per HTTP attempt, including retries and failures)", labelnames=("scene", "provider", "model", "success"), registry=REGISTRY, ) LLM_TOKENS = Counter( "meida_llm_tokens_total", "LLM tokens by kind; `cached` drives the prompt-cache hit-rate panel (NFR-COST-03)", labelnames=("scene", "provider", "model", "kind"), registry=REGISTRY, ) LLM_COST_CENTS = Counter( "meida_llm_cost_cents_total", "Accumulated LLM cost in cents, attributable to scene/provider/model", labelnames=("scene", "provider", "model"), registry=REGISTRY, ) LLM_LATENCY = Histogram( "meida_llm_latency_seconds", "LLM provider call latency", labelnames=("scene", "provider", "model"), buckets=(0.2, 0.5, 1.0, 2.0, 4.0, 8.0, 15.0, 30.0, 60.0), registry=REGISTRY, ) # --- 业务漏斗 / 任务健康 ------------------------------------------------- QUOTA_EVENTS = Counter( "meida_quota_events_total", "Quota three-phase events (reserve/commit/refund) and their outcome", labelnames=("quota_key", "phase", "result"), registry=REGISTRY, ) TASK_TRANSITIONS = Counter( "meida_task_transitions_total", "tk_task state machine transitions", labelnames=("task_type", "status"), registry=REGISTRY, ) def setup_tracing(app: FastAPI, settings: Settings) -> None: """Install the OTel tracer provider and instrument FastAPI. Disabled by default locally; enable with ``OTEL_ENABLED=true`` and an OTLP endpoint. """ if not settings.otel_enabled: return from opentelemetry import trace from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor resource = Resource.create( {"service.name": settings.otel_service_name, "deployment.environment": settings.app_env} ) provider = TracerProvider(resource=resource) if settings.otel_exporter_otlp_endpoint: provider.add_span_processor( BatchSpanProcessor( OTLPSpanExporter(endpoint=f"{settings.otel_exporter_otlp_endpoint}/v1/traces") ) ) trace.set_tracer_provider(provider) FastAPIInstrumentor.instrument_app(app, tracer_provider=provider) def current_trace_id() -> str | None: """Return the active W3C trace id, if any.""" try: from opentelemetry import trace except ImportError: # pragma: no cover return None span = trace.get_current_span() ctx: Any = span.get_span_context() if not getattr(ctx, "is_valid", False): return None return format(ctx.trace_id, "032x")