Опирается на правила: R-ERR-OBS-1R-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Что
domainDomainError и наследники
validationInputValidationError, RequestValidationError
integrationIntegrationError и наследники
technicalTechnicalError
unexpectedcatch-all Exception (вне иерархии)

Тег exceptionsimple 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])) > 0R-ERR-OBS-X1Алёрт только на unexpected / technical
Алёрт «ERROR в логах» без фильтраR-ERR-OBS-X1rate(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 — нормативные формулировки.