Compare commits
10 commits
ff6efb958d
...
1fbf77f879
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fbf77f879 | ||
|
|
597eae9b97 | ||
|
|
9ea3414bc0 | ||
|
|
0829ad6c12 | ||
|
|
67bdc287e9 | ||
|
|
0c55329769 | ||
|
|
741e63e978 | ||
|
|
cb4560db5c | ||
|
|
a930185754 | ||
|
|
05543bbbbb |
30 changed files with 1197 additions and 102 deletions
85
AGENTS.md
Normal file
85
AGENTS.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Project Guide
|
||||
|
||||
## Project
|
||||
- Python clean architecture template
|
||||
- FastAPI is the current HTTP adapter
|
||||
- The web layer must stay replaceable
|
||||
- Repository and usecase instances are created once at startup
|
||||
- Config comes from YAML plus env into one dataclass tree
|
||||
- Logs metrics and traces stay behind interfaces and use OTel adapters
|
||||
- Request logging middleware is used
|
||||
- Metrics middleware is used
|
||||
- Custom trace middleware is not used
|
||||
- API versioning lives under `/api/v1`
|
||||
|
||||
## Structure
|
||||
- `domain/` core model and domain errors
|
||||
- `domain/error.py` domain errors
|
||||
- `domain/user.py` example domain model
|
||||
- `usecase/` interfaces and usecases
|
||||
- `usecase/interface.py` repository and observability interfaces
|
||||
- `usecase/user.py` example user usecase
|
||||
- `repository/` repository implementations
|
||||
- `repository/user.py` example in-memory repository
|
||||
- `adapter/config/` config models and loader
|
||||
- `adapter/otel/` logger metrics tracer and OTel setup
|
||||
- `adapter/di/` container and startup wiring
|
||||
- `adapter/http/fastapi/` app dependencies lifespan middleware routers
|
||||
- `config/` app YAML and OTel collector config
|
||||
- `docs/` ADR files
|
||||
- `tasks.md` task list
|
||||
- `main.py` local entrypoint
|
||||
|
||||
## Boundaries
|
||||
- Keep dependency direction inward
|
||||
- `domain` imports nothing internal
|
||||
- `usecase` may import `domain`
|
||||
- `repository` may import `usecase` and `domain`
|
||||
- `adapter` may import `usecase` and `domain`
|
||||
- Do not import FastAPI into `domain` or `usecase`
|
||||
- Do not import OpenTelemetry into `domain` or `usecase`
|
||||
- Keep HTTP models and middleware inside `adapter/http/fastapi/`
|
||||
|
||||
## Workflow
|
||||
- Use `tasks.md` for planning
|
||||
- Do not use Beads
|
||||
- Do not use `bd`
|
||||
- Use `uv` for Python commands and dependency management
|
||||
- Do not create commits on your own
|
||||
- Work on one task at a time
|
||||
- Prefer delegation for implementation
|
||||
- Delegate only one task at a time
|
||||
- After one task return to the user with result verification and next options
|
||||
- Wait for the user before the next task commit or fix
|
||||
|
||||
## Makefile
|
||||
- `make install` install deps with `uv`
|
||||
- `make run` start the app locally
|
||||
- `make run-otel` start the app with OTel env
|
||||
- `make lint` run `ruff`
|
||||
- `make typecheck` run `mypy`
|
||||
- `make test` run `pytest`
|
||||
- `make pre-commit` run lint typecheck test
|
||||
- `make compose-build` build the containers
|
||||
- `make compose-up` start app and collector
|
||||
- `make compose-down` stop the stack
|
||||
- `make compose-logs` show app and collector logs
|
||||
- `make compose-ps` show compose status
|
||||
|
||||
## Runtime
|
||||
- Local run needs `APP_API_TOKEN` and `APP_SIGNING_KEY`
|
||||
- Base config lives in `config/app.yaml`
|
||||
- Env overrides come from shell vars or `.env`
|
||||
- OTel collector config lives in `config/otel-collector.yaml`
|
||||
|
||||
## Style
|
||||
- Comments are short and have no trailing period
|
||||
- Error messages are short and have no trailing period
|
||||
- Use single-word comments when possible
|
||||
- Use single-word error messages when possible
|
||||
- Keep names simple
|
||||
- Keep adapters thin
|
||||
- Keep `__init__.py` empty
|
||||
- Prefer explicit wiring over magic
|
||||
- Do not expand scope without user approval
|
||||
- Do not `from __future__ import annotations`
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
FROM python:3.13-slim AS build
|
||||
|
||||
ENV UV_LINK_MODE=copy
|
||||
WORKDIR /app
|
||||
|
||||
RUN python -m pip install --no-cache-dir uv
|
||||
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --locked --no-dev --no-install-project
|
||||
|
||||
|
||||
FROM python:3.13-slim AS run
|
||||
|
||||
ENV PATH="/app/.venv/bin:${PATH}" \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
VIRTUAL_ENV=/app/.venv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN useradd --system --create-home --shell /usr/sbin/nologin appuser
|
||||
|
||||
COPY --from=build --chown=appuser:appuser /app/.venv /app/.venv
|
||||
COPY --chown=appuser:appuser adapter /app/adapter
|
||||
COPY --chown=appuser:appuser config /app/config
|
||||
COPY --chown=appuser:appuser domain /app/domain
|
||||
COPY --chown=appuser:appuser repository /app/repository
|
||||
COPY --chown=appuser:appuser usecase /app/usecase
|
||||
COPY --chown=appuser:appuser main.py /app/main.py
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8123
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ from pathlib import Path
|
|||
|
||||
from adapter.config.loader import load_config
|
||||
from adapter.config.model import AppConfig
|
||||
from adapter.otel.bootstrap import OtelRuntime, setup_otel
|
||||
from adapter.observability.factory import build_observability
|
||||
from adapter.observability.runtime import ObservabilityRuntime
|
||||
from domain.user import User
|
||||
from repository.user import InMemoryUserRepository
|
||||
from usecase.user import GetUser
|
||||
|
||||
|
|
@ -22,7 +24,7 @@ class AppUsecases:
|
|||
@dataclass(slots=True)
|
||||
class AppContainer:
|
||||
config: AppConfig
|
||||
observability: OtelRuntime
|
||||
observability: ObservabilityRuntime
|
||||
repositories: AppRepositories
|
||||
usecases: AppUsecases
|
||||
_is_shutdown: bool = field(default=False, init=False, repr=False)
|
||||
|
|
@ -41,15 +43,21 @@ def build_container(
|
|||
config_path: Path | str | None = None,
|
||||
env_path: Path | str | None = None,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
config: AppConfig | None = None,
|
||||
) -> AppContainer:
|
||||
config = load_config(
|
||||
config_path=config_path,
|
||||
env_path=env_path,
|
||||
environ=environ,
|
||||
)
|
||||
observability = setup_otel(config)
|
||||
app_config = config
|
||||
if app_config is None:
|
||||
app_config = load_config(
|
||||
config_path=config_path,
|
||||
env_path=env_path,
|
||||
environ=environ,
|
||||
)
|
||||
|
||||
user_repository = InMemoryUserRepository()
|
||||
observability = build_observability(app_config)
|
||||
|
||||
user_repository = InMemoryUserRepository(
|
||||
observability.tracer, [User(id='123', email='aza@gglamer.ru', name='gglamer')]
|
||||
)
|
||||
repositories = AppRepositories(user=user_repository)
|
||||
usecases = AppUsecases(
|
||||
get_user=GetUser(
|
||||
|
|
@ -60,7 +68,7 @@ def build_container(
|
|||
)
|
||||
|
||||
return AppContainer(
|
||||
config=config,
|
||||
config=app_config,
|
||||
observability=observability,
|
||||
repositories=repositories,
|
||||
usecases=usecases,
|
||||
|
|
|
|||
0
adapter/http/__init__.py
Normal file
0
adapter/http/__init__.py
Normal file
0
adapter/http/fastapi/__init__.py
Normal file
0
adapter/http/fastapi/__init__.py
Normal file
65
adapter/http/fastapi/app.py
Normal file
65
adapter/http/fastapi/app.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from collections.abc import Callable
|
||||
|
||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||
|
||||
from adapter.config.loader import load_config
|
||||
from adapter.config.model import AppConfig
|
||||
from adapter.di.container import AppContainer, build_container
|
||||
from adapter.http.fastapi.dependencies import APP_CONFIG_STATE, APP_CONTAINER_STATE
|
||||
from adapter.http.fastapi.middleware import register_middleware
|
||||
from adapter.http.fastapi.routers.v1.router import router as v1_router
|
||||
from fastapi import FastAPI
|
||||
|
||||
API_V1_PREFIX = '/api/v1'
|
||||
|
||||
|
||||
def create_app(config: AppConfig | None = None) -> FastAPI:
|
||||
app_config = load_config() if config is None else config
|
||||
container = build_container(config=app_config)
|
||||
app: FastAPI | None = None
|
||||
|
||||
try:
|
||||
app = FastAPI(title=app_config.app.name)
|
||||
setattr(app.state, APP_CONFIG_STATE, app_config)
|
||||
setattr(app.state, APP_CONTAINER_STATE, container)
|
||||
app.add_event_handler('shutdown', _build_shutdown_handler(app, container))
|
||||
register_middleware(app, app_config)
|
||||
app.include_router(v1_router, prefix=API_V1_PREFIX)
|
||||
|
||||
FastAPIInstrumentor.instrument_app(
|
||||
app,
|
||||
tracer_provider=container.observability.tracer_provider,
|
||||
meter_provider=container.observability.meter_provider,
|
||||
exclude_spans=['send', 'receive'],
|
||||
)
|
||||
|
||||
return app
|
||||
except Exception:
|
||||
try:
|
||||
if app is not None:
|
||||
_uninstrument_app(app)
|
||||
finally:
|
||||
container.shutdown()
|
||||
raise
|
||||
|
||||
|
||||
def _build_shutdown_handler(
|
||||
app: FastAPI,
|
||||
container: AppContainer,
|
||||
) -> Callable[[], None]:
|
||||
def shutdown() -> None:
|
||||
try:
|
||||
_uninstrument_app(app)
|
||||
finally:
|
||||
container.shutdown()
|
||||
|
||||
return shutdown
|
||||
|
||||
|
||||
def _uninstrument_app(app: FastAPI) -> None:
|
||||
if _is_instrumented(app):
|
||||
FastAPIInstrumentor.uninstrument_app(app)
|
||||
|
||||
|
||||
def _is_instrumented(app: FastAPI) -> bool:
|
||||
return bool(getattr(app, '_is_instrumented_by_opentelemetry', False))
|
||||
19
adapter/http/fastapi/dependencies.py
Normal file
19
adapter/http/fastapi/dependencies.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from typing import cast
|
||||
|
||||
from adapter.di.container import AppContainer
|
||||
from fastapi import Depends, Request
|
||||
from usecase.user import GetUser
|
||||
|
||||
APP_CONTAINER_STATE = 'container'
|
||||
APP_CONFIG_STATE = 'config'
|
||||
|
||||
|
||||
def get_container(request: Request) -> AppContainer:
|
||||
container = getattr(request.app.state, APP_CONTAINER_STATE, None)
|
||||
if container is None:
|
||||
raise RuntimeError('container unavailable')
|
||||
return cast(AppContainer, container)
|
||||
|
||||
|
||||
def get_get_user(container: AppContainer = Depends(get_container)) -> GetUser:
|
||||
return container.usecases.get_user
|
||||
33
adapter/http/fastapi/middleware.py
Normal file
33
adapter/http/fastapi/middleware.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from time import perf_counter
|
||||
|
||||
from adapter.config.model import AppConfig
|
||||
from adapter.http.fastapi.dependencies import get_container
|
||||
from fastapi import FastAPI, Request, Response
|
||||
|
||||
|
||||
def register_middleware(app: FastAPI, config: AppConfig) -> None:
|
||||
@app.middleware('http')
|
||||
async def request_logging_middleware(
|
||||
request: Request,
|
||||
call_next,
|
||||
) -> Response:
|
||||
start = perf_counter()
|
||||
status_code = 500
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status_code = response.status_code
|
||||
return response
|
||||
finally:
|
||||
duration_ms = (perf_counter() - start) * 1000
|
||||
container = get_container(request)
|
||||
attrs: dict[str, str | int | float | bool] = {
|
||||
'http.method': request.method,
|
||||
'http.path': request.url.path,
|
||||
'http.status_code': status_code,
|
||||
'http.duration_ms': duration_ms,
|
||||
}
|
||||
container.observability.logger.info(
|
||||
'http_request',
|
||||
attrs=attrs,
|
||||
)
|
||||
40
adapter/http/fastapi/routers/v1/router.py
Normal file
40
adapter/http/fastapi/routers/v1/router.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from adapter.di.container import AppContainer
|
||||
from adapter.http.fastapi.dependencies import get_container, get_get_user
|
||||
from adapter.http.fastapi.schemas import ErrorResponse, HealthResponse, UserResponse
|
||||
from domain.error import UserNotFoundError
|
||||
from usecase.user import GetUser, GetUserQuery
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
'/health',
|
||||
response_model=HealthResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def health(container: AppContainer = Depends(get_container)) -> HealthResponse:
|
||||
return HealthResponse(
|
||||
status='ok',
|
||||
app=container.config.app.name,
|
||||
env=container.config.app.env,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
'/users/{user_id}',
|
||||
response_model=UserResponse,
|
||||
responses={status.HTTP_404_NOT_FOUND: {'model': ErrorResponse}},
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_user(user_id: str, usecase: GetUser = Depends(get_get_user)) -> UserResponse:
|
||||
try:
|
||||
user = usecase.execute(GetUserQuery(user_id=user_id))
|
||||
except UserNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
return UserResponse(id=user.id, email=user.email, name=user.name)
|
||||
17
adapter/http/fastapi/schemas.py
Normal file
17
adapter/http/fastapi/schemas.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
app: str
|
||||
env: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
0
adapter/observability/__init__.py
Normal file
0
adapter/observability/__init__.py
Normal file
101
adapter/observability/factory.py
Normal file
101
adapter/observability/factory.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
from collections.abc import Callable
|
||||
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
from adapter.config.model import AppConfig
|
||||
from adapter.otel.bootstrap import OtelRuntime, setup_otel
|
||||
from usecase.interface import Logger, Metrics, Tracer
|
||||
|
||||
from .logging import FileLogger, StdoutLogger
|
||||
from .noop import NoopMetrics, NoopTracer
|
||||
from .runtime import ObservabilityRuntime
|
||||
|
||||
|
||||
def build_observability(config: AppConfig) -> ObservabilityRuntime:
|
||||
otel_runtime = _setup_otel_runtime(config)
|
||||
|
||||
try:
|
||||
logger, logger_shutdown = _build_logger(config, otel_runtime)
|
||||
except Exception:
|
||||
if otel_runtime is not None:
|
||||
otel_runtime.shutdown()
|
||||
raise
|
||||
|
||||
metrics: Metrics = NoopMetrics()
|
||||
tracer: Tracer = NoopTracer()
|
||||
meter_provider: MeterProvider | None = None
|
||||
tracer_provider: TracerProvider | None = None
|
||||
shutdown_callback_list: list[Callable[[], None]] = []
|
||||
|
||||
if logger_shutdown is not None:
|
||||
shutdown_callback_list.append(logger_shutdown)
|
||||
|
||||
if otel_runtime is not None:
|
||||
meter_provider = otel_runtime.meter_provider
|
||||
tracer_provider = otel_runtime.tracer_provider
|
||||
shutdown_callback_list.append(otel_runtime.shutdown)
|
||||
|
||||
if config.metrics.enabled:
|
||||
if otel_runtime is None or otel_runtime.metrics is None:
|
||||
raise ValueError('missing otel metrics')
|
||||
metrics = otel_runtime.metrics
|
||||
|
||||
if config.tracing.enabled:
|
||||
if otel_runtime is None or otel_runtime.tracer is None:
|
||||
raise ValueError('missing otel tracer')
|
||||
tracer = otel_runtime.tracer
|
||||
|
||||
return ObservabilityRuntime(
|
||||
logger=logger,
|
||||
metrics=metrics,
|
||||
tracer=tracer,
|
||||
meter_provider=meter_provider,
|
||||
tracer_provider=tracer_provider,
|
||||
_shutdown_callbacks=tuple(shutdown_callback_list),
|
||||
)
|
||||
|
||||
|
||||
def _setup_otel_runtime(config: AppConfig) -> OtelRuntime | None:
|
||||
enable_logs = config.logging.output == 'otel'
|
||||
enable_metrics = config.metrics.enabled
|
||||
enable_tracing = config.tracing.enabled
|
||||
|
||||
if not any((enable_logs, enable_metrics, enable_tracing)):
|
||||
return None
|
||||
|
||||
return setup_otel(
|
||||
config,
|
||||
enable_logs=enable_logs,
|
||||
enable_metrics=enable_metrics,
|
||||
enable_tracing=enable_tracing,
|
||||
)
|
||||
|
||||
|
||||
def _build_logger(
|
||||
config: AppConfig,
|
||||
otel_runtime: OtelRuntime | None,
|
||||
) -> tuple[Logger, Callable[[], None] | None]:
|
||||
if config.logging.output == 'stdout':
|
||||
stdout_logger = StdoutLogger(
|
||||
f'{config.app.name}.stdout',
|
||||
config.logging.level,
|
||||
config.logging.format,
|
||||
)
|
||||
return stdout_logger, stdout_logger.shutdown
|
||||
if config.logging.output == 'file':
|
||||
file_path = config.logging.file_path
|
||||
if file_path is None:
|
||||
raise ValueError('missing logging.file_path')
|
||||
file_logger = FileLogger(
|
||||
file_path,
|
||||
f'{config.app.name}.file',
|
||||
config.logging.level,
|
||||
config.logging.format,
|
||||
)
|
||||
return file_logger, file_logger.shutdown
|
||||
if config.logging.output == 'otel':
|
||||
if otel_runtime is None or otel_runtime.logger is None:
|
||||
raise ValueError('missing otel logger')
|
||||
return otel_runtime.logger, None
|
||||
raise ValueError('invalid logging output')
|
||||
152
adapter/observability/logging.py
Normal file
152
adapter/observability/logging.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import json
|
||||
import logging
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
from usecase.interface import Attrs, AttrValue
|
||||
|
||||
_LEVELS = {
|
||||
'DEBUG': logging.DEBUG,
|
||||
'INFO': logging.INFO,
|
||||
'WARN': logging.WARNING,
|
||||
'WARNING': logging.WARNING,
|
||||
'ERROR': logging.ERROR,
|
||||
'FATAL': logging.CRITICAL,
|
||||
'CRITICAL': logging.CRITICAL,
|
||||
}
|
||||
|
||||
|
||||
class BaseLogger:
|
||||
def __init__(self, logger: logging.Logger, handler: logging.Handler) -> None:
|
||||
self._logger = logger
|
||||
self._handler = handler
|
||||
|
||||
def debug(self, message: str, attrs: Attrs | None = None) -> None:
|
||||
self._emit(logging.DEBUG, message, attrs)
|
||||
|
||||
def info(self, message: str, attrs: Attrs | None = None) -> None:
|
||||
self._emit(logging.INFO, message, attrs)
|
||||
|
||||
def warning(self, message: str, attrs: Attrs | None = None) -> None:
|
||||
self._emit(logging.WARNING, message, attrs)
|
||||
|
||||
def error(self, message: str, attrs: Attrs | None = None) -> None:
|
||||
self._emit(logging.ERROR, message, attrs)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._logger.removeHandler(self._handler)
|
||||
try:
|
||||
self._handler.close()
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
|
||||
def _emit(self, level: int, message: str, attrs: Attrs | None) -> None:
|
||||
extra = {'attrs': None if attrs is None else dict(attrs)}
|
||||
self._logger.log(level, message, extra=extra)
|
||||
|
||||
|
||||
class StdoutLogger(BaseLogger):
|
||||
def __init__(self, name: str, level: str, output_format: str) -> None:
|
||||
handler = SafeStreamHandler(sys.stdout)
|
||||
super().__init__(_build_logger(name, level, output_format, handler), handler)
|
||||
|
||||
|
||||
class FileLogger(BaseLogger):
|
||||
def __init__(self, path: str, name: str, level: str, output_format: str) -> None:
|
||||
file_path = Path(path)
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = SafeFileHandler(file_path, mode='a', encoding='utf-8')
|
||||
except OSError as exc:
|
||||
raise ValueError('invalid logging.file_path') from exc
|
||||
|
||||
try:
|
||||
logger = _build_logger(name, level, output_format, handler)
|
||||
except Exception:
|
||||
handler.close()
|
||||
raise
|
||||
|
||||
super().__init__(logger, handler)
|
||||
|
||||
|
||||
class SafeStreamHandler(logging.StreamHandler):
|
||||
@override
|
||||
def handleError(self, record: logging.LogRecord) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class SafeFileHandler(logging.FileHandler):
|
||||
@override
|
||||
def handleError(self, record: logging.LogRecord) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class TextFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
parts = [_timestamp(record.created), record.levelname, record.getMessage()]
|
||||
attrs = _record_attrs(record)
|
||||
if attrs is not None:
|
||||
for key in sorted(attrs):
|
||||
parts.append(f'{key}={json.dumps(attrs[key], separators=(",", ":"))}')
|
||||
return ' '.join(parts)
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict[str, object] = {
|
||||
'timestamp': _timestamp(record.created),
|
||||
'level': record.levelname,
|
||||
'message': record.getMessage(),
|
||||
}
|
||||
attrs = _record_attrs(record)
|
||||
if attrs is not None:
|
||||
payload['attrs'] = attrs
|
||||
return json.dumps(payload, separators=(',', ':'), sort_keys=True)
|
||||
|
||||
|
||||
def _build_logger(
|
||||
name: str,
|
||||
level: str,
|
||||
output_format: str,
|
||||
handler: logging.Handler,
|
||||
) -> logging.Logger:
|
||||
logger = logging.Logger(name)
|
||||
logger.setLevel(_log_level_value(level))
|
||||
logger.propagate = False
|
||||
handler.setLevel(_log_level_value(level))
|
||||
handler.setFormatter(_formatter(output_format))
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
|
||||
def _formatter(output_format: str) -> logging.Formatter:
|
||||
normalized = output_format.strip().lower()
|
||||
if normalized not in {'text', 'json'}:
|
||||
raise ValueError('invalid log format')
|
||||
if normalized == 'json':
|
||||
return JsonFormatter()
|
||||
return TextFormatter()
|
||||
|
||||
|
||||
def _record_attrs(record: logging.LogRecord) -> dict[str, AttrValue] | None:
|
||||
attrs = getattr(record, 'attrs', None)
|
||||
if not isinstance(attrs, dict):
|
||||
return None
|
||||
return attrs
|
||||
|
||||
|
||||
def _timestamp(created: float) -> str:
|
||||
return (
|
||||
datetime.fromtimestamp(created, tz=UTC)
|
||||
.isoformat(timespec='milliseconds')
|
||||
.replace('+00:00', 'Z')
|
||||
)
|
||||
|
||||
|
||||
def _log_level_value(level: str) -> int:
|
||||
normalized = level.strip().upper()
|
||||
if normalized not in _LEVELS:
|
||||
raise ValueError('invalid log level')
|
||||
return _LEVELS[normalized]
|
||||
54
adapter/observability/noop.py
Normal file
54
adapter/observability/noop.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from types import TracebackType
|
||||
|
||||
from usecase.interface import Attrs, AttrValue
|
||||
|
||||
|
||||
class NoopMetrics:
|
||||
def increment(
|
||||
self,
|
||||
name: str,
|
||||
value: int = 1,
|
||||
attrs: Attrs | None = None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
def record(
|
||||
self,
|
||||
name: str,
|
||||
value: float,
|
||||
attrs: Attrs | None = None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class NoopSpan:
|
||||
def set_attribute(self, name: str, value: AttrValue) -> None:
|
||||
return None
|
||||
|
||||
def record_error(self, error: Exception) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class NoopSpanContext:
|
||||
def __init__(self) -> None:
|
||||
self._span = NoopSpan()
|
||||
|
||||
def __enter__(self) -> NoopSpan:
|
||||
return self._span
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
traceback: TracebackType | None,
|
||||
) -> bool | None:
|
||||
return None
|
||||
|
||||
|
||||
class NoopTracer:
|
||||
def start_span(
|
||||
self,
|
||||
name: str,
|
||||
attrs: Attrs | None = None,
|
||||
) -> NoopSpanContext:
|
||||
return NoopSpanContext()
|
||||
28
adapter/observability/runtime.py
Normal file
28
adapter/observability/runtime.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
from usecase.interface import Logger, Metrics, Tracer
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ObservabilityRuntime:
|
||||
logger: Logger
|
||||
metrics: Metrics
|
||||
tracer: Tracer
|
||||
meter_provider: MeterProvider | None = None
|
||||
tracer_provider: TracerProvider | None = None
|
||||
_shutdown_callbacks: tuple[Callable[[], None], ...] = ()
|
||||
_is_shutdown: bool = field(default=False, init=False, repr=False)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if self._is_shutdown:
|
||||
return
|
||||
|
||||
try:
|
||||
for callback in self._shutdown_callbacks:
|
||||
callback()
|
||||
finally:
|
||||
self._is_shutdown = True
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -4,11 +4,18 @@ app:
|
|||
|
||||
http:
|
||||
host: 0.0.0.0
|
||||
port: 8000
|
||||
api_prefix: /api/v1
|
||||
port: 8123
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
output: stdout
|
||||
format: json
|
||||
|
||||
metrics:
|
||||
enabled: false
|
||||
|
||||
tracing:
|
||||
enabled: false
|
||||
|
||||
otel:
|
||||
service_name: web-python-skelet
|
||||
|
|
|
|||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: run
|
||||
depends_on:
|
||||
- otel-collector
|
||||
environment:
|
||||
APP_API_TOKEN: ${APP_API_TOKEN:?APP_API_TOKEN is required}
|
||||
APP_SIGNING_KEY: ${APP_SIGNING_KEY:?APP_SIGNING_KEY is required}
|
||||
APP_ENV: docker
|
||||
APP_HTTP_HOST: 0.0.0.0
|
||||
APP_HTTP_PORT: '8123'
|
||||
APP_LOGGING_OUTPUT: otel
|
||||
APP_METRICS_ENABLED: 'true'
|
||||
APP_TRACING_ENABLED: 'true'
|
||||
APP_OTEL_LOGS_ENDPOINT: http://otel-collector:4318/v1/logs
|
||||
APP_OTEL_METRICS_ENDPOINT: http://otel-collector:4318/v1/metrics
|
||||
APP_OTEL_TRACES_ENDPOINT: http://otel-collector:4318/v1/traces
|
||||
ports:
|
||||
- '127.0.0.1:8123:8123'
|
||||
|
||||
otel-collector:
|
||||
image: grafana/otel-lgtm:latest
|
||||
ports:
|
||||
- '127.0.0.1:3000:3000'
|
||||
volumes:
|
||||
- lgtm-data:/data
|
||||
|
||||
volumes:
|
||||
lgtm-data:
|
||||
16
docs/001-composition-root-and-lifetimes.md
Normal file
16
docs/001-composition-root-and-lifetimes.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# 001 Composition Root and Lifetimes
|
||||
|
||||
Context
|
||||
- The template must initialize repository and usecase objects once and reuse them across requests.
|
||||
- FastAPI integration must not leak framework concerns into inner layers.
|
||||
|
||||
Decision
|
||||
- Keep a single composition root in `adapter/di/container.py`.
|
||||
- Build repositories, usecases, config, and observability adapters during application startup.
|
||||
- Store the container in application state and expose instances through thin HTTP dependencies.
|
||||
- Do not create repository or usecase objects per request.
|
||||
|
||||
Consequences
|
||||
- Object lifetimes stay explicit and testable.
|
||||
- Replacing the web framework affects only the HTTP adapter layer.
|
||||
- Startup failures surface early instead of during request handling.
|
||||
16
docs/002-config-yaml-plus-env.md
Normal file
16
docs/002-config-yaml-plus-env.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# 002 Config From YAML and Env
|
||||
|
||||
Context
|
||||
- The service needs readable project configuration and safe handling of sensitive values.
|
||||
- The final configuration object should be easy to inject and test.
|
||||
|
||||
Decision
|
||||
- Keep non-sensitive defaults in YAML files under `config/`.
|
||||
- Read sensitive values only from environment variables.
|
||||
- Merge YAML and env sources into one `AppConfig` dataclass tree in `adapter/config/`.
|
||||
- Keep configuration parsing and validation outside `domain/` and `usecase/`.
|
||||
|
||||
Consequences
|
||||
- Local configuration remains simple to inspect and version.
|
||||
- Secrets stay out of committed files.
|
||||
- Inner layers depend on typed config data, not on env or YAML readers.
|
||||
17
docs/003-observability-via-interfaces.md
Normal file
17
docs/003-observability-via-interfaces.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# 003 Observability Via Interfaces
|
||||
|
||||
Context
|
||||
- Logging, metrics, and tracing are required, but inner layers must stay independent from OpenTelemetry.
|
||||
- The project needs request logging and metrics middleware.
|
||||
|
||||
Decision
|
||||
- Define observability interfaces in `usecase/interface.py`.
|
||||
- Implement those interfaces with OpenTelemetry adapters in `adapter/otel/`.
|
||||
- Use request logging middleware and metrics middleware in the FastAPI adapter.
|
||||
- Do not add a custom trace middleware by default.
|
||||
- Use standard ASGI/FastAPI OpenTelemetry instrumentation for request spans.
|
||||
|
||||
Consequences
|
||||
- Usecases stay portable and framework-agnostic.
|
||||
- Trace behavior remains consistent with OpenTelemetry defaults.
|
||||
- Extra span enrichment can be added later without changing inner-layer contracts.
|
||||
16
docs/004-versioned-http-api.md
Normal file
16
docs/004-versioned-http-api.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# 004 Versioned HTTP API
|
||||
|
||||
Context
|
||||
- The template needs explicit API versioning and an HTTP boundary that can be replaced later.
|
||||
- FastAPI is the initial framework, but it must not become a hard dependency for core logic.
|
||||
|
||||
Decision
|
||||
- Place HTTP transport code under `adapter/http/fastapi/`.
|
||||
- Mount routers under `/api/v1` and keep version-specific routers in `routers/v1/`.
|
||||
- Map HTTP requests to usecase calls through thin dependencies and handlers only.
|
||||
- Keep framework-specific request and response models in the HTTP adapter layer.
|
||||
|
||||
Consequences
|
||||
- New API versions can be introduced without changing usecase contracts.
|
||||
- Replacing FastAPI means rewriting only the HTTP adapter.
|
||||
- Domain and usecase layers remain free from transport concerns.
|
||||
17
docs/005-fastapi-otel-early-instrumentation.md
Normal file
17
docs/005-fastapi-otel-early-instrumentation.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# 005 Early FastAPI OTel Instrumentation
|
||||
|
||||
Context
|
||||
- HTTP spans and HTTP metrics are provided by FastAPI/ASGI OpenTelemetry middleware.
|
||||
- Starlette caches `middleware_stack` on first ASGI entry, including lifespan startup.
|
||||
- Instrumenting FastAPI inside lifespan is too late for the initial middleware stack.
|
||||
|
||||
Decision
|
||||
- Build the application container before returning the FastAPI app from `create_app`.
|
||||
- Configure FastAPI OpenTelemetry instrumentation in the app factory, not in lifespan.
|
||||
- Pass the configured tracer and meter providers directly to `FastAPIInstrumentor.instrument_app(...)`.
|
||||
- Keep lifespan focused on shutdown and resource cleanup.
|
||||
|
||||
Consequences
|
||||
- `OpenTelemetryMiddleware` is present in the runtime stack without manual stack rebuilds.
|
||||
- HTTP traces and HTTP metrics use the same startup wiring as other singleton adapters.
|
||||
- Observability bootstrap stays explicit in the outer adapter layer.
|
||||
15
main.py
Normal file
15
main.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import uvicorn
|
||||
|
||||
from adapter.config.loader import load_config
|
||||
from adapter.http.fastapi.app import create_app
|
||||
|
||||
config = load_config()
|
||||
app = create_app(config)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
uvicorn.run(app, host=config.http.host, port=config.http.port)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
[project]
|
||||
name = "web-python-skelet"
|
||||
name = "master"
|
||||
version = "0.0.1"
|
||||
description = ""
|
||||
readme = "README.md"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
from collections.abc import Iterable
|
||||
|
||||
from domain.user import User
|
||||
from usecase.interface import UserRepository
|
||||
from usecase.interface import Tracer, UserRepository
|
||||
|
||||
|
||||
class InMemoryUserRepository(UserRepository):
|
||||
def __init__(self, users: Iterable[User] | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
tracer: Tracer,
|
||||
users: Iterable[User] | None = None,
|
||||
) -> None:
|
||||
self._users = {user.id: user for user in users or ()}
|
||||
self._tracer = tracer
|
||||
|
||||
def get(self, user_id: str) -> User | None:
|
||||
return self._users.get(user_id)
|
||||
with self._tracer.start_span('repository.user', attrs={'user.id': user_id}):
|
||||
return self._users.get(user_id)
|
||||
|
||||
def get_by_email(self, email: str) -> User | None:
|
||||
for user in self._users.values():
|
||||
|
|
|
|||
142
tasks.md
Normal file
142
tasks.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# План работ: web-python-skelet
|
||||
|
||||
## Контекст
|
||||
|
||||
- Источник требований: `AGENTS.md` и ADR `docs/001`-`docs/004`
|
||||
- Текущее состояние: в `adapter/`, `domain/`, `usecase/`, `repository/`, `test/` пока только `__init__.py`
|
||||
- Отсутствуют рабочие каталоги и файлы из целевой структуры: `adapter/config/`, `adapter/otel/`, `adapter/di/`, `adapter/http/fastapi/`, `config/`, `main.py`
|
||||
- Ограничения: `docs/` и `tasks.md` не добавлять в git; коммиты не делать; работать по одной задаче
|
||||
- ADR пока покрывают архитектуру, новые ADR нужны только если по ходу работ изменится решение
|
||||
|
||||
## Правила выполнения
|
||||
|
||||
- Каждую задачу выполнять отдельным заходом, без параллельной реализации
|
||||
- Каждый субагент отдает diff, список измененных файлов и проверку, но не делает commit
|
||||
- Если в задаче всплывает архитектурное изменение, остановиться и вынести вопрос на согласование
|
||||
|
||||
## Очередь задач
|
||||
|
||||
### T01. Базовый каркас домена и usecase
|
||||
|
||||
- Исполнитель: `primary-agent` (scaffolding)
|
||||
- Статус: completed
|
||||
- Зависимости: нет
|
||||
- Commit required: no
|
||||
- Scope: создать базовые файлы и контракты в `domain/`, `usecase/`, `repository/`
|
||||
- Файлы: `domain/error.py`, `domain/user.py`, `usecase/interface.py`, `usecase/user.py`, `repository/user.py`
|
||||
- Критерии приемки: зависимости направлены внутрь; в `domain/` и `usecase/` нет FastAPI/OTel; есть пример сущности, ошибок, портов и простого usecase
|
||||
|
||||
### T02. Конфиг из YAML и env
|
||||
|
||||
- Субагент: `feature-developer`
|
||||
- Статус: completed
|
||||
- Зависимости: `T01`
|
||||
- Commit required: no
|
||||
- Scope: собрать typed-config слой в `adapter/config/` и подготовить базовые yaml-файлы
|
||||
- Файлы: `adapter/config/*`, `config/app.yaml`
|
||||
- Критерии приемки: конфиг собирается в одну dataclass-структуру; секреты читаются из env; парсинг и валидация не протекают в inner layers
|
||||
|
||||
### T03. Observability порты и OTel adapter
|
||||
|
||||
- Субагент: `feature-developer`
|
||||
- Статус: completed
|
||||
- Зависимости: `T01`, `T02`
|
||||
- Commit required: no
|
||||
- Scope: реализовать логгер, метрики, трейсинг и bootstrap OTel в `adapter/otel/` через интерфейсы из `usecase/interface.py`
|
||||
- Файлы: `adapter/otel/*`, `config/otel-collector.yaml`
|
||||
- Критерии приемки: inner layers знают только интерфейсы; OTLP exporter настраивается из конфига; нет кастомного trace middleware
|
||||
|
||||
### T04. Composition root и lifetime singleton-объектов
|
||||
|
||||
- Субагент: `feature-developer`
|
||||
- Статус: completed
|
||||
- Зависимости: `T01`, `T02`, `T03`
|
||||
- Commit required: no
|
||||
- Scope: собрать контейнер и startup wiring в `adapter/di/`
|
||||
- Файлы: `adapter/di/container.py`, `adapter/di/__init__.py`
|
||||
- Критерии приемки: repository/usecase создаются один раз на старте; контейнер хранит инстансы явно; нет пересоздания на HTTP-запрос
|
||||
|
||||
### T05. FastAPI adapter как заменяемый web layer
|
||||
|
||||
- Субагент: `feature-developer`
|
||||
- Статус: completed
|
||||
- Зависимости: `T04`
|
||||
- Commit required: no
|
||||
- Scope: поднять HTTP adapter в `adapter/http/fastapi/` с app factory, lifespan, dependencies, middleware и router ` /api/v1`
|
||||
- Файлы: `adapter/http/fastapi/*`, `main.py`
|
||||
- Критерии приемки: FastAPI изолирован в adapter-слое; handlers тонкие; request logging и metrics middleware подключены; usecase/repository берутся из контейнера
|
||||
|
||||
### T06. Локальный runtime и compose-окружение
|
||||
|
||||
- Субагент: `feature-developer`
|
||||
- Статус: completed
|
||||
- Зависимости: `T02`, `T03`, `T05`
|
||||
- Commit required: no
|
||||
- Scope: добавить контейнерный runtime для сервиса и compose-окружение с OTel и UI для просмотра логов, метрик и трейсов
|
||||
- Файлы: `Dockerfile`, `docker-compose.yml`
|
||||
- Ограничения: не трогать репозиторный `config/app.yaml`; docker должен прокидывать свой runtime-config внутрь контейнера; Dockerfile только с двумя стадиями `build` и `run`
|
||||
- Критерии приемки: `make compose-build` и `make compose-up` опираются на существующие файлы; сервис поднимается в контейнере; OTel telemetry уходит в dockerized stack; есть UI для просмотра логов, метрик и трейсов; для локального docker-окружения достаточно только `Dockerfile` и `docker-compose.yml`
|
||||
|
||||
### T07. Тесты на lifetimes, config и HTTP smoke
|
||||
|
||||
- Субагент: `test-engineer`
|
||||
- Статус: pending
|
||||
- Зависимости: `T01`, `T02`, `T03`, `T04`, `T05`, `T06`
|
||||
- Commit required: no
|
||||
- Scope: покрыть тестами ключевые архитектурные гарантии
|
||||
- Файлы: `test/*`
|
||||
- Критерии приемки: есть тест на singleton lifetime для repository/usecase; есть тест merge YAML+env; есть smoke-тест для ` /api/v1`; тесты не тянут FastAPI/OTel в inner layers
|
||||
|
||||
### T08. Архитектурный и boundary review
|
||||
|
||||
- Субагент: `code-reviewer`
|
||||
- Статус: pending
|
||||
- Зависимости: `T07`
|
||||
- Commit required: no
|
||||
- Scope: проверить импорты, соблюдение слоев, startup lifetimes и заменяемость web adapter
|
||||
- Файлы: весь измененный код
|
||||
- Критерии приемки: dependency direction не нарушен; FastAPI и OTel не протекают в `domain/` и `usecase/`; замечания сформулированы как точечные правки или подтверждение готовности к review
|
||||
|
||||
### T09. Конфигурируемый runtime observability
|
||||
|
||||
- Субагент: `feature-developer`
|
||||
- Статус: completed
|
||||
- Зависимости: `T03`, `T04`, `T05`
|
||||
- Commit required: no
|
||||
- Scope: сделать конфигурируемый runtime observability с настраиваемым sink и форматом логов, плюс отдельными флагами для метрик и трейсов
|
||||
- Файлы: `adapter/config/*`, `adapter/observability/*`, `adapter/otel/*`, `adapter/di/*`, `adapter/http/fastapi/*`, `config/app.yaml`, при необходимости `main.py`
|
||||
- Конфиг: `logging.output=stdout|file|otel`, `logging.format=text|json`, `logging.file_path=...`, `metrics.enabled=true|false`, `tracing.enabled=true|false`
|
||||
- Решение: вынести выбор runtime в отдельный adapter-layer factory; `domain/` и `usecase/` не менять; для `stdout` и `file` поддержать оба формата `text` и `json`; `logging.file_path` использовать только при `logging.output=file`; при отключенных метриках и трейсах использовать `Noop`-реализации; OTel runtime поднимать только если нужен хотя бы для одного сигнала
|
||||
- Критерии приемки: при `logging.output=stdout` логи идут в stdout в формате `text` или `json` по конфигу; при `logging.output=file` логи пишутся в файл по пути `logging.file_path` в формате `text` или `json`; при `logging.output=otel` логи уходят в collector; `metrics.enabled=false` отключает метрики и metrics middleware; `tracing.enabled=false` отключает FastAPI instrumentation и tracer runtime; DI продолжает отдавать единый runtime через контейнер; внутренние слои по-прежнему знают только порты
|
||||
|
||||
### T10. ADR: раннее подключение OTel к FastAPI
|
||||
|
||||
- Исполнитель: `primary-agent` (docs)
|
||||
- Статус: completed
|
||||
- Зависимости: нет
|
||||
- Commit required: no
|
||||
- Scope: зафиксировать правило, что FastAPI OTel instrumentation выполняется до первой сборки `middleware_stack`
|
||||
- Файлы: `docs/005-fastapi-otel-early-instrumentation.md`
|
||||
- Критерии приемки: ADR занимает 10-20 строк; описаны context, decision, consequences; решение не переписывает историю прошлых ADR
|
||||
|
||||
### T11. Перенос FastAPI OTel bootstrap в app factory
|
||||
|
||||
- Субагент: `feature-developer`
|
||||
- Статус: completed
|
||||
- Зависимости: `T10`
|
||||
- Commit required: no
|
||||
- Scope: перенести создание container, установку `FastAPIInstrumentor.instrument_app(...)` из `lifespan` в `create_app`, оставив в `lifespan` только shutdown
|
||||
- Файлы: `adapter/http/fastapi/app.py`, `adapter/http/fastapi/lifespan.py`, при необходимости `adapter/http/fastapi/dependencies.py`
|
||||
- Ограничения: не использовать ручной rebuild `app.middleware_stack`; не менять `domain/` и `usecase/`; не добавлять бизнес-логику; сохранить singleton-lifetime container
|
||||
- Критерии приемки: instrumentation происходит до первой сборки middleware stack; `OpenTelemetryMiddleware` попадает в runtime stack без workaround; shutdown закрывает instrumentation и runtime один раз; compose-конфиг продолжает работать
|
||||
|
||||
### T12. Регрессионная проверка HTTP telemetry wiring
|
||||
|
||||
- Субагент: `test-engineer`
|
||||
- Статус: pending
|
||||
- Зависимости: `T11`
|
||||
- Commit required: no
|
||||
- Scope: добавить проверку, что раннее instrumentation wiring сохраняется и не требует ручного rebuild middleware stack
|
||||
- Файлы: `test/*`
|
||||
- Ограничения: без реального collector; проверять через ASGI/lifespan или локальные assertions по app runtime; не тянуть FastAPI и OTel в inner-layer тесты
|
||||
- Критерии приемки: тест подтверждает, что при включенных metrics/tracing `OpenTelemetryMiddleware` присутствует в runtime stack; тест не зависит от внешнего OTel collector; существующие архитектурные границы не нарушены
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue