23 KiB
План внедрения ClickHouse Dictionaries и агрегатов для AWatch-rus / DetMir
Дата: 2026-06-23
Статус: рабочий архитектурный план.
1. Общая цель
Цель внедрения ClickHouse Dictionaries и Materialized Views - подготовить AWatch-rus / DetMir к росту объема данных и числу пользователей отчетности.
Сырые события ActivityWatch остаются в fact-таблицах. Отчеты и Grafana не
должны постоянно выполнять тяжелые JOIN и агрегации по миллионам или
миллиардам строк. Обогащение и схлопывание данных нужно переносить на этап
записи, backfill или scheduled aggregation.
Базовая схема:
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
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
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);
Отчет по слепым зонам
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
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
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
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
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
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
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 или контекста.
Задел под будущую детализацию:
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, а готовые бизнес-срезы:
Дата / Час
Отделение
Отдел
Сотрудник
Категория продуктивности
Категория приложения или домена
Сумма секунд
Количество событий
Это переносит CPU-нагрузку с момента открытия dashboard на момент insert/backfill и практически убирает тяжелые вычисления из пользовательских запросов Grafana.
Общая схема
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
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
);
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
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
);
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.
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:
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 уже положила в агрегат строки вида:
2026-06-23 / Сыктывкар / ОТ / productive / office / 18420 sec
то Grafana выполняет простой SELECT sum(duration_sec) ... GROUP BY ... по
маленькой таблице. Это дает кратный выигрыш и делает dashboard стабильным.
Backfill и rebuild
Materialized View обрабатывает только новые вставки после создания MV. Исторические данные нужно пересчитать отдельно.
Порядок:
- создать aggregate table;
- создать materialized view для новых данных;
- выполнить
INSERT INTO aggregate SELECT ... FROM raw WHERE ...за историю; - сверить суммы raw vs aggregate;
- переключить Grafana на aggregate;
- документировать rebuild-период, если справочники изменились.
Важно: если словари поменялись после агрегации, старые агрегаты сами не пересчитаются. Для строгой актуальности нужен rebuild затронутого периода или snapshot/history dimensions.
Coverage aggregate для качества справочников
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
Минимальные проверки:
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;
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 |