From 341799ff9d4d9c28b6c669fbd8dbe57634c4a19d Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Tue, 23 Jun 2026 13:33:59 +0300 Subject: [PATCH] docs(clickhouse): plan dictionaries and aggregate sprints --- .../DICTIONARIES_IMPLEMENTATION_PLAN_RU.md | 762 ++++++++++++++++++ 1 file changed, 762 insertions(+) create mode 100644 docs/clickhouse/DICTIONARIES_IMPLEMENTATION_PLAN_RU.md diff --git a/docs/clickhouse/DICTIONARIES_IMPLEMENTATION_PLAN_RU.md b/docs/clickhouse/DICTIONARIES_IMPLEMENTATION_PLAN_RU.md new file mode 100644 index 0000000..a2a4f7f --- /dev/null +++ b/docs/clickhouse/DICTIONARIES_IMPLEMENTATION_PLAN_RU.md @@ -0,0 +1,762 @@ +# План внедрения 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` |