Токен попал в Go-лог: как задать безопасное представление для slog
В обработчик добавили logger.Info с целым объектом подключения. На локальной машине это помогло найти неверный адрес сервиса. В централизованный журнал вместе с адресом ушёл API-токен. Удаление одной строки исправляет эпизод, но похожий объект легко залогировать в другом месте. Полезнее задать ему безопасное представление рядом с типом.
Выберите поля, которые нужны для расследования
Для диагностики подключения обычно достаточно имени зависимости, адреса без секретов и номера попытки. Пароль, токен и пользовательское тело запроса редко помогают понять таймаут. Список разрешённых полей проще контролировать, чем последовательное удаление известных секретных ключей из произвольного объекта. При добавлении нового поля оно не должно автоматически попадать в лог.
В slog тип может реализовать LogValuer. Метод LogValue возвращает значение, которое структурный обработчик использует при разрешении атрибута. Для конфигурации зависимости можно вернуть группу с несколькими публичными полями, а секрет вообще не включать. Для отдельного типа Secret метод возвращает фиксированную маску. Это два полезных уровня: безопасный объект целиком и защита одиночного секрета.
Проверьте результат как JSON
Пример пишет в bytes.Buffer через JSONHandler, затем разбирает полученную строку. Тест проверяет и точное значение безопасных полей, и отсутствие исходного токена во всём выводе. Время и служебные атрибуты не влияют на результат, потому что мы не сравниваем строку целиком. Программе нужен Go 1.21 или новее.
package main
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"strings"
)
type Secret string
func (s Secret) LogValue() slog.Value { return slog.StringValue("[REDACTED]") }
type Dependency struct {
Name, Host string
Token Secret
}
func (d Dependency) LogValue() slog.Value {
return slog.GroupValue(slog.String("name", d.Name), slog.String("host", d.Host))
}
func main() {
var b bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&b, nil))
d := Dependency{"billing", "billing.internal", Secret("test-only-token-9481")}
logger.Info("dependency call", "dependency", d, "credential", d.Token)
var row map[string]any
if err := json.Unmarshal(b.Bytes(), &row); err != nil {
panic(err)
}
group := row["dependency"].(map[string]any)
fmt.Println(group["name"], group["host"])
fmt.Println(row["credential"])
fmt.Println(strings.Contains(b.String(), string(d.Token)))
}
В результате токен отсутствует, зависимость остаётся узнаваемой, а отдельный Secret превращается в [REDACTED]. Сохранение структуры важно: поиск по dependency.name устойчивее поиска слова внутри произвольной строки. При ошибке внешнего вызова можно добавить request_id и длительность, сохранив возможность связать записи разных этапов.
Не теряйте защиту до вызова логгера
LogValuer срабатывает, когда тип попадает в slog как значение. Если заранее преобразовать Secret в string, информация о типе исчезнет. То же произойдёт при ручном составлении сообщения с исходным токеном. Поэтому форматирование секретов через fmt.Sprintf, конкатенацию и прямую передачу в поле ошибки нужно искать отдельно. Новый тип помогает сделать правильный путь обычным, но не переписывает уже построенные строки.
У адреса тоже может быть секретная часть. URL с паролем, ключом в query или подписью в пути нельзя бездумно добавлять в разрешённые поля. Для журнала полезно хранить отдельно логическое имя сервиса и адрес, построенный из разрешённых частей. Внешнее сообщение ошибки иногда повторяет полный URL: его следует сопоставить с публичной категорией сбоя, а подробности очищать до записи.
HandlerOptions.ReplaceAttr пригодится для общих правил организации: удалить определённые атрибуты, переименовать поле уровня, нормализовать время. Однако общее правило по имени token не найдёт секрет внутри message или пользовательского error. Начинайте с проектирования полей в месте возникновения события, а центральную фильтрацию используйте как дополнительный слой.
Для регрессии добавьте тест на каждый объект, который регулярно передаётся целиком: настройки клиента, сведения о пользователе, входящее событие. Проверьте вложенные группы и указатели. Полезно включить контрольный секрет с уникальной строкой и искать её в результате. Не используйте настоящий ключ в тестовом наборе: тестовые журналы тоже сохраняются в CI.
После исправления новых записей остаётся старый журнал. Если в нём был действующий токен, его меняют и ограничивают доступ к сохранённым копиям по принятому процессу. Дальше журнал должен отвечать на конкретный вопрос: какая зависимость отказала, какой запрос затронут и какой шаг успел завершиться. Безопасное представление сохраняет эти ответы.
Закрепите набор диагностических полей
Для одного события задайте постоянные имена полей и типы значений. Если duration то строка, то число, поиск медленных вызовов в хранилище логов осложнится. Тест можно проверять на обязательные dependency, request_id и outcome, а не на порядок ключей JSON. При этом высокая кардинальность имеет цену: полный URL и произвольный текст запроса создают множество уникальных значений. Для сводки лучше фиксированное имя операции, например cancel_order. Идентификатор конкретного запроса оставляйте для перехода к его событиям, а публичное имя операции — для сравнения с соседними вызовами.
Продолжите до взаимодействия сервисов
Безопасные поля позволяют перейти от отдельных сообщений к истории операции. В платном курсе микросервисов на Go вы будете читать логи, метрики и трассировки, разбирать HTTP- и gRPC-вызовы и проверять сценарии отказа. Следующая задача — проследить, какой шаг успел завершиться между запросом, транзакцией и доставкой события. Откройте программу курса «Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker».
Продолжить на практике
- Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗
От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.
