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.
This commit is contained in:
Executable
+116
@@ -0,0 +1,116 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user