Аудит-лог
Хто, що і коли змінив — одна append-only колекція audit__logs, декоратор @audited і відновлення стану з дельт.
Документ описує, як на бекенді kumquat зберігається історія змін: хто, що і коли змінив. Одна
append-only колекція audit__logs збирає записи з усіх точок входу — HTTP-запитів, Celery-воркерів,
вебхуків Clerk і CLI. Поля колекції — у Mongo-схема.
Основні файли:
packages/kumquat-audit/src/kumquat_audit/decorator.py— декоратор@audited.packages/kumquat-audit/src/kumquat_audit/context.py— контекст актора (хто діє).packages/kumquat-audit/src/kumquat_audit/models.py— документAuditLog.packages/kumquat-audit/src/kumquat_audit/replay.py— відновлення стану з дельт.packages/kumquat-audit/src/kumquat_audit/repository.py— читання і запис.kumquat_api/middleware.py— відкриття контексту на HTTP-запит.kumquat_api/worker/celery_app.py— те саме для Celery (сигналtask_prerun).kumquat_api/routes/audit_logs.py— ендпоінти читання.
Навіщо
До цього історію тримали дві саморобні колекції: task__transitions (переходи статусів) і
mention_notification_logs (спроби сповістити про згадку). Кожна — своя модель, свій репозиторій,
свій індекс. Решта змін не логувалась узагалі: правки задач і продуктів, створення креативів
автоматизацією, зміни графів, CRUD користувачів.
Тепер це одна колекція, один спосіб запису і один спосіб читання. Обидві старі колекції згорнуті в неї (див. «Що згорнуто»).
Що таке один запис
Рядок описує одну дію над однією сутністю.
| Поле | Що це |
|---|---|
at |
коли, UTC |
action |
що сталося: task.created, task.status_changed, product.deleted, … |
entity_type / entity_id |
над чим: task + id |
actor_id / actor_kind |
хто: Clerk-id і його вид — user, system, webhook, cli |
source |
звідки прилетіло: api, worker, webhook, cli |
request_id |
id HTTP-запиту або Celery-таски — щоб зшити кілька записів однієї операції |
changes |
що саме змінилось — застосований $set |
metadata |
решта аргументів методу (наприклад, попередній статус) |
entity_id завжди рядок, бо типи id різні по колекціях: ObjectId у задач, креативів і графів,
код продукту (CNI) у продуктів, Clerk-id (user_...) у користувачів. Один рядковий тип тримає
колекцію, індекси і API читання однаковими для всіх.
Як пишеться
Один рядок декоратора на метод запису репозиторію — і більше нічого в бізнес-логіці:
class TasksRepository(BaseMongoRepository):
collection_name = "task__tasks"
audit_entity = "task"
@audited("task.updated")
async def update(self, task_id: PyObjectId, changes: dict[str, Any]) -> Task | None:
...
Декоратор сам розбирає сигнатуру обгорнутого методу:
- перший аргумент після
self— id сутності (або сам документ, якщо це створення); - єдиний аргумент-
dict— цеchanges; - решта аргументів — у
metadata, під іменами параметрів.
Розбір іде по іменах параметрів, а не по позиціях. Це не косметика: у
update_if_status(task_id, expected_status, changes) дельта третім аргументом, і правило «другий
аргумент — це зміни» записало б туди рядок-guard. Заразом звідти безкоштовно береться попередній
статус — він і є expected_status.
Декоратор стоїть на репозиторіях, а не на роутах. Обробники воркфлоу
(TaskWorkflowHandlers) конструюються у воркері напряму з репозиторіїв — повз сервіси і повз HTTP.
Хук на рівні роута не побачив би ні ревертів автоматизації, ні масового створення креативів.
Виняток — CRUD користувачів у Clerk: там локального запису немає взагалі, тож декоратор стоїть на
методах сервісу.
Якщо запису не було — рядка не буде. Метод, який повернув None або False, нічого не змінив:
програна CAS-гонка, видалення неіснуючого id, порожній список. Такі виклики лог не засмічують.
Помилка запису аудиту валить операцію. Її навмисно не ковтають: дірка в ланцюжку тихо ламає всі наступні відновлення стану, а після згортання переходів — ще й губить рядок історії задачі. Краще видима помилка, ніж непомітно зіпсована історія.
Хто діє
Актора ніхто не передає аргументами — він живе в контексті запиту (ContextVar), який відкривають
три точки входу.
| Звідки | Хто відкриває контекст | Актор |
|---|---|---|
| HTTP | AuditContextMiddleware + get_current_user |
користувач із перевіреного Clerk JWT |
| Celery | сигнал task_prerun |
actor_id з kwargs таски, інакше system |
| CLI | audit_context(...) навколо команди |
cli |
| Вебхук Clerk | роут вебхука | webhook |
Middleware спрацьовує до резолву залежностей, тож на той момент викликач ще невідомий: контекст
відкривається з одним лише request_id, а get_current_user домальовує актора. Оскільки цей
dependency змонтований на кожному автентифікованому роутері, одного рядка вистачає на весь API.
Через чергу актор передається явно: run_task_transition і notify_comment_mention приймають
actor_id іменованим аргументом, бо task_prerun читає його саме з kwargs таски. Без цього
кожна зміна, зроблена автоматизацією на замовлення людини, виглядала б як дія системи.
Записувати можна й без контексту — тоді запис буде system. Це нормальна ситуація: analyst_api
використовує той самий get_current_user, але свого middleware не має.
Дельти, а не знімки
Рядок несе тільки те, що змінилось, а не весь документ після запису. Виняток — рядок створення:
там changes і є повний документ, і саме він служить якорем.
Тому стан на будь-який момент відновлюється відтворенням: беремо всі рядки сутності до потрібного
часу і послідовно накладаємо їх один на одного (replay.py). Ключі — dotted-path
(automation.state), тож накладання повторює семантику $set: проміжні під-документи створюються,
листок перезаписується, списки замінюються цілком.
Знімки розглядались і відкинуті: Creative — найтовстіший документ (~115 полів), і при машинній
частоті запису знімки давали б десятки гігабайт на рік. Дельта тих самих змін — кількасот байт.
changes може бути null. Так позначається подія, яка нічого локально не змінила — наприклад,
спроба надіслати DM. Відтворення такі рядки просто пропускає.
Поля з __ на початку в дельту не потрапляють. Такий префікс позначає сигнал іншій частині
системи, а не те, чим є сутність: task__tasks.__last_comment_id існує лише для того, щоб change
stream мав на чому спрацювати (див. Коментарі до задач). У історії
воно нічого не означає, а відтворення повернуло б його у відновлений документ.
Префікс саме подвійний, бо _id — поле як усі інші: у рядку створення воно називає документ, який
відтворення збирає назад. З одинарним підкресленням правило вимагало б окремого винятку на _id,
а виняток у фільтрі — це те, про що наступного разу забудуть.
Запис, після фільтрації якого не лишилось нічого, рядка не пише взагалі — інакше історія показала б зміну, якої користувач не може собі пояснити.
Що логується
| Сутність | Дії |
|---|---|
task |
task.created, task.updated, task.status_changed |
product |
product.created, product.updated, product.deleted |
creative |
creative.created (по рядку на кожну картку) |
node_graph |
node_graph.created, node_graph.updated, node_graph.deleted |
graph_run |
graph_run.started |
comment |
comment.created, comment.updated, comment.deleted, comment.mention_notified |
user |
user.created, user.updated, user.deleted (дії адміна) і user.mirror_synced, user.mirror_updated, user.mirror_deleted (дзеркало Clerk) |
Подвійні записи по користувачах — навмисні: адмін видаляє користувача → user.deleted (актор —
адмін), потім прилітає вебхук Clerk → user.mirror_deleted (актор — webhook). Це дві різні
події, і обидві варті зберігання.
Свідомо не логується:
- стан вузлів
graph__runs— пише на кожну подію виконання графа, втопило б людську історію; фіксується лише старт запуску; assets— індекс згенерованих файлів і так є довговічним записом того, що вийшло;- лічильник номерів задач — це не бізнес-зміна;
- оновлення метрик креативів (
kumquat-creative-metrics) — машинна частота по всьому каталогу; analyst-agent,gdrive-sync— окремі процеси зі своїми станами;- Slack-сповіщення, окрім DM про згадку;
- Google Drive — методи клієнта синхронні, тож поки що поза скоупом.
Аудит вмикається на конкретний метод, а не обгортає драйвер Mongo цілком. Плюс — у лог не потрапляє машинний шум. Мінус — новий метод запису легко забути задекорувати.
Що згорнуто
Переходи статусів. Колишня колекція task__transitions — це тепер action = "task.status_changed": куди перейшли лежить у changes.status, звідки — у
metadata.expected_status (це той самий guard, під яким робився CAS-запис). Історія задачі
(GET /tasks/{id}/history) і відкат перерваного переходу (abort_transition) читають уже
audit__logs.
Історія задачі віддає чотири дії — task.status_changed, task.updated, task.deleted,
task.restored (HISTORY_ACTIONS), — а редагування показує іменами полів зі свого changes
(TaskTransition.changed_fields). Два наслідки для запису. По-перше, update_task звіряє payload
з поточним документом і пише лише реальну дельту: форма редагування PATCH-ить задачу цілком, тож
інакше кожне збереження називало б ~45 змінених полів. По-друге, рядок, у якому не лишилось нічого,
окрім updated_at та automation.* (HISTORY_BOOKKEEPING_FIELDS), відсікається запитом
(HISTORY_QUERY_EXTRA), а не фільтром по результату — інакше такі рядки все одно займали б місце
в сторінці й у total. Автоматизація пише кілька таких на кожен запуск.
Сповіщення про згадки. Колишня mention_notification_logs — це action = "comment.mention_notified" з changes = null, отримувачем у metadata.recipient_id і результатом
(sent / skipped_no_email) у metadata.status. Сутність рядка — коментар, а не користувач.
Читання
Два ендпоінти, обидва тільки для адмінів. Звичайний користувач і так бачить зріз цієї колекції в історії задачі — під адмінкою закритий саме наскрізний перегляд.
GET /api/v1/audit-logs?entity_type=task&entity_id=…&actor_id=…&action=…&since=…&until=…
Новіші зверху, фільтри комбінуються, пагінація через limit/offset.
GET /api/v1/audit-logs/{entity_type}/{entity_id}/history?at=…
Відтворює документ таким, яким він був на момент at (без параметра — на зараз). Це ж і спосіб
перевірити цілісність: без at результат має збігатися з живим документом, а розбіжність означає,
що хтось писав повз лог.
Чого лог не покаже
- Записи повз репозиторії. CLI-сідери пишуть у колекції напряму, як і будь-що, зроблене руками
в
mongosh. Такі зміни ламають ланцюжок дельт непомітно — відновлення почне розходитись із реальністю. Ловиться це порівнянням/historyз живим документом. - Метрики креативів — не логуються свідомо, тож відтворення дає авторські поля креативу, але не його метрики.
- Переходи, зроблені автоматизацією. Обробники пишуть статус через звичайний
update, а не через CAS, тож в історії задачі їх не було й раніше — згортання цього не змінило.