Add initial work from Codex
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user