Опирается на правила: R-ERR-OBS-1R-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 остаётся OKR-ERR-OBS-2span.RecordError + span.SetStatus(codes.Error, ...)
Нет type лейбла — только exceptionR-ERR-OBS-1Всегда оба лейбла: {type, exception}

Куда дальше

  • Иерархия ошибок — откуда берутся kind и typeName для лейблов.
  • Где return, где обрабатывать — httperr.Write — единственная точка инкремента метрики.
  • Mapping в ProblemDetails — инкремент и span.RecordError в одном Write.
  • Логирование ошибок — три сигнала (метрика + трейс + лог) из одного места.
  • Retry-семантика — почему integration CB-алёрт важен при открытом CB.
  • Errors-as-values vs panic — panicunexpected метрика.
  • Shared-контракт R-ERR-OBS — нормативные формулировки.