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.
117 lines
3.8 KiB
Python
Executable File
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")
|