Push the rest
This commit is contained in:
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
174
backend/app/core/audit.py
Normal file
174
backend/app/core/audit.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from opentelemetry import trace
|
||||
from sqlalchemy import DateTime, Integer, String, Text, JSON
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker, Session
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def current_span_context() -> tuple[str | None, str | None]:
|
||||
ctx = trace.get_current_span().get_span_context()
|
||||
if not ctx.is_valid:
|
||||
return None, None
|
||||
return f"{ctx.trace_id:032x}", f"{ctx.span_id:016x}"
|
||||
|
||||
|
||||
class SharedBase(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class AuditLog(SharedBase):
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, index=True)
|
||||
action: Mapped[str] = mapped_column(String(100), index=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="success")
|
||||
actor_type: Mapped[str] = mapped_column(String(20), index=True)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
domain: Mapped[str] = mapped_column(String(50), index=True)
|
||||
service: Mapped[str] = mapped_column(String(50), index=True)
|
||||
entity_type: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
trace_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
span_id: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
payload: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
|
||||
class JobExecution(SharedBase):
|
||||
__tablename__ = "job_executions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, index=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
job_name: Mapped[str] = mapped_column(String(100), index=True)
|
||||
domain: Mapped[str] = mapped_column(String(50), index=True)
|
||||
status: Mapped[str] = mapped_column(String(20), index=True)
|
||||
records_processed: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
trace_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
span_id: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
|
||||
|
||||
class ExportRecord(SharedBase):
|
||||
__tablename__ = "export_records"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, index=True)
|
||||
domain: Mapped[str] = mapped_column(String(50), index=True)
|
||||
service: Mapped[str] = mapped_column(String(50))
|
||||
source_view: Mapped[str] = mapped_column(String(100), index=True)
|
||||
format: Mapped[str] = mapped_column(String(10))
|
||||
filters_applied: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
row_count: Mapped[int] = mapped_column(Integer)
|
||||
file_size_bytes: Mapped[int] = mapped_column(Integer)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
trace_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
span_id: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
|
||||
|
||||
def append_audit(
|
||||
factory: sessionmaker[Session],
|
||||
*,
|
||||
action: str,
|
||||
actor_type: str,
|
||||
domain: str,
|
||||
service: str,
|
||||
entity_type: str | None = None,
|
||||
actor_id: str | None = None,
|
||||
status: str = "success",
|
||||
payload: dict | None = None,
|
||||
) -> None:
|
||||
trace_id, span_id = current_span_context()
|
||||
try:
|
||||
with factory() as session:
|
||||
session.add(AuditLog(
|
||||
action=action,
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
domain=domain,
|
||||
service=service,
|
||||
entity_type=entity_type,
|
||||
trace_id=trace_id,
|
||||
span_id=span_id,
|
||||
status=status,
|
||||
payload=payload or {},
|
||||
))
|
||||
session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("Failed to write audit record (action=%s): %s", action, exc)
|
||||
|
||||
|
||||
def record_job_start(
|
||||
factory: sessionmaker[Session],
|
||||
job_name: str,
|
||||
domain: str,
|
||||
trace_id: str | None,
|
||||
span_id: str | None,
|
||||
) -> str:
|
||||
job_id = str(uuid4())
|
||||
try:
|
||||
with factory() as session:
|
||||
session.add(JobExecution(
|
||||
id=job_id,
|
||||
job_name=job_name,
|
||||
domain=domain,
|
||||
status="running",
|
||||
trace_id=trace_id,
|
||||
span_id=span_id,
|
||||
))
|
||||
session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("Failed to record job start (job=%s): %s", job_name, exc)
|
||||
return job_id
|
||||
|
||||
|
||||
def record_job_complete(
|
||||
factory: sessionmaker[Session],
|
||||
job_id: str,
|
||||
started_at: datetime,
|
||||
records_processed: int,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
duration_ms = int((now - started_at).total_seconds() * 1000)
|
||||
try:
|
||||
with factory() as session:
|
||||
session.query(JobExecution).filter_by(id=job_id).update({
|
||||
"status": "success",
|
||||
"completed_at": now,
|
||||
"records_processed": records_processed,
|
||||
"duration_ms": duration_ms,
|
||||
})
|
||||
session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("Failed to record job completion (id=%s): %s", job_id, exc)
|
||||
|
||||
|
||||
def record_job_failure(
|
||||
factory: sessionmaker[Session],
|
||||
job_id: str,
|
||||
started_at: datetime,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
duration_ms = int((now - started_at).total_seconds() * 1000)
|
||||
try:
|
||||
with factory() as session:
|
||||
session.query(JobExecution).filter_by(id=job_id).update({
|
||||
"status": "failure",
|
||||
"completed_at": now,
|
||||
"duration_ms": duration_ms,
|
||||
"error_message": error_message[:2000],
|
||||
})
|
||||
session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("Failed to record job failure (id=%s): %s", job_id, exc)
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
@@ -22,30 +21,20 @@ class Settings(BaseSettings):
|
||||
api_port: int = 8000
|
||||
|
||||
cors_origins: str = "http://localhost:5173"
|
||||
request_timeout_seconds: float = 20.0
|
||||
|
||||
mssql_host: str = "localhost"
|
||||
mssql_port: int = 1433
|
||||
mssql_username: str = "sa"
|
||||
mssql_password: str = "Password!123"
|
||||
mssql_driver: str = "ODBC Driver 18 for SQL Server"
|
||||
mssql_trust_server_certificate: bool = False
|
||||
# Go analytics service
|
||||
analytics_service_url: str = "http://localhost:8080"
|
||||
|
||||
wwi_database: str = "WorldWideImporters"
|
||||
aw_database: str = "AdventureWorks2022DWH"
|
||||
wwi_connection_string: str | None = None
|
||||
aw_connection_string: str | None = None
|
||||
# PostgreSQL — write store for derived data
|
||||
postgres_host: str = "localhost"
|
||||
postgres_port: int = 5432
|
||||
postgres_database: str = "otel_bi_app"
|
||||
postgres_username: str = "otel_bi_app"
|
||||
postgres_password: str = "otel_bi_app"
|
||||
postgres_sslmode: str = "require"
|
||||
postgres_database: str = "otel_bi"
|
||||
postgres_username: str = "otel_bi"
|
||||
postgres_password: str = "otel_bi"
|
||||
postgres_sslmode: str = "prefer"
|
||||
postgres_connection_string: str | None = None
|
||||
postgres_required: bool = True
|
||||
query_service_url: str = "http://localhost:8101"
|
||||
analytics_service_url: str = "http://localhost:8102"
|
||||
persistence_service_url: str = "http://localhost:8103"
|
||||
|
||||
# Frontend OIDC JWT validation
|
||||
require_frontend_auth: bool = True
|
||||
frontend_jwt_issuer_url: str = ""
|
||||
frontend_jwt_audience: str = ""
|
||||
@@ -53,18 +42,21 @@ class Settings(BaseSettings):
|
||||
frontend_jwt_algorithm: str = "RS256"
|
||||
frontend_required_scopes: str = ""
|
||||
frontend_clock_skew_seconds: int = Field(default=30, ge=0, le=300)
|
||||
internal_service_auth_enabled: bool = True
|
||||
internal_service_shared_secret: str = "change-me"
|
||||
internal_service_token_ttl_seconds: int = Field(default=120, ge=30, le=900)
|
||||
internal_service_token_audience: str = "bi-internal"
|
||||
internal_service_allowed_issuers: str = "api-gateway"
|
||||
internal_token_clock_skew_seconds: int = Field(default=15, ge=0, le=120)
|
||||
|
||||
# Frontend OIDC client config (served via /api/config)
|
||||
frontend_oidc_client_id: str = ""
|
||||
frontend_oidc_scope: str = "openid profile email"
|
||||
|
||||
# OpenTelemetry
|
||||
otel_service_name: str = "otel-bi-backend"
|
||||
otel_service_namespace: str = "final-thesis"
|
||||
otel_collector_endpoint: str = "http://localhost:4318"
|
||||
otel_export_timeout_ms: int = 10000
|
||||
|
||||
# Report output — points at the K8s CSI / SMB mountpoint in production
|
||||
report_output_dir: str = "/tmp/otel-bi-reports"
|
||||
|
||||
# Analytics defaults (forwarded to Go service as query params)
|
||||
forecast_horizon_days: int = Field(default=30, ge=7, le=180)
|
||||
default_history_days: int = Field(default=365, ge=30, le=1460)
|
||||
ranking_default_top_n: int = Field(default=10, ge=3, le=100)
|
||||
@@ -72,58 +64,22 @@ class Settings(BaseSettings):
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [
|
||||
origin.strip() for origin in self.cors_origins.split(",") if origin.strip()
|
||||
]
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def frontend_required_scopes_list(self) -> list[str]:
|
||||
return [
|
||||
scope.strip()
|
||||
for scope in self.frontend_required_scopes.split(" ")
|
||||
if scope.strip()
|
||||
]
|
||||
|
||||
@property
|
||||
def internal_service_allowed_issuers_list(self) -> list[str]:
|
||||
return [
|
||||
issuer.strip()
|
||||
for issuer in self.internal_service_allowed_issuers.split(",")
|
||||
if issuer.strip()
|
||||
]
|
||||
|
||||
def _build_mssql_connection_url(self, database: str) -> str:
|
||||
driver = quote_plus(self.mssql_driver)
|
||||
user = quote_plus(self.mssql_username)
|
||||
password = quote_plus(self.mssql_password)
|
||||
trust_cert = "yes" if self.mssql_trust_server_certificate else "no"
|
||||
return (
|
||||
f"mssql+pyodbc://{user}:{password}@{self.mssql_host}:{self.mssql_port}/{database}"
|
||||
f"?driver={driver}&TrustServerCertificate={trust_cert}&ApplicationIntent=ReadOnly"
|
||||
)
|
||||
|
||||
@property
|
||||
def wwi_connection_url(self) -> str:
|
||||
return self.wwi_connection_string or self._build_mssql_connection_url(
|
||||
self.wwi_database
|
||||
)
|
||||
|
||||
@property
|
||||
def aw_connection_url(self) -> str:
|
||||
return self.aw_connection_string or self._build_mssql_connection_url(
|
||||
self.aw_database
|
||||
)
|
||||
return [s.strip() for s in self.frontend_required_scopes.split(" ") if s.strip()]
|
||||
|
||||
@property
|
||||
def postgres_connection_url(self) -> str:
|
||||
if self.postgres_connection_string:
|
||||
return self.postgres_connection_string
|
||||
|
||||
from urllib.parse import quote_plus
|
||||
user = quote_plus(self.postgres_username)
|
||||
password = quote_plus(self.postgres_password)
|
||||
return (
|
||||
f"postgresql+psycopg://{user}:{password}@{self.postgres_host}:{self.postgres_port}/"
|
||||
f"{self.postgres_database}?sslmode={self.postgres_sslmode}"
|
||||
f"postgresql+psycopg://{user}:{password}@{self.postgres_host}:{self.postgres_port}"
|
||||
f"/{self.postgres_database}?sslmode={self.postgres_sslmode}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
27
backend/app/core/db.py
Normal file
27
backend/app/core/db.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def create_postgres_engine() -> Engine:
|
||||
return create_engine(
|
||||
settings.postgres_connection_url,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=1800,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
future=True,
|
||||
)
|
||||
|
||||
|
||||
def create_session_factory(engine: Engine) -> sessionmaker[Session]:
|
||||
return sessionmaker(
|
||||
bind=engine,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
27
backend/app/core/executor.py
Normal file
27
backend/app/core/executor.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
# Shared executor for CPU-bound analytics (pandas/sklearn) and sync MSSQL I/O
|
||||
# (pyodbc is inherently synchronous and blocks the event loop if called directly).
|
||||
#
|
||||
# Workers are capped at 8 to avoid overwhelming the MSSQL connection pools.
|
||||
# In K8s: set ANALYTICS_WORKERS to match the pod's CPU limit.
|
||||
_WORKERS = min(8, int(os.environ.get("ANALYTICS_WORKERS", "0")) or (os.cpu_count() or 2) * 2)
|
||||
|
||||
_executor: ThreadPoolExecutor | None = None
|
||||
|
||||
|
||||
def get_executor() -> ThreadPoolExecutor:
|
||||
global _executor
|
||||
if _executor is None:
|
||||
_executor = ThreadPoolExecutor(max_workers=_WORKERS, thread_name_prefix="analytics")
|
||||
return _executor
|
||||
|
||||
|
||||
def shutdown_executor() -> None:
|
||||
global _executor
|
||||
if _executor is not None:
|
||||
_executor.shutdown(wait=False)
|
||||
_executor = None
|
||||
82
backend/app/core/export.py
Normal file
82
backend/app/core/export.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4, landscape
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.platypus import (
|
||||
Paragraph,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
|
||||
_PAGE_W, _ = landscape(A4)
|
||||
_MARGIN = 1.5 * cm
|
||||
_HEADER_BG = colors.HexColor("#1a56db")
|
||||
_ROW_BG = colors.HexColor("#eef2ff")
|
||||
|
||||
|
||||
def _pdf_table(rows: list[dict]) -> Table:
|
||||
if not rows:
|
||||
table_data: list[list] = [["No data available"]]
|
||||
n_cols = 1
|
||||
else:
|
||||
headers = list(rows[0].keys())
|
||||
n_cols = len(headers)
|
||||
table_data = [headers] + [
|
||||
[str(row.get(h, "")) for h in headers] for row in rows
|
||||
]
|
||||
|
||||
col_w = (_PAGE_W - 2 * _MARGIN) / n_cols
|
||||
t = Table(table_data, colWidths=[col_w] * n_cols, repeatRows=1)
|
||||
|
||||
style: list = [
|
||||
("BACKGROUND", (0, 0), (-1, 0), _HEADER_BG),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 8),
|
||||
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 7),
|
||||
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#d1d5db")),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 3),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 5),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 5),
|
||||
]
|
||||
for i in range(1, len(table_data)):
|
||||
bg = _ROW_BG if i % 2 == 1 else colors.white
|
||||
style.append(("BACKGROUND", (0, i), (-1, i), bg))
|
||||
|
||||
t.setStyle(TableStyle(style))
|
||||
return t
|
||||
|
||||
|
||||
def to_pdf_bytes(rows: list[dict], title: str, subtitle: str = "") -> bytes:
|
||||
"""Serialise *rows* to a single-sheet PDF and return the raw bytes."""
|
||||
buf = io.BytesIO()
|
||||
styles = getSampleStyleSheet()
|
||||
story = []
|
||||
|
||||
story.append(Paragraph(title, styles["Title"]))
|
||||
if subtitle:
|
||||
story.append(Spacer(1, 0.2 * cm))
|
||||
story.append(Paragraph(subtitle, styles["Normal"]))
|
||||
story.append(Spacer(1, 0.5 * cm))
|
||||
story.append(_pdf_table(rows))
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
buf,
|
||||
pagesize=landscape(A4),
|
||||
leftMargin=_MARGIN,
|
||||
rightMargin=_MARGIN,
|
||||
topMargin=_MARGIN,
|
||||
bottomMargin=_MARGIN,
|
||||
)
|
||||
doc.build(story)
|
||||
return buf.getvalue()
|
||||
@@ -7,24 +7,27 @@ from typing import Any
|
||||
from fastapi import FastAPI
|
||||
from opentelemetry import metrics, trace
|
||||
from opentelemetry.baggage.propagation import W3CBaggagePropagator
|
||||
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
|
||||
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||
from opentelemetry.instrumentation.logging import LoggingInstrumentor
|
||||
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
|
||||
from opentelemetry.propagate import set_global_textmap
|
||||
from opentelemetry.propagators.composite import CompositePropagator
|
||||
from opentelemetry.sdk._logs import LoggerProvider
|
||||
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
||||
from opentelemetry._logs import set_logger_provider
|
||||
|
||||
try:
|
||||
from opentelemetry.instrumentation.system_metrics import SystemMetricsInstrumentor
|
||||
except ImportError: # pragma: no cover - defensive fallback for minimal envs
|
||||
except ImportError:
|
||||
SystemMetricsInstrumentor = None # type: ignore[assignment]
|
||||
|
||||
from app.core.config import Settings
|
||||
@@ -36,12 +39,14 @@ LOGGER = logging.getLogger(__name__)
|
||||
class TelemetryProviders:
|
||||
tracer_provider: TracerProvider
|
||||
meter_provider: MeterProvider
|
||||
logger_provider: LoggerProvider
|
||||
|
||||
|
||||
def configure_otel(settings: Settings) -> TelemetryProviders:
|
||||
set_global_textmap(
|
||||
CompositePropagator([TraceContextTextMapPropagator(), W3CBaggagePropagator()])
|
||||
)
|
||||
|
||||
resource = Resource.create(
|
||||
{
|
||||
"service.name": settings.otel_service_name,
|
||||
@@ -50,34 +55,54 @@ def configure_otel(settings: Settings) -> TelemetryProviders:
|
||||
}
|
||||
)
|
||||
|
||||
trace_exporter = OTLPSpanExporter(
|
||||
endpoint=f"{settings.otel_collector_endpoint}/v1/traces",
|
||||
timeout=settings.otel_export_timeout_ms / 1000,
|
||||
)
|
||||
tracer_provider = TracerProvider(resource=resource)
|
||||
tracer_provider.add_span_processor(BatchSpanProcessor(trace_exporter))
|
||||
tracer_provider.add_span_processor(
|
||||
BatchSpanProcessor(
|
||||
OTLPSpanExporter(
|
||||
endpoint=f"{settings.otel_collector_endpoint}/v1/traces",
|
||||
timeout=settings.otel_export_timeout_ms / 1000,
|
||||
)
|
||||
)
|
||||
)
|
||||
trace.set_tracer_provider(tracer_provider)
|
||||
|
||||
metric_reader = PeriodicExportingMetricReader(
|
||||
exporter=OTLPMetricExporter(
|
||||
endpoint=f"{settings.otel_collector_endpoint}/v1/metrics",
|
||||
timeout=settings.otel_export_timeout_ms / 1000,
|
||||
),
|
||||
export_interval_millis=10000,
|
||||
meter_provider = MeterProvider(
|
||||
resource=resource,
|
||||
metric_readers=[
|
||||
PeriodicExportingMetricReader(
|
||||
exporter=OTLPMetricExporter(
|
||||
endpoint=f"{settings.otel_collector_endpoint}/v1/metrics",
|
||||
timeout=settings.otel_export_timeout_ms / 1000,
|
||||
),
|
||||
export_interval_millis=10_000,
|
||||
)
|
||||
],
|
||||
)
|
||||
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
||||
metrics.set_meter_provider(meter_provider)
|
||||
|
||||
logger_provider = LoggerProvider(resource=resource)
|
||||
logger_provider.add_log_record_processor(
|
||||
BatchLogRecordProcessor(
|
||||
OTLPLogExporter(
|
||||
endpoint=f"{settings.otel_collector_endpoint}/v1/logs",
|
||||
timeout=settings.otel_export_timeout_ms / 1000,
|
||||
)
|
||||
)
|
||||
)
|
||||
set_logger_provider(logger_provider)
|
||||
|
||||
LoggingInstrumentor().instrument(set_logging_format=True)
|
||||
|
||||
if SystemMetricsInstrumentor is not None:
|
||||
SystemMetricsInstrumentor().instrument()
|
||||
else:
|
||||
LOGGER.warning(
|
||||
"System metrics instrumentor not available, runtime host metrics disabled."
|
||||
)
|
||||
LOGGER.info("OpenTelemetry providers configured")
|
||||
LOGGER.warning("SystemMetricsInstrumentor not available — skipping.")
|
||||
|
||||
LOGGER.info("OTel providers configured", extra={"service.name": settings.otel_service_name})
|
||||
return TelemetryProviders(
|
||||
tracer_provider=tracer_provider, meter_provider=meter_provider
|
||||
tracer_provider=tracer_provider,
|
||||
meter_provider=meter_provider,
|
||||
logger_provider=logger_provider,
|
||||
)
|
||||
|
||||
|
||||
@@ -85,19 +110,15 @@ def instrument_fastapi(app: FastAPI) -> None:
|
||||
FastAPIInstrumentor.instrument_app(app)
|
||||
|
||||
|
||||
def instrument_sqlalchemy_engines(engines: dict[str, Any]) -> None:
|
||||
def instrument_sqlalchemy(engines: dict[str, Any]) -> None:
|
||||
for engine in engines.values():
|
||||
SQLAlchemyInstrumentor().instrument(engine=engine)
|
||||
|
||||
|
||||
def instrument_httpx_clients() -> None:
|
||||
HTTPXClientInstrumentor().instrument()
|
||||
|
||||
|
||||
def shutdown_otel(providers: TelemetryProviders) -> None:
|
||||
HTTPXClientInstrumentor().uninstrument()
|
||||
if SystemMetricsInstrumentor is not None:
|
||||
SystemMetricsInstrumentor().uninstrument()
|
||||
LoggingInstrumentor().uninstrument()
|
||||
providers.meter_provider.shutdown()
|
||||
providers.tracer_provider.shutdown()
|
||||
providers.logger_provider.shutdown()
|
||||
|
||||
187
backend/app/core/reports.py
Normal file
187
backend/app/core/reports.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4, landscape
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.platypus import (
|
||||
PageBreak,
|
||||
Paragraph,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
|
||||
_PAGE_W, _ = landscape(A4)
|
||||
_MARGIN = 1.5 * cm
|
||||
_HEADER_BG = colors.HexColor("#1a56db")
|
||||
_ROW_BG = colors.HexColor("#eef2ff")
|
||||
|
||||
|
||||
def _normalise(rows: list[dict] | dict) -> list[dict]:
|
||||
if isinstance(rows, dict):
|
||||
return [rows]
|
||||
return rows or []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# XLSX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _save_xlsx(data: dict, path: str, report_id: str, generated_at: str) -> None:
|
||||
wb = openpyxl.Workbook()
|
||||
|
||||
ws = wb.active
|
||||
ws.title = "Metadata"
|
||||
ws.append(["Field", "Value"])
|
||||
ws.append(["Generated At (UTC)", generated_at])
|
||||
ws.append(["Report ID", report_id])
|
||||
|
||||
sheets = [
|
||||
("AW Sales KPIs", _normalise(data.get("aw_sales_kpis", {}))),
|
||||
("AW Sales History", _normalise(data.get("aw_sales_history", []))),
|
||||
("AW Sales Forecast", _normalise(data.get("aw_sales_forecast", []))),
|
||||
("AW Rep Scores", _normalise(data.get("aw_rep_scores", []))),
|
||||
("AW Product Demand", _normalise(data.get("aw_product_demand", []))),
|
||||
("WWI Sales KPIs", _normalise(data.get("wwi_sales_kpis", {}))),
|
||||
("WWI Stock Recs", _normalise(data.get("wwi_stock_recommendations", []))),
|
||||
("WWI Supplier Scores", _normalise(data.get("wwi_supplier_scores", []))),
|
||||
("WWI Business Events", _normalise(data.get("wwi_business_events", []))),
|
||||
]
|
||||
|
||||
for sheet_name, rows in sheets:
|
||||
ws = wb.create_sheet(title=sheet_name)
|
||||
if rows:
|
||||
ws.append(list(rows[0].keys()))
|
||||
for row in rows:
|
||||
ws.append([str(v) if v is not None else "" for v in row.values()])
|
||||
else:
|
||||
ws.append(["No data"])
|
||||
|
||||
wb.save(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _pdf_table(rows: list[dict] | dict) -> Table:
|
||||
data = _normalise(rows)
|
||||
if not data:
|
||||
table_data: list[list] = [["No data available"]]
|
||||
n_cols = 1
|
||||
else:
|
||||
headers = list(data[0].keys())
|
||||
n_cols = len(headers)
|
||||
table_data = [headers] + [
|
||||
[str(row.get(h, "")) for h in headers] for row in data
|
||||
]
|
||||
|
||||
col_w = (_PAGE_W - 2 * _MARGIN) / n_cols
|
||||
t = Table(table_data, colWidths=[col_w] * n_cols, repeatRows=1)
|
||||
|
||||
style: list = [
|
||||
("BACKGROUND", (0, 0), (-1, 0), _HEADER_BG),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 8),
|
||||
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 7),
|
||||
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#d1d5db")),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 3),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 5),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 5),
|
||||
]
|
||||
for i in range(1, len(table_data)):
|
||||
bg = _ROW_BG if i % 2 == 1 else colors.white
|
||||
style.append(("BACKGROUND", (0, i), (-1, i), bg))
|
||||
|
||||
t.setStyle(TableStyle(style))
|
||||
return t
|
||||
|
||||
|
||||
def _section(story: list, title: str, rows: list[dict] | dict, styles) -> None:
|
||||
story.append(Paragraph(title, styles["Heading2"]))
|
||||
story.append(Spacer(1, 0.25 * cm))
|
||||
story.append(_pdf_table(rows))
|
||||
story.append(Spacer(1, 0.5 * cm))
|
||||
|
||||
|
||||
def _save_pdf(data: dict, path: str, report_id: str, generated_at: str) -> None:
|
||||
styles = getSampleStyleSheet()
|
||||
story: list = []
|
||||
|
||||
story.append(Paragraph("OTel BI Platform — Generated Report", styles["Title"]))
|
||||
story.append(Spacer(1, 0.2 * cm))
|
||||
story.append(Paragraph(
|
||||
f"Report ID: {report_id} | Generated: {generated_at}",
|
||||
styles["Normal"],
|
||||
))
|
||||
story.append(Spacer(1, 0.6 * cm))
|
||||
|
||||
story.append(Paragraph("AdventureWorks DW", styles["Heading1"]))
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
_section(story, "Sales KPIs", data.get("aw_sales_kpis", {}), styles)
|
||||
_section(story, "Sales History", data.get("aw_sales_history", []), styles)
|
||||
story.append(PageBreak())
|
||||
_section(story, "Sales Forecast", data.get("aw_sales_forecast", []), styles)
|
||||
_section(story, "Rep Scores", data.get("aw_rep_scores", []), styles)
|
||||
_section(story, "Product Demand", data.get("aw_product_demand", []), styles)
|
||||
story.append(PageBreak())
|
||||
|
||||
story.append(Paragraph("WideWorldImporters DW", styles["Heading1"]))
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
_section(story, "Sales KPIs", data.get("wwi_sales_kpis", {}), styles)
|
||||
_section(story, "Stock Recommendations", data.get("wwi_stock_recommendations", []), styles)
|
||||
story.append(PageBreak())
|
||||
_section(story, "Supplier Scores", data.get("wwi_supplier_scores", []), styles)
|
||||
_section(story, "Business Events", data.get("wwi_business_events", []), styles)
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
path,
|
||||
pagesize=landscape(A4),
|
||||
leftMargin=_MARGIN,
|
||||
rightMargin=_MARGIN,
|
||||
topMargin=_MARGIN,
|
||||
bottomMargin=_MARGIN,
|
||||
)
|
||||
doc.build(story)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def save_report(data: dict, output_dir: str) -> dict:
|
||||
"""Generate XLSX and PDF reports from aggregated BI data and write both to *output_dir*."""
|
||||
now = datetime.now(timezone.utc)
|
||||
ts = now.strftime("%Y%m%d_%H%M%S")
|
||||
uid = uuid.uuid4().hex[:6]
|
||||
report_id = f"{ts}_{uid}"
|
||||
generated_at = now.isoformat()
|
||||
|
||||
out = Path(output_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
base = f"otel_bi_report_{report_id}"
|
||||
xlsx_path = str(out / f"{base}.xlsx")
|
||||
pdf_path = str(out / f"{base}.pdf")
|
||||
|
||||
_save_xlsx(data, xlsx_path, report_id, generated_at)
|
||||
_save_pdf(data, pdf_path, report_id, generated_at)
|
||||
|
||||
return {
|
||||
"report_id": report_id,
|
||||
"generated_at": generated_at,
|
||||
"xlsx": {"filename": f"{base}.xlsx", "path": xlsx_path},
|
||||
"pdf": {"filename": f"{base}.pdf", "path": pdf_path},
|
||||
}
|
||||
@@ -2,11 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from time import time
|
||||
from uuid import uuid4
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jwt import InvalidTokenError, PyJWKClient
|
||||
|
||||
@@ -23,14 +21,6 @@ class FrontendPrincipal:
|
||||
token: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class InternalPrincipal:
|
||||
subject: str
|
||||
scopes: list[str]
|
||||
claims: dict
|
||||
token: str
|
||||
|
||||
|
||||
class FrontendJWTVerifier:
|
||||
@property
|
||||
def jwks_url(self) -> str:
|
||||
@@ -66,7 +56,6 @@ class FrontendJWTVerifier:
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="FRONTEND_JWT_AUDIENCE is not configured.",
|
||||
)
|
||||
|
||||
try:
|
||||
signing_key = self._jwks_client().get_signing_key_from_jwt(token).key
|
||||
claims = jwt.decode(
|
||||
@@ -92,103 +81,13 @@ class FrontendJWTVerifier:
|
||||
|
||||
scopes = self._extract_scopes(claims)
|
||||
required = settings.frontend_required_scopes_list
|
||||
missing = [scope for scope in required if scope not in scopes]
|
||||
missing = [s for s in required if s not in scopes]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing required scope(s): {', '.join(missing)}",
|
||||
)
|
||||
return FrontendPrincipal(
|
||||
subject=subject, scopes=scopes, claims=claims, token=token
|
||||
)
|
||||
|
||||
|
||||
class InternalTokenManager:
|
||||
token_type = "internal-service"
|
||||
|
||||
@staticmethod
|
||||
def _assert_secret() -> str:
|
||||
secret = settings.internal_service_shared_secret
|
||||
if not secret or secret == "change-me":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="INTERNAL_SERVICE_SHARED_SECRET must be configured.",
|
||||
)
|
||||
if len(secret.encode("utf-8")) < 32:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=(
|
||||
"INTERNAL_SERVICE_SHARED_SECRET must be at least 32 bytes for "
|
||||
"HS256 token signing."
|
||||
),
|
||||
)
|
||||
return secret
|
||||
|
||||
def mint(
|
||||
self,
|
||||
*,
|
||||
subject: str,
|
||||
scopes: list[str],
|
||||
source_service: str,
|
||||
) -> str:
|
||||
now = int(time())
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"scope": " ".join(scopes),
|
||||
"iss": source_service,
|
||||
"aud": settings.internal_service_token_audience,
|
||||
"typ": self.token_type,
|
||||
"iat": now,
|
||||
"nbf": now,
|
||||
"exp": now + settings.internal_service_token_ttl_seconds,
|
||||
"jti": str(uuid4()),
|
||||
}
|
||||
return jwt.encode(payload, self._assert_secret(), algorithm="HS256")
|
||||
|
||||
def verify(self, token: str) -> InternalPrincipal:
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
self._assert_secret(),
|
||||
algorithms=["HS256"],
|
||||
audience=settings.internal_service_token_audience,
|
||||
options={
|
||||
"require": ["sub", "iss", "aud", "exp", "iat", "nbf", "jti", "typ"]
|
||||
},
|
||||
leeway=settings.internal_token_clock_skew_seconds,
|
||||
)
|
||||
except InvalidTokenError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid internal service token.",
|
||||
) from exc
|
||||
|
||||
subject = str(claims.get("sub") or "")
|
||||
if not subject:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Internal token missing subject.",
|
||||
)
|
||||
|
||||
issuer = str(claims.get("iss") or "")
|
||||
if issuer not in settings.internal_service_allowed_issuers_list:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Internal token issuer is not allowed.",
|
||||
)
|
||||
|
||||
token_type = str(claims.get("typ") or "")
|
||||
if token_type != self.token_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Internal token type is invalid.",
|
||||
)
|
||||
|
||||
scope = claims.get("scope")
|
||||
scopes = [item for item in str(scope).split(" ") if item] if scope else []
|
||||
return InternalPrincipal(
|
||||
subject=subject, scopes=scopes, claims=claims, token=token
|
||||
)
|
||||
return FrontendPrincipal(subject=subject, scopes=scopes, claims=claims, token=token)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -196,36 +95,14 @@ def get_frontend_verifier() -> FrontendJWTVerifier:
|
||||
return FrontendJWTVerifier()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_internal_token_manager() -> InternalTokenManager:
|
||||
return InternalTokenManager()
|
||||
|
||||
|
||||
def require_frontend_principal(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(BEARER_SCHEME),
|
||||
) -> FrontendPrincipal:
|
||||
if not settings.require_frontend_auth:
|
||||
return FrontendPrincipal(subject="anonymous", scopes=[], claims={}, token="")
|
||||
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing bearer token.",
|
||||
)
|
||||
return get_frontend_verifier().verify(credentials.credentials)
|
||||
|
||||
|
||||
def require_internal_principal(
|
||||
internal_token: str | None = Header(default=None, alias="x-internal-service-token"),
|
||||
) -> InternalPrincipal:
|
||||
if not settings.internal_service_auth_enabled:
|
||||
return InternalPrincipal(
|
||||
subject="internal-unauth", scopes=[], claims={}, token=""
|
||||
)
|
||||
|
||||
if not internal_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing x-internal-service-token header.",
|
||||
)
|
||||
return get_internal_token_manager().verify(internal_token)
|
||||
|
||||
Reference in New Issue
Block a user