Restructure with Turborepo
Run Tests on Branches / Backend Tests (push) Successful in 3m38s
Run Tests on Branches / Frontend Tests (push) Successful in 3m28s

This commit is contained in:
Antoni Nuñez Romeu
2026-07-06 15:51:53 +02:00
parent f66cafbbc3
commit 190b3d163d
277 changed files with 53253 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
from datetime import datetime, timezone
class PIPException(Exception):
def __init__(self, message: str, code: str = "PIP_ERROR", status_code: int = 500, details: dict | None = None):
super().__init__(message)
self.message = message
self.code = code
self.status_code = status_code
self.details = details or {}
self.timestamp = datetime.now(timezone.utc).isoformat()
def to_dict(self) -> dict:
return {
"error": {
"code": self.code,
"message": self.message,
"details": self.details,
"timestamp": self.timestamp,
}
}
class NotFoundException(PIPException):
def __init__(self, resource: str, resource_id: str | None = None):
msg = f"{resource} not found"
if resource_id:
msg = f"{resource} '{resource_id}' not found"
super().__init__(message=msg, code="NOT_FOUND", status_code=404, details={"resource": resource, "resource_id": resource_id})
class ConflictException(PIPException):
def __init__(self, message: str, details: dict | None = None):
super().__init__(message=message, code="CONFLICT", status_code=409, details=details)
class ValidationException(PIPException):
def __init__(self, message: str, details: dict | None = None):
super().__init__(message=message, code="VALIDATION_ERROR", status_code=422, details=details)
class UnauthorizedException(PIPException):
def __init__(self, message: str = "Authentication required"):
super().__init__(message=message, code="UNAUTHORIZED", status_code=401)
class ForbiddenException(PIPException):
def __init__(self, message: str = "Insufficient permissions", required_role: str | None = None):
details = {}
if required_role:
details["required_role"] = required_role
super().__init__(message=message, code="FORBIDDEN", status_code=403, details=details)
class RateLimitException(PIPException):
def __init__(self, message: str = "Rate limit exceeded"):
super().__init__(message=message, code="RATE_LIMIT_EXCEEDED", status_code=429)
class ServiceUnavailableException(PIPException):
def __init__(self, service: str, message: str | None = None):
msg = message or f"Service '{service}' is unavailable"
super().__init__(message=msg, code="SERVICE_UNAVAILABLE", status_code=503, details={"service": service})
class ConnectorException(PIPException):
def __init__(self, connector_type: str, message: str, details: dict | None = None):
d = details or {}
d["connector_type"] = connector_type
super().__init__(message=message, code="CONNECTOR_ERROR", status_code=502, details=d)
class SyncException(PIPException):
def __init__(self, pharmacy_id: str, message: str, details: dict | None = None):
d = details or {}
d["pharmacy_id"] = pharmacy_id
super().__init__(message=message, code="SYNC_ERROR", status_code=502, details=d)
+56
View File
@@ -0,0 +1,56 @@
import logging
import sys
from typing import Any
import structlog
from src.infrastructure.config.settings import settings
def configure_logging() -> None:
shared_processors: list[Any] = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.UnicodeDecoder(),
]
if settings.LOG_JSON_FORMAT:
renderer = structlog.processors.JSONRenderer()
else:
renderer = structlog.dev.ConsoleRenderer()
structlog.configure(
processors=[
*shared_processors,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
formatter = structlog.stdlib.ProcessorFormatter(
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
renderer,
],
foreign_pre_chain=shared_processors,
)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
root_logger = logging.getLogger()
root_logger.handlers.clear()
root_logger.addHandler(handler)
root_logger.setLevel(settings.LOG_LEVEL.upper())
for noisy in ("uvicorn.access", "sqlalchemy.engine", "httpx", "aioredis"):
logging.getLogger(noisy).setLevel(logging.WARNING)
def get_logger(name: str | None = None) -> Any:
return structlog.get_logger(name)
+94
View File
@@ -0,0 +1,94 @@
import time
from collections.abc import Callable
from typing import Any
from fastapi import Request, Response
from prometheus_client import Counter, Gauge, Histogram
REQUEST_COUNT = Counter(
"pip_http_requests_total",
"Total HTTP requests",
["method", "endpoint", "status_code"],
)
REQUEST_DURATION = Histogram(
"pip_http_request_duration_seconds",
"HTTP request duration in seconds",
["method", "endpoint"],
buckets=[0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],
)
REQUESTS_IN_PROGRESS = Gauge(
"pip_http_requests_in_progress",
"HTTP requests currently in progress",
["method", "endpoint"],
)
DB_QUERY_DURATION = Histogram(
"pip_db_query_duration_seconds",
"Database query duration in seconds",
["operation"],
buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0],
)
CONNECTOR_OPERATIONS = Counter(
"pip_connector_operations_total",
"Total connector operations",
["connector_type", "operation", "status"],
)
SYNC_JOBS = Counter(
"pip_sync_jobs_total",
"Total sync jobs",
["strategy", "status"],
)
SYNC_DURATION = Histogram(
"pip_sync_duration_seconds",
"Sync job duration in seconds",
["strategy"],
buckets=[1.0, 5.0, 10.0, 30.0, 60.0, 300.0, 600.0, 1800.0],
)
ACTIVE_CONNECTIONS = Gauge(
"pip_active_connections",
"Active infrastructure connections",
["type"],
)
CACHE_OPERATIONS = Counter(
"pip_cache_operations_total",
"Cache operations",
["operation", "result"],
)
RESERVATION_OPERATIONS = Counter(
"pip_reservation_operations_total",
"Reservation operations",
["operation", "status"],
)
async def metrics_middleware(request: Request, call_next: Callable) -> Response:
if request.url.path == "/metrics" or request.url.path.startswith("/docs") or request.url.path.startswith("/openapi"):
return await call_next(request)
method = request.method
endpoint = request.url.path
REQUESTS_IN_PROGRESS.labels(method=method, endpoint=endpoint).inc()
start_time = time.perf_counter()
try:
response = await call_next(request)
status_code = response.status_code
except Exception:
status_code = 500
raise
finally:
duration = time.perf_counter() - start_time
REQUEST_COUNT.labels(method=method, endpoint=endpoint, status_code=status_code).inc()
REQUEST_DURATION.labels(method=method, endpoint=endpoint).observe(duration)
REQUESTS_IN_PROGRESS.labels(method=method, endpoint=endpoint).dec()
return response
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from src.infrastructure.config.settings import settings
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncEngine
def setup_tracing(app: Any = None, engine: AsyncEngine | None = None) -> None:
if not settings.OTEL_TRACES_ENABLED:
return
resource = Resource.create({"service.name": settings.OTEL_SERVICE_NAME})
provider = TracerProvider(resource=resource)
otlp_exporter = OTLPSpanExporter(endpoint=settings.OTEL_EXPORTER_OTLP_ENDPOINT)
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
trace.set_tracer_provider(provider)
if engine is not None:
sync_engine = engine.sync_engine if hasattr(engine, "sync_engine") else engine
SQLAlchemyInstrumentor().instrument(engine=sync_engine)
RedisInstrumentor().instrument()
if app is not None:
FastAPIInstrumentor.instrument_app(app)
def get_tracer(name: str = "pip-platform") -> trace.Tracer:
return trace.get_tracer(name)