Skip to content
Kumquat
Esc
↑↓navigate↵open⌘Jpreview
On this page

Аудит-лог

Хто, що і коли змінив — одна 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, тож в історії задачі їх не було й раніше — згортання цього не змінило.

Was this page helpful?