Files
AWatch-rus/docs/clickhouse/DICTIONARIES_IMPLEMENTATION_PLAN_RU.md
T

763 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План внедрения ClickHouse Dictionaries и агрегатов для AWatch-rus / DetMir
Дата: `2026-06-23`
Статус: рабочий архитектурный план.
## 1. Общая цель
Цель внедрения ClickHouse Dictionaries и Materialized Views - подготовить
AWatch-rus / DetMir к росту объема данных и числу пользователей отчетности.
Сырые события ActivityWatch остаются в fact-таблицах. Отчеты и Grafana не
должны постоянно выполнять тяжелые `JOIN` и агрегации по миллионам или
миллиардам строк. Обогащение и схлопывание данных нужно переносить на этап
записи, backfill или scheduled aggregation.
Базовая схема:
```text
raw ActivityWatch events
-> dimension tables
-> ClickHouse Dictionaries
-> Materialized Views / aggregate tables
-> Grafana / reports
```
Dictionaries не являются источником истины. Источник истины - обычные таблицы
измерений, импорт из NetBox/XLS/инвентаризации или утвержденные ручные
справочники.
## 2. Спринт 1. Оргструктура и слепые зоны
### Цель
Связать сырые события AWatch-rus с оргструктурой компании и выявить слепые зоны
в привязке рабочих мест, пользователей и подразделений.
### Решение
Создаются:
- `dim_workstation_user` - dimension/current snapshot;
- `dict_workstation_user` - словарь для lookup по паре `host_name + user_login`.
Так как ключ составной, используется `COMPLEX_KEY_HASHED()`.
### Source table
```sql
CREATE TABLE IF NOT EXISTS dim_workstation_user
(
host_name String,
user_login String,
employee_name String,
department String,
branch String,
position String,
source LowCardinality(String),
is_active UInt8 DEFAULT 1,
updated_at DateTime DEFAULT now()
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY (host_name, user_login);
```
### Dictionary
```sql
CREATE DICTIONARY IF NOT EXISTS dict_workstation_user
(
host_name String,
user_login String,
employee_name String,
department String,
branch String,
position String,
is_active UInt8
)
PRIMARY KEY host_name, user_login
SOURCE(CLICKHOUSE(TABLE 'dim_workstation_user'))
LAYOUT(COMPLEX_KEY_HASHED())
LIFETIME(MIN 3600 MAX 86400);
```
### Отчет по слепым зонам
```sql
SELECT
host_name,
user_login,
count() AS events
FROM aw_raw_events
WHERE dictGetStringOrDefault(
'dict_workstation_user',
'employee_name',
(host_name, user_login),
''
) = ''
GROUP BY host_name, user_login
ORDER BY events DESC
LIMIT 100;
```
### Definition of Done
- создана таблица `dim_workstation_user`;
- создан словарь `dict_workstation_user`;
- словарь находится в статусе `LOADED`;
- есть стартовая загрузка данных;
- есть отчет unknown `host_name + user_login`;
- есть первый обогащенный запрос по сырым событиям;
- документирован источник истины для оргструктуры.
## 3. Спринт 2. Продуктивность и классификация desktop software
### Цель
Уйти от анализа миллионов строк процессов к понятным бизнес-метрикам по
использованию ПО на рабочих местах.
Сырые события должны обогащаться не только `process_name`, а нормализованным
именем приложения, категорией, признаком продуктивности и уровнем риска.
### Решение
Создаются:
- `dim_application_category`;
- `dict_application_category`;
- отчет top unknown processes;
- первый отчет продуктивности по отделам и сотрудникам.
### Source table
```sql
CREATE TABLE IF NOT EXISTS dim_application_category
(
process_name String,
application_name String,
vendor String,
category LowCardinality(String),
productivity_class LowCardinality(String),
risk_level LowCardinality(String),
is_system UInt8 DEFAULT 0,
is_active UInt8 DEFAULT 1,
source LowCardinality(String),
comment String,
updated_at DateTime DEFAULT now()
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY process_name;
```
### Dictionary
```sql
CREATE DICTIONARY IF NOT EXISTS dict_application_category
(
process_name String,
application_name String,
vendor String,
category String,
productivity_class String,
risk_level String,
is_system UInt8,
is_active UInt8
)
PRIMARY KEY process_name
SOURCE(CLICKHOUSE(TABLE 'dim_application_category'))
LAYOUT(HASHED())
LIFETIME(MIN 3600 MAX 86400);
```
### Базовые классы продуктивности
| Значение | Смысл |
|---|---|
| `productive` | рабочее приложение |
| `neutral` | системное или вспомогательное приложение |
| `non_productive` | явно нерабочее использование |
| `risky` | потенциальный риск ИБ или нежелательное ПО |
| `unknown` | нет классификации |
### Отчет unknown processes
```sql
SELECT
process_name,
count() AS events,
sum(duration_sec) AS duration_sec
FROM aw_window_events
WHERE dictGetStringOrDefault(
'dict_application_category',
'category',
process_name,
''
) = ''
GROUP BY process_name
ORDER BY duration_sec DESC
LIMIT 100;
```
### Definition of Done
- создана таблица `dim_application_category`;
- создан словарь `dict_application_category`;
- загружен стартовый список известных процессов;
- есть top unknown processes;
- есть отчет продуктивности в разрезе отдела/сотрудника;
- классификация не используется как дисциплинарный вывод без ручной проверки;
- назначен владелец справочника классификации ПО.
## 4. Спринт 3. Веб-аналитика и глубинная фильтрация
### Цель
Перейти от анализа заголовков окон браузера и полных URL к нормальной
веб-аналитике: домены, категории сайтов, рабочая/нерабочая активность,
рисковые ресурсы и unknown domains.
### Решение
Создаются:
- нормализация URL в `domain_name`;
- `dim_domain_category`;
- `dict_domain_category`;
- top unknown domains;
- browser productivity report;
- задел под path-level правила.
### Нормализация URL
```sql
SELECT
url,
lowerUTF8(
parseURL(
if(position(url, '://') = 0, concat('http://', url), url),
'host'
)
) AS domain_name
FROM aw_browser_events;
```
Если в данных уже есть корректный полный URL, можно использовать `domain(url)`.
Если URL спрятан только в title, regex parsing допускается как fallback, но не
как основной путь.
### Source table
```sql
CREATE TABLE IF NOT EXISTS dim_domain_category
(
domain String,
site_name String,
category LowCardinality(String),
productivity_class LowCardinality(String),
risk_level LowCardinality(String),
business_allowed UInt8 DEFAULT 0,
source LowCardinality(String),
comment String,
is_active UInt8 DEFAULT 1,
updated_at DateTime DEFAULT now()
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY domain;
```
### Dictionary
```sql
CREATE DICTIONARY IF NOT EXISTS dict_domain_category
(
domain String,
site_name String,
category String,
productivity_class String,
risk_level String,
business_allowed UInt8,
is_active UInt8
)
PRIMARY KEY domain
SOURCE(CLICKHOUSE(TABLE 'dim_domain_category'))
LAYOUT(HASHED())
LIFETIME(MIN 3600 MAX 86400);
```
### Начальные категории доменов
| Категория | Смысл |
|---|---|
| `internal_service` | внутренние корпоративные сервисы |
| `banking` | банковские и финансовые ресурсы |
| `government` | государственные сервисы |
| `work_service` | рабочие SaaS/порталы/документация |
| `developer` | Git, документация, package registry |
| `mail` | почтовые сервисы |
| `messenger` | web-мессенджеры |
| `cloud_storage` | облачные хранилища |
| `search` | поисковые системы |
| `news` | новости |
| `social` | социальные сети |
| `media` | видео/аудио/стриминг |
| `shopping` | покупки и маркетплейсы |
| `job_search` | сайты поиска работы |
| `ai_service` | внешние AI-сервисы |
| `unknown` | не классифицировано |
| `risky` | рискованный или нежелательный ресурс |
### Path-level правила
Классификация по домену не всегда достаточна. Для `github.com`, `youtube.com`,
`docs.google.com`, `mail.ru` может потребоваться учет path или контекста.
Задел под будущую детализацию:
```sql
CREATE TABLE IF NOT EXISTS dim_url_rule
(
rule_id String,
domain String,
path_pattern String,
category LowCardinality(String),
productivity_class LowCardinality(String),
risk_level LowCardinality(String),
priority UInt16 DEFAULT 100,
is_active UInt8 DEFAULT 1,
comment String,
updated_at DateTime DEFAULT now()
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY (domain, priority, rule_id);
```
На спринте 3 `dim_url_rule` можно вести как backlog правил и применять точечно.
### Definition of Done
- создана таблица `dim_domain_category`;
- создан словарь `dict_domain_category`;
- есть нормализация URL в `domain_name`;
- есть стартовая классификация top domains;
- есть top unknown domains;
- есть browser productivity report;
- ограничения доменной модели и необходимость path-level правил задокументированы.
## 5. Спринт 4. Enterprise-масштабирование и стабильность
### Цель
Гарантировать, что система не ляжет при росте компании и объема событий.
Grafana не должна при каждом открытии dashboard на лету агрегировать миллиарды
сырых строк.
### Ключевое архитектурное правило
Materialized Views должны сразу складывать в агрегированные таблицы уже
обогащенные измерения, полученные через dictionaries.
То есть Grafana должна читать не `user_id` и не `process_name`, требующие
дальнейшего JOIN, а готовые бизнес-срезы:
```text
Дата / Час
Отделение
Отдел
Сотрудник
Категория продуктивности
Категория приложения или домена
Сумма секунд
Количество событий
```
Это переносит CPU-нагрузку с момента открытия dashboard на момент insert/backfill
и практически убирает тяжелые вычисления из пользовательских запросов Grafana.
### Общая схема
```text
raw events
-> Materialized View с dictGet* enrichment
-> SummingMergeTree / AggregatingMergeTree aggregate table
-> Grafana читает готовые агрегаты
```
Raw tables остаются для расследований и drill-down. Штатные dashboards должны
читать агрегаты.
### Выбор движка
| Движок | Где использовать |
|---|---|
| `SummingMergeTree` | суммы duration/count, основные dashboards |
| `AggregatingMergeTree` | uniq, quantile, topK, сложные агрегатные состояния |
Рекомендация: начинать с `SummingMergeTree`. `AggregatingMergeTree` подключать
только при доказанной необходимости.
### Desktop hourly aggregate
```sql
CREATE TABLE IF NOT EXISTS agg_aw_desktop_hourly
(
date Date,
hour DateTime,
branch String,
department String,
employee_name String,
host_name String,
user_login String,
application_name String,
app_category String,
productivity_class String,
risk_level String,
duration_sec UInt64,
event_count UInt64
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY
(
date,
hour,
branch,
department,
employee_name,
productivity_class,
app_category,
application_name,
host_name,
user_login
);
```
```sql
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_aw_desktop_hourly
TO agg_aw_desktop_hourly
AS
SELECT
toDate(event_time) AS date,
toStartOfHour(event_time) AS hour,
dictGetStringOrDefault('dict_workstation_user', 'branch', (host_name, user_login), 'unknown') AS branch,
dictGetStringOrDefault('dict_workstation_user', 'department', (host_name, user_login), 'unknown') AS department,
dictGetStringOrDefault('dict_workstation_user', 'employee_name', (host_name, user_login), 'unknown') AS employee_name,
host_name,
user_login,
dictGetStringOrDefault('dict_application_category', 'application_name', process_name, process_name) AS application_name,
dictGetStringOrDefault('dict_application_category', 'category', process_name, 'unknown') AS app_category,
dictGetStringOrDefault('dict_application_category', 'productivity_class', process_name, 'unknown') AS productivity_class,
dictGetStringOrDefault('dict_application_category', 'risk_level', process_name, 'unknown') AS risk_level,
sum(duration_sec) AS duration_sec,
count() AS event_count
FROM aw_window_events
GROUP BY
date,
hour,
branch,
department,
employee_name,
host_name,
user_login,
application_name,
app_category,
productivity_class,
risk_level;
```
### Browser hourly aggregate
```sql
CREATE TABLE IF NOT EXISTS agg_aw_browser_hourly
(
date Date,
hour DateTime,
branch String,
department String,
employee_name String,
host_name String,
user_login String,
domain_name String,
site_name String,
domain_category String,
productivity_class String,
risk_level String,
duration_sec UInt64,
event_count UInt64
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY
(
date,
hour,
branch,
department,
employee_name,
productivity_class,
domain_category,
domain_name,
host_name,
user_login
);
```
```sql
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_aw_browser_hourly
TO agg_aw_browser_hourly
AS
WITH
lowerUTF8(
parseURL(
if(position(url, '://') = 0, concat('http://', url), url),
'host'
)
) AS domain_name
SELECT
toDate(event_time) AS date,
toStartOfHour(event_time) AS hour,
dictGetStringOrDefault('dict_workstation_user', 'branch', (host_name, user_login), 'unknown') AS branch,
dictGetStringOrDefault('dict_workstation_user', 'department', (host_name, user_login), 'unknown') AS department,
dictGetStringOrDefault('dict_workstation_user', 'employee_name', (host_name, user_login), 'unknown') AS employee_name,
host_name,
user_login,
domain_name,
dictGetStringOrDefault('dict_domain_category', 'site_name', domain_name, domain_name) AS site_name,
dictGetStringOrDefault('dict_domain_category', 'category', domain_name, 'unknown') AS domain_category,
dictGetStringOrDefault('dict_domain_category', 'productivity_class', domain_name, 'unknown') AS productivity_class,
dictGetStringOrDefault('dict_domain_category', 'risk_level', domain_name, 'unknown') AS risk_level,
sum(duration_sec) AS duration_sec,
count() AS event_count
FROM aw_browser_events
WHERE domain_name != ''
GROUP BY
date,
hour,
branch,
department,
employee_name,
host_name,
user_login,
domain_name,
site_name,
domain_category,
productivity_class,
risk_level;
```
### Daily aggregate для управленческих отчетов
Daily layer лучше строить из hourly aggregates, чтобы не дублировать enrichment
логику и не перечитывать raw events.
```sql
CREATE TABLE IF NOT EXISTS agg_aw_activity_daily
(
date Date,
branch String,
department String,
source_type LowCardinality(String),
productivity_class String,
category String,
duration_sec UInt64,
event_count UInt64
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY
(
date,
branch,
department,
source_type,
productivity_class,
category
);
```
Пример загрузки из desktop hourly:
```sql
INSERT INTO agg_aw_activity_daily
SELECT
date,
branch,
department,
'desktop' AS source_type,
productivity_class,
app_category AS category,
sum(duration_sec) AS duration_sec,
sum(event_count) AS event_count
FROM agg_aw_desktop_hourly
WHERE date = yesterday()
GROUP BY
date,
branch,
department,
source_type,
productivity_class,
category;
```
### Почему обогащение нужно делать до Grafana
Если Grafana читает raw tables и выполняет `dictGet*` или `JOIN` при каждом
открытии панели, CPU ClickHouse будет расходоваться на одни и те же вычисления.
При росте компании это станет узким местом.
Если Materialized View уже положила в агрегат строки вида:
```text
2026-06-23 / Сыктывкар / ОТ / productive / office / 18420 sec
```
то Grafana выполняет простой `SELECT sum(duration_sec) ... GROUP BY ...` по
маленькой таблице. Это дает кратный выигрыш и делает dashboard стабильным.
### Backfill и rebuild
Materialized View обрабатывает только новые вставки после создания MV.
Исторические данные нужно пересчитать отдельно.
Порядок:
1. создать aggregate table;
2. создать materialized view для новых данных;
3. выполнить `INSERT INTO aggregate SELECT ... FROM raw WHERE ...` за историю;
4. сверить суммы raw vs aggregate;
5. переключить Grafana на aggregate;
6. документировать rebuild-период, если справочники изменились.
Важно: если словари поменялись после агрегации, старые агрегаты сами не
пересчитаются. Для строгой актуальности нужен rebuild затронутого периода или
snapshot/history dimensions.
### Coverage aggregate для качества справочников
```sql
CREATE TABLE IF NOT EXISTS agg_aw_dictionary_coverage_daily
(
date Date,
source_type LowCardinality(String),
unknown_type LowCardinality(String),
unknown_key String,
events UInt64,
duration_sec UInt64
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (date, source_type, unknown_type, unknown_key);
```
Эта таблица нужна, чтобы видеть:
- unknown workstations;
- unknown processes;
- unknown domains;
- динамику качества справочников.
### Grafana policy
После спринта 4 штатные dashboards должны читать:
- `agg_aw_desktop_hourly`;
- `agg_aw_browser_hourly`;
- `agg_aw_activity_daily`;
- `agg_aw_dictionary_coverage_daily`.
Raw tables допустимы только для расследований, drill-down и технической
диагностики.
### Monitoring
Минимальные проверки:
```sql
SELECT
table,
sum(rows) AS rows,
formatReadableSize(sum(bytes_on_disk)) AS size
FROM system.parts
WHERE active
GROUP BY table
ORDER BY rows DESC;
```
```sql
SELECT
name,
status,
element_count,
last_exception
FROM system.dictionaries
WHERE name LIKE 'dict_%';
```
### Retention
Начальная рекомендация:
| Слой | Retention |
|---|---|
| raw events | 90-180 дней или по политике проекта |
| hourly aggregates | 12-24 месяца |
| daily aggregates | 3-5 лет или по требованиям отчетности |
| dictionary coverage | 12 месяцев |
TTL включать только после согласования требований отчетности и хранения.
### Definition of Done
Спринт 4 считается завершенным, когда:
- создана `agg_aw_desktop_hourly`;
- создана `mv_aw_desktop_hourly`;
- создана `agg_aw_browser_hourly`;
- создана `mv_aw_browser_hourly`;
- создан daily aggregate layer;
- создан coverage aggregate для unknown значений;
- выполнен backfill хотя бы за один исторический период;
- сверены суммы raw vs aggregate;
- минимум один Grafana dashboard переключен на aggregate table;
- Grafana не выполняет штатные отчеты по raw events;
- документирован rebuild/backfill runbook;
- есть мониторинг таблиц, словарей и unknown coverage.
### Риски
| Риск | Митигация |
|---|---|
| MV не обработала исторические данные | обязательный backfill |
| Ошибка классификации попала в агрегаты | rebuild затронутого периода |
| Слишком высокая кардинальность aggregate | не тащить URL/title в основные агрегаты |
| Grafana продолжает читать raw tables | dashboard review и policy запрета raw для штатных панелей |
| Словари изменились после агрегации | rebuild или snapshot/history dimensions |
| Слишком много parts | контролировать batch insert, partitioning, merge health |
| Сложность `AggregatingMergeTree` | начинать с `SummingMergeTree` |