Опирается на правила:
R-ERR-OBS-1…R-ERR-OBS-3иR-ERR-OBS-X1из Error Handling Style Guide → раздел 7. Observability.
Важно знать
app_errors_total{type, exception}— Counter черезpromauto.type=domain/validation/integration/technical/unexpected,exception= имя типа ошибки.span.RecordError(err)+span.SetStatus(codes.Error, ...)— каждая ошибка помечает активный span как ERROR.- Алёрты — на паттерны, не на каждую ошибку: рост
unexpected→ новый баг; ростintegration→ деградация внешки; аномальный рост конкретногоdomain-кода → изменилось бизнес-условие.- Алёрт «любой exception в логах» — запрещён.
Domainнормально частая — пользователи нарушают правила в штатном режиме.app_errors_totalинкрементится внутриhttperr.Write— один раз, на edge. Не в домене, не в адаптере.span.RecordError— на активном span в контексте запроса, не на глобальном tracer'е.
Метрики + трейсинг + логи — три независимых сигнала об одной ошибке. Каждый нужен: метрика → алёрт и дашборд, трейс → диагностика конкретного запроса, лог → полный контекст с сообщением. Раскрытие правил R-ERR-OBS-* ниже.
R-ERR-OBS-1 — app_errors_total
prometheus/client_golang + promauto для автоматической регистрации:
// edge/httperr/metrics.go
package httperr
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var appErrorsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "app_errors_total",
Help: "Application errors by type and exception class",
},
[]string{"type", "exception"},
)
Инкремент внутри httperr.Write:
func Write(w http.ResponseWriter, r *http.Request, err error) {
kind := apperr.KindOf(err)
appErrorsTotal.WithLabelValues(kindLabel(kind), typeName(err)).Inc()
// ...
}
Лейблы:
func kindLabel(k apperr.Kind) string {
switch k {
case apperr.Domain: return "domain"
case apperr.Validation: return "validation"
case apperr.Integration: return "integration"
case apperr.Technical: return "technical"
default: return "unexpected"
}
}
func typeName(err error) string {
if err == nil {
return "nil"
}
t := reflect.TypeOf(err)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
return t.Name()
}
panic в Recoverer-middleware:
appErrorsTotal.WithLabelValues("unexpected", "panic").Inc()
Примеры серий в Prometheus:
app_errors_total{type="domain", exception="InsufficientFundsError"} 42
app_errors_total{type="domain", exception="OrderAlreadyShippedError"} 7
app_errors_total{type="integration", exception="GatewayError"} 3
app_errors_total{type="validation", exception="ValidationError"} 15
app_errors_total{type="unexpected", exception="panic"} 0
R-ERR-OBS-2 — span.RecordError + span.SetStatus
OpenTelemetry: каждая ошибка помечает активный span как ERROR.
// edge/httperr/render.go
import (
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
func Write(w http.ResponseWriter, r *http.Request, err error) {
span := trace.SpanFromContext(r.Context())
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
// ...
}
span.RecordError(err) добавляет событие exception в span с атрибутами exception.type и exception.message. Чтобы также записать exception.stacktrace, передай опцию: span.RecordError(err, trace.WithStackTrace(true)). span.SetStatus(codes.Error, ...) помечает span как ERROR — трейс-бэкенд (Jaeger, Tempo) сможет фильтровать по ошибочным запросам.
Для долгих операций с несколькими вложенными span'ами — записывать ошибку на том span'е, где она произошла, и помечать родительский:
// adapters/out/payment/client.go
func (c *Client) Register(ctx context.Context, cmd RegisterCommand) (RegisterResult, error) {
ctx, span := tracer.Start(ctx, "payment.Register")
defer span.End()
result, err := c.doRegister(ctx, cmd)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "payment register failed")
return RegisterResult{}, &GatewayError{Op: "register", Err: err}
}
return result, nil
}
Локальный span в адаптере записывает ошибку в своё место трейса — диагностика видна на правильном уровне. Родительский span на edge также помечается через httperr.Write.
R-ERR-OBS-3 — алёрты на паттерны
Четыре паттерна для алёртов — по нарастанию срочности:
| Паттерн | Сигнал | Действие |
|---|---|---|
Рост unexpected / panic | Новый баг попал в прод | Немедленно: открыть issue, смотреть логи |
Рост integration для конкретной системы | Деградация внешки (Сбер, каталог) | Инцидент: дежурный смотрит CB, status-page |
Аномальный рост конкретного domain-кода | Изменилось бизнес-условие или клиент изменил поведение | Анализ: BI + product |
Рост validation | Клиент сломал контракт (новая версия мобильного приложения) | Анализ: API-версия, backward compatibility |
Примеры Prometheus rules:
groups:
- name: error-handling
rules:
- alert: UnexpectedErrorsIncreasing
expr: |
rate(app_errors_total{type=~"unexpected|technical"}[5m]) > 0.1
for: 2m
labels:
severity: critical
annotations:
summary: "Unexpected errors: {{ $value | humanize }} rps"
- alert: IntegrationDegraded
expr: |
rate(app_errors_total{type="integration"}[5m]) > 1
for: 5m
labels:
severity: warning
annotations:
summary: "Integration errors: {{ $labels.exception }} {{ $value | humanize }} rps"
- alert: DomainErrorAnomalous
expr: |
rate(app_errors_total{type="domain"}[5m])
> 10 * rate(app_errors_total{type="domain"}[1h] offset 1d)
for: 10m
labels:
severity: info
annotations:
summary: "Domain error pattern change: {{ $labels.exception }}"
DomainErrorAnomalous срабатывает не на абсолютное число, а на отклонение от нормы: если InsufficientFundsError стал встречаться в 10 раз чаще, чем вчера в то же время — это сигнал о новом акционном предложении или проблеме с балансами.
R-ERR-OBS-X1 — запрет алёрта на любой exception
# ПЛОХО — алёрт на любую строку ERROR в логах
- alert: AnyException
expr: rate(log_messages_total{level="error"}[1m]) > 0
Почему нет:
Domainошибки (InsufficientFundsError,OrderAlreadyShippedError) — нормальная бизнес-активность. В нагруженный день их тысячи. Алёрт на каждую — непрерывный шум.Validation— клиент ввёл некорректные данные. Тоже нормально.- Алёрт должен отвечать на вопрос «нужно ли сейчас вмешаться». На
Domain/Validation— нет, это штатный режим.
Правильно: алёртить только на unexpected + technical. Integration — по порогу и тренду.
Дашборд
Стандартный набор панелей для Grafana:
[Error Rate by Type] — rate(app_errors_total[5m]) by (type)
[Top Domain Errors] — topk(10, rate(app_errors_total{type="domain"}[5m]) by (exception))
[Integration by System] — rate(app_errors_total{type="integration"}[5m]) by (exception)
[Unexpected Errors] — rate(app_errors_total{type=~"unexpected|technical"}[5m])
[Error Ratio] — rate(app_errors_total[5m]) / rate(http_requests_total[5m])
Последняя панель (Error Ratio) — отношение ошибочных ответов к общему числу запросов. При росте трафика абсолютное число ошибок растёт — это норма. Аномалия — рост доли ошибок.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Алёрт rate(log_messages{level="error"} > 0) | R-ERR-OBS-X1 | Алёрт только на type=~"unexpected\|technical" |
appErrorsTotal.Inc() в доменном коде | R-ERR-OBS-1 | Инкремент только в httperr.Write |
Нет span.RecordError(err) — span остаётся OK | R-ERR-OBS-2 | span.RecordError + span.SetStatus(codes.Error, ...) |
Нет type лейбла — только exception | R-ERR-OBS-1 | Всегда оба лейбла: {type, exception} |
Куда дальше
- Иерархия ошибок — откуда берутся
kindиtypeNameдля лейблов. - Где return, где обрабатывать —
httperr.Write— единственная точка инкремента метрики. - Mapping в ProblemDetails — инкремент и
span.RecordErrorв одномWrite. - Логирование ошибок — три сигнала (метрика + трейс + лог) из одного места.
- Retry-семантика — почему
integrationCB-алёрт важен при открытом CB. - Errors-as-values vs panic —
panic→unexpectedметрика. - Shared-контракт R-ERR-OBS — нормативные формулировки.