Files
meida_backend/packages/shared/observability.py
T
xsl 12583e9739 feat(shared): cross-cutting plumbing and pure domain layer
Typed pydantic-settings config, structlog JSON logging with request/trace id
propagation and redaction of phone numbers, tokens and face keys, the unified
response envelope and error-code table from 架构文档 §9.2, async SQLAlchemy
session management, ORM models for the eight foundation tables, and the
OpenTelemetry/Prometheus wiring.

packages/domain stays pure logic -- snowflake ids, the quota three-phase
protocol and the tk_task state machine -- so it can be tested without a
database and is held to mypy strict.
2026-08-14 00:26:39 +08:00

117 lines
3.8 KiB
Python
Executable File

"""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")