Опирается на правила:
R-ERR-OBS-1…R-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 | Когда |
|---|---|
domain | DomainError и наследники |
validation | InputValidationError |
integration | IntegrationError и наследники |
technical | TechnicalError |
unexpected | catch-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.messageexception.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 без лейбла exception | R-ERR-OBS-1 | { type, exception } — нужна детализация по классу |
Span без span.recordException(err) | R-ERR-OBS-2 | В каждом фильтре вызывать span.recordException + setStatus(ERROR) |
traceId не включён в ProblemDetails-ответ | R-ERR-OBS-2 | sendProblem(res, ..., { traceId: getTraceId() }) |
| Разные имена метрики в разных сервисах | R-ERR-OBS-1 | app_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-* — метрики, трейсинг, логирование на уровне платформы.