Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
|
|
@ -0,0 +1,92 @@
|
|||
"""Per-request middleware that emits events to the dashboard sink.
|
||||
|
||||
Captures: request_id (X-Request-ID header or generated UUID), endpoint
|
||||
(URL path), HTTP method, duration, status_code. Skips /health and noisy
|
||||
internal paths. Endpoint string is normalized to the route template
|
||||
(no params) so a high-cardinality table doesn't blow up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from brain_api.events.sink import get_global_sink
|
||||
|
||||
# Endpoints we don't want to record — too noisy / not interesting in History.
|
||||
_SKIP_PREFIXES = (
|
||||
"/health",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/metrics",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_endpoint(path: str) -> str:
|
||||
"""Collapse path params so we don't blow up the request_history table.
|
||||
|
||||
/v1/analysis_atom/123 → /v1/analysis_atom/{id}
|
||||
/v1/fact_status/45/versions → /v1/fact_status/{id}/versions
|
||||
/v1/verification_cache/abc/free → /v1/verification_cache/{hash}/{tier}
|
||||
"""
|
||||
parts = path.split("/")
|
||||
if len(parts) >= 4 and parts[1] == "v1" and parts[2] == "analysis_atom":
|
||||
if len(parts) == 4 and parts[3].isdigit():
|
||||
return "/v1/analysis_atom/{id}"
|
||||
if len(parts) >= 4 and parts[1] == "v1" and parts[2] == "fact_status":
|
||||
if parts[3].isdigit():
|
||||
tail = "/" + "/".join(parts[4:]) if len(parts) > 4 else ""
|
||||
return f"/v1/fact_status/{{id}}{tail}"
|
||||
if (
|
||||
len(parts) >= 5
|
||||
and parts[1] == "v1"
|
||||
and parts[2] == "verification_cache"
|
||||
and parts[4] in ("free", "premium")
|
||||
):
|
||||
return "/v1/verification_cache/{hash}/{tier}"
|
||||
return path
|
||||
|
||||
|
||||
async def event_emit_middleware(
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
"""Starlette HTTP middleware: emit a dashboard event per request."""
|
||||
path = request.url.path
|
||||
if any(path.startswith(p) for p in _SKIP_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
request_id = request.headers.get("x-request-id") or uuid.uuid4().hex[:32]
|
||||
started = time.monotonic()
|
||||
status_code = 500
|
||||
error: str | None = None
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status_code = response.status_code
|
||||
return response
|
||||
except Exception as e: # noqa: BLE001
|
||||
error = f"{type(e).__name__}: {e}"
|
||||
raise
|
||||
finally:
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
sink = get_global_sink()
|
||||
if sink is not None and sink.enabled:
|
||||
sink.emit(
|
||||
{
|
||||
"request_id": request_id,
|
||||
"tier": "n/a",
|
||||
"endpoint": _normalize_endpoint(path),
|
||||
# Brain has no upstream "provider" concept — it resolves
|
||||
# locally (PG + atomic + LLM router). Leaving null keeps
|
||||
# the column UI honest (renders as "—").
|
||||
"provider": None,
|
||||
"duration_ms": duration_ms,
|
||||
"status_code": status_code,
|
||||
"error": error,
|
||||
}
|
||||
)
|
||||
125
ai_platform/modules/didi_brain/brain_api/events/sink.py
Normal file
125
ai_platform/modules/didi_brain/brain_api/events/sink.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Async event sink that forwards brain request events to the AI platform dashboard.
|
||||
|
||||
Fire-and-forget — dashboard outages must never affect brain latency or
|
||||
availability. Pattern mirrors web-api/events/sink.py 1:1, with module='brain'
|
||||
baked in so events are distinguishable in the unified Insights/History view.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("brain_api.events.sink")
|
||||
|
||||
|
||||
class DashboardEventSink:
|
||||
"""POSTs request events to the dashboard /api/ingest/event endpoint.
|
||||
|
||||
The sink runs all writes through a bounded queue processed by a single
|
||||
worker task. Overflow drops oldest. Failures are logged but swallowed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dashboard_url: str | None,
|
||||
token: str | None = None,
|
||||
queue_size: int = 1000,
|
||||
request_timeout: float = 5.0,
|
||||
) -> None:
|
||||
self.dashboard_url = dashboard_url.rstrip("/") if dashboard_url else None
|
||||
self.token = token
|
||||
self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=queue_size)
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._worker_task: asyncio.Task[None] | None = None
|
||||
self._request_timeout = request_timeout
|
||||
self._enabled = bool(dashboard_url)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
async def start(self) -> None:
|
||||
if not self._enabled:
|
||||
logger.info("brain DashboardEventSink disabled (no dashboard URL)")
|
||||
return
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(
|
||||
connect=2.0, read=self._request_timeout, write=2.0, pool=5.0
|
||||
),
|
||||
limits=httpx.Limits(max_connections=5, max_keepalive_connections=2),
|
||||
)
|
||||
self._worker_task = asyncio.create_task(self._worker())
|
||||
logger.info("brain DashboardEventSink started → %s", self.dashboard_url)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._worker_task is not None:
|
||||
self._worker_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await self._worker_task
|
||||
self._worker_task = None
|
||||
if self._client is not None and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
def emit(self, event: dict[str, Any]) -> None:
|
||||
"""Enqueue an event for async send. Never raises.
|
||||
|
||||
The caller does NOT have to set 'module' — we stamp it here so all
|
||||
events from this process are tagged 'brain' regardless of who called.
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
event = {**event, "module": "brain"}
|
||||
try:
|
||||
self._queue.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
# Drop the oldest event to make room for the new one.
|
||||
try:
|
||||
_ = self._queue.get_nowait()
|
||||
self._queue.put_nowait(event)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
async def _worker(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
event = await self._queue.get()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
try:
|
||||
await self._send(event)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("Event dropped (%s): %s", type(e).__name__, e)
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
|
||||
async def _send(self, event: dict[str, Any]) -> None:
|
||||
if self._client is None or self.dashboard_url is None:
|
||||
return
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
resp = await self._client.post(
|
||||
f"{self.dashboard_url}/api/ingest/event",
|
||||
json=event,
|
||||
headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
# Module-level singleton — initialized in app.py lifespan.
|
||||
_sink: DashboardEventSink | None = None
|
||||
|
||||
|
||||
def init_global_sink(sink: DashboardEventSink) -> None:
|
||||
global _sink
|
||||
_sink = sink
|
||||
|
||||
|
||||
def get_global_sink() -> DashboardEventSink | None:
|
||||
return _sink
|
||||
Loading…
Add table
Add a link
Reference in a new issue