Опирается на правила: 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}; exception — simple class name.
  • OpenTelemetry span помечается как ERROR на исключение: SpanStatus.ERROR + recordException(e). В Spring-инструментации делается автоматически.
  • Алёрты — на необычные паттерны, не на каждый exception. unexpected растёт = новый баг; integration растёт = внешка деградирует; domain стабилен (рост = бизнес-условие изменилось); validation стабилен (рост = клиент сломал контракт).
  • Запрещено: алёрт «любое исключение в логах». Шум — DomainException нормально, частая.
  • Алёртить только на unexpected / technical — то, что не должно случаться.

ERROR-логи + метрики + traces — это три источника правды для observability ошибок. Каждый отвечает на свой вопрос: метрика — «сколько и какого типа», trace — «в каком запросе, через какую цепочку», лог — «детали для разбора». Алёрт строится на метрике (быстрее и стабильнее, чем log-grep), на нужных категориях. Раскрытие правил R-ERR-OBS-* ниже.

Метрика app_errors_total

R-ERR-OBS-1: Counter с двумя тэгами.

@Component
@RequiredArgsConstructor
public class ErrorMetrics {

    private final MeterRegistry registry;

    public void recordError(Throwable ex, String type) {
        Counter.builder("app.errors.total")
            .tag("type", type)
            .tag("exception", ex.getClass().getSimpleName())
            .register(registry)
            .increment();
    }
}

Используется в @RestControllerAdvice:

@ExceptionHandler(DomainException.class)
public ResponseEntity<ProblemDetail> handleDomain(DomainException ex) {
    errorMetrics.recordError(ex, "domain");
    log.warn("Domain rule violated: {}", ex.getMessage(), ex);
    // ...
}

@ExceptionHandler(IntegrationException.class)
public ResponseEntity<ProblemDetail> handleIntegration(IntegrationException ex) {
    errorMetrics.recordError(ex, "integration");
    // ...
}

@ExceptionHandler(Throwable.class)
public ResponseEntity<ProblemDetail> handleUnexpected(Throwable ex) {
    errorMetrics.recordError(ex, "unexpected");
    // ...
}

Values:

Tag typeЧто
domainDomainException и наследники
validationValidationException, MethodArgumentNotValidException
integrationIntegrationException и наследники
technicalTechnicalException
unexpectedcatch-all Throwable (ничего из иерархии)

Tag exceptionsimple class name (не FQDN): InsufficientFundsException, SberRegisterException. Это даёт низкую cardinality (несколько десятков значений в худшем случае), Prometheus справляется без проблем.

Не добавляем в tag-ы поля типа customerId, orderId — это высокая cardinality, кардинально ломает Prometheus. Эти данные — в ProblemDetails.properties и в логах.

OpenTelemetry — span как ERROR

R-ERR-OBS-2: trace-span помечается на исключение.

При Spring Boot + Spring Cloud Sleuth / OpenTelemetry-инструментации это делается автоматически для исключений, долетевших до @RestControllerAdvice или до конца HTTP-обработки. В trace в Jaeger / Tempo span будет показан красным, с прикреплённым stacktrace в attributes.

Если ловишь исключение внутри handler'а и хочешь зафиксировать в trace без re-throw (что разрешено только в out-adapter с fallback'ом):

@Component
@RequiredArgsConstructor
public class SberClientAdapter implements PaymentPort {

    private final Tracer tracer;                                       // OpenTelemetry tracer

    @Override
    @CircuitBreaker(name = "sber", fallbackMethod = "registerFallback")
    public RegisterResult register(RegisterCommand cmd) {
        // ...
    }

    private RegisterResult registerFallback(RegisterCommand cmd, CallNotPermittedException ex) {
        var span = Span.current();
        span.setStatus(StatusCode.ERROR, "Sber CB open");
        span.recordException(ex);
        throw new SberUnavailableException(cmd.orderId(), ex);
    }
}

На практике автоматики Spring + OpenTelemetry достаточно — мануальный recordException нужен редко.

Алёрты — на паттерны, не на отдельные exceptions

R-ERR-OBS-3: что алёртить, что не алёртить.

Алёртим:

  • Резкий рост unexpected (catch-all). Это сигнал нового бага — появился тип исключения, не описанный в иерархии. Создавать issue, добавлять тип, делать unit-test.
  • Рост integration для конкретной системы. Внешка деградирует — но в этом случае алёрт лучше делать через метрику Resilience4j circuitbreaker_state (быстрее). app_errors_total{type="integration", exception="SberRegisterException"} — комплементарный сигнал.
  • Рост domain для одного типа. Например, InsufficientFundsException обычно даёт 50 в час; вдруг 500. Это бизнес-сигнал — возможно изменилось бизнес-условие или конкурент уронил курс, и пользователи массово упёрлись в правило. Команду продукта надо предупредить.
  • Любой рост technical. Это инфраструктурный сбой, требует внимания.

Не алёртим:

  • Стабильный baseline domain. Это нормальная нагрузка — каждый день есть N попыток списать с пустой карты.
  • Стабильный baseline validation. Клиенты иногда шлют плохой input — нормально.
  • На каждое отдельное исключение в логах. Это shutdown algorithm для on-call.

Что запрещено

R-ERR-OBS-X1: алёрт «любое исключение в логах».

Конфиг алёрта типа:

# ПЛОХО
sum(rate(app_errors_total[5m])) > 0

Это сработает на любой DomainException (нормально), ValidationException (нормально), любую другую ошибку. On-call дежурный получит 50 SMS в час, отключит алёрты, и настоящие инциденты будут пропущены.

Правильно — алёртить только на категории, которые не должны случаться, или их рост:

# ХОРОШО — резкий рост unexpected
rate(app_errors_total{type="unexpected"}[5m]) > 0.5  # больше 0.5 событий/сек
# ХОРОШО — CB открылся
sum by (name) (resilience4j_circuitbreaker_state{state="open"}) > 0
# ХОРОШО — рост technical
rate(app_errors_total{type="technical"}[5m])
    > 3 * rate(app_errors_total{type="technical"}[1h])
# скорость за 5 минут в 3 раза выше, чем за час — внутренняя проблема

Каждый алёрт — это сигнал to-act. Если on-call не должен ничего делать, увидев алёрт — это шум, не алёрт.

Куда дальше