feat(monitoring): observability stack + app instrumentation

- /health/ready (DB+MinIO+Casdoor checks, webhook post-deploy gate),
  /metrics via prometheus-fastapi-instrumentator, Sentry/GlitchTip capture,
  X-Request-ID middleware + JSON access logging (PROCUREMENT_LOG_FORMAT)
- server-side analytics (OpenPanel Track API): login, quote lifecycle,
  receipts — fire-and-forget, disabled without env config
- monitoring/ compose project: GlitchTip(+pg), Prometheus(+alerts),
  Grafana(provisioned ERP dashboard), Loki+Promtail, node/postgres
  exporters, cAdvisor — mem-limited (~2.2GB), loopback admin UIs,
  separate from app deploys; erp_shared network for scraping
- frontend: @openpanel/web + @sentry/react (build-arg gated, no-op when
  unset), funnel events (login, wizard steps, quote submitted)
- compose: api healthcheck, observability env wiring, .env.example keys
This commit is contained in:
MMD
2026-09-18 22:18:02 +03:30
parent f7b1f7f751
commit e17d1f0883
26 changed files with 1106 additions and 3 deletions
+76
View File
@@ -0,0 +1,76 @@
"""Server-side product analytics — OpenPanel Track API.
Fire-and-forget business events (quote lifecycle, receipts, logins) so the
OpenPanel dashboards show what actually happens inside the ERP, not just
page views. Never raises: analytics must not break a business flow.
Configuration (all optional — disabled when any is missing):
PROCUREMENT_OPENPANEL_API_URL e.g. http://openpanel-api:3333 (self-hosted)
PROCUREMENT_OPENPANEL_CLIENT_ID project client id (write mode)
PROCUREMENT_OPENPANEL_CLIENT_SECRET project client secret
Contract (OpenPanel Track API): POST {API_URL}/track with headers
``openpanel-client-id`` / ``openpanel-client-secret`` and a JSON body
``{"type": "track"|"identify", "profileId": ..., "event"/"properties": ...}``.
"""
import logging
from typing import Any
import httpx
from app.config import settings
logger = logging.getLogger("procurement.analytics")
_TIMEOUT = httpx.Timeout(3.0)
def _configured() -> bool:
return bool(
settings.OPENPANEL_API_URL
and settings.OPENPANEL_CLIENT_ID
and settings.OPENPANEL_CLIENT_SECRET
)
async def track(event: str, properties: dict[str, Any] | None = None, profile_id: str | None = None) -> None:
"""Record a named event; swallows every error."""
if not _configured():
return
body = {
"type": "track",
"event": event,
"properties": properties or {},
}
if profile_id:
body["profileId"] = profile_id
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
await client.post(
f"{settings.OPENPANEL_API_URL.rstrip('/')}/track",
json=body,
headers={
"openpanel-client-id": settings.OPENPANEL_CLIENT_ID,
"openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET,
},
)
except Exception as exc: # noqa: BLE001 — analytics must never raise
logger.debug("analytics track failed (%s): %s", event, exc)
async def identify(profile_id: str, properties: dict[str, Any] | None = None) -> None:
"""Create/update a user profile (e.g. on login)."""
if not _configured():
return
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
await client.post(
f"{settings.OPENPANEL_API_URL.rstrip('/')}/track",
json={"type": "identify", "profileId": profile_id, "properties": properties or {}},
headers={
"openpanel-client-id": settings.OPENPANEL_CLIENT_ID,
"openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET,
},
)
except Exception as exc: # noqa: BLE001
logger.debug("analytics identify failed: %s", exc)
+15
View File
@@ -4,6 +4,7 @@ Every mutation here runs in ONE ``async with db.begin()`` block; any failure
rolls the whole operation back. All state changes log a quote_event in the
same transaction.
"""
import asyncio
import logging
from datetime import date, datetime, timezone
from uuid import UUID
@@ -33,6 +34,7 @@ from app.schemas.procurement import (
QuoteUpdate,
RejectQuoteIn,
)
from app.services import analytics
from app.services.material_service import ensure_brand_material_belong
from app.utils.errors import ConflictError, NotFoundError, ValidationError
@@ -56,6 +58,19 @@ def utcnow() -> datetime:
def _add_event(db: AsyncSession, quote_id: UUID, event_type: str, payload: dict | None = None, actor: str | None = None) -> None:
db.add(QuoteEvent(quote_id=quote_id, event_type=event_type, payload=payload or {}, actor=actor))
# Product analytics (fire-and-forget, never blocks or raises): the whole
# quote lifecycle funnels through here, so one hook covers every
# status transition for the OpenPanel dashboards.
try:
asyncio.get_running_loop().create_task(
analytics.track(
f"quote_{event_type}",
{"quote_id": str(quote_id), "actor": actor, **(payload or {})},
profile_id=actor,
)
)
except RuntimeError: # no running loop (defensive — callers are async)
pass
async def get_quote_or_404(db: AsyncSession, quote_id: UUID) -> Quote: