Skip to content
274 changes: 236 additions & 38 deletions README.md

Large diffs are not rendered by default.

13 changes: 5 additions & 8 deletions modal_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import modal

from modal_common import build_fastapi_config, get_env_config
from modal_common import build_fastapi_config, configure_env_vars, get_env_config

# SETTING MODAL ENVIRONMENT
MODAL_ENV = os.environ.get("MODAL_ENV", "dev")
Expand All @@ -14,11 +14,7 @@
APP_NAME = f"{env_config.app_name}-{env_config.env_name}"
app = modal.App(APP_NAME)

_otlp_endpoint = os.environ.get("GRAFANA_OTLP_ENDPOINT") or env_config.otel_endpoint
if _otlp_endpoint:
os.environ.setdefault("OTEL_EXPORTER_OTLP_ENDPOINT", _otlp_endpoint)
os.environ.setdefault("OTEL_SERVICE_NAME", env_config.service_name or env_config.app_name)
os.environ.setdefault("MODAL_ENV", env_config.env_name)
configure_env_vars(env_config)


# SETTING MODAL PROJECT
Expand All @@ -38,8 +34,9 @@ def startup(self) -> None:
# Runs once per container after snapshot restore — never on the request hot path.
# Network-bound setup (OTLP connections) must live here; they cannot survive
# a snapshot because file descriptors and sockets are not portable across restores.
from src.observability import setup_telemetry
from src.infrastructure import record_cold_start, setup_telemetry
setup_telemetry()
record_cold_start() # fires against the real MeterProvider — always exported

@modal.asgi_app()
def fastapi_app(self):
Expand All @@ -52,7 +49,7 @@ def fastapi_app(self):
def main():
# Mirror what @enter does in the Modal container so telemetry works locally too.
# The env vars above are already set; setup_telemetry() reads them at call time.
from src.observability import setup_telemetry
from src.infrastructure import setup_telemetry
setup_telemetry()
from src.main import app as fastapi_app
from uvicorn import run
Expand Down
21 changes: 21 additions & 0 deletions modal_common.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from dataclasses import dataclass, field
from typing import Dict, List, Optional

Expand Down Expand Up @@ -78,6 +79,9 @@ class EnvConfig:
secrets: list = field(default_factory=list)
volumes: Dict[str, modal.Volume] = field(default_factory=lambda: FASTAPI_VOLUME)

# CORS — restrict origins per environment; ["*"] allows all (dev only)
cors_origins: List[str] = field(default_factory=lambda: ["*"])

# OBSERVABILITY — set in prod preset only; None = telemetry disabled (feat/dev)
otel_endpoint: Optional[str] = None # Grafana Cloud OTLP base URL
service_name: Optional[str] = None # defaults to app_name when None
Expand All @@ -86,6 +90,7 @@ class EnvConfig:
FEAT = EnvConfig(
env_name="feat",
server_domain="feat-app.modal.run",
cors_origins=["*"],
otel_endpoint=None, # endpoint comes from GRAFANA_OTLP_ENDPOINT inside the grafana-otlp secret
secrets=[
modal.Secret.from_name("fastapi-auth-secrets"),
Expand All @@ -96,6 +101,7 @@ class EnvConfig:
DEV = EnvConfig(
env_name="dev",
server_domain="dev-app.modal.run",
cors_origins=["*"], # TODO: replace with your actual Modal domain when known
otel_endpoint=None, # no telemetry in dev — keeps cost at zero
secrets=[
modal.Secret.from_name("fastapi-auth-secrets"),
Expand All @@ -106,6 +112,7 @@ class EnvConfig:
PROD = EnvConfig(
env_name="prod",
server_domain="prod-app.modal.run",
cors_origins=["*"], # TODO: replace with your actual Modal domain when known
# min_containers=1, # Uncomment to keep 1 warm container in production
otel_endpoint=None, # endpoint comes from GRAFANA_OTLP_ENDPOINT inside the grafana-otlp secret
secrets=[
Expand All @@ -131,6 +138,20 @@ def get_env_config(env_name: str) -> EnvConfig:

return ENV_CONFIGS[env_name]

def configure_env_vars(env: EnvConfig) -> None:
_otlp_endpoint = os.environ.get("GRAFANA_OTLP_ENDPOINT") or env.otel_endpoint
if _otlp_endpoint:
os.environ.setdefault("OTEL_EXPORTER_OTLP_ENDPOINT", _otlp_endpoint)
os.environ.setdefault("OTEL_SERVICE_NAME", env.service_name or env.app_name)
os.environ.setdefault("MODAL_ENV", env.env_name)
# App metadata — read by src/config.py so src/ never imports modal_common directly
os.environ.setdefault("APP_NAME", env.app_name)
os.environ.setdefault("APP_VERSION", env.app_version)
os.environ.setdefault("APP_DESCRIPTION", env.app_description)
os.environ.setdefault("SERVER_PREFIX", env.server_prefix)
os.environ.setdefault("CORS_ORIGINS", ",".join(env.cors_origins))


def build_fastapi_config(env: EnvConfig) -> dict:
config = {
"image": cpu_image,
Expand Down
18 changes: 18 additions & 0 deletions scripts/jwt_token_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import jwt
import time
import argparse

def get_jwt_token(secret: str, scopes: list[str] = None) -> str:
payload = {
"sub": "test-user",
"exp": int(time.time()) + 86400,
"scopes": scopes or ["items:read", "items:write"],
}
return jwt.encode(payload, secret, algorithm="HS256")

if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--secret", type=str, required=True)
parser.add_argument("--scopes", type=str, required=False)
args = parser.parse_args()
print(get_jwt_token(args.secret))
Empty file added src/adapters/__init__.py
Empty file.
Empty file added src/adapters/http/__init__.py
Empty file.
123 changes: 62 additions & 61 deletions src/observability/middleware.py → src/adapters/http/middleware.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""
src/observability/middleware.py
src/adapters/http/middleware.py
================================
FastAPI/Starlette middleware that auto-instruments every HTTP request.
Inbound HTTP adapter — telemetry sidecar at the HTTP boundary.
Wraps every request in an OTel span, records metrics, and emits structured logs.
Add once in main.py — no per-route changes needed.

from src.observability.middleware import TelemetryMiddleware
from src.adapters.http.middleware import TelemetryMiddleware
app.add_middleware(TelemetryMiddleware)

What is recorded automatically per request
Expand Down Expand Up @@ -47,53 +48,51 @@
from starlette.responses import Response
from starlette.routing import Match

from src.observability.setup import get_meter, get_tracer
from src.infrastructure.setup import get_meter, get_tracer

logger = logging.getLogger(__name__)

# ── Instruments ────────────────────────────────────────────────────────────
# Created at import time against whatever MeterProvider is set.
# If setup_telemetry() hasn't been called yet (e.g. in tests), these
# bind to the global no-op provider and become true no-ops.

_meter = get_meter()

_request_count = _meter.create_counter(
"http.server.request.count",
description="Total HTTP requests received",
unit="1",
)
_request_duration = _meter.create_histogram(
"http.server.request.duration",
description="HTTP request wall-clock duration",
unit="ms",
)
_active_requests = _meter.create_up_down_counter(
"http.server.active_requests",
description="HTTP requests currently in flight",
unit="1",
)
_error_count = _meter.create_counter(
"http.server.error.count",
description="HTTP responses with 4xx or 5xx status",
unit="1",
)
_response_size = _meter.create_histogram(
"http.server.response.size",
description="HTTP response body size",
unit="By",
)

# ── Cold-start counter ─────────────────────────────────────────────────────
# Incremented once at module import, which happens during @enter warmup.
# A spike in this counter in Grafana = containers cold-starting.

_cold_starts = _meter.create_counter(
"app.container.cold_start.count",
description="Number of container cold starts (module imports)",
unit="1",
)
_cold_starts.add(1, {"service": "modal-fastapi"})
# ── Lazy instrument initialisation ─────────────────────────────────────────
# Instruments are created on the first request, not at module import time.
# This guarantees they are bound to the real MeterProvider set by
# setup_telemetry() in Modal's startup() hook — not to the no-op provider
# that exists at snapshot / import time.

_instruments: dict | None = None


def _get_instruments() -> dict:
global _instruments
if _instruments is None:
meter = get_meter()
_instruments = {
"request_count": meter.create_counter(
"http.server.request.count",
description="Total HTTP requests received",
unit="1",
),
"request_duration": meter.create_histogram(
"http.server.request.duration",
description="HTTP request wall-clock duration",
unit="ms",
),
"active_requests": meter.create_up_down_counter(
"http.server.active_requests",
description="HTTP requests currently in flight",
unit="1",
),
"error_count": meter.create_counter(
"http.server.error.count",
description="HTTP responses with 4xx or 5xx status",
unit="1",
),
"response_size": meter.create_histogram(
"http.server.response.size",
description="HTTP response body size",
unit="By",
),
}
return _instruments


# ── Helpers ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -135,18 +134,19 @@ async def dispatch(
request: Request,
call_next: RequestResponseEndpoint,
) -> Response:
tracer = get_tracer()
route = _route_template(request)
method = request.method
t_start = time.perf_counter()
tracer = get_tracer()
instr = _get_instruments()
route = _route_template(request)
method = request.method
t_start = time.perf_counter()
# Generate once per request so the span always has a non-empty session_id.
# setdefault below lets a route override this with its own value if it sets
# the x-session-id header explicitly — the middleware value is the fallback.
req_session_id = str(uuid.uuid4())

# ── In-flight gauge ───────────────────────────────────────────
try:
_active_requests.add(1, {"method": method, "route": route})
instr["active_requests"].add(1, {"method": method, "route": route})
except Exception:
pass

Expand Down Expand Up @@ -178,8 +178,8 @@ async def dispatch(
except Exception as exc:
span.record_exception(exc)
span.set_status(StatusCode.ERROR, str(exc))
_record(method, route, 500, (time.perf_counter() - t_start) * 1000, 0)
_active_requests.add(-1, {"method": method, "route": route})
_record(instr, method, route, 500, (time.perf_counter() - t_start) * 1000, 0)
instr["active_requests"].add(-1, {"method": method, "route": route})
raise

# ── Annotate span ─────────────────────────────────────────
Expand All @@ -201,7 +201,7 @@ async def dispatch(
# ── Record metrics ────────────────────────────────────────
duration_ms = (time.perf_counter() - t_start) * 1000
content_length = int(response.headers.get("content-length", 0))
_record(method, route, status, duration_ms, content_length)
_record(instr, method, route, status, duration_ms, content_length)

# ── Structured log ────────────────────────────────────────
ctx = span.get_span_context()
Expand All @@ -221,11 +221,12 @@ async def dispatch(
},
)

_active_requests.add(-1, {"method": method, "route": route})
instr["active_requests"].add(-1, {"method": method, "route": route})
return response


def _record(
instr: dict,
method: str,
route: str,
status: int,
Expand All @@ -235,11 +236,11 @@ def _record(
"""Record all per-request metrics. Swallows exceptions silently."""
try:
lb = _labels(method, route, status)
_request_count.add(1, lb)
_request_duration.record(duration_ms, lb)
instr["request_count"].add(1, lb)
instr["request_duration"].record(duration_ms, lb)
if status >= 400:
_error_count.add(1, lb)
instr["error_count"].add(1, lb)
if response_bytes > 0:
_response_size.record(response_bytes, lb)
instr["response_size"].record(response_bytes, lb)
except Exception:
logger.debug("Metric recording failed", exc_info=True)
logger.debug("Metric recording failed", exc_info=True)
Empty file.
42 changes: 42 additions & 0 deletions src/adapters/persistence/memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""
src/adapters/persistence/memory.py
====================================
In-memory persistence adapter — ships with the template.

Satisfies ItemRepository Protocol structurally (no inheritance needed).
Replace or supplement with a concrete DB adapter in src/deps.py.

Thread-safety note: sufficient for Modal's single-threaded async workers.
For multi-threaded environments, protect _store with asyncio.Lock.
"""
from __future__ import annotations

from typing import Dict, List, Optional, Tuple

from src.core.domain.item import Item


class InMemoryItemRepository:
def __init__(self) -> None:
self._store: Dict[str, Item] = {}

async def get(self, item_id: str) -> Optional[Item]:
return self._store.get(item_id)

async def list(self, limit: int = 100, offset: int = 0) -> Tuple[List[Item], int]:
all_items = list(self._store.values())
total = len(all_items)
return all_items[offset: offset + limit], total

async def create(self, item: Item) -> Item:
self._store[item.id] = item
return item

async def update(self, item: Item) -> Optional[Item]:
if item.id not in self._store:
return None
self._store[item.id] = item
return item

async def delete(self, item_id: str) -> bool:
return self._store.pop(item_id, None) is not None
Empty file.
Loading