diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..03787a2 --- /dev/null +++ b/AGENTS.md @@ -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` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..50c46b1 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/adapter/config/loader.py b/adapter/config/loader.py index c91b811..3953923 100644 --- a/adapter/config/loader.py +++ b/adapter/config/loader.py @@ -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)) diff --git a/adapter/config/model.py b/adapter/config/model.py index dd8643c..2e7d74e 100644 --- a/adapter/config/model.py +++ b/adapter/config/model.py @@ -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 diff --git a/adapter/di/container.py b/adapter/di/container.py index b1efdbe..8c08e7f 100644 --- a/adapter/di/container.py +++ b/adapter/di/container.py @@ -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, diff --git a/adapter/http/__init__.py b/adapter/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapter/http/fastapi/__init__.py b/adapter/http/fastapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapter/http/fastapi/app.py b/adapter/http/fastapi/app.py new file mode 100644 index 0000000..c23d0f9 --- /dev/null +++ b/adapter/http/fastapi/app.py @@ -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)) diff --git a/adapter/http/fastapi/dependencies.py b/adapter/http/fastapi/dependencies.py new file mode 100644 index 0000000..5afba58 --- /dev/null +++ b/adapter/http/fastapi/dependencies.py @@ -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 diff --git a/adapter/http/fastapi/middleware.py b/adapter/http/fastapi/middleware.py new file mode 100644 index 0000000..83d277e --- /dev/null +++ b/adapter/http/fastapi/middleware.py @@ -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, + ) diff --git a/adapter/http/fastapi/routers/v1/router.py b/adapter/http/fastapi/routers/v1/router.py new file mode 100644 index 0000000..df3d575 --- /dev/null +++ b/adapter/http/fastapi/routers/v1/router.py @@ -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) diff --git a/adapter/http/fastapi/schemas.py b/adapter/http/fastapi/schemas.py new file mode 100644 index 0000000..e11c95b --- /dev/null +++ b/adapter/http/fastapi/schemas.py @@ -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 diff --git a/adapter/observability/__init__.py b/adapter/observability/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapter/observability/factory.py b/adapter/observability/factory.py new file mode 100644 index 0000000..8f1f215 --- /dev/null +++ b/adapter/observability/factory.py @@ -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') diff --git a/adapter/observability/logging.py b/adapter/observability/logging.py new file mode 100644 index 0000000..f6d8f90 --- /dev/null +++ b/adapter/observability/logging.py @@ -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] diff --git a/adapter/observability/noop.py b/adapter/observability/noop.py new file mode 100644 index 0000000..fe7d190 --- /dev/null +++ b/adapter/observability/noop.py @@ -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() diff --git a/adapter/observability/runtime.py b/adapter/observability/runtime.py new file mode 100644 index 0000000..371d883 --- /dev/null +++ b/adapter/observability/runtime.py @@ -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 diff --git a/adapter/otel/bootstrap.py b/adapter/otel/bootstrap.py index cf332bd..923087f 100644 --- a/adapter/otel/bootstrap.py +++ b/adapter/otel/bootstrap.py @@ -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, diff --git a/config/app.yaml b/config/app.yaml index 79ad34e..7aa81f4 100644 --- a/config/app.yaml +++ b/config/app.yaml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..86e1bbb --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/001-composition-root-and-lifetimes.md b/docs/001-composition-root-and-lifetimes.md new file mode 100644 index 0000000..c3ce3ec --- /dev/null +++ b/docs/001-composition-root-and-lifetimes.md @@ -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. diff --git a/docs/002-config-yaml-plus-env.md b/docs/002-config-yaml-plus-env.md new file mode 100644 index 0000000..42622b6 --- /dev/null +++ b/docs/002-config-yaml-plus-env.md @@ -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. diff --git a/docs/003-observability-via-interfaces.md b/docs/003-observability-via-interfaces.md new file mode 100644 index 0000000..c1b5770 --- /dev/null +++ b/docs/003-observability-via-interfaces.md @@ -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. diff --git a/docs/004-versioned-http-api.md b/docs/004-versioned-http-api.md new file mode 100644 index 0000000..6656524 --- /dev/null +++ b/docs/004-versioned-http-api.md @@ -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. diff --git a/docs/005-fastapi-otel-early-instrumentation.md b/docs/005-fastapi-otel-early-instrumentation.md new file mode 100644 index 0000000..2c52687 --- /dev/null +++ b/docs/005-fastapi-otel-early-instrumentation.md @@ -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. diff --git a/main.py b/main.py new file mode 100644 index 0000000..8236ece --- /dev/null +++ b/main.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 062cb93..b72a3e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "web-python-skelet" +name = "master" version = "0.0.1" description = "" readme = "README.md" diff --git a/repository/user.py b/repository/user.py index c95e52d..1dfa212 100644 --- a/repository/user.py +++ b/repository/user.py @@ -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(): diff --git a/tasks.md b/tasks.md new file mode 100644 index 0000000..b50acb2 --- /dev/null +++ b/tasks.md @@ -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; существующие архитектурные границы не нарушены diff --git a/usecase/user.py b/usecase/user.py index 7f839b0..dfb45b6 100644 --- a/usecase/user.py +++ b/usecase/user.py @@ -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