Опирается на правила: R-ERR-OBS-1R-ERR-OBS-3 и R-ERR-OBS-X1 из Error Handling Style Guide → раздел 7. Observability.

Важно знать

  • Метрика app_errors_total{type, exception} — Counter через prom-client. Обязательна в каждом Exception Filter.
  • type — категория: domain / validation / integration / technical / unexpected.
  • exception — имя класса: InsufficientFundsError, PaymentGatewayError, и т.д.
  • Span на исключение помечается ERROR: span.setStatus({ code: SpanStatusCode.ERROR }) + span.recordException(err).
  • Алёртить на паттерны, не на каждое исключение: рост unexpected → баг, рост integration → деградация внешки.
  • Алёрт «любое исключение в логах» — антипаттерн. DomainError нормально часта; алёртить только на unexpected / technical.

Observability ошибок — это про понимание системы в Production. Не «зафиксировать все ошибки», а «видеть паттерны». DomainError растёт у одного type-URL → изменилось бизнес-условие (клиент нашёл обходной путь). integration-ошибки растут → деградация внешней системы. unexpected → баг в продакшене. Каждый из этих сигналов требует разной реакции. Раскрытие правил R-ERR-OBS-* ниже.

Метрика app_errors_total

R-ERR-OBS-1: Counter app_errors_total с лейблами type и exception.

// metrics/app-errors.metric.ts
import { Counter } from 'prom-client';

export const appErrorsTotal = new Counter({
  name: 'app_errors_total',
  help: 'Total number of application errors by type and exception class',
  labelNames: ['type', 'exception'] as const,
});

Регистрация в DI-контейнере (чтобы метрика создавалась один раз):

// metrics/metrics.module.ts
import { Module } from '@nestjs/common';
import { makeCounterProvider } from '@willsoto/nestjs-prometheus';

@Module({
  providers: [
    makeCounterProvider({
      name: 'app_errors_total',
      help: 'Total number of application errors by type and exception class',
      labelNames: ['type', 'exception'],
    }),
  ],
  exports: ['PROM_METRIC_APP_ERRORS_TOTAL'],
})
export class MetricsModule {}

Или проще — экспортируемый синглтон из metrics/app-errors.metric.ts и прямой импорт в фильтры:

// edge/filters/domain-error.filter.ts
import { appErrorsTotal } from '../../metrics/app-errors.metric';

@Catch(DomainError)
export class DomainErrorFilter implements ExceptionFilter {
  catch(err: DomainError, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    appErrorsTotal.inc({ type: 'domain', exception: err.name });
    // logger.warn(...) — см. exception-logging.md
  }
}

Значения type:

typeКогда
domainDomainError и наследники
validationInputValidationError
integrationIntegrationError и наследники
technicalTechnicalError
unexpectedcatch-all @Catch()

exception = err.name — имя класса. this.name = new.target.name в AppError конструкторе гарантирует правильное имя даже для наследников.

Пример Prometheus-запросов:

# Ошибки по типу за последние 5 минут
rate(app_errors_total[5m])

# Только unexpected
rate(app_errors_total{type="unexpected"}[5m])

# Конкретное исключение
rate(app_errors_total{exception="PaymentGatewayError"}[5m])

# Доля unexpected среди всех
rate(app_errors_total{type="unexpected"}[5m]) /
  rate(app_errors_total[5m])

Трейсинг — span.recordException

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

// edge/filters/domain-error.filter.ts
import { trace, SpanStatusCode } from '@opentelemetry/api';

@Catch(DomainError)
export class DomainErrorFilter implements ExceptionFilter {
  catch(err: DomainError, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();

    const span = trace.getActiveSpan();
    if (span) {
      span.recordException(err);
      span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
    }

    appErrorsTotal.inc({ type: 'domain', exception: err.name });
    sendProblem(res, 422, 'Operation cannot be completed', err.message, {
      type: `https://api.example.com/errors/${toKebabCase(err.name)}`,
    });
  }
}
// edge/filters/unexpected.filter.ts
@Catch()
export class UnexpectedFilter implements ExceptionFilter {
  catch(err: unknown, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();

    const span = trace.getActiveSpan();
    if (span) {
      if (err instanceof Error) {
        span.recordException(err);
      } else {
        span.recordException(new Error(String(err)));
      }
      span.setStatus({ code: SpanStatusCode.ERROR });
    }

    appErrorsTotal.inc({ type: 'unexpected', exception: (err as Error)?.name ?? 'Unknown' });
    sendProblem(res, 500, 'Internal Server Error', `traceId: ${getTraceId()}`);
  }
}

span.recordException(err) записывает в span:

  • exception.type = имя класса ошибки
  • exception.message = err.message
  • exception.stacktrace = err.stack

span.setStatus({ code: SpanStatusCode.ERROR }) помечает span как failed — в Jaeger / Tempo такой span отображается красным, легко найти в waterfall.

Correlation через traceId

traceId в ProblemDetails-ответе — это W3C Trace Context trace-id. Клиент обращается в поддержку с traceId → оператор находит span в Jaeger → видит весь путь запроса + записанное исключение.

// telemetry/trace-context.ts
import { trace, context } from '@opentelemetry/api';

export function getTraceId(): string {
  const span = trace.getActiveSpan();
  if (!span) return 'unknown';
  const { traceId } = span.spanContext();
  return traceId;
}

Алёрты на паттерны, не на события

R-ERR-OBS-3: алёрты реагируют на аномальные паттерны, не на каждое исключение.

Рост unexpected → баг

# alerting/rules/errors.yml
groups:
  - name: app-errors
    rules:
      - alert: UnexpectedErrorRateHigh
        expr: rate(app_errors_total{type="unexpected"}[5m]) > 0.01
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Unexpected errors rate above threshold"
          description: "{{ $value }} unexpected errors/sec — likely a bug in production"

Порог 0.01 (1 ошибка в 100 секунд) — настраивается под базовый уровень трафика.

Рост integration → деградация внешки

      - alert: IntegrationErrorRateHigh
        expr: rate(app_errors_total{type="integration"}[5m]) > 0.1
        for: 3m
        labels:
          severity: warning
        annotations:
          summary: "Integration error rate high — external system degraded"
          description: "System: {{ $labels.exception }}, rate: {{ $value }}/sec"

Рост domain для одного кода → изменилось бизнес-условие

      - alert: DomainErrorSpike
        expr: |
          increase(app_errors_total{type="domain"}[10m]) > 50
          and
          rate(app_errors_total{type="domain"}[5m])
            / rate(app_errors_total{type="domain"}[30m] offset 1h) > 3
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Domain error spike — business condition changed"
          description: "Exception: {{ $labels.exception }} — check if a client changed their integration"

3× рост по сравнению с историческим baseline за последний час — аномалия. Может означать: новая версия мобильного приложения сломала контракт; upstream-система начала посылать другие данные.

Рост validation → клиент сломал контракт

      - alert: ValidationErrorRateHigh
        expr: rate(app_errors_total{type="validation"}[5m]) > 0.2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Validation error rate high — client may have broken API contract"
          description: "Rate: {{ $value }}/sec — check recent client deployments"

Антипаттерн — алёрт на любое исключение

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

# ПЛОХО
- alert: AnyError
  expr: rate(app_errors_total[5m]) > 0
  annotations:
    summary: "There are errors in the application"

DomainError нормально часта — клиенты регулярно отправляют запросы, нарушающие бизнес-правила. В активном сервисе обработки платежей Сбера — сотни DailyLimitExceededError в час. Это нормально. Алёрт на это создаёт постоянный шум, команда перестаёт реагировать — «опять оно».

Алёртить только на:

  • unexpected — всегда баг, требует немедленного расследования.
  • technical — внутренняя проблема, требует расследования.
  • Аномальный рост integration — деградация внешки, нужен incident response.
  • Аномальный рост domain по конкретному коду — изменение в поведении клиентов.

Dashboards

Пример Grafana-дашборда для мониторинга ошибок:

Row 1: Error Overview
  - Panel: Error rate by type [time series] — rate(app_errors_total[5m]) by type
  - Panel: Error breakdown [pie] — increase(app_errors_total[1h]) by type
  - Panel: Unexpected errors [stat] — rate(app_errors_total{type="unexpected"}[5m])

Row 2: Domain Errors
  - Panel: Domain errors by code [time series] — by exception
  - Panel: Top domain error codes [table] — topk(10, ...)

Row 3: Integration
  - Panel: Integration errors by system [time series] — by exception
  - Panel: Circuit breaker events [logs] — из Loki по cb.open/cb.reset

Row 4: Traces
  - Panel: Error traces [Jaeger query] — tags: error=true

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

АнтипаттернПравилоЧто взамен
Алёрт на любое исключение в логахR-ERR-OBS-X1Алёртить только на unexpected/technical и аномальные паттерны
app_errors_total без лейбла exceptionR-ERR-OBS-1{ type, exception } — нужна детализация по классу
Span без span.recordException(err)R-ERR-OBS-2В каждом фильтре вызывать span.recordException + setStatus(ERROR)
traceId не включён в ProblemDetails-ответR-ERR-OBS-2sendProblem(res, ..., { traceId: getTraceId() })
Разные имена метрики в разных сервисахR-ERR-OBS-1app_errors_total — стандартное имя; разделение по сервису через Prometheus job лейбл

Куда дальше

  • Логирование исключений — логи поверх метрик и трейсов.
  • Mapping в ProblemDetails — как traceId попадает в ответ клиенту.
  • Иерархия исключений — почему err.name правильное в метриках.
  • Где throw, где catch — фильтры как единственная точка инкрементации метрики.
  • Retry-семантика — паттерн integration-ошибок при CB open/close.
  • Result-types vs exceptions — почему исключения видны в трейсах, Result — нет.
  • Observability Style Guide → R-OBS-* — метрики, трейсинг, логирование на уровне платформы.