Compare commits
No commits in common. "1fbf77f879c89b8ce3b2611b60e12ae28fbb52ac" and "ff6efb958df309b45759a2d8dfac280147ef983a" have entirely different histories.
1fbf77f879
...
ff6efb958d
30 changed files with 102 additions and 1197 deletions
85
AGENTS.md
85
AGENTS.md
|
|
@ -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`
|
|
||||||
35
Dockerfile
35
Dockerfile
|
|
@ -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"]
|
|
||||||
|
|
@ -10,10 +10,8 @@ from .model import (
|
||||||
AppSectionConfig,
|
AppSectionConfig,
|
||||||
HttpConfig,
|
HttpConfig,
|
||||||
LoggingConfig,
|
LoggingConfig,
|
||||||
MetricsConfig,
|
|
||||||
OtelConfig,
|
OtelConfig,
|
||||||
SecurityConfig,
|
SecurityConfig,
|
||||||
TracingConfig,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
|
@ -36,44 +34,9 @@ def load_config(
|
||||||
app_section = _section(yaml_data, 'app')
|
app_section = _section(yaml_data, 'app')
|
||||||
http_section = _section(yaml_data, 'http')
|
http_section = _section(yaml_data, 'http')
|
||||||
logging_section = _section(yaml_data, 'logging')
|
logging_section = _section(yaml_data, 'logging')
|
||||||
metrics_section = _section(yaml_data, 'metrics')
|
otel_section = _section(yaml_data, 'otel')
|
||||||
tracing_section = _section(yaml_data, 'tracing')
|
|
||||||
security_section = _section(yaml_data, 'security')
|
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(
|
return AppConfig(
|
||||||
app=AppSectionConfig(
|
app=AppSectionConfig(
|
||||||
name=_yaml_or_env_str(
|
name=_yaml_or_env_str(
|
||||||
|
|
@ -96,6 +59,13 @@ def load_config(
|
||||||
env_values,
|
env_values,
|
||||||
'APP_HTTP_PORT',
|
'APP_HTTP_PORT',
|
||||||
),
|
),
|
||||||
|
api_prefix=_yaml_or_env_str(
|
||||||
|
http_section,
|
||||||
|
'api_prefix',
|
||||||
|
'http.api_prefix',
|
||||||
|
env_values,
|
||||||
|
'APP_HTTP_API_PREFIX',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
logging=LoggingConfig(
|
logging=LoggingConfig(
|
||||||
level=_yaml_or_env_str(
|
level=_yaml_or_env_str(
|
||||||
|
|
@ -105,28 +75,43 @@ def load_config(
|
||||||
env_values,
|
env_values,
|
||||||
'APP_LOGGING_LEVEL',
|
'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(
|
||||||
metrics=MetricsConfig(enabled=metrics_enabled),
|
service_name=_yaml_or_env_str(
|
||||||
tracing=TracingConfig(enabled=tracing_enabled),
|
otel_section,
|
||||||
otel=_load_otel_config(
|
'service_name',
|
||||||
yaml_data,
|
'otel.service_name',
|
||||||
env_values,
|
env_values,
|
||||||
enable_logs=logging_output == 'otel',
|
'APP_OTEL_SERVICE_NAME',
|
||||||
enable_metrics=metrics_enabled,
|
),
|
||||||
enable_tracing=tracing_enabled,
|
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(
|
security=SecurityConfig(
|
||||||
token_header=_yaml_or_env_str(
|
token_header=_yaml_or_env_str(
|
||||||
|
|
@ -206,94 +191,6 @@ def _section(data: Mapping[str, object], name: str) -> dict[str, object]:
|
||||||
return section
|
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(
|
def _yaml_or_env_str(
|
||||||
section: Mapping[str, object],
|
section: Mapping[str, object],
|
||||||
field_name: str,
|
field_name: str,
|
||||||
|
|
@ -322,35 +219,6 @@ def _yaml_or_env_int(
|
||||||
return _as_int(section[field_name], label)
|
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:
|
def _env_str(env: Mapping[str, str], name: str) -> str:
|
||||||
if name not in env:
|
if name not in env:
|
||||||
raise ConfigError(f'missing {name}')
|
raise ConfigError(f'missing {name}')
|
||||||
|
|
@ -383,18 +251,6 @@ def _as_int(value: object, label: str) -> int:
|
||||||
raise ConfigError(f'invalid {label}')
|
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:
|
def _display_path(path: Path) -> str:
|
||||||
if path.is_relative_to(ROOT_DIR):
|
if path.is_relative_to(ROOT_DIR):
|
||||||
return str(path.relative_to(ROOT_DIR))
|
return str(path.relative_to(ROOT_DIR))
|
||||||
|
|
|
||||||
|
|
@ -11,24 +11,12 @@ class AppSectionConfig:
|
||||||
class HttpConfig:
|
class HttpConfig:
|
||||||
host: str
|
host: str
|
||||||
port: int
|
port: int
|
||||||
|
api_prefix: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class LoggingConfig:
|
class LoggingConfig:
|
||||||
level: str
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|
@ -52,7 +40,5 @@ class AppConfig:
|
||||||
app: AppSectionConfig
|
app: AppSectionConfig
|
||||||
http: HttpConfig
|
http: HttpConfig
|
||||||
logging: LoggingConfig
|
logging: LoggingConfig
|
||||||
metrics: MetricsConfig
|
|
||||||
tracing: TracingConfig
|
|
||||||
otel: OtelConfig
|
otel: OtelConfig
|
||||||
security: SecurityConfig
|
security: SecurityConfig
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,7 @@ from pathlib import Path
|
||||||
|
|
||||||
from adapter.config.loader import load_config
|
from adapter.config.loader import load_config
|
||||||
from adapter.config.model import AppConfig
|
from adapter.config.model import AppConfig
|
||||||
from adapter.observability.factory import build_observability
|
from adapter.otel.bootstrap import OtelRuntime, setup_otel
|
||||||
from adapter.observability.runtime import ObservabilityRuntime
|
|
||||||
from domain.user import User
|
|
||||||
from repository.user import InMemoryUserRepository
|
from repository.user import InMemoryUserRepository
|
||||||
from usecase.user import GetUser
|
from usecase.user import GetUser
|
||||||
|
|
||||||
|
|
@ -24,7 +22,7 @@ class AppUsecases:
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class AppContainer:
|
class AppContainer:
|
||||||
config: AppConfig
|
config: AppConfig
|
||||||
observability: ObservabilityRuntime
|
observability: OtelRuntime
|
||||||
repositories: AppRepositories
|
repositories: AppRepositories
|
||||||
usecases: AppUsecases
|
usecases: AppUsecases
|
||||||
_is_shutdown: bool = field(default=False, init=False, repr=False)
|
_is_shutdown: bool = field(default=False, init=False, repr=False)
|
||||||
|
|
@ -43,21 +41,15 @@ def build_container(
|
||||||
config_path: Path | str | None = None,
|
config_path: Path | str | None = None,
|
||||||
env_path: Path | str | None = None,
|
env_path: Path | str | None = None,
|
||||||
environ: Mapping[str, str] | None = None,
|
environ: Mapping[str, str] | None = None,
|
||||||
config: AppConfig | None = None,
|
|
||||||
) -> AppContainer:
|
) -> AppContainer:
|
||||||
app_config = config
|
config = load_config(
|
||||||
if app_config is None:
|
|
||||||
app_config = load_config(
|
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
env_path=env_path,
|
env_path=env_path,
|
||||||
environ=environ,
|
environ=environ,
|
||||||
)
|
)
|
||||||
|
observability = setup_otel(config)
|
||||||
|
|
||||||
observability = build_observability(app_config)
|
user_repository = InMemoryUserRepository()
|
||||||
|
|
||||||
user_repository = InMemoryUserRepository(
|
|
||||||
observability.tracer, [User(id='123', email='aza@gglamer.ru', name='gglamer')]
|
|
||||||
)
|
|
||||||
repositories = AppRepositories(user=user_repository)
|
repositories = AppRepositories(user=user_repository)
|
||||||
usecases = AppUsecases(
|
usecases = AppUsecases(
|
||||||
get_user=GetUser(
|
get_user=GetUser(
|
||||||
|
|
@ -68,7 +60,7 @@ def build_container(
|
||||||
)
|
)
|
||||||
|
|
||||||
return AppContainer(
|
return AppContainer(
|
||||||
config=app_config,
|
config=config,
|
||||||
observability=observability,
|
observability=observability,
|
||||||
repositories=repositories,
|
repositories=repositories,
|
||||||
usecases=usecases,
|
usecases=usecases,
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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')
|
|
||||||
|
|
@ -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]
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from dataclasses import dataclass
|
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._log_exporter import OTLPLogExporter
|
||||||
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
|
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
|
||||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||||
|
|
@ -20,33 +22,21 @@ from .tracing import OtelTracer
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class OtelRuntime:
|
class OtelRuntime:
|
||||||
logger_provider: LoggerProvider | None = None
|
logger_provider: LoggerProvider
|
||||||
meter_provider: MeterProvider | None = None
|
meter_provider: MeterProvider
|
||||||
tracer_provider: TracerProvider | None = None
|
tracer_provider: TracerProvider
|
||||||
logger: OtelLogger | None = None
|
logger: OtelLogger
|
||||||
metrics: OtelMetrics | None = None
|
metrics: OtelMetrics
|
||||||
tracer: OtelTracer | None = None
|
tracer: OtelTracer
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
if self.meter_provider is not None:
|
|
||||||
self.meter_provider.shutdown()
|
self.meter_provider.shutdown()
|
||||||
if self.tracer_provider is not None:
|
|
||||||
self.tracer_provider.shutdown()
|
self.tracer_provider.shutdown()
|
||||||
if self.logger_provider is not None:
|
|
||||||
self.logger_provider.shutdown()
|
self.logger_provider.shutdown()
|
||||||
|
|
||||||
|
|
||||||
def setup_otel(
|
def setup_otel(config: AppConfig) -> OtelRuntime:
|
||||||
config: AppConfig,
|
if config.otel.metric_export_interval <= 0:
|
||||||
*,
|
|
||||||
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')
|
raise ValueError('invalid metric export interval')
|
||||||
|
|
||||||
resource = Resource.create(
|
resource = Resource.create(
|
||||||
|
|
@ -56,24 +46,11 @@ def setup_otel(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
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 = LoggerProvider(resource=resource, shutdown_on_exit=False)
|
||||||
logger_provider.add_log_record_processor(
|
logger_provider.add_log_record_processor(
|
||||||
BatchLogRecordProcessor(OTLPLogExporter(endpoint=config.otel.logs_endpoint))
|
BatchLogRecordProcessor(OTLPLogExporter(endpoint=config.otel.logs_endpoint))
|
||||||
)
|
)
|
||||||
logger = OtelLogger(
|
|
||||||
logger_provider.get_logger(scope_name), config.logging.level
|
|
||||||
)
|
|
||||||
|
|
||||||
if enable_metrics:
|
|
||||||
metric_reader = PeriodicExportingMetricReader(
|
metric_reader = PeriodicExportingMetricReader(
|
||||||
OTLPMetricExporter(endpoint=config.otel.metrics_endpoint),
|
OTLPMetricExporter(endpoint=config.otel.metrics_endpoint),
|
||||||
export_interval_millis=config.otel.metric_export_interval,
|
export_interval_millis=config.otel.metric_export_interval,
|
||||||
|
|
@ -83,13 +60,19 @@ def setup_otel(
|
||||||
resource=resource,
|
resource=resource,
|
||||||
shutdown_on_exit=False,
|
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 = TracerProvider(resource=resource, shutdown_on_exit=False)
|
||||||
tracer_provider.add_span_processor(
|
tracer_provider.add_span_processor(
|
||||||
BatchSpanProcessor(OTLPSpanExporter(endpoint=config.otel.traces_endpoint))
|
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))
|
tracer = OtelTracer(tracer_provider.get_tracer(scope_name))
|
||||||
|
|
||||||
return OtelRuntime(
|
return OtelRuntime(
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,11 @@ app:
|
||||||
|
|
||||||
http:
|
http:
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 8123
|
port: 8000
|
||||||
|
api_prefix: /api/v1
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level: INFO
|
level: INFO
|
||||||
output: stdout
|
|
||||||
format: json
|
|
||||||
|
|
||||||
metrics:
|
|
||||||
enabled: false
|
|
||||||
|
|
||||||
tracing:
|
|
||||||
enabled: false
|
|
||||||
|
|
||||||
otel:
|
otel:
|
||||||
service_name: web-python-skelet
|
service_name: web-python-skelet
|
||||||
|
|
|
||||||
|
|
@ -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:
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
15
main.py
15
main.py
|
|
@ -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()
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[project]
|
[project]
|
||||||
name = "master"
|
name = "web-python-skelet"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
description = ""
|
description = ""
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,14 @@
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from domain.user import User
|
from domain.user import User
|
||||||
from usecase.interface import Tracer, UserRepository
|
from usecase.interface import UserRepository
|
||||||
|
|
||||||
|
|
||||||
class InMemoryUserRepository(UserRepository):
|
class InMemoryUserRepository(UserRepository):
|
||||||
def __init__(
|
def __init__(self, users: Iterable[User] | None = None) -> None:
|
||||||
self,
|
|
||||||
tracer: Tracer,
|
|
||||||
users: Iterable[User] | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._users = {user.id: user for user in users or ()}
|
self._users = {user.id: user for user in users or ()}
|
||||||
self._tracer = tracer
|
|
||||||
|
|
||||||
def get(self, user_id: str) -> User | None:
|
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:
|
def get_by_email(self, email: str) -> User | None:
|
||||||
|
|
|
||||||
142
tasks.md
142
tasks.md
|
|
@ -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; существующие архитектурные границы не нарушены
|
|
||||||
|
|
@ -37,4 +37,5 @@ class GetUser:
|
||||||
raise error
|
raise error
|
||||||
|
|
||||||
span.set_attribute('user.email', user.email)
|
span.set_attribute('user.email', user.email)
|
||||||
|
self._logger.info('user_loaded', attrs={'user_id': user.id})
|
||||||
return user
|
return user
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue