261 lines
7.7 KiB
Python
261 lines
7.7 KiB
Python
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)
|