Опирается на правила:
R-ERR-OBS-1…R-ERR-OBS-3иR-ERR-OBS-X1из Error Handling Style Guide → раздел 7. Observability.
Важно знать
- Метрика
app_errors_total{type=..., exception=...}(Counter) — основа дашборда ошибок.type∈ {domain,validation,integration,technical,unexpected}.- OpenTelemetry span помечается
ERRORна исключение:span.set_status(Status(StatusCode.ERROR))+span.record_exception(exc).- Алёрты — на необычные паттерны, не на каждый exception.
unexpectedрастёт = новый баг;integrationрастёт = внешка деградирует.- Запрещено: алёрт «любое исключение в логах» —
DomainErrorнормально частая.- Алёртить только на
unexpected/technical— то, что не должно случаться.prometheus_client— инструментирование; OpenTelemetry — трейсинг; оба подключаются на edge.
ERROR-логи + метрики + traces — три источника правды. Каждый отвечает на свой вопрос: метрика — «сколько и какого типа», trace — «в каком запросе, через какую цепочку», лог — «детали для разбора». Алёрт строится на метрике (быстрее и стабильнее, чем поиск по логам), на нужных категориях. Раскрытие правил R-ERR-OBS-* ниже.
Метрика app_errors_total
R-ERR-OBS-1: Counter с двумя тегами — type и exception.
# app/metrics.py
from prometheus_client import Counter
app_errors_total = Counter(
"app_errors_total",
"Application errors by type and exception class",
["type", "exception"],
)
Инкремент в каждом edge-handler:
# app/error_handlers.py
async def _handle_domain(request: Request, exc: DomainError) -> JSONResponse:
logger.warning("domain rule violated", error=str(exc), exc_type=type(exc).__name__)
app_errors_total.labels(type="domain", exception=type(exc).__name__).inc()
return problem(422, "Operation cannot be completed", str(exc), trace_id=get_trace_id())
async def _handle_integration(request: Request, exc: IntegrationError) -> JSONResponse:
logger.warning("integration error", exc_type=type(exc).__name__, error=str(exc))
app_errors_total.labels(type="integration", exception=type(exc).__name__).inc()
return problem(502, "External system temporarily unavailable", "upstream error",
trace_id=get_trace_id())
async def _handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
logger.error("unexpected error — not in hierarchy", exc_info=exc,
exc_type=type(exc).__name__)
app_errors_total.labels(type="unexpected", exception=type(exc).__name__).inc()
return problem(500, "Internal Server Error", "internal error", trace_id=get_trace_id())
Значения тега type:
Тег type | Что |
|---|---|
domain | DomainError и наследники |
validation | InputValidationError, RequestValidationError |
integration | IntegrationError и наследники |
technical | TechnicalError |
unexpected | catch-all Exception (вне иерархии) |
Тег exception — simple class name (InsufficientFundsError, PaymentGatewayError). Это низкая cardinality — несколько десятков значений в худшем случае. Prometheus справляется без проблем.
Не добавляем в теги customer_id, order_id, product_id — это высокая cardinality, ломает Prometheus. Структурированный контекст — в structlog-записях и в ProblemDetails-ответе.
Экспозиция метрик через эндпоинт:
# app/main.py
from prometheus_client import make_asgi_app
from fastapi import FastAPI
app = FastAPI()
metrics_app = make_asgi_app()
app.mount("/metrics", metrics_app)
OpenTelemetry — span как ERROR
R-ERR-OBS-2: trace-span помечается на исключение.
При FastAPI + OpenTelemetry-инструментации (opentelemetry-instrumentation-fastapi) span автоматически помечается ERROR для необработанных исключений. Дополнительно в edge-handler:
# app/error_handlers.py
from opentelemetry import trace
from opentelemetry.trace import StatusCode, Status
async def _handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
logger.error("unexpected error", exc_info=exc)
app_errors_total.labels(type="unexpected", exception=type(exc).__name__).inc()
span = trace.get_current_span()
span.set_status(Status(StatusCode.ERROR, description=type(exc).__name__))
span.record_exception(exc)
return problem(500, "Internal Server Error", "internal error", trace_id=get_trace_id())
span.record_exception(exc) — добавляет в span атрибуты exception.type, exception.message, exception.stacktrace (стандарт OTel Semantic Conventions). В Jaeger / Grafana Tempo span показан красным с прикреплённым stacktrace.
Хелпер get_trace_id() — извлекает trace ID из текущего контекста для traceId в response:
# app/tracing.py
from opentelemetry import trace
def get_trace_id() -> str | None:
span = trace.get_current_span()
ctx = span.get_span_context()
if not ctx.is_valid:
return None
return format(ctx.trace_id, "032x")
Для IntegrationError с circuit breaker — помечаем span при маппинге CircuitBreakerError:
# adapters/out/sber/client.py
from opentelemetry import trace
from opentelemetry.trace import StatusCode, Status
from aiobreaker import CircuitBreakerError
from adapters.out.sber.errors import SberGatewayUnavailableError
class SberClientAdapter(PaymentPort):
async def register(self, cmd: RegisterCommand) -> RegisterResult:
try:
return await breaker.call_async(self._do_register, cmd)
except CircuitBreakerError as e:
span = trace.get_current_span()
span.set_status(Status(StatusCode.ERROR, description="sber circuit breaker open"))
span.record_exception(e)
raise SberGatewayUnavailableError(order_id=cmd.order_id) from e
На практике автоматики opentelemetry-instrumentation-fastapi + opentelemetry-instrumentation-httpx достаточно — мануальный record_exception нужен редко (только при явном перехвате, например CircuitBreakerError).
Алёрты — на паттерны, не на отдельные exceptions
R-ERR-OBS-3: что алёртить, что не алёртить.
Алёртим:
Резкий рост unexpected — сигнал нового бага:
rate(app_errors_total{type="unexpected"}[5m]) > 0.5
Появился тип исключения вне иерархии. Создавать задачу: добавить тип, написать тест, найти причину.
Рост integration для конкретной системы — деградация внешки:
rate(app_errors_total{type="integration", exception=~"Sber.*"}[5m])
> 3 * rate(app_errors_total{type="integration", exception=~"Sber.*"}[1h])
Но лучше алёртить напрямую через circuit breaker (если используется aiobreaker): он быстрее реагирует и надёжнее, чем подсчёт метрик ошибок.
Рост domain для одного кода — бизнес-сигнал:
rate(app_errors_total{type="domain", exception="InsufficientFundsError"}[5m])
> 5 * rate(app_errors_total{type="domain", exception="InsufficientFundsError"}[1h])
InsufficientFundsError обычно даёт стабильный baseline. Резкий рост = изменилось бизнес-условие или внешняя ситуация. Команду продукта нужно предупредить.
Любой рост technical — инфраструктурный сбой:
rate(app_errors_total{type="technical"}[5m]) > 0.1
Не алёртим:
- Стабильный baseline
domain— это нормальная нагрузка: N попыток списать с пустого счёта в день. - Стабильный baseline
validation— клиенты иногда шлют некорректные данные, это нормально. - На каждое отдельное исключение в логах — это shutdown on-call команды.
Дашборд ошибок
Стандартный дашборд Grafana для FastAPI-сервиса. Три панели:
Ошибки по типу (rate/5m):
sum by (type) (rate(app_errors_total[5m]))
Показывает тренды по категориям. В норме domain и validation — стабильные невысокие линии, integration — редкие пики, unexpected и technical — нули.
Топ исключений:
topk(10, sum by (exception) (rate(app_errors_total[1h])))
Какие конкретные exception-значения генерируют больше всего ошибок.
Error rate от всех запросов:
sum(rate(app_errors_total[5m]))
/ sum(rate(http_requests_total[5m]))
Доля запросов, завершившихся ошибкой. В норме domain-ошибки — часть этого числа, но unexpected — должно стремиться к нулю.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Алёрт sum(rate(app_errors_total[5m])) > 0 | R-ERR-OBS-X1 | Алёрт только на unexpected / technical |
Алёрт «ERROR в логах» без фильтра | R-ERR-OBS-X1 | rate(app_errors_total{type="unexpected"}[5m]) > threshold |
app_errors_total{customer_id=..., order_id=...} | R-ERR-OBS-1 | Только type и exception — низкая cardinality |
| Нет метрики — ошибки только в логах | R-ERR-OBS-1 | Метрика обязательна для алёртов |
R-ERR-OBS-X1 — почему алёрт «любое исключение» разрушителен:
# ПЛОХО — сработает на каждый DomainError
sum(rate(app_errors_total[5m])) > 0
В любом production-сервисе нормальная нагрузка генерирует InsufficientFundsError, OrderAlreadyShippedError, RequestValidationError постоянно. Алёрт на всё это = постоянный шум = команда отключает алёрты = настоящие инциденты пропускаются.
Правильный алёрт — только на то, что не должно случаться:
# ХОРОШО — только неожиданные ошибки
rate(app_errors_total{type="unexpected"}[5m]) > 0.2
Каждый алёрт — сигнал к действию. Если on-call получает алёрт и не знает, что делать — это шум, не алёрт.
Куда дальше
- Логирование исключений — почему
DomainError→ WARNING, и как это связано с алёртами. - Mapping в ProblemDetails —
traceIdв response для cross-system корреляции. - Иерархия исключений — почему имена
exception-тегов информативны. - Где raise, где catch — единственная точка инкремента метрики.
- Retry-семантика — circuit breaker и метрики интеграций.
- Result types vs exceptions — почему Result не даёт observability.
- Error Handling Style Guide → раздел 7 — нормативные формулировки.