Add initial work from Codex
This commit is contained in:
1
backend/microservices/__init__.py
Normal file
1
backend/microservices/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Microservices package for BI platform."""
|
||||
1
backend/microservices/analytics/__init__.py
Normal file
1
backend/microservices/analytics/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Analytics and forecasting microservice."""
|
||||
260
backend/microservices/analytics/main.py
Normal file
260
backend/microservices/analytics/main.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
|
||||
import httpx
|
||||
import pandas as pd
|
||||
from fastapi import Depends, FastAPI, Query, Request, Response
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.otel import (
|
||||
TelemetryProviders,
|
||||
configure_otel,
|
||||
instrument_fastapi,
|
||||
instrument_httpx_clients,
|
||||
shutdown_otel,
|
||||
)
|
||||
from app.core.security import InternalPrincipal, require_internal_principal
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
from microservices.common.http import current_trace_headers, with_internal_service_token
|
||||
|
||||
logging.basicConfig(level=settings.log_level)
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FORWARD_HEADERS: ContextVar[dict[str, str]] = ContextVar("forward_headers", default={})
|
||||
|
||||
|
||||
class QueryWarehouseClient:
|
||||
def __init__(self, client: httpx.Client, query_service_url: str) -> None:
|
||||
self.client = client
|
||||
self.query_service_url = query_service_url.rstrip("/")
|
||||
|
||||
def _fetch(self, path: str) -> pd.DataFrame:
|
||||
response = self.client.get(
|
||||
f"{self.query_service_url}{path}",
|
||||
headers=FORWARD_HEADERS.get(),
|
||||
timeout=settings.request_timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return pd.DataFrame(response.json())
|
||||
|
||||
def fetch_daily_sales(self) -> pd.DataFrame:
|
||||
return self._fetch("/internal/daily-sales")
|
||||
|
||||
def fetch_product_performance(self) -> pd.DataFrame:
|
||||
return self._fetch("/internal/product-performance")
|
||||
|
||||
def fetch_customer_performance(self) -> pd.DataFrame:
|
||||
return self._fetch("/internal/customer-performance")
|
||||
|
||||
|
||||
class PersistenceProxy:
|
||||
def __init__(self, client: httpx.Client, persistence_service_url: str) -> None:
|
||||
self.client = client
|
||||
self.persistence_service_url = persistence_service_url.rstrip("/")
|
||||
|
||||
def _post(self, path: str, payload: dict) -> None:
|
||||
response = self.client.post(
|
||||
f"{self.persistence_service_url}{path}",
|
||||
headers=FORWARD_HEADERS.get(),
|
||||
json=payload,
|
||||
timeout=settings.request_timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
def record_forecast_run(
|
||||
self,
|
||||
*,
|
||||
horizon_days: int,
|
||||
payload: list[dict],
|
||||
trigger_source: str,
|
||||
trace_id: str | None,
|
||||
span_id: str | None,
|
||||
) -> None:
|
||||
self._post(
|
||||
"/internal/forecast-runs",
|
||||
{
|
||||
"horizon_days": horizon_days,
|
||||
"payload": payload,
|
||||
"trigger_source": trigger_source,
|
||||
"trace_id": trace_id,
|
||||
"span_id": span_id,
|
||||
},
|
||||
)
|
||||
|
||||
def record_ranking_run(
|
||||
self,
|
||||
*,
|
||||
top_n: int,
|
||||
payload: list[dict],
|
||||
trigger_source: str,
|
||||
trace_id: str | None,
|
||||
span_id: str | None,
|
||||
) -> None:
|
||||
self._post(
|
||||
"/internal/ranking-runs",
|
||||
{
|
||||
"top_n": top_n,
|
||||
"payload": payload,
|
||||
"trigger_source": trigger_source,
|
||||
"trace_id": trace_id,
|
||||
"span_id": span_id,
|
||||
},
|
||||
)
|
||||
|
||||
def record_recommendation_run(
|
||||
self,
|
||||
*,
|
||||
payload: list[dict],
|
||||
trigger_source: str,
|
||||
trace_id: str | None,
|
||||
span_id: str | None,
|
||||
) -> None:
|
||||
self._post(
|
||||
"/internal/recommendation-runs",
|
||||
{
|
||||
"payload": payload,
|
||||
"trigger_source": trigger_source,
|
||||
"trace_id": trace_id,
|
||||
"span_id": span_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
telemetry: TelemetryProviders = configure_otel(settings)
|
||||
instrument_httpx_clients()
|
||||
|
||||
http_client = httpx.Client()
|
||||
warehouse_client = QueryWarehouseClient(http_client, settings.query_service_url)
|
||||
persistence_proxy = PersistenceProxy(http_client, settings.persistence_service_url)
|
||||
app.state.http_client = http_client
|
||||
app.state.analytics = AnalyticsService(warehouse_client, persistence_proxy)
|
||||
LOGGER.info("Analytics service ready")
|
||||
yield
|
||||
http_client.close()
|
||||
shutdown_otel(telemetry)
|
||||
|
||||
|
||||
app = FastAPI(title="analytics-service", version="0.1.0", lifespan=lifespan)
|
||||
instrument_fastapi(app)
|
||||
|
||||
|
||||
def _analytics() -> AnalyticsService:
|
||||
return app.state.analytics
|
||||
|
||||
|
||||
def _with_request_headers(request: Request):
|
||||
headers = current_trace_headers()
|
||||
incoming_internal = request.headers.get("x-internal-service-token")
|
||||
if incoming_internal:
|
||||
headers = with_internal_service_token(headers, incoming_internal)
|
||||
token = FORWARD_HEADERS.set(headers)
|
||||
return token
|
||||
|
||||
|
||||
@app.get("/internal/health")
|
||||
def health(request: Request, response: Response) -> dict:
|
||||
token = _with_request_headers(request)
|
||||
try:
|
||||
response.headers.update(current_trace_headers())
|
||||
return {"status": "ok", "service": "analytics-service"}
|
||||
finally:
|
||||
FORWARD_HEADERS.reset(token)
|
||||
|
||||
|
||||
@app.get("/internal/kpis")
|
||||
def kpis(
|
||||
request: Request,
|
||||
response: Response,
|
||||
_auth: InternalPrincipal = Depends(require_internal_principal),
|
||||
) -> dict:
|
||||
token = _with_request_headers(request)
|
||||
try:
|
||||
response.headers.update(current_trace_headers())
|
||||
return _analytics().get_kpis()
|
||||
finally:
|
||||
FORWARD_HEADERS.reset(token)
|
||||
|
||||
|
||||
@app.get("/internal/history")
|
||||
def history(
|
||||
request: Request,
|
||||
response: Response,
|
||||
days_back: int = Query(default=settings.default_history_days, ge=30, le=1460),
|
||||
_auth: InternalPrincipal = Depends(require_internal_principal),
|
||||
) -> list[dict]:
|
||||
token = _with_request_headers(request)
|
||||
try:
|
||||
response.headers.update(current_trace_headers())
|
||||
return _analytics().get_history_points(days_back=days_back)
|
||||
finally:
|
||||
FORWARD_HEADERS.reset(token)
|
||||
|
||||
|
||||
@app.get("/internal/forecasts")
|
||||
def forecasts(
|
||||
request: Request,
|
||||
response: Response,
|
||||
days: int = Query(default=settings.forecast_horizon_days, ge=7, le=180),
|
||||
_auth: InternalPrincipal = Depends(require_internal_principal),
|
||||
) -> list[dict]:
|
||||
token = _with_request_headers(request)
|
||||
try:
|
||||
response.headers.update(current_trace_headers())
|
||||
return _analytics().get_forecast(
|
||||
horizon_days=days, trigger_source="analytics.api.forecasts", persist=True
|
||||
)
|
||||
finally:
|
||||
FORWARD_HEADERS.reset(token)
|
||||
|
||||
|
||||
@app.get("/internal/rankings")
|
||||
def rankings(
|
||||
request: Request,
|
||||
response: Response,
|
||||
top_n: int = Query(default=settings.ranking_default_top_n, ge=3, le=100),
|
||||
_auth: InternalPrincipal = Depends(require_internal_principal),
|
||||
) -> list[dict]:
|
||||
token = _with_request_headers(request)
|
||||
try:
|
||||
response.headers.update(current_trace_headers())
|
||||
return _analytics().get_rankings(
|
||||
top_n=top_n, trigger_source="analytics.api.rankings", persist=True
|
||||
)
|
||||
finally:
|
||||
FORWARD_HEADERS.reset(token)
|
||||
|
||||
|
||||
@app.get("/internal/recommendations")
|
||||
def recommendations(
|
||||
request: Request,
|
||||
response: Response,
|
||||
_auth: InternalPrincipal = Depends(require_internal_principal),
|
||||
) -> list[dict]:
|
||||
token = _with_request_headers(request)
|
||||
try:
|
||||
response.headers.update(current_trace_headers())
|
||||
return _analytics().get_recommendations(
|
||||
trigger_source="analytics.api.recommendations", persist=True
|
||||
)
|
||||
finally:
|
||||
FORWARD_HEADERS.reset(token)
|
||||
|
||||
|
||||
@app.get("/internal/dashboard")
|
||||
def dashboard(
|
||||
request: Request,
|
||||
response: Response,
|
||||
_auth: InternalPrincipal = Depends(require_internal_principal),
|
||||
) -> dict:
|
||||
token = _with_request_headers(request)
|
||||
try:
|
||||
response.headers.update(current_trace_headers())
|
||||
snapshot = _analytics().get_dashboard()
|
||||
return snapshot.__dict__
|
||||
finally:
|
||||
FORWARD_HEADERS.reset(token)
|
||||
1
backend/microservices/api_gateway/__init__.py
Normal file
1
backend/microservices/api_gateway/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Public API gateway microservice."""
|
||||
326
backend/microservices/api_gateway/main.py
Normal file
326
backend/microservices/api_gateway/main.py
Normal file
@@ -0,0 +1,326 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from time import perf_counter
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.otel import (
|
||||
TelemetryProviders,
|
||||
configure_otel,
|
||||
instrument_fastapi,
|
||||
instrument_httpx_clients,
|
||||
shutdown_otel,
|
||||
)
|
||||
from app.core.security import (
|
||||
FrontendPrincipal,
|
||||
get_internal_token_manager,
|
||||
require_frontend_principal,
|
||||
)
|
||||
from microservices.common.http import current_trace_headers, with_internal_service_token
|
||||
|
||||
logging.basicConfig(level=settings.log_level)
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _raise_upstream(exc: httpx.HTTPStatusError) -> None:
|
||||
detail = exc.response.text
|
||||
raise HTTPException(status_code=exc.response.status_code, detail=detail) from exc
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
telemetry: TelemetryProviders = configure_otel(settings)
|
||||
instrument_httpx_clients()
|
||||
app.state.http_client = httpx.Client()
|
||||
LOGGER.info("API gateway ready")
|
||||
yield
|
||||
app.state.http_client.close()
|
||||
shutdown_otel(telemetry)
|
||||
|
||||
|
||||
app = FastAPI(title="api-gateway-service", version="0.1.0", lifespan=lifespan)
|
||||
instrument_fastapi(app)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["x-trace-id", "x-span-id"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
||||
response.headers["X-Permitted-Cross-Domain-Policies"] = "none"
|
||||
response.headers["Strict-Transport-Security"] = (
|
||||
"max-age=31536000; includeSubDomains"
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
def _client() -> httpx.Client:
|
||||
return app.state.http_client
|
||||
|
||||
|
||||
def _upstream_headers(principal: FrontendPrincipal) -> dict[str, str]:
|
||||
token = get_internal_token_manager().mint(
|
||||
subject=principal.subject,
|
||||
scopes=principal.scopes,
|
||||
source_service="api-gateway",
|
||||
)
|
||||
return with_internal_service_token(current_trace_headers(), token)
|
||||
|
||||
|
||||
def _get_json(url: str, principal: FrontendPrincipal) -> dict | list:
|
||||
try:
|
||||
response = _client().get(
|
||||
url,
|
||||
headers=_upstream_headers(principal),
|
||||
timeout=settings.request_timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
|
||||
def _audit_payload(
|
||||
request: Request, response: Response, started: float, principal: FrontendPrincipal
|
||||
) -> dict:
|
||||
headers = current_trace_headers()
|
||||
return {
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"query_string": request.url.query,
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": (perf_counter() - started) * 1000,
|
||||
"trace_id": headers.get("x-trace-id"),
|
||||
"span_id": headers.get("x-span-id"),
|
||||
"client_ip": request.client.host if request.client else None,
|
||||
"user_agent": request.headers.get("user-agent"),
|
||||
"details": {
|
||||
"subject": principal.subject,
|
||||
"scopes": principal.scopes,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _persist_audit(
|
||||
request: Request, response: Response, started: float, principal: FrontendPrincipal
|
||||
) -> None:
|
||||
if not request.url.path.startswith("/api/"):
|
||||
return
|
||||
try:
|
||||
_client().post(
|
||||
f"{settings.persistence_service_url.rstrip('/')}/internal/audit-logs",
|
||||
headers=_upstream_headers(principal),
|
||||
json=_audit_payload(request, response, started, principal),
|
||||
timeout=settings.request_timeout_seconds,
|
||||
).raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
LOGGER.warning("Audit persistence failed: %s", exc)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health(response: Response) -> dict:
|
||||
response.headers.update(current_trace_headers())
|
||||
return {"status": "ok", "service": "api-gateway-service"}
|
||||
|
||||
|
||||
@app.get("/api/telemetry/status")
|
||||
def telemetry_status(
|
||||
request: Request,
|
||||
response: Response,
|
||||
principal: FrontendPrincipal = Depends(require_frontend_principal),
|
||||
) -> dict:
|
||||
started = perf_counter()
|
||||
response.headers.update(current_trace_headers())
|
||||
payload = {
|
||||
"status": "instrumented",
|
||||
"service_name": "api-gateway-service",
|
||||
"collector_endpoint": settings.otel_collector_endpoint,
|
||||
"trace_id": current_trace_headers().get("x-trace-id"),
|
||||
"span_id": current_trace_headers().get("x-span-id"),
|
||||
"trace_headers": ["traceparent", "tracestate", "baggage", "x-trace-id"],
|
||||
"subject": principal.subject,
|
||||
}
|
||||
_persist_audit(request, response, started, principal)
|
||||
return payload
|
||||
|
||||
|
||||
@app.get("/api/kpis")
|
||||
def kpis(
|
||||
request: Request,
|
||||
response: Response,
|
||||
principal: FrontendPrincipal = Depends(require_frontend_principal),
|
||||
) -> dict:
|
||||
started = perf_counter()
|
||||
response.headers.update(current_trace_headers())
|
||||
payload = _get_json(
|
||||
f"{settings.analytics_service_url.rstrip('/')}/internal/kpis", principal
|
||||
)
|
||||
_persist_audit(request, response, started, principal)
|
||||
return payload # type: ignore[return-value]
|
||||
|
||||
|
||||
@app.get("/api/history")
|
||||
def history(
|
||||
request: Request,
|
||||
response: Response,
|
||||
days_back: int = Query(default=settings.default_history_days, ge=30, le=1460),
|
||||
principal: FrontendPrincipal = Depends(require_frontend_principal),
|
||||
) -> list[dict]:
|
||||
started = perf_counter()
|
||||
response.headers.update(current_trace_headers())
|
||||
payload = _get_json(
|
||||
f"{settings.analytics_service_url.rstrip('/')}/internal/history?days_back={days_back}",
|
||||
principal,
|
||||
)
|
||||
_persist_audit(request, response, started, principal)
|
||||
return payload # type: ignore[return-value]
|
||||
|
||||
|
||||
@app.get("/api/forecasts")
|
||||
def forecasts(
|
||||
request: Request,
|
||||
response: Response,
|
||||
days: int = Query(default=settings.forecast_horizon_days, ge=7, le=180),
|
||||
principal: FrontendPrincipal = Depends(require_frontend_principal),
|
||||
) -> list[dict]:
|
||||
started = perf_counter()
|
||||
response.headers.update(current_trace_headers())
|
||||
payload = _get_json(
|
||||
f"{settings.analytics_service_url.rstrip('/')}/internal/forecasts?days={days}",
|
||||
principal,
|
||||
)
|
||||
_persist_audit(request, response, started, principal)
|
||||
return payload # type: ignore[return-value]
|
||||
|
||||
|
||||
@app.get("/api/rankings")
|
||||
def rankings(
|
||||
request: Request,
|
||||
response: Response,
|
||||
top_n: int = Query(default=settings.ranking_default_top_n, ge=3, le=100),
|
||||
principal: FrontendPrincipal = Depends(require_frontend_principal),
|
||||
) -> list[dict]:
|
||||
started = perf_counter()
|
||||
response.headers.update(current_trace_headers())
|
||||
payload = _get_json(
|
||||
f"{settings.analytics_service_url.rstrip('/')}/internal/rankings?top_n={top_n}",
|
||||
principal,
|
||||
)
|
||||
_persist_audit(request, response, started, principal)
|
||||
return payload # type: ignore[return-value]
|
||||
|
||||
|
||||
@app.get("/api/recommendations")
|
||||
def recommendations(
|
||||
request: Request,
|
||||
response: Response,
|
||||
principal: FrontendPrincipal = Depends(require_frontend_principal),
|
||||
) -> list[dict]:
|
||||
started = perf_counter()
|
||||
response.headers.update(current_trace_headers())
|
||||
payload = _get_json(
|
||||
f"{settings.analytics_service_url.rstrip('/')}/internal/recommendations",
|
||||
principal,
|
||||
)
|
||||
_persist_audit(request, response, started, principal)
|
||||
return payload # type: ignore[return-value]
|
||||
|
||||
|
||||
@app.get("/api/dashboard")
|
||||
def dashboard(
|
||||
request: Request,
|
||||
response: Response,
|
||||
principal: FrontendPrincipal = Depends(require_frontend_principal),
|
||||
) -> dict:
|
||||
started = perf_counter()
|
||||
response.headers.update(current_trace_headers())
|
||||
payload = _get_json(
|
||||
f"{settings.analytics_service_url.rstrip('/')}/internal/dashboard", principal
|
||||
)
|
||||
_persist_audit(request, response, started, principal)
|
||||
return payload # type: ignore[return-value]
|
||||
|
||||
|
||||
@app.get("/api/storage/audit-logs")
|
||||
def storage_audit_logs(
|
||||
request: Request,
|
||||
response: Response,
|
||||
limit: int = Query(default=settings.storage_default_limit, ge=1, le=500),
|
||||
principal: FrontendPrincipal = Depends(require_frontend_principal),
|
||||
) -> list[dict]:
|
||||
started = perf_counter()
|
||||
response.headers.update(current_trace_headers())
|
||||
payload = _get_json(
|
||||
f"{settings.persistence_service_url.rstrip('/')}/internal/audit-logs?limit={limit}",
|
||||
principal,
|
||||
)
|
||||
_persist_audit(request, response, started, principal)
|
||||
return payload # type: ignore[return-value]
|
||||
|
||||
|
||||
@app.get("/api/storage/forecasts")
|
||||
def storage_forecasts(
|
||||
request: Request,
|
||||
response: Response,
|
||||
limit: int = Query(default=settings.storage_default_limit, ge=1, le=500),
|
||||
principal: FrontendPrincipal = Depends(require_frontend_principal),
|
||||
) -> list[dict]:
|
||||
started = perf_counter()
|
||||
response.headers.update(current_trace_headers())
|
||||
payload = _get_json(
|
||||
f"{settings.persistence_service_url.rstrip('/')}/internal/forecast-runs?limit={limit}",
|
||||
principal,
|
||||
)
|
||||
_persist_audit(request, response, started, principal)
|
||||
return payload # type: ignore[return-value]
|
||||
|
||||
|
||||
@app.get("/api/storage/rankings")
|
||||
def storage_rankings(
|
||||
request: Request,
|
||||
response: Response,
|
||||
limit: int = Query(default=settings.storage_default_limit, ge=1, le=500),
|
||||
principal: FrontendPrincipal = Depends(require_frontend_principal),
|
||||
) -> list[dict]:
|
||||
started = perf_counter()
|
||||
response.headers.update(current_trace_headers())
|
||||
payload = _get_json(
|
||||
f"{settings.persistence_service_url.rstrip('/')}/internal/ranking-runs?limit={limit}",
|
||||
principal,
|
||||
)
|
||||
_persist_audit(request, response, started, principal)
|
||||
return payload # type: ignore[return-value]
|
||||
|
||||
|
||||
@app.get("/api/storage/recommendations")
|
||||
def storage_recommendations(
|
||||
request: Request,
|
||||
response: Response,
|
||||
limit: int = Query(default=settings.storage_default_limit, ge=1, le=500),
|
||||
principal: FrontendPrincipal = Depends(require_frontend_principal),
|
||||
) -> list[dict]:
|
||||
started = perf_counter()
|
||||
response.headers.update(current_trace_headers())
|
||||
payload = _get_json(
|
||||
f"{settings.persistence_service_url.rstrip('/')}/internal/recommendation-runs?limit={limit}",
|
||||
principal,
|
||||
)
|
||||
_persist_audit(request, response, started, principal)
|
||||
return payload # type: ignore[return-value]
|
||||
1
backend/microservices/bi_query/__init__.py
Normal file
1
backend/microservices/bi_query/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Read-only MSSQL query microservice."""
|
||||
85
backend/microservices/bi_query/main.py
Normal file
85
backend/microservices/bi_query/main.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import pandas as pd
|
||||
from fastapi import Depends, FastAPI, Response
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.otel import (
|
||||
TelemetryProviders,
|
||||
configure_otel,
|
||||
instrument_fastapi,
|
||||
instrument_sqlalchemy_engines,
|
||||
shutdown_otel,
|
||||
)
|
||||
from app.core.security import InternalPrincipal, require_internal_principal
|
||||
from app.db.engine import create_warehouse_engines, dispose_engines
|
||||
from app.services.warehouse_service import ReadOnlyWarehouseClient
|
||||
from microservices.common.http import current_trace_headers
|
||||
|
||||
logging.basicConfig(level=settings.log_level)
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _frame_to_rows(df: pd.DataFrame) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
for _, row in df.iterrows():
|
||||
payload: dict = {}
|
||||
for key, value in row.items():
|
||||
if hasattr(value, "isoformat"):
|
||||
payload[str(key)] = value.isoformat()
|
||||
else:
|
||||
payload[str(key)] = value
|
||||
rows.append(payload)
|
||||
return rows
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
telemetry: TelemetryProviders = configure_otel(settings)
|
||||
engines = create_warehouse_engines()
|
||||
instrument_sqlalchemy_engines(engines)
|
||||
app.state.query_client = ReadOnlyWarehouseClient(engines)
|
||||
LOGGER.info("BI query service ready with read-only MSSQL engines")
|
||||
yield
|
||||
dispose_engines(engines)
|
||||
shutdown_otel(telemetry)
|
||||
|
||||
|
||||
app = FastAPI(title="bi-query-service", version="0.1.0", lifespan=lifespan)
|
||||
instrument_fastapi(app)
|
||||
|
||||
|
||||
@app.get("/internal/health")
|
||||
def health(response: Response) -> dict:
|
||||
response.headers.update(current_trace_headers())
|
||||
return {"status": "ok", "service": "bi-query-service"}
|
||||
|
||||
|
||||
@app.get("/internal/daily-sales")
|
||||
def daily_sales(
|
||||
response: Response, _auth: InternalPrincipal = Depends(require_internal_principal)
|
||||
) -> list[dict]:
|
||||
response.headers.update(current_trace_headers())
|
||||
client: ReadOnlyWarehouseClient = app.state.query_client
|
||||
return _frame_to_rows(client.fetch_daily_sales())
|
||||
|
||||
|
||||
@app.get("/internal/product-performance")
|
||||
def product_performance(
|
||||
response: Response, _auth: InternalPrincipal = Depends(require_internal_principal)
|
||||
) -> list[dict]:
|
||||
response.headers.update(current_trace_headers())
|
||||
client: ReadOnlyWarehouseClient = app.state.query_client
|
||||
return _frame_to_rows(client.fetch_product_performance())
|
||||
|
||||
|
||||
@app.get("/internal/customer-performance")
|
||||
def customer_performance(
|
||||
response: Response, _auth: InternalPrincipal = Depends(require_internal_principal)
|
||||
) -> list[dict]:
|
||||
response.headers.update(current_trace_headers())
|
||||
client: ReadOnlyWarehouseClient = app.state.query_client
|
||||
return _frame_to_rows(client.fetch_customer_performance())
|
||||
1
backend/microservices/common/__init__.py
Normal file
1
backend/microservices/common/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared helpers for microservices."""
|
||||
19
backend/microservices/common/http.py
Normal file
19
backend/microservices/common/http.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from opentelemetry import trace
|
||||
|
||||
|
||||
def current_trace_headers() -> dict[str, str]:
|
||||
span_context = trace.get_current_span().get_span_context()
|
||||
if not span_context.is_valid:
|
||||
return {}
|
||||
return {
|
||||
"x-trace-id": f"{span_context.trace_id:032x}",
|
||||
"x-span-id": f"{span_context.span_id:016x}",
|
||||
}
|
||||
|
||||
|
||||
def with_internal_service_token(headers: dict[str, str], token: str) -> dict[str, str]:
|
||||
merged = dict(headers)
|
||||
merged["x-internal-service-token"] = token
|
||||
return merged
|
||||
1
backend/microservices/persistence/__init__.py
Normal file
1
backend/microservices/persistence/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""PostgreSQL persistence microservice."""
|
||||
176
backend/microservices/persistence/main.py
Normal file
176
backend/microservices/persistence/main.py
Normal file
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI, Query, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.otel import (
|
||||
TelemetryProviders,
|
||||
configure_otel,
|
||||
instrument_fastapi,
|
||||
instrument_sqlalchemy_engines,
|
||||
shutdown_otel,
|
||||
)
|
||||
from app.core.security import InternalPrincipal, require_internal_principal
|
||||
from app.db.postgres import (
|
||||
create_postgres_engine,
|
||||
create_postgres_session_factory,
|
||||
initialize_postgres_schema,
|
||||
)
|
||||
from app.services.persistence_service import PersistenceService
|
||||
from microservices.common.http import current_trace_headers
|
||||
|
||||
logging.basicConfig(level=settings.log_level)
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuditLogIn(BaseModel):
|
||||
method: str
|
||||
path: str
|
||||
query_string: str = ""
|
||||
status_code: int
|
||||
duration_ms: float
|
||||
trace_id: str | None = None
|
||||
span_id: str | None = None
|
||||
client_ip: str | None = None
|
||||
user_agent: str | None = None
|
||||
details: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ForecastRunIn(BaseModel):
|
||||
horizon_days: int
|
||||
payload: list[dict]
|
||||
trigger_source: str
|
||||
trace_id: str | None = None
|
||||
span_id: str | None = None
|
||||
|
||||
|
||||
class RankingRunIn(BaseModel):
|
||||
top_n: int
|
||||
payload: list[dict]
|
||||
trigger_source: str
|
||||
trace_id: str | None = None
|
||||
span_id: str | None = None
|
||||
|
||||
|
||||
class RecommendationRunIn(BaseModel):
|
||||
payload: list[dict]
|
||||
trigger_source: str
|
||||
trace_id: str | None = None
|
||||
span_id: str | None = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
telemetry: TelemetryProviders = configure_otel(settings)
|
||||
engine = create_postgres_engine()
|
||||
initialize_postgres_schema(engine)
|
||||
instrument_sqlalchemy_engines({"appdb": engine})
|
||||
app.state.persistence_service = PersistenceService(
|
||||
create_postgres_session_factory(engine)
|
||||
)
|
||||
LOGGER.info("Persistence service ready with PostgreSQL")
|
||||
yield
|
||||
engine.dispose()
|
||||
shutdown_otel(telemetry)
|
||||
|
||||
|
||||
app = FastAPI(title="persistence-service", version="0.1.0", lifespan=lifespan)
|
||||
instrument_fastapi(app)
|
||||
|
||||
|
||||
def _service() -> PersistenceService:
|
||||
return app.state.persistence_service
|
||||
|
||||
|
||||
@app.get("/internal/health")
|
||||
def health(response: Response) -> dict:
|
||||
response.headers.update(current_trace_headers())
|
||||
return {"status": "ok", "service": "persistence-service"}
|
||||
|
||||
|
||||
@app.post("/internal/audit-logs")
|
||||
def create_audit_log(
|
||||
payload: AuditLogIn,
|
||||
response: Response,
|
||||
_auth: InternalPrincipal = Depends(require_internal_principal),
|
||||
) -> dict:
|
||||
response.headers.update(current_trace_headers())
|
||||
_service().record_audit_log(**payload.model_dump())
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/internal/forecast-runs")
|
||||
def create_forecast_run(
|
||||
payload: ForecastRunIn,
|
||||
response: Response,
|
||||
_auth: InternalPrincipal = Depends(require_internal_principal),
|
||||
) -> dict:
|
||||
response.headers.update(current_trace_headers())
|
||||
_service().record_forecast_run(**payload.model_dump())
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/internal/ranking-runs")
|
||||
def create_ranking_run(
|
||||
payload: RankingRunIn,
|
||||
response: Response,
|
||||
_auth: InternalPrincipal = Depends(require_internal_principal),
|
||||
) -> dict:
|
||||
response.headers.update(current_trace_headers())
|
||||
_service().record_ranking_run(**payload.model_dump())
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/internal/recommendation-runs")
|
||||
def create_recommendation_run(
|
||||
payload: RecommendationRunIn,
|
||||
response: Response,
|
||||
_auth: InternalPrincipal = Depends(require_internal_principal),
|
||||
) -> dict:
|
||||
response.headers.update(current_trace_headers())
|
||||
_service().record_recommendation_run(**payload.model_dump())
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/internal/audit-logs")
|
||||
def list_audit_logs(
|
||||
response: Response,
|
||||
limit: int = Query(default=settings.storage_default_limit, ge=1, le=500),
|
||||
_auth: InternalPrincipal = Depends(require_internal_principal),
|
||||
) -> list[dict]:
|
||||
response.headers.update(current_trace_headers())
|
||||
return _service().list_audit_logs(limit=limit)
|
||||
|
||||
|
||||
@app.get("/internal/forecast-runs")
|
||||
def list_forecast_runs(
|
||||
response: Response,
|
||||
limit: int = Query(default=settings.storage_default_limit, ge=1, le=500),
|
||||
_auth: InternalPrincipal = Depends(require_internal_principal),
|
||||
) -> list[dict]:
|
||||
response.headers.update(current_trace_headers())
|
||||
return _service().list_forecast_runs(limit=limit)
|
||||
|
||||
|
||||
@app.get("/internal/ranking-runs")
|
||||
def list_ranking_runs(
|
||||
response: Response,
|
||||
limit: int = Query(default=settings.storage_default_limit, ge=1, le=500),
|
||||
_auth: InternalPrincipal = Depends(require_internal_principal),
|
||||
) -> list[dict]:
|
||||
response.headers.update(current_trace_headers())
|
||||
return _service().list_ranking_runs(limit=limit)
|
||||
|
||||
|
||||
@app.get("/internal/recommendation-runs")
|
||||
def list_recommendation_runs(
|
||||
response: Response,
|
||||
limit: int = Query(default=settings.storage_default_limit, ge=1, le=500),
|
||||
_auth: InternalPrincipal = Depends(require_internal_principal),
|
||||
) -> list[dict]:
|
||||
response.headers.update(current_trace_headers())
|
||||
return _service().list_recommendation_runs(limit=limit)
|
||||
Reference in New Issue
Block a user