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)