---
title: Аудит-лог
description: Хто, що і коли змінив — одна append-only колекція audit__logs, декоратор @audited і відновлення стану з дельт.
---

Документ описує, як на бекенді kumquat зберігається історія змін: хто, що і коли змінив. Одна
append-only колекція `audit__logs` збирає записи з усіх точок входу — HTTP-запитів, Celery-воркерів,
вебхуків Clerk і CLI. Поля колекції — у [Mongo-схема](/backend/db-schema).

Основні файли:

- `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 читання однаковими для всіх.


## Як пишеться

Один рядок декоратора на метод запису репозиторію — і більше нічого в бізнес-логіці:

```python
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 мав на чому спрацювати (див. [Коментарі до задач](/backend/comments-for-task)). У історії
воно нічого не означає, а відтворення повернуло б його у відновлений документ.

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