Audit: практичное audit-логирование для Go
На работе понадобилось сделать аудит-логи для довольно сложной сущности. Задача была простая: отрисовывать историю изменения статусов. Кто поменял, когда поменял, что было до этого.
Никакого event sourcing в проекте не было. Готовой истории событий, из которой можно собрать этот таймлайн, тоже. Ее предстояло записывать самим.
Я решил не ограничиваться статусами и сразу сделать историю для всех полей сущности. В итоге фичу так и не запустили, а код остался. Ну не пропадать же добру. Вынес его в отдельную библиотеку audit .
А обычных логов не хватит? #
Записать «пользователь поменял статус заказа» в обычный лог несложно. Потом можно найти эту запись в системе сбора логов и посмотреть, что происходило.
Но здесь задача немного другая. Историю надо не просто найти при расследовании какого-то факапа, а показать в интерфейсе. Причем не всю жизнь сервиса, а изменения конкретной сущности. Или вообще одного ее поля.
Для этого хочется иметь понятную модель: вот сущность, вот автор изменения, вот поля, которые он поменял. А не каждый раз восстанавливать происходящее по сообщениям «обновление прошло успешно».
Именно эту задачу и решает audit. Не CQRS, не event sourcing, а история изменений.
Что записываем #
Сущность в библиотеке - обычный строковый ключ. Например, order:12345. Библиотеке не нужно знать,
что такое заказ, какие у него методы и в какой таблице он лежит.
При создании записываем автора, описание и значения полей:
logger := audit.New()
logger.Create(
"order:12345",
"john.doe",
"Order created",
map[string]audit.Value{
"status": audit.PlainValue("pending"),
"total": audit.PlainValue(99.99),
"payment_token": audit.HiddenValue(),
},
)
При изменении - то же самое, но только с нужными полями:
logger.Update(
"order:12345",
"warehouse.system",
"Order shipped",
map[string]audit.Value{
"status": audit.PlainValue("shipped"),
"tracking_number": audit.PlainValue("TRK123456789"),
},
)
Никакой рефлексии и автоматического сравнения структур. Какие изменения передали, такие и попали в историю.
Если честно, я уже не помню, почему изначально отказался от автоматического diff. Возможно, хотел больше контроля над тем, какие данные туда попадут. Придумывать красивую историю принятия этого решения не буду.
Но у текущего подхода этот контроль действительно есть. Не надо отдавать библиотеке всю сущность и надеяться, что она правильно разберется с ее содержимым. Поля перечисляются явно.
Для чувствительных значений есть HiddenValue(). В истории будет видно само поле, но вместо
значения - ***. Например, можно зафиксировать изменение токена, не записывая сам токен в
аудит-лог.
Понятно, что библиотека не угадывает, какие данные чувствительные. Если передать токен через
PlainValue, скрытым он от этого не станет.
Что потом читаем #
Полную историю сущности можно получить через Logs:
logs := logger.Logs("order:12345")
В каждой записи будут автор, описание, время и изменения полей с from и to. Для статуса из
примера выше - сначала null → pending, потом pending → shipped.
Но ради истории статусов не хочется тащить изменения суммы, токенов и всего остального. Поэтому можно запросить события только по конкретному полю:
events := logger.Events("order:12345", "status")
Это как раз исходная задача: получить цепочку изменений статуса и отрисовать ее в интерфейсе. При этом история остальных полей никуда не девается. Понадобится разобраться, кто поменял сумму, - можно запросить и ее.
Где все это хранить #
Библиотека не выбирает за вас хранилище. Для этого есть интерфейс Storage: реализуете его и
подключаете через WithStorage.
Например, в репозитории есть простая реализация с JSON-файлом :
storage := NewJSONFileStorage("audit_events.json")
logger := audit.New(audit.WithStorage(storage))
NewJSONFileStorage здесь - функция из примера, а не часть самой библиотеки.
Вместо файла можно использовать базу данных, Kafka или другой backend. Куда именно писать аудит-логи - решаете вы. Библиотека занимается моделью событий и историей изменений.
А можно через slog? #
Да. Для этого есть отдельный пакет audit/slog. Его handler извлекает аудит-события из
структурированных логов. В записи указываются сущность, действие и автор, остальные атрибуты
передают данные:
slog.Info(
"User account created",
auditslog.AttrEntity, "user:123",
auditslog.AttrAction, "create",
auditslog.AttrAuthor, "admin",
"email", "alice@example.com",
"role", "editor",
)
Этот пример предполагает, что audit-handler уже подключен к slog. Сам по себе вызов slog.Info
ничего в библиотеку не запишет.
При настройке handler можно указать, как извлекать ключ сущности и какие записи отправлять в аудит. Если сущности в записи нет или запись не проходит фильтр, аудит-событие не создается.
Получается, одна запись может остаться обычным логом для системы сбора логов и одновременно стать
событием, с которым можно работать через Logs и Events.
Что мне не нравится #
Самая неудобная часть для меня - описание полей через map[string]audit.Value. Для каждого
изменения приходится руками собирать map со строковыми ключами. Не совсем go-way.
Явность здесь полезна, но записывать все это не так приятно, как хотелось бы. Так что интерфейс записи - та часть библиотеки, которой я не вполне доволен.
В остальном она делает то, ради чего появилась: позволяет записывать аудит-логи в нужное хранилище и получать историю изменений сущности или отдельного поля.
Фичу на работе так и не запустили. Но если вам тоже нужно просто показать, кто и когда поменял статус, а готовой истории событий в проекте нет, возможно, audit пригодится.