Compare commits

...

10 commits

Author SHA1 Message Date
Azamat
1fbf77f879 [feat] setup master from fork with fastapi + otel 2026-03-26 21:29:41 +03:00
Azamat
597eae9b97 [fix] add __init__ adapter/http 2026-03-24 12:57:17 +03:00
Azamat
9ea3414bc0 [fix] reduce duplicate fastapi telemetry 2026-03-24 12:15:55 +03:00
Azamat
0829ad6c12 [feat] add docker 2026-03-20 22:44:46 +03:00
Azamat
67bdc287e9 [feat] add basic user 2026-03-20 21:47:54 +03:00
Azamat
0c55329769 [feat] wire observability into app 2026-03-20 21:38:51 +03:00
Azamat
741e63e978 [feat] add observability runtime 2026-03-20 21:38:42 +03:00
Azamat
cb4560db5c [feat] add observability config 2026-03-20 21:38:28 +03:00
Azamat
a930185754 [feat] add fastapi adapter 2026-03-20 15:06:39 +03:00
Azamat
05543bbbbb [fix] otel providers local to container runtime 2026-03-20 14:00:48 +03:00
30 changed files with 1197 additions and 102 deletions

85
AGENTS.md Normal file
View file

@ -0,0 +1,85 @@
# Project Guide
## Project
- Python clean architecture template
- FastAPI is the current HTTP adapter
- The web layer must stay replaceable
- Repository and usecase instances are created once at startup
- Config comes from YAML plus env into one dataclass tree
- Logs metrics and traces stay behind interfaces and use OTel adapters
- Request logging middleware is used
- Metrics middleware is used
- Custom trace middleware is not used
- API versioning lives under `/api/v1`
## Structure
- `domain/` core model and domain errors
- `domain/error.py` domain errors
- `domain/user.py` example domain model
- `usecase/` interfaces and usecases
- `usecase/interface.py` repository and observability interfaces
- `usecase/user.py` example user usecase
- `repository/` repository implementations
- `repository/user.py` example in-memory repository
- `adapter/config/` config models and loader
- `adapter/otel/` logger metrics tracer and OTel setup
- `adapter/di/` container and startup wiring
- `adapter/http/fastapi/` app dependencies lifespan middleware routers
- `config/` app YAML and OTel collector config
- `docs/` ADR files
- `tasks.md` task list
- `main.py` local entrypoint
## Boundaries
- Keep dependency direction inward
- `domain` imports nothing internal
- `usecase` may import `domain`
- `repository` may import `usecase` and `domain`
- `adapter` may import `usecase` and `domain`
- Do not import FastAPI into `domain` or `usecase`
- Do not import OpenTelemetry into `domain` or `usecase`
- Keep HTTP models and middleware inside `adapter/http/fastapi/`
## Workflow
- Use `tasks.md` for planning
- Do not use Beads
- Do not use `bd`
- Use `uv` for Python commands and dependency management
- Do not create commits on your own
- Work on one task at a time
- Prefer delegation for implementation
- Delegate only one task at a time
- After one task return to the user with result verification and next options
- Wait for the user before the next task commit or fix
## Makefile
- `make install` install deps with `uv`
- `make run` start the app locally
- `make run-otel` start the app with OTel env
- `make lint` run `ruff`
- `make typecheck` run `mypy`
- `make test` run `pytest`
- `make pre-commit` run lint typecheck test
- `make compose-build` build the containers
- `make compose-up` start app and collector
- `make compose-down` stop the stack
- `make compose-logs` show app and collector logs
- `make compose-ps` show compose status
## Runtime
- Local run needs `APP_API_TOKEN` and `APP_SIGNING_KEY`
- Base config lives in `config/app.yaml`
- Env overrides come from shell vars or `.env`
- OTel collector config lives in `config/otel-collector.yaml`
## Style
- Comments are short and have no trailing period
- Error messages are short and have no trailing period
- Use single-word comments when possible
- Use single-word error messages when possible
- Keep names simple
- Keep adapters thin
- Keep `__init__.py` empty
- Prefer explicit wiring over magic
- Do not expand scope without user approval
- Do not `from __future__ import annotations`

35
Dockerfile Normal file
View file

@ -0,0 +1,35 @@
FROM python:3.13-slim AS build
ENV UV_LINK_MODE=copy
WORKDIR /app
RUN python -m pip install --no-cache-dir uv
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev --no-install-project
FROM python:3.13-slim AS run
ENV PATH="/app/.venv/bin:${PATH}" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
VIRTUAL_ENV=/app/.venv
WORKDIR /app
RUN useradd --system --create-home --shell /usr/sbin/nologin appuser
COPY --from=build --chown=appuser:appuser /app/.venv /app/.venv
COPY --chown=appuser:appuser adapter /app/adapter
COPY --chown=appuser:appuser config /app/config
COPY --chown=appuser:appuser domain /app/domain
COPY --chown=appuser:appuser repository /app/repository
COPY --chown=appuser:appuser usecase /app/usecase
COPY --chown=appuser:appuser main.py /app/main.py
USER appuser
EXPOSE 8123
CMD ["python", "main.py"]

View file

@ -10,8 +10,10 @@ from .model import (
AppSectionConfig,
HttpConfig,
LoggingConfig,
MetricsConfig,
OtelConfig,
SecurityConfig,
TracingConfig,
)
ROOT_DIR = Path(__file__).resolve().parents[2]
@ -34,9 +36,44 @@ def load_config(
app_section = _section(yaml_data, 'app')
http_section = _section(yaml_data, 'http')
logging_section = _section(yaml_data, 'logging')
otel_section = _section(yaml_data, 'otel')
metrics_section = _section(yaml_data, 'metrics')
tracing_section = _section(yaml_data, 'tracing')
security_section = _section(yaml_data, 'security')
logging_output = _yaml_or_env_choice(
logging_section,
'output',
'logging.output',
env_values,
{'stdout', 'file', 'otel'},
'APP_LOGGING_OUTPUT',
)
logging_format = _yaml_or_env_choice(
logging_section,
'format',
'logging.format',
env_values,
{'text', 'json'},
'APP_LOGGING_FORMAT',
)
metrics_enabled = _yaml_or_env_bool(
metrics_section,
'enabled',
'metrics.enabled',
env_values,
'APP_METRICS_ENABLED',
)
tracing_enabled = _yaml_or_env_bool(
tracing_section,
'enabled',
'tracing.enabled',
env_values,
'APP_TRACING_ENABLED',
)
return AppConfig(
app=AppSectionConfig(
name=_yaml_or_env_str(
@ -59,13 +96,6 @@ def load_config(
env_values,
'APP_HTTP_PORT',
),
api_prefix=_yaml_or_env_str(
http_section,
'api_prefix',
'http.api_prefix',
env_values,
'APP_HTTP_API_PREFIX',
),
),
logging=LoggingConfig(
level=_yaml_or_env_str(
@ -75,43 +105,28 @@ def load_config(
env_values,
'APP_LOGGING_LEVEL',
),
output=logging_output,
format=logging_format,
file_path=(
None
if logging_output != 'file'
else _yaml_or_env_str(
logging_section,
'file_path',
'logging.file_path',
env_values,
'APP_LOGGING_FILE_PATH',
)
),
),
otel=OtelConfig(
service_name=_yaml_or_env_str(
otel_section,
'service_name',
'otel.service_name',
env_values,
'APP_OTEL_SERVICE_NAME',
),
logs_endpoint=_yaml_or_env_str(
otel_section,
'logs_endpoint',
'otel.logs_endpoint',
env_values,
'APP_OTEL_LOGS_ENDPOINT',
),
metrics_endpoint=_yaml_or_env_str(
otel_section,
'metrics_endpoint',
'otel.metrics_endpoint',
env_values,
'APP_OTEL_METRICS_ENDPOINT',
),
traces_endpoint=_yaml_or_env_str(
otel_section,
'traces_endpoint',
'otel.traces_endpoint',
env_values,
'APP_OTEL_TRACES_ENDPOINT',
),
metric_export_interval=_yaml_or_env_int(
otel_section,
'metric_export_interval',
'otel.metric_export_interval',
env_values,
'OTEL_METRIC_EXPORT_INTERVAL',
),
metrics=MetricsConfig(enabled=metrics_enabled),
tracing=TracingConfig(enabled=tracing_enabled),
otel=_load_otel_config(
yaml_data,
env_values,
enable_logs=logging_output == 'otel',
enable_metrics=metrics_enabled,
enable_tracing=tracing_enabled,
),
security=SecurityConfig(
token_header=_yaml_or_env_str(
@ -191,6 +206,94 @@ def _section(data: Mapping[str, object], name: str) -> dict[str, object]:
return section
def _optional_section(data: Mapping[str, object], name: str) -> dict[str, object]:
value = data.get(name)
if value is None:
return {}
if not isinstance(value, dict):
raise ConfigError(f'invalid {name}')
section: dict[str, object] = {}
for key, item in value.items():
if not isinstance(key, str):
raise ConfigError(f'invalid {name}')
section[key] = item
return section
def _load_otel_config(
data: Mapping[str, object],
env: Mapping[str, str],
*,
enable_logs: bool,
enable_metrics: bool,
enable_tracing: bool,
) -> OtelConfig:
if not any((enable_logs, enable_metrics, enable_tracing)):
return OtelConfig(
service_name='',
logs_endpoint='',
metrics_endpoint='',
traces_endpoint='',
metric_export_interval=0,
)
otel_section = _optional_section(data, 'otel')
service_name = _yaml_or_env_str(
otel_section,
'service_name',
'otel.service_name',
env,
'APP_OTEL_SERVICE_NAME',
)
logs_endpoint = ''
metrics_endpoint = ''
traces_endpoint = ''
metric_export_interval = 0
if enable_logs:
logs_endpoint = _yaml_or_env_str(
otel_section,
'logs_endpoint',
'otel.logs_endpoint',
env,
'APP_OTEL_LOGS_ENDPOINT',
)
if enable_metrics:
metrics_endpoint = _yaml_or_env_str(
otel_section,
'metrics_endpoint',
'otel.metrics_endpoint',
env,
'APP_OTEL_METRICS_ENDPOINT',
)
metric_export_interval = _yaml_or_env_int(
otel_section,
'metric_export_interval',
'otel.metric_export_interval',
env,
'OTEL_METRIC_EXPORT_INTERVAL',
)
if enable_tracing:
traces_endpoint = _yaml_or_env_str(
otel_section,
'traces_endpoint',
'otel.traces_endpoint',
env,
'APP_OTEL_TRACES_ENDPOINT',
)
return OtelConfig(
service_name=service_name,
logs_endpoint=logs_endpoint,
metrics_endpoint=metrics_endpoint,
traces_endpoint=traces_endpoint,
metric_export_interval=metric_export_interval,
)
def _yaml_or_env_str(
section: Mapping[str, object],
field_name: str,
@ -219,6 +322,35 @@ def _yaml_or_env_int(
return _as_int(section[field_name], label)
def _yaml_or_env_bool(
section: Mapping[str, object],
field_name: str,
label: str,
env: Mapping[str, str],
env_name: str | None = None,
) -> bool:
if env_name is not None and env_name in env:
return _as_bool(env[env_name], env_name)
if field_name not in section:
raise ConfigError(f'missing {label}')
return _as_bool(section[field_name], label)
def _yaml_or_env_choice(
section: Mapping[str, object],
field_name: str,
label: str,
env: Mapping[str, str],
choices: set[str],
env_name: str | None = None,
) -> str:
value = _yaml_or_env_str(section, field_name, label, env, env_name)
normalized = value.lower()
if normalized not in choices:
raise ConfigError(f'invalid {label}')
return normalized
def _env_str(env: Mapping[str, str], name: str) -> str:
if name not in env:
raise ConfigError(f'missing {name}')
@ -251,6 +383,18 @@ def _as_int(value: object, label: str) -> int:
raise ConfigError(f'invalid {label}')
def _as_bool(value: object, label: str) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
text = value.strip().lower()
if text == 'true':
return True
if text == 'false':
return False
raise ConfigError(f'invalid {label}')
def _display_path(path: Path) -> str:
if path.is_relative_to(ROOT_DIR):
return str(path.relative_to(ROOT_DIR))

View file

@ -11,12 +11,24 @@ class AppSectionConfig:
class HttpConfig:
host: str
port: int
api_prefix: str
@dataclass(frozen=True, slots=True)
class LoggingConfig:
level: str
output: str
format: str
file_path: str | None
@dataclass(frozen=True, slots=True)
class MetricsConfig:
enabled: bool
@dataclass(frozen=True, slots=True)
class TracingConfig:
enabled: bool
@dataclass(frozen=True, slots=True)
@ -40,5 +52,7 @@ class AppConfig:
app: AppSectionConfig
http: HttpConfig
logging: LoggingConfig
metrics: MetricsConfig
tracing: TracingConfig
otel: OtelConfig
security: SecurityConfig

View file

@ -4,7 +4,9 @@ from pathlib import Path
from adapter.config.loader import load_config
from adapter.config.model import AppConfig
from adapter.otel.bootstrap import OtelRuntime, setup_otel
from adapter.observability.factory import build_observability
from adapter.observability.runtime import ObservabilityRuntime
from domain.user import User
from repository.user import InMemoryUserRepository
from usecase.user import GetUser
@ -22,7 +24,7 @@ class AppUsecases:
@dataclass(slots=True)
class AppContainer:
config: AppConfig
observability: OtelRuntime
observability: ObservabilityRuntime
repositories: AppRepositories
usecases: AppUsecases
_is_shutdown: bool = field(default=False, init=False, repr=False)
@ -41,15 +43,21 @@ def build_container(
config_path: Path | str | None = None,
env_path: Path | str | None = None,
environ: Mapping[str, str] | None = None,
config: AppConfig | None = None,
) -> AppContainer:
config = load_config(
config_path=config_path,
env_path=env_path,
environ=environ,
)
observability = setup_otel(config)
app_config = config
if app_config is None:
app_config = load_config(
config_path=config_path,
env_path=env_path,
environ=environ,
)
user_repository = InMemoryUserRepository()
observability = build_observability(app_config)
user_repository = InMemoryUserRepository(
observability.tracer, [User(id='123', email='aza@gglamer.ru', name='gglamer')]
)
repositories = AppRepositories(user=user_repository)
usecases = AppUsecases(
get_user=GetUser(
@ -60,7 +68,7 @@ def build_container(
)
return AppContainer(
config=config,
config=app_config,
observability=observability,
repositories=repositories,
usecases=usecases,

0
adapter/http/__init__.py Normal file
View file

View file

View file

@ -0,0 +1,65 @@
from collections.abc import Callable
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from adapter.config.loader import load_config
from adapter.config.model import AppConfig
from adapter.di.container import AppContainer, build_container
from adapter.http.fastapi.dependencies import APP_CONFIG_STATE, APP_CONTAINER_STATE
from adapter.http.fastapi.middleware import register_middleware
from adapter.http.fastapi.routers.v1.router import router as v1_router
from fastapi import FastAPI
API_V1_PREFIX = '/api/v1'
def create_app(config: AppConfig | None = None) -> FastAPI:
app_config = load_config() if config is None else config
container = build_container(config=app_config)
app: FastAPI | None = None
try:
app = FastAPI(title=app_config.app.name)
setattr(app.state, APP_CONFIG_STATE, app_config)
setattr(app.state, APP_CONTAINER_STATE, container)
app.add_event_handler('shutdown', _build_shutdown_handler(app, container))
register_middleware(app, app_config)
app.include_router(v1_router, prefix=API_V1_PREFIX)
FastAPIInstrumentor.instrument_app(
app,
tracer_provider=container.observability.tracer_provider,
meter_provider=container.observability.meter_provider,
exclude_spans=['send', 'receive'],
)
return app
except Exception:
try:
if app is not None:
_uninstrument_app(app)
finally:
container.shutdown()
raise
def _build_shutdown_handler(
app: FastAPI,
container: AppContainer,
) -> Callable[[], None]:
def shutdown() -> None:
try:
_uninstrument_app(app)
finally:
container.shutdown()
return shutdown
def _uninstrument_app(app: FastAPI) -> None:
if _is_instrumented(app):
FastAPIInstrumentor.uninstrument_app(app)
def _is_instrumented(app: FastAPI) -> bool:
return bool(getattr(app, '_is_instrumented_by_opentelemetry', False))

View file

@ -0,0 +1,19 @@
from typing import cast
from adapter.di.container import AppContainer
from fastapi import Depends, Request
from usecase.user import GetUser
APP_CONTAINER_STATE = 'container'
APP_CONFIG_STATE = 'config'
def get_container(request: Request) -> AppContainer:
container = getattr(request.app.state, APP_CONTAINER_STATE, None)
if container is None:
raise RuntimeError('container unavailable')
return cast(AppContainer, container)
def get_get_user(container: AppContainer = Depends(get_container)) -> GetUser:
return container.usecases.get_user

View file

@ -0,0 +1,33 @@
from time import perf_counter
from adapter.config.model import AppConfig
from adapter.http.fastapi.dependencies import get_container
from fastapi import FastAPI, Request, Response
def register_middleware(app: FastAPI, config: AppConfig) -> None:
@app.middleware('http')
async def request_logging_middleware(
request: Request,
call_next,
) -> Response:
start = perf_counter()
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
return response
finally:
duration_ms = (perf_counter() - start) * 1000
container = get_container(request)
attrs: dict[str, str | int | float | bool] = {
'http.method': request.method,
'http.path': request.url.path,
'http.status_code': status_code,
'http.duration_ms': duration_ms,
}
container.observability.logger.info(
'http_request',
attrs=attrs,
)

View file

@ -0,0 +1,40 @@
from fastapi import APIRouter, Depends, HTTPException, status
from adapter.di.container import AppContainer
from adapter.http.fastapi.dependencies import get_container, get_get_user
from adapter.http.fastapi.schemas import ErrorResponse, HealthResponse, UserResponse
from domain.error import UserNotFoundError
from usecase.user import GetUser, GetUserQuery
router = APIRouter()
@router.get(
'/health',
response_model=HealthResponse,
status_code=status.HTTP_200_OK,
)
def health(container: AppContainer = Depends(get_container)) -> HealthResponse:
return HealthResponse(
status='ok',
app=container.config.app.name,
env=container.config.app.env,
)
@router.get(
'/users/{user_id}',
response_model=UserResponse,
responses={status.HTTP_404_NOT_FOUND: {'model': ErrorResponse}},
status_code=status.HTTP_200_OK,
)
def get_user(user_id: str, usecase: GetUser = Depends(get_get_user)) -> UserResponse:
try:
user = usecase.execute(GetUserQuery(user_id=user_id))
except UserNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
return UserResponse(id=user.id, email=user.email, name=user.name)

View file

@ -0,0 +1,17 @@
from pydantic import BaseModel
class HealthResponse(BaseModel):
status: str
app: str
env: str
class UserResponse(BaseModel):
id: str
email: str
name: str
class ErrorResponse(BaseModel):
detail: str

View file

View file

@ -0,0 +1,101 @@
from collections.abc import Callable
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.trace import TracerProvider
from adapter.config.model import AppConfig
from adapter.otel.bootstrap import OtelRuntime, setup_otel
from usecase.interface import Logger, Metrics, Tracer
from .logging import FileLogger, StdoutLogger
from .noop import NoopMetrics, NoopTracer
from .runtime import ObservabilityRuntime
def build_observability(config: AppConfig) -> ObservabilityRuntime:
otel_runtime = _setup_otel_runtime(config)
try:
logger, logger_shutdown = _build_logger(config, otel_runtime)
except Exception:
if otel_runtime is not None:
otel_runtime.shutdown()
raise
metrics: Metrics = NoopMetrics()
tracer: Tracer = NoopTracer()
meter_provider: MeterProvider | None = None
tracer_provider: TracerProvider | None = None
shutdown_callback_list: list[Callable[[], None]] = []
if logger_shutdown is not None:
shutdown_callback_list.append(logger_shutdown)
if otel_runtime is not None:
meter_provider = otel_runtime.meter_provider
tracer_provider = otel_runtime.tracer_provider
shutdown_callback_list.append(otel_runtime.shutdown)
if config.metrics.enabled:
if otel_runtime is None or otel_runtime.metrics is None:
raise ValueError('missing otel metrics')
metrics = otel_runtime.metrics
if config.tracing.enabled:
if otel_runtime is None or otel_runtime.tracer is None:
raise ValueError('missing otel tracer')
tracer = otel_runtime.tracer
return ObservabilityRuntime(
logger=logger,
metrics=metrics,
tracer=tracer,
meter_provider=meter_provider,
tracer_provider=tracer_provider,
_shutdown_callbacks=tuple(shutdown_callback_list),
)
def _setup_otel_runtime(config: AppConfig) -> OtelRuntime | None:
enable_logs = config.logging.output == 'otel'
enable_metrics = config.metrics.enabled
enable_tracing = config.tracing.enabled
if not any((enable_logs, enable_metrics, enable_tracing)):
return None
return setup_otel(
config,
enable_logs=enable_logs,
enable_metrics=enable_metrics,
enable_tracing=enable_tracing,
)
def _build_logger(
config: AppConfig,
otel_runtime: OtelRuntime | None,
) -> tuple[Logger, Callable[[], None] | None]:
if config.logging.output == 'stdout':
stdout_logger = StdoutLogger(
f'{config.app.name}.stdout',
config.logging.level,
config.logging.format,
)
return stdout_logger, stdout_logger.shutdown
if config.logging.output == 'file':
file_path = config.logging.file_path
if file_path is None:
raise ValueError('missing logging.file_path')
file_logger = FileLogger(
file_path,
f'{config.app.name}.file',
config.logging.level,
config.logging.format,
)
return file_logger, file_logger.shutdown
if config.logging.output == 'otel':
if otel_runtime is None or otel_runtime.logger is None:
raise ValueError('missing otel logger')
return otel_runtime.logger, None
raise ValueError('invalid logging output')

View file

@ -0,0 +1,152 @@
import json
import logging
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import override
from usecase.interface import Attrs, AttrValue
_LEVELS = {
'DEBUG': logging.DEBUG,
'INFO': logging.INFO,
'WARN': logging.WARNING,
'WARNING': logging.WARNING,
'ERROR': logging.ERROR,
'FATAL': logging.CRITICAL,
'CRITICAL': logging.CRITICAL,
}
class BaseLogger:
def __init__(self, logger: logging.Logger, handler: logging.Handler) -> None:
self._logger = logger
self._handler = handler
def debug(self, message: str, attrs: Attrs | None = None) -> None:
self._emit(logging.DEBUG, message, attrs)
def info(self, message: str, attrs: Attrs | None = None) -> None:
self._emit(logging.INFO, message, attrs)
def warning(self, message: str, attrs: Attrs | None = None) -> None:
self._emit(logging.WARNING, message, attrs)
def error(self, message: str, attrs: Attrs | None = None) -> None:
self._emit(logging.ERROR, message, attrs)
def shutdown(self) -> None:
self._logger.removeHandler(self._handler)
try:
self._handler.close()
except (OSError, ValueError):
return
def _emit(self, level: int, message: str, attrs: Attrs | None) -> None:
extra = {'attrs': None if attrs is None else dict(attrs)}
self._logger.log(level, message, extra=extra)
class StdoutLogger(BaseLogger):
def __init__(self, name: str, level: str, output_format: str) -> None:
handler = SafeStreamHandler(sys.stdout)
super().__init__(_build_logger(name, level, output_format, handler), handler)
class FileLogger(BaseLogger):
def __init__(self, path: str, name: str, level: str, output_format: str) -> None:
file_path = Path(path)
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
handler = SafeFileHandler(file_path, mode='a', encoding='utf-8')
except OSError as exc:
raise ValueError('invalid logging.file_path') from exc
try:
logger = _build_logger(name, level, output_format, handler)
except Exception:
handler.close()
raise
super().__init__(logger, handler)
class SafeStreamHandler(logging.StreamHandler):
@override
def handleError(self, record: logging.LogRecord) -> None:
return None
class SafeFileHandler(logging.FileHandler):
@override
def handleError(self, record: logging.LogRecord) -> None:
return None
class TextFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
parts = [_timestamp(record.created), record.levelname, record.getMessage()]
attrs = _record_attrs(record)
if attrs is not None:
for key in sorted(attrs):
parts.append(f'{key}={json.dumps(attrs[key], separators=(",", ":"))}')
return ' '.join(parts)
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, object] = {
'timestamp': _timestamp(record.created),
'level': record.levelname,
'message': record.getMessage(),
}
attrs = _record_attrs(record)
if attrs is not None:
payload['attrs'] = attrs
return json.dumps(payload, separators=(',', ':'), sort_keys=True)
def _build_logger(
name: str,
level: str,
output_format: str,
handler: logging.Handler,
) -> logging.Logger:
logger = logging.Logger(name)
logger.setLevel(_log_level_value(level))
logger.propagate = False
handler.setLevel(_log_level_value(level))
handler.setFormatter(_formatter(output_format))
logger.addHandler(handler)
return logger
def _formatter(output_format: str) -> logging.Formatter:
normalized = output_format.strip().lower()
if normalized not in {'text', 'json'}:
raise ValueError('invalid log format')
if normalized == 'json':
return JsonFormatter()
return TextFormatter()
def _record_attrs(record: logging.LogRecord) -> dict[str, AttrValue] | None:
attrs = getattr(record, 'attrs', None)
if not isinstance(attrs, dict):
return None
return attrs
def _timestamp(created: float) -> str:
return (
datetime.fromtimestamp(created, tz=UTC)
.isoformat(timespec='milliseconds')
.replace('+00:00', 'Z')
)
def _log_level_value(level: str) -> int:
normalized = level.strip().upper()
if normalized not in _LEVELS:
raise ValueError('invalid log level')
return _LEVELS[normalized]

View file

@ -0,0 +1,54 @@
from types import TracebackType
from usecase.interface import Attrs, AttrValue
class NoopMetrics:
def increment(
self,
name: str,
value: int = 1,
attrs: Attrs | None = None,
) -> None:
return None
def record(
self,
name: str,
value: float,
attrs: Attrs | None = None,
) -> None:
return None
class NoopSpan:
def set_attribute(self, name: str, value: AttrValue) -> None:
return None
def record_error(self, error: Exception) -> None:
return None
class NoopSpanContext:
def __init__(self) -> None:
self._span = NoopSpan()
def __enter__(self) -> NoopSpan:
return self._span
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
traceback: TracebackType | None,
) -> bool | None:
return None
class NoopTracer:
def start_span(
self,
name: str,
attrs: Attrs | None = None,
) -> NoopSpanContext:
return NoopSpanContext()

View file

@ -0,0 +1,28 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.trace import TracerProvider
from usecase.interface import Logger, Metrics, Tracer
@dataclass(slots=True)
class ObservabilityRuntime:
logger: Logger
metrics: Metrics
tracer: Tracer
meter_provider: MeterProvider | None = None
tracer_provider: TracerProvider | None = None
_shutdown_callbacks: tuple[Callable[[], None], ...] = ()
_is_shutdown: bool = field(default=False, init=False, repr=False)
def shutdown(self) -> None:
if self._is_shutdown:
return
try:
for callback in self._shutdown_callbacks:
callback()
finally:
self._is_shutdown = True

View file

@ -1,7 +1,5 @@
from dataclasses import dataclass
from opentelemetry import metrics, trace
from opentelemetry._logs import set_logger_provider
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
@ -22,21 +20,33 @@ from .tracing import OtelTracer
@dataclass(frozen=True, slots=True)
class OtelRuntime:
logger_provider: LoggerProvider
meter_provider: MeterProvider
tracer_provider: TracerProvider
logger: OtelLogger
metrics: OtelMetrics
tracer: OtelTracer
logger_provider: LoggerProvider | None = None
meter_provider: MeterProvider | None = None
tracer_provider: TracerProvider | None = None
logger: OtelLogger | None = None
metrics: OtelMetrics | None = None
tracer: OtelTracer | None = None
def shutdown(self) -> None:
self.meter_provider.shutdown()
self.tracer_provider.shutdown()
self.logger_provider.shutdown()
if self.meter_provider is not None:
self.meter_provider.shutdown()
if self.tracer_provider is not None:
self.tracer_provider.shutdown()
if self.logger_provider is not None:
self.logger_provider.shutdown()
def setup_otel(config: AppConfig) -> OtelRuntime:
if config.otel.metric_export_interval <= 0:
def setup_otel(
config: AppConfig,
*,
enable_logs: bool,
enable_metrics: bool,
enable_tracing: bool,
) -> OtelRuntime:
if not any((enable_logs, enable_metrics, enable_tracing)):
raise ValueError('empty otel runtime')
if enable_metrics and config.otel.metric_export_interval <= 0:
raise ValueError('invalid metric export interval')
resource = Resource.create(
@ -46,34 +56,41 @@ def setup_otel(config: AppConfig) -> OtelRuntime:
}
)
logger_provider = LoggerProvider(resource=resource, shutdown_on_exit=False)
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(OTLPLogExporter(endpoint=config.otel.logs_endpoint))
)
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=config.otel.metrics_endpoint),
export_interval_millis=config.otel.metric_export_interval,
)
meter_provider = MeterProvider(
metric_readers=[metric_reader],
resource=resource,
shutdown_on_exit=False,
)
tracer_provider = TracerProvider(resource=resource, shutdown_on_exit=False)
tracer_provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint=config.otel.traces_endpoint))
)
set_logger_provider(logger_provider)
metrics.set_meter_provider(meter_provider)
trace.set_tracer_provider(tracer_provider)
scope_name = config.app.name
logger = OtelLogger(logger_provider.get_logger(scope_name), config.logging.level)
metrics_adapter = OtelMetrics(meter_provider.get_meter(scope_name))
tracer = OtelTracer(tracer_provider.get_tracer(scope_name))
logger_provider = None
meter_provider = None
tracer_provider = None
logger = None
metrics_adapter = None
tracer = None
if enable_logs:
logger_provider = LoggerProvider(resource=resource, shutdown_on_exit=False)
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(OTLPLogExporter(endpoint=config.otel.logs_endpoint))
)
logger = OtelLogger(
logger_provider.get_logger(scope_name), config.logging.level
)
if enable_metrics:
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=config.otel.metrics_endpoint),
export_interval_millis=config.otel.metric_export_interval,
)
meter_provider = MeterProvider(
metric_readers=[metric_reader],
resource=resource,
shutdown_on_exit=False,
)
metrics_adapter = OtelMetrics(meter_provider.get_meter(scope_name))
if enable_tracing:
tracer_provider = TracerProvider(resource=resource, shutdown_on_exit=False)
tracer_provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint=config.otel.traces_endpoint))
)
tracer = OtelTracer(tracer_provider.get_tracer(scope_name))
return OtelRuntime(
logger_provider=logger_provider,

View file

@ -4,11 +4,18 @@ app:
http:
host: 0.0.0.0
port: 8000
api_prefix: /api/v1
port: 8123
logging:
level: INFO
output: stdout
format: json
metrics:
enabled: false
tracing:
enabled: false
otel:
service_name: web-python-skelet

32
docker-compose.yml Normal file
View file

@ -0,0 +1,32 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
target: run
depends_on:
- otel-collector
environment:
APP_API_TOKEN: ${APP_API_TOKEN:?APP_API_TOKEN is required}
APP_SIGNING_KEY: ${APP_SIGNING_KEY:?APP_SIGNING_KEY is required}
APP_ENV: docker
APP_HTTP_HOST: 0.0.0.0
APP_HTTP_PORT: '8123'
APP_LOGGING_OUTPUT: otel
APP_METRICS_ENABLED: 'true'
APP_TRACING_ENABLED: 'true'
APP_OTEL_LOGS_ENDPOINT: http://otel-collector:4318/v1/logs
APP_OTEL_METRICS_ENDPOINT: http://otel-collector:4318/v1/metrics
APP_OTEL_TRACES_ENDPOINT: http://otel-collector:4318/v1/traces
ports:
- '127.0.0.1:8123:8123'
otel-collector:
image: grafana/otel-lgtm:latest
ports:
- '127.0.0.1:3000:3000'
volumes:
- lgtm-data:/data
volumes:
lgtm-data:

View file

@ -0,0 +1,16 @@
# 001 Composition Root and Lifetimes
Context
- The template must initialize repository and usecase objects once and reuse them across requests.
- FastAPI integration must not leak framework concerns into inner layers.
Decision
- Keep a single composition root in `adapter/di/container.py`.
- Build repositories, usecases, config, and observability adapters during application startup.
- Store the container in application state and expose instances through thin HTTP dependencies.
- Do not create repository or usecase objects per request.
Consequences
- Object lifetimes stay explicit and testable.
- Replacing the web framework affects only the HTTP adapter layer.
- Startup failures surface early instead of during request handling.

View file

@ -0,0 +1,16 @@
# 002 Config From YAML and Env
Context
- The service needs readable project configuration and safe handling of sensitive values.
- The final configuration object should be easy to inject and test.
Decision
- Keep non-sensitive defaults in YAML files under `config/`.
- Read sensitive values only from environment variables.
- Merge YAML and env sources into one `AppConfig` dataclass tree in `adapter/config/`.
- Keep configuration parsing and validation outside `domain/` and `usecase/`.
Consequences
- Local configuration remains simple to inspect and version.
- Secrets stay out of committed files.
- Inner layers depend on typed config data, not on env or YAML readers.

View file

@ -0,0 +1,17 @@
# 003 Observability Via Interfaces
Context
- Logging, metrics, and tracing are required, but inner layers must stay independent from OpenTelemetry.
- The project needs request logging and metrics middleware.
Decision
- Define observability interfaces in `usecase/interface.py`.
- Implement those interfaces with OpenTelemetry adapters in `adapter/otel/`.
- Use request logging middleware and metrics middleware in the FastAPI adapter.
- Do not add a custom trace middleware by default.
- Use standard ASGI/FastAPI OpenTelemetry instrumentation for request spans.
Consequences
- Usecases stay portable and framework-agnostic.
- Trace behavior remains consistent with OpenTelemetry defaults.
- Extra span enrichment can be added later without changing inner-layer contracts.

View file

@ -0,0 +1,16 @@
# 004 Versioned HTTP API
Context
- The template needs explicit API versioning and an HTTP boundary that can be replaced later.
- FastAPI is the initial framework, but it must not become a hard dependency for core logic.
Decision
- Place HTTP transport code under `adapter/http/fastapi/`.
- Mount routers under `/api/v1` and keep version-specific routers in `routers/v1/`.
- Map HTTP requests to usecase calls through thin dependencies and handlers only.
- Keep framework-specific request and response models in the HTTP adapter layer.
Consequences
- New API versions can be introduced without changing usecase contracts.
- Replacing FastAPI means rewriting only the HTTP adapter.
- Domain and usecase layers remain free from transport concerns.

View file

@ -0,0 +1,17 @@
# 005 Early FastAPI OTel Instrumentation
Context
- HTTP spans and HTTP metrics are provided by FastAPI/ASGI OpenTelemetry middleware.
- Starlette caches `middleware_stack` on first ASGI entry, including lifespan startup.
- Instrumenting FastAPI inside lifespan is too late for the initial middleware stack.
Decision
- Build the application container before returning the FastAPI app from `create_app`.
- Configure FastAPI OpenTelemetry instrumentation in the app factory, not in lifespan.
- Pass the configured tracer and meter providers directly to `FastAPIInstrumentor.instrument_app(...)`.
- Keep lifespan focused on shutdown and resource cleanup.
Consequences
- `OpenTelemetryMiddleware` is present in the runtime stack without manual stack rebuilds.
- HTTP traces and HTTP metrics use the same startup wiring as other singleton adapters.
- Observability bootstrap stays explicit in the outer adapter layer.

15
main.py Normal file
View file

@ -0,0 +1,15 @@
import uvicorn
from adapter.config.loader import load_config
from adapter.http.fastapi.app import create_app
config = load_config()
app = create_app(config)
def run() -> None:
uvicorn.run(app, host=config.http.host, port=config.http.port)
if __name__ == '__main__':
run()

View file

@ -1,5 +1,5 @@
[project]
name = "web-python-skelet"
name = "master"
version = "0.0.1"
description = ""
readme = "README.md"

View file

@ -1,15 +1,21 @@
from collections.abc import Iterable
from domain.user import User
from usecase.interface import UserRepository
from usecase.interface import Tracer, UserRepository
class InMemoryUserRepository(UserRepository):
def __init__(self, users: Iterable[User] | None = None) -> None:
def __init__(
self,
tracer: Tracer,
users: Iterable[User] | None = None,
) -> None:
self._users = {user.id: user for user in users or ()}
self._tracer = tracer
def get(self, user_id: str) -> User | None:
return self._users.get(user_id)
with self._tracer.start_span('repository.user', attrs={'user.id': user_id}):
return self._users.get(user_id)
def get_by_email(self, email: str) -> User | None:
for user in self._users.values():

142
tasks.md Normal file
View file

@ -0,0 +1,142 @@
# План работ: web-python-skelet
## Контекст
- Источник требований: `AGENTS.md` и ADR `docs/001`-`docs/004`
- Текущее состояние: в `adapter/`, `domain/`, `usecase/`, `repository/`, `test/` пока только `__init__.py`
- Отсутствуют рабочие каталоги и файлы из целевой структуры: `adapter/config/`, `adapter/otel/`, `adapter/di/`, `adapter/http/fastapi/`, `config/`, `main.py`
- Ограничения: `docs/` и `tasks.md` не добавлять в git; коммиты не делать; работать по одной задаче
- ADR пока покрывают архитектуру, новые ADR нужны только если по ходу работ изменится решение
## Правила выполнения
- Каждую задачу выполнять отдельным заходом, без параллельной реализации
- Каждый субагент отдает diff, список измененных файлов и проверку, но не делает commit
- Если в задаче всплывает архитектурное изменение, остановиться и вынести вопрос на согласование
## Очередь задач
### T01. Базовый каркас домена и usecase
- Исполнитель: `primary-agent` (scaffolding)
- Статус: completed
- Зависимости: нет
- Commit required: no
- Scope: создать базовые файлы и контракты в `domain/`, `usecase/`, `repository/`
- Файлы: `domain/error.py`, `domain/user.py`, `usecase/interface.py`, `usecase/user.py`, `repository/user.py`
- Критерии приемки: зависимости направлены внутрь; в `domain/` и `usecase/` нет FastAPI/OTel; есть пример сущности, ошибок, портов и простого usecase
### T02. Конфиг из YAML и env
- Субагент: `feature-developer`
- Статус: completed
- Зависимости: `T01`
- Commit required: no
- Scope: собрать typed-config слой в `adapter/config/` и подготовить базовые yaml-файлы
- Файлы: `adapter/config/*`, `config/app.yaml`
- Критерии приемки: конфиг собирается в одну dataclass-структуру; секреты читаются из env; парсинг и валидация не протекают в inner layers
### T03. Observability порты и OTel adapter
- Субагент: `feature-developer`
- Статус: completed
- Зависимости: `T01`, `T02`
- Commit required: no
- Scope: реализовать логгер, метрики, трейсинг и bootstrap OTel в `adapter/otel/` через интерфейсы из `usecase/interface.py`
- Файлы: `adapter/otel/*`, `config/otel-collector.yaml`
- Критерии приемки: inner layers знают только интерфейсы; OTLP exporter настраивается из конфига; нет кастомного trace middleware
### T04. Composition root и lifetime singleton-объектов
- Субагент: `feature-developer`
- Статус: completed
- Зависимости: `T01`, `T02`, `T03`
- Commit required: no
- Scope: собрать контейнер и startup wiring в `adapter/di/`
- Файлы: `adapter/di/container.py`, `adapter/di/__init__.py`
- Критерии приемки: repository/usecase создаются один раз на старте; контейнер хранит инстансы явно; нет пересоздания на HTTP-запрос
### T05. FastAPI adapter как заменяемый web layer
- Субагент: `feature-developer`
- Статус: completed
- Зависимости: `T04`
- Commit required: no
- Scope: поднять HTTP adapter в `adapter/http/fastapi/` с app factory, lifespan, dependencies, middleware и router ` /api/v1`
- Файлы: `adapter/http/fastapi/*`, `main.py`
- Критерии приемки: FastAPI изолирован в adapter-слое; handlers тонкие; request logging и metrics middleware подключены; usecase/repository берутся из контейнера
### T06. Локальный runtime и compose-окружение
- Субагент: `feature-developer`
- Статус: completed
- Зависимости: `T02`, `T03`, `T05`
- Commit required: no
- Scope: добавить контейнерный runtime для сервиса и compose-окружение с OTel и UI для просмотра логов, метрик и трейсов
- Файлы: `Dockerfile`, `docker-compose.yml`
- Ограничения: не трогать репозиторный `config/app.yaml`; docker должен прокидывать свой runtime-config внутрь контейнера; Dockerfile только с двумя стадиями `build` и `run`
- Критерии приемки: `make compose-build` и `make compose-up` опираются на существующие файлы; сервис поднимается в контейнере; OTel telemetry уходит в dockerized stack; есть UI для просмотра логов, метрик и трейсов; для локального docker-окружения достаточно только `Dockerfile` и `docker-compose.yml`
### T07. Тесты на lifetimes, config и HTTP smoke
- Субагент: `test-engineer`
- Статус: pending
- Зависимости: `T01`, `T02`, `T03`, `T04`, `T05`, `T06`
- Commit required: no
- Scope: покрыть тестами ключевые архитектурные гарантии
- Файлы: `test/*`
- Критерии приемки: есть тест на singleton lifetime для repository/usecase; есть тест merge YAML+env; есть smoke-тест для ` /api/v1`; тесты не тянут FastAPI/OTel в inner layers
### T08. Архитектурный и boundary review
- Субагент: `code-reviewer`
- Статус: pending
- Зависимости: `T07`
- Commit required: no
- Scope: проверить импорты, соблюдение слоев, startup lifetimes и заменяемость web adapter
- Файлы: весь измененный код
- Критерии приемки: dependency direction не нарушен; FastAPI и OTel не протекают в `domain/` и `usecase/`; замечания сформулированы как точечные правки или подтверждение готовности к review
### T09. Конфигурируемый runtime observability
- Субагент: `feature-developer`
- Статус: completed
- Зависимости: `T03`, `T04`, `T05`
- Commit required: no
- Scope: сделать конфигурируемый runtime observability с настраиваемым sink и форматом логов, плюс отдельными флагами для метрик и трейсов
- Файлы: `adapter/config/*`, `adapter/observability/*`, `adapter/otel/*`, `adapter/di/*`, `adapter/http/fastapi/*`, `config/app.yaml`, при необходимости `main.py`
- Конфиг: `logging.output=stdout|file|otel`, `logging.format=text|json`, `logging.file_path=...`, `metrics.enabled=true|false`, `tracing.enabled=true|false`
- Решение: вынести выбор runtime в отдельный adapter-layer factory; `domain/` и `usecase/` не менять; для `stdout` и `file` поддержать оба формата `text` и `json`; `logging.file_path` использовать только при `logging.output=file`; при отключенных метриках и трейсах использовать `Noop`-реализации; OTel runtime поднимать только если нужен хотя бы для одного сигнала
- Критерии приемки: при `logging.output=stdout` логи идут в stdout в формате `text` или `json` по конфигу; при `logging.output=file` логи пишутся в файл по пути `logging.file_path` в формате `text` или `json`; при `logging.output=otel` логи уходят в collector; `metrics.enabled=false` отключает метрики и metrics middleware; `tracing.enabled=false` отключает FastAPI instrumentation и tracer runtime; DI продолжает отдавать единый runtime через контейнер; внутренние слои по-прежнему знают только порты
### T10. ADR: раннее подключение OTel к FastAPI
- Исполнитель: `primary-agent` (docs)
- Статус: completed
- Зависимости: нет
- Commit required: no
- Scope: зафиксировать правило, что FastAPI OTel instrumentation выполняется до первой сборки `middleware_stack`
- Файлы: `docs/005-fastapi-otel-early-instrumentation.md`
- Критерии приемки: ADR занимает 10-20 строк; описаны context, decision, consequences; решение не переписывает историю прошлых ADR
### T11. Перенос FastAPI OTel bootstrap в app factory
- Субагент: `feature-developer`
- Статус: completed
- Зависимости: `T10`
- Commit required: no
- Scope: перенести создание container, установку `FastAPIInstrumentor.instrument_app(...)` из `lifespan` в `create_app`, оставив в `lifespan` только shutdown
- Файлы: `adapter/http/fastapi/app.py`, `adapter/http/fastapi/lifespan.py`, при необходимости `adapter/http/fastapi/dependencies.py`
- Ограничения: не использовать ручной rebuild `app.middleware_stack`; не менять `domain/` и `usecase/`; не добавлять бизнес-логику; сохранить singleton-lifetime container
- Критерии приемки: instrumentation происходит до первой сборки middleware stack; `OpenTelemetryMiddleware` попадает в runtime stack без workaround; shutdown закрывает instrumentation и runtime один раз; compose-конфиг продолжает работать
### T12. Регрессионная проверка HTTP telemetry wiring
- Субагент: `test-engineer`
- Статус: pending
- Зависимости: `T11`
- Commit required: no
- Scope: добавить проверку, что раннее instrumentation wiring сохраняется и не требует ручного rebuild middleware stack
- Файлы: `test/*`
- Ограничения: без реального collector; проверять через ASGI/lifespan или локальные assertions по app runtime; не тянуть FastAPI и OTel в inner-layer тесты
- Критерии приемки: тест подтверждает, что при включенных metrics/tracing `OpenTelemetryMiddleware` присутствует в runtime stack; тест не зависит от внешнего OTel collector; существующие архитектурные границы не нарушены

View file

@ -37,5 +37,4 @@ class GetUser:
raise error
span.set_attribute('user.email', user.email)
self._logger.info('user_loaded', attrs={'user_id': user.id})
return user