Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,15 @@ FRONTEND_URL=http://localhost:5173

# Cookie domain (leave empty for localhost development)
COOKIE_DOMAIN=

# Logging
LOG_LEVEL=INFO

# OpenTelemetry (disabled by default)
OTEL_ENABLED=false
OTEL_SERVICE_NAME=api-template
OTEL_EXPORTER_ENDPOINT=http://localhost:4317

# Feature flags — prefix with FEATURE_ and set to true/false
# FEATURE_NEW_DASHBOARD=true
# FEATURE_BETA_EXPORT=false
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,34 @@ Authentication uses httpOnly cookies with short-lived access tokens and rotating
- **Production config validation**: Rejects weak secrets and default database credentials at startup
- **Security event logging**: Structured logs for login, logout, registration, token refresh, and rate limit events

## Logging, Telemetry & Feature Flags

### Structured Logging

Logging uses [structlog](https://www.structlog.org/) for structured output. In development you get colored console logs; in production, JSON.

Every request is assigned a unique `X-Request-ID` header (or reuses one from the incoming request), and it's automatically bound to all log entries for that request.

Configure via `LOG_LEVEL` env var (default: `INFO`).

### OpenTelemetry

OpenTelemetry tracing is included but disabled by default. To enable, set `OTEL_ENABLED=true` and point `OTEL_EXPORTER_ENDPOINT` at your collector (e.g. Jaeger, Grafana Tempo). FastAPI is auto-instrumented — no code changes needed.

### Analytics

`app/analytics.py` provides an `AnalyticsBackend` protocol with `track()` and `identify()` methods. The default `LogAnalyticsBackend` writes events to structlog. Swap it out by replacing the `analytics` module-level instance with your own implementation (e.g. Segment, PostHog).

Use the `get_analytics()` FastAPI dependency to access it in route handlers.

### Feature Flags

Feature flags are read from `FEATURE_*` environment variables at startup (no database required). Set `FEATURE_<NAME>=true` or `false` in your `.env`.

The `GET /flags` endpoint (requires authentication) returns all flags as a JSON object, consumed by the web-template's `FeatureFlagProvider`.

Use the `get_feature_flags()` dependency in route handlers to check flags server-side via `flags.is_enabled("flag_name")`.

## Database Migrations

This project uses Alembic for database migrations.
Expand Down Expand Up @@ -249,8 +277,12 @@ api-template/
│ ├── schemas/
│ │ ├── note.py # Note request/response schemas
│ │ └── user.py # User schemas (FastAPI-Users)
│ ├── analytics.py # Analytics event abstraction
│ ├── config.py # Settings with production validation
│ ├── database.py # Async SQLAlchemy setup
│ ├── features.py # Feature flags (env-var backed)
│ ├── logging.py # Structlog configuration
│ ├── telemetry.py # OpenTelemetry setup
│ └── main.py # App entry point, middleware, routes
├── alembic/
│ ├── versions/ # Migration files
Expand All @@ -276,6 +308,11 @@ api-template/
| `CORS_ORIGINS` | Comma-separated allowed origins (production) | (empty — dev uses localhost:5100-5199) |
| `FRONTEND_URL` | Frontend URL for redirects | `http://localhost:5173` |
| `COOKIE_DOMAIN`| Cookie domain (leave empty for localhost) | (empty) |
| `LOG_LEVEL` | Logging level | `INFO` |
| `OTEL_ENABLED` | Enable OpenTelemetry tracing | `false` |
| `OTEL_SERVICE_NAME` | Service name for traces | `api-template` |
| `OTEL_EXPORTER_ENDPOINT` | OTLP gRPC collector endpoint | `http://localhost:4317` |
| `FEATURE_*` | Feature flags (e.g. `FEATURE_NEW_DASHBOARD=true`) | (none) |

## License

Expand Down
53 changes: 53 additions & 0 deletions app/analytics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Analytics event abstraction with a pluggable backend."""

from __future__ import annotations

from typing import Any, Protocol

from app.logging import get_logger

logger = get_logger("app.analytics")


class AnalyticsBackend(Protocol):
"""Protocol that any analytics backend must satisfy."""

def track(
self,
event: str,
*,
properties: dict[str, Any] | None = None,
user_id: str | None = None,
) -> None: ...

def identify(self, user_id: str, *, traits: dict[str, Any] | None = None) -> None: ...


class LogAnalyticsBackend:
"""Default backend that writes analytics events via structlog."""

def track(
self,
event: str,
*,
properties: dict[str, Any] | None = None,
user_id: str | None = None,
) -> None:
logger.info(
"analytics.track",
analytics_event=event,
properties=properties or {},
user_id=user_id,
)

def identify(self, user_id: str, *, traits: dict[str, Any] | None = None) -> None:
logger.info("analytics.identify", user_id=user_id, traits=traits or {})


# Module-level instance — swap with any AnalyticsBackend-compatible object.
analytics: AnalyticsBackend = LogAnalyticsBackend()


def get_analytics() -> AnalyticsBackend:
"""FastAPI dependency returning the current analytics backend."""
return analytics
37 changes: 16 additions & 21 deletions app/auth/security_logging.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import logging
from enum import StrEnum

import structlog
from fastapi import Request

logger = logging.getLogger("app.security")
logger = structlog.get_logger("app.security")


class SecurityEvent(StrEnum):
Expand All @@ -29,22 +29,17 @@ def log_security_event(
ip = request.client.host if request.client else None
user_agent = request.headers.get("user-agent")

extra = {
"security_event": event.value,
"ip": ip,
"user_agent": user_agent,
"user_id": user_id,
"email": email,
}

message = f"[{event.value}]"
if email:
message += f" email={email}"
if user_id:
message += f" user_id={user_id}"
if ip:
message += f" ip={ip}"
if detail:
message += f" {detail}"

logger.info(message, extra=extra)
request_id = None
if request is not None:
request_id = getattr(request.state, "request_id", None)

logger.info(
"security_event",
security_event=event.value,
ip=ip,
user_agent=user_agent,
user_id=user_id,
email=email,
detail=detail,
request_id=request_id,
)
4 changes: 2 additions & 2 deletions app/auth/users.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
from collections.abc import AsyncGenerator
from uuid import UUID

import structlog
from fastapi import Depends, Request, Response
from fastapi.security import OAuth2PasswordRequestForm
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models
Expand All @@ -15,7 +15,7 @@
from app.database import async_session_maker, get_async_session
from app.models.user import User

logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)


async def get_user_db(
Expand Down
8 changes: 8 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ class Settings(BaseSettings):
# Cookie auth
cookie_domain: str | None = None

# Logging
log_level: str = "INFO"

# OpenTelemetry
otel_enabled: bool = False
otel_service_name: str = "api-template"
otel_exporter_endpoint: str = "http://localhost:4317"

@property
def is_development(self) -> bool:
return self.environment == "development"
Expand Down
57 changes: 57 additions & 0 deletions app/features.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Environment-variable-backed feature flags."""

from __future__ import annotations

import os
from typing import Any

from fastapi import APIRouter, Depends

from app.auth import current_active_user
from app.models.user import User

router = APIRouter(tags=["features"])

# Prefix used to discover feature flags from the environment.
_ENV_PREFIX = "FEATURE_"


class FeatureFlags:
"""Read ``FEATURE_*`` env vars at startup and expose a simple query API."""

def __init__(self) -> None:
self._flags: dict[str, bool] = {}
for key, value in os.environ.items():
if key.startswith(_ENV_PREFIX):
flag_name = key[len(_ENV_PREFIX) :].lower()
self._flags[flag_name] = value.lower() in {"1", "true", "yes"}

def is_enabled(self, flag_name: str, *, context: dict[str, Any] | None = None) -> bool:
"""Return whether *flag_name* is enabled.

An optional *context* dict (e.g. user info) is accepted for future
targeting support but is currently unused.
"""
return self._flags.get(flag_name, False)

def all_flags(self) -> dict[str, bool]:
"""Return a copy of every registered flag and its current state."""
return dict(self._flags)


# Module-level singleton — initialised once at import time.
feature_flags = FeatureFlags()


def get_feature_flags() -> FeatureFlags:
"""FastAPI dependency returning the feature flags singleton."""
return feature_flags


@router.get("/flags")
async def list_flags(
user: User = Depends(current_active_user),
flags: FeatureFlags = Depends(get_feature_flags),
) -> dict[str, bool]:
"""Return enabled feature flags for the current user."""
return flags.all_flags()
58 changes: 58 additions & 0 deletions app/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Structured logging configuration using structlog."""

import logging
import sys

import structlog

from app.config import settings


def setup_logging() -> None:
"""Configure structlog with JSON output in production, colored console in dev."""
shared_processors: list[structlog.types.Processor] = [
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.is_development:
renderer: structlog.types.Processor = structlog.dev.ConsoleRenderer()
else:
renderer = structlog.processors.JSONRenderer()

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,
],
)

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())

# Quiet noisy third-party loggers
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)


def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
"""Return a structlog bound logger."""
return structlog.get_logger(name)
Loading