diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 03787a2..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,85 +0,0 @@ -# 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 deleted file mode 100644 index 50c46b1..0000000 --- a/Dockerfile +++ /dev/null @@ -1,35 +0,0 @@ -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 3953923..c91b811 100644 --- a/adapter/config/loader.py +++ b/adapter/config/loader.py @@ -10,10 +10,8 @@ from .model import ( AppSectionConfig, HttpConfig, LoggingConfig, - MetricsConfig, OtelConfig, SecurityConfig, - TracingConfig, ) ROOT_DIR = Path(__file__).resolve().parents[2] @@ -36,44 +34,9 @@ def load_config( app_section = _section(yaml_data, 'app') http_section = _section(yaml_data, 'http') logging_section = _section(yaml_data, 'logging') - metrics_section = _section(yaml_data, 'metrics') - tracing_section = _section(yaml_data, 'tracing') + otel_section = _section(yaml_data, 'otel') 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( @@ -96,6 +59,13 @@ 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( @@ -105,28 +75,43 @@ 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', - ) - ), ), - 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, + 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', + ), ), security=SecurityConfig( token_header=_yaml_or_env_str( @@ -206,94 +191,6 @@ 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, @@ -322,35 +219,6 @@ 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}') @@ -383,18 +251,6 @@ 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 2e7d74e..dd8643c 100644 --- a/adapter/config/model.py +++ b/adapter/config/model.py @@ -11,24 +11,12 @@ 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) @@ -52,7 +40,5 @@ 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 8c08e7f..b1efdbe 100644 --- a/adapter/di/container.py +++ b/adapter/di/container.py @@ -4,9 +4,7 @@ from pathlib import Path from adapter.config.loader import load_config from adapter.config.model import AppConfig -from adapter.observability.factory import build_observability -from adapter.observability.runtime import ObservabilityRuntime -from domain.user import User +from adapter.otel.bootstrap import OtelRuntime, setup_otel from repository.user import InMemoryUserRepository from usecase.user import GetUser @@ -24,7 +22,7 @@ class AppUsecases: @dataclass(slots=True) class AppContainer: config: AppConfig - observability: ObservabilityRuntime + observability: OtelRuntime repositories: AppRepositories usecases: AppUsecases _is_shutdown: bool = field(default=False, init=False, repr=False) @@ -43,21 +41,15 @@ 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: - app_config = config - if app_config is None: - app_config = load_config( - config_path=config_path, - env_path=env_path, - environ=environ, - ) - - observability = build_observability(app_config) - - user_repository = InMemoryUserRepository( - observability.tracer, [User(id='123', email='aza@gglamer.ru', name='gglamer')] + config = load_config( + config_path=config_path, + env_path=env_path, + environ=environ, ) + observability = setup_otel(config) + + user_repository = InMemoryUserRepository() repositories = AppRepositories(user=user_repository) usecases = AppUsecases( get_user=GetUser( @@ -68,7 +60,7 @@ def build_container( ) return AppContainer( - config=app_config, + config=config, observability=observability, repositories=repositories, usecases=usecases, diff --git a/adapter/http/__init__.py b/adapter/http/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/adapter/http/fastapi/__init__.py b/adapter/http/fastapi/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/adapter/http/fastapi/app.py b/adapter/http/fastapi/app.py deleted file mode 100644 index c23d0f9..0000000 --- a/adapter/http/fastapi/app.py +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 5afba58..0000000 --- a/adapter/http/fastapi/dependencies.py +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 83d277e..0000000 --- a/adapter/http/fastapi/middleware.py +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index df3d575..0000000 --- a/adapter/http/fastapi/routers/v1/router.py +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index e11c95b..0000000 --- a/adapter/http/fastapi/schemas.py +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/adapter/observability/factory.py b/adapter/observability/factory.py deleted file mode 100644 index 8f1f215..0000000 --- a/adapter/observability/factory.py +++ /dev/null @@ -1,101 +0,0 @@ -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 deleted file mode 100644 index f6d8f90..0000000 --- a/adapter/observability/logging.py +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index fe7d190..0000000 --- a/adapter/observability/noop.py +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 371d883..0000000 --- a/adapter/observability/runtime.py +++ /dev/null @@ -1,28 +0,0 @@ -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 923087f..cf332bd 100644 --- a/adapter/otel/bootstrap.py +++ b/adapter/otel/bootstrap.py @@ -1,5 +1,7 @@ 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 @@ -20,33 +22,21 @@ from .tracing import OtelTracer @dataclass(frozen=True, slots=True) class OtelRuntime: - 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 + logger_provider: LoggerProvider + meter_provider: MeterProvider + tracer_provider: TracerProvider + logger: OtelLogger + metrics: OtelMetrics + tracer: OtelTracer def shutdown(self) -> None: - 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() + self.meter_provider.shutdown() + self.tracer_provider.shutdown() + self.logger_provider.shutdown() -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: +def setup_otel(config: AppConfig) -> OtelRuntime: + if config.otel.metric_export_interval <= 0: raise ValueError('invalid metric export interval') resource = Resource.create( @@ -56,41 +46,34 @@ def setup_otel( } ) + 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_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)) + 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)) return OtelRuntime( logger_provider=logger_provider, diff --git a/config/app.yaml b/config/app.yaml index 7aa81f4..79ad34e 100644 --- a/config/app.yaml +++ b/config/app.yaml @@ -4,18 +4,11 @@ app: http: host: 0.0.0.0 - port: 8123 + port: 8000 + api_prefix: /api/v1 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 deleted file mode 100644 index 86e1bbb..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index c3ce3ec..0000000 --- a/docs/001-composition-root-and-lifetimes.md +++ /dev/null @@ -1,16 +0,0 @@ -# 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 deleted file mode 100644 index 42622b6..0000000 --- a/docs/002-config-yaml-plus-env.md +++ /dev/null @@ -1,16 +0,0 @@ -# 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 deleted file mode 100644 index c1b5770..0000000 --- a/docs/003-observability-via-interfaces.md +++ /dev/null @@ -1,17 +0,0 @@ -# 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 deleted file mode 100644 index 6656524..0000000 --- a/docs/004-versioned-http-api.md +++ /dev/null @@ -1,16 +0,0 @@ -# 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 deleted file mode 100644 index 2c52687..0000000 --- a/docs/005-fastapi-otel-early-instrumentation.md +++ /dev/null @@ -1,17 +0,0 @@ -# 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 deleted file mode 100644 index 8236ece..0000000 --- a/main.py +++ /dev/null @@ -1,15 +0,0 @@ -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 b72a3e7..062cb93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "master" +name = "web-python-skelet" version = "0.0.1" description = "" readme = "README.md" diff --git a/repository/user.py b/repository/user.py index 1dfa212..c95e52d 100644 --- a/repository/user.py +++ b/repository/user.py @@ -1,21 +1,15 @@ from collections.abc import Iterable from domain.user import User -from usecase.interface import Tracer, UserRepository +from usecase.interface import UserRepository class InMemoryUserRepository(UserRepository): - def __init__( - self, - tracer: Tracer, - users: Iterable[User] | None = None, - ) -> None: + def __init__(self, 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: - with self._tracer.start_span('repository.user', attrs={'user.id': user_id}): - return self._users.get(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 deleted file mode 100644 index b50acb2..0000000 --- a/tasks.md +++ /dev/null @@ -1,142 +0,0 @@ -# План работ: 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 dfb45b6..7f839b0 100644 --- a/usecase/user.py +++ b/usecase/user.py @@ -37,4 +37,5 @@ class GetUser: raise error span.set_attribute('user.email', user.email) + self._logger.info('user_loaded', attrs={'user_id': user.id}) return user