Add initial work from Codex

This commit is contained in:
2026-03-20 15:13:33 +01:00
parent 19771ddd37
commit adb5c1a439
48 changed files with 7054 additions and 16 deletions

View File

@@ -0,0 +1 @@
"""Microservices package for BI platform."""

View File

@@ -0,0 +1 @@
"""Analytics and forecasting microservice."""

View 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)

View File

@@ -0,0 +1 @@
"""Public API gateway microservice."""

View 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]

View File

@@ -0,0 +1 @@
"""Read-only MSSQL query microservice."""

View 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())

View File

@@ -0,0 +1 @@
"""Shared helpers for microservices."""

View 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

View File

@@ -0,0 +1 @@
"""PostgreSQL persistence microservice."""

View 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)