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 пригодится.