1163 lines
40 KiB
Markdown
1163 lines
40 KiB
Markdown
# План внедрения ClickHouse Dictionaries для AWatch-rus / DetMir
|
||
|
||
Дата: `2026-06-23`
|
||
|
||
Статус: рабочий архитектурный план. Спринты 1-4 зафиксированы как
|
||
последовательность внедрения. Для `SHARKON2025` источники P1 сверены с живым
|
||
ActivityWatch API и зафиксированы в
|
||
`docs/clickhouse/AW_WORKFORCE_SOURCES_SHARKON2025_RU.md`.
|
||
|
||
Реализация первого воспроизводимого слоя в репозитории:
|
||
`clickhouse-workforce/`. В нем зафиксированы DDL, локальный ClickHouse scaffold,
|
||
demo seed и smoke-проверка для dictionaries, materialized views и агрегатов.
|
||
|
||
P2 реализуется Rust loader-ом `adk-rust/crates/aw-workforce-ingest`, который
|
||
читает bounded ranges из ActivityWatch и пишет `JSONEachRow` в
|
||
`aw_window_events` / `aw_browser_events`.
|
||
|
||
P4 добавляет production-режим loader-а: state file с `last_end`, overlap-окно
|
||
для защиты от поздних событий, retry/backoff и systemd timer
|
||
`clickhouse-workforce/ops/aw-workforce-ingest.timer`.
|
||
|
||
P5 добавляет администрируемый source of truth для справочников:
|
||
`clickhouse-workforce/catalog/*.tsv`. Загрузка выполняется через
|
||
`clickhouse-workforce/ops/apply_catalogs.sh`; удаление/отключение строки
|
||
делается либо удалением из TSV, либо `is_active=0`. Для уже материализованных
|
||
агрегатов после изменения категорий используется
|
||
`clickhouse-workforce/admin/rebuild_aggregates.sql`.
|
||
|
||
## 1. Общий принцип
|
||
|
||
ClickHouse Dictionaries внедряются как слой быстрого обогащения событий, а не
|
||
как новый источник истины.
|
||
|
||
Источник истины:
|
||
|
||
```text
|
||
исходные справочники / NetBox / XLS / инвентаризация / ручная классификация
|
||
-> dimension tables в ClickHouse
|
||
-> ClickHouse Dictionaries
|
||
-> отчеты, materialized views, Grafana
|
||
```
|
||
|
||
Сырые события AWatch-rus остаются fact-таблицами. Справочники и dictionaries
|
||
используются для привязки событий к оргструктуре, приложениям, категориям и
|
||
рискам.
|
||
|
||
## 2. Спринт 1. Оргструктура и слепые зоны
|
||
|
||
### Цель
|
||
|
||
Связать сырые события AWatch-rus с оргструктурой компании и выявить слепые зоны
|
||
в привязке рабочих мест, пользователей и подразделений.
|
||
|
||
### Принятое архитектурное решение
|
||
|
||
Создаются:
|
||
|
||
- `dim_workstation_user` - dimension/current snapshot, а не таблица фактов;
|
||
- `dict_workstation_user` - ClickHouse Dictionary для lookup по паре
|
||
`host_name + user_login`.
|
||
|
||
Ключ словаря комплексный, поэтому используется `COMPLEX_KEY_HASHED()`, а не
|
||
обычный `HASHED()`.
|
||
|
||
`CACHE()` на первом этапе не используется: справочник рабочих мест должен быть
|
||
достаточно небольшим, чтобы держать его целиком в памяти.
|
||
|
||
### Source table
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS dim_workstation_user
|
||
(
|
||
host_name String,
|
||
user_login String,
|
||
user_domain String,
|
||
|
||
employee_id 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,
|
||
user_domain String,
|
||
|
||
employee_id 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
|
||
event_time,
|
||
host_name,
|
||
user_login,
|
||
dictGetStringOrDefault(
|
||
'dict_workstation_user',
|
||
'employee_name',
|
||
(host_name, user_login),
|
||
'unknown'
|
||
) AS employee_name,
|
||
dictGetStringOrDefault(
|
||
'dict_workstation_user',
|
||
'department',
|
||
(host_name, user_login),
|
||
'unknown'
|
||
) AS department,
|
||
dictGetStringOrDefault(
|
||
'dict_workstation_user',
|
||
'branch',
|
||
(host_name, user_login),
|
||
'unknown'
|
||
) AS branch
|
||
FROM aw_raw_events;
|
||
```
|
||
|
||
### Отчет по слепым зонам
|
||
|
||
```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
|
||
|
||
Спринт 1 считается завершенным, когда:
|
||
|
||
- создана таблица `dim_workstation_user`;
|
||
- создан словарь `dict_workstation_user`;
|
||
- словарь находится в статусе `LOADED`;
|
||
- есть стартовая загрузка данных;
|
||
- есть SQL-отчет по неизвестным `host_name + user_login`;
|
||
- есть хотя бы один обогащенный запрос по сырым событиям;
|
||
- есть проверка состояния словаря через `system.dictionaries`;
|
||
- документирован источник истины для оргструктуры.
|
||
|
||
## 3. Спринт 2. Продуктивность и классификация софта
|
||
|
||
### Цель
|
||
|
||
Уйти от анализа миллионов строк процессов к понятным бизнес-метрикам по
|
||
использованию программного обеспечения на рабочих местах.
|
||
|
||
События рабочего стола должны обогащаться не только именем процесса, а
|
||
категорией приложения, признаком продуктивности, уровнем риска и нормализованным
|
||
именем приложения.
|
||
|
||
### Что должно появиться
|
||
|
||
Создаются:
|
||
|
||
- `dim_application_category` - справочник классификации процессов и приложений;
|
||
- `dict_application_category` - ClickHouse Dictionary для lookup по
|
||
`process_name`;
|
||
- отчет по неизвестным процессам;
|
||
- первый отчет/витрина продуктивности по отделам и сотрудникам.
|
||
|
||
### Бизнес-метрики спринта 2
|
||
|
||
Минимальный набор метрик:
|
||
|
||
- время в продуктивных приложениях;
|
||
- время в условно нейтральных приложениях;
|
||
- время в непродуктивных приложениях;
|
||
- время в административных/технических инструментах;
|
||
- время в неизвестных процессах;
|
||
- top unknown processes по числу событий и длительности;
|
||
- top risky processes;
|
||
- разрез по отделу, филиалу, сотруднику, рабочей станции.
|
||
|
||
### Категории приложений
|
||
|
||
Начальный набор категорий:
|
||
|
||
| Категория | Смысл |
|
||
|---|---|
|
||
| `office` | офисные приложения, документы, таблицы |
|
||
| `browser` | браузеры без оценки домена |
|
||
| `1c` | 1C и связанные клиенты |
|
||
| `banking` | банковские и внутренние рабочие приложения |
|
||
| `messenger` | корпоративные и внешние мессенджеры |
|
||
| `mail` | почтовые клиенты |
|
||
| `admin_tool` | администрирование, диагностика, удаленный доступ |
|
||
| `development` | разработка, скрипты, IDE |
|
||
| `security_tool` | средства защиты, DLP, AV, мониторинг |
|
||
| `media` | аудио/видео/плееры |
|
||
| `archive` | архиваторы, файловые утилиты |
|
||
| `system` | системные процессы ОС |
|
||
| `unknown` | не классифицировано |
|
||
| `risky` | потенциально рискованный или нежелательный софт |
|
||
|
||
### Признак продуктивности
|
||
|
||
Поле `productivity_class` используется отдельно от категории.
|
||
|
||
Начальные значения:
|
||
|
||
| Значение | Смысл |
|
||
|---|---|
|
||
| `productive` | рабочее приложение |
|
||
| `neutral` | системное или вспомогательное приложение |
|
||
| `non_productive` | явно нерабочее использование |
|
||
| `risky` | потенциальный риск ИБ или нежелательное ПО |
|
||
| `unknown` | нет классификации |
|
||
|
||
Важно: продуктивность не должна автоматически считаться дисциплинарным выводом.
|
||
Это аналитическая классификация для управленческого отчета и выявления слепых
|
||
зон.
|
||
|
||
### 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
|
||
|
||
Для спринта 2 используется обычный `HASHED()`, потому что ключ один:
|
||
`process_name`.
|
||
|
||
```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);
|
||
```
|
||
|
||
### Использование в запросах
|
||
|
||
```sql
|
||
SELECT
|
||
event_time,
|
||
host_name,
|
||
user_login,
|
||
process_name,
|
||
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
|
||
FROM aw_window_events;
|
||
```
|
||
|
||
### Отчет 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;
|
||
```
|
||
|
||
### Пример витрины продуктивности
|
||
|
||
```sql
|
||
SELECT
|
||
toDate(event_time) AS date,
|
||
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,
|
||
dictGetStringOrDefault(
|
||
'dict_application_category',
|
||
'productivity_class',
|
||
process_name,
|
||
'unknown'
|
||
) AS productivity_class,
|
||
sum(duration_sec) AS duration_sec
|
||
FROM aw_window_events
|
||
GROUP BY
|
||
date,
|
||
department,
|
||
employee_name,
|
||
productivity_class
|
||
ORDER BY
|
||
date,
|
||
department,
|
||
employee_name,
|
||
productivity_class;
|
||
```
|
||
|
||
### Definition of Done
|
||
|
||
Спринт 2 считается завершенным, когда:
|
||
|
||
- создана таблица `dim_application_category`;
|
||
- создан словарь `dict_application_category`;
|
||
- словарь находится в статусе `LOADED`;
|
||
- загружен стартовый список известных процессов;
|
||
- есть отчет top unknown processes;
|
||
- есть отчет по продуктивности в разрезе отдела/сотрудника;
|
||
- неизвестные процессы видны как отдельная зона качества данных;
|
||
- классификация не используется как дисциплинарный вывод без ручной проверки;
|
||
- документирован владелец справочника классификации ПО.
|
||
|
||
## 4. Спринт 3. Веб-аналитика и глубинная фильтрация
|
||
|
||
### Цель
|
||
|
||
Перейти от анализа сырых заголовков окон браузера и полных URL к нормальной
|
||
веб-аналитике: домены, категории сайтов, признаки рабочей/нерабочей активности,
|
||
рисковые ресурсы и слепые зоны классификации.
|
||
|
||
Для современного сотрудника значительная часть работы идет в браузере, поэтому
|
||
без доменной классификации отчеты по продуктивности будут неполными: браузер
|
||
сам по себе не показывает, была ли активность рабочей, нейтральной или
|
||
нежелательной.
|
||
|
||
### Что должно появиться
|
||
|
||
Создаются:
|
||
|
||
- нормализованное поле `domain` в запросах, view или materialized view;
|
||
- `dim_domain_category` - справочник классификации доменов;
|
||
- `dict_domain_category` - ClickHouse Dictionary для lookup по `domain`;
|
||
- отчет top unknown domains;
|
||
- отчет browser activity по категориям сайтов;
|
||
- правила глубинной фильтрации для URL/path, если одного домена недостаточно.
|
||
|
||
### Нормализация URL в домен
|
||
|
||
Источник может содержать:
|
||
|
||
- полный URL: `https://github.com/org/repo/issues`;
|
||
- адрес без схемы: `github.com/org/repo`;
|
||
- заголовок окна с доменом внутри строки;
|
||
- browser bucket с отдельным URL-полем;
|
||
- пустые или неполные значения.
|
||
|
||
Базовое правило: сначала извлекать чистый домен, потом классифицировать его
|
||
через dictionary.
|
||
|
||
Пример для поля с полным URL:
|
||
|
||
```sql
|
||
SELECT
|
||
url,
|
||
lowerUTF8(domain(url)) AS domain_name
|
||
FROM aw_browser_events;
|
||
```
|
||
|
||
Если `domain()` недостаточно из-за строк без схемы, использовать нормализацию:
|
||
|
||
```sql
|
||
SELECT
|
||
url,
|
||
lowerUTF8(
|
||
domain(
|
||
if(
|
||
position(url, '://') = 0,
|
||
concat('http://', url),
|
||
url
|
||
)
|
||
)
|
||
) AS domain_name
|
||
FROM aw_browser_events;
|
||
```
|
||
|
||
На ClickHouse `24.8` в локальном scaffold проверена функция `domain()`:
|
||
|
||
```sql
|
||
SELECT
|
||
url,
|
||
lowerUTF8(
|
||
domain(if(position(url, '://') = 0, concat('http://', url), url))
|
||
) AS domain_name
|
||
FROM aw_browser_events;
|
||
```
|
||
|
||
Для строк, где URL спрятан в title, допустим отдельный regex-pass, но его не
|
||
нужно делать основным путем, если в событиях уже есть URL.
|
||
|
||
### 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
|
||
|
||
Для домена используется один ключ, поэтому достаточно `HASHED()`.
|
||
|
||
```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, developer tools |
|
||
| `mail` | почтовые сервисы |
|
||
| `messenger` | web-мессенджеры |
|
||
| `cloud_storage` | облачные хранилища |
|
||
| `search` | поисковые системы |
|
||
| `news` | новости |
|
||
| `social` | социальные сети |
|
||
| `media` | видео/аудио/стриминг |
|
||
| `shopping` | покупки и маркетплейсы |
|
||
| `job_search` | сайты поиска работы |
|
||
| `ai_service` | внешние AI-сервисы |
|
||
| `unknown` | не классифицировано |
|
||
| `risky` | рискованный или нежелательный ресурс |
|
||
|
||
### Признак продуктивности для доменов
|
||
|
||
Используется тот же подход, что в спринте 2:
|
||
|
||
| Значение | Смысл |
|
||
|---|---|
|
||
| `productive` | рабочий ресурс |
|
||
| `neutral` | вспомогательный или неоднозначный ресурс |
|
||
| `non_productive` | явно нерабочий ресурс |
|
||
| `risky` | потенциальный ИБ-риск или нежелательный ресурс |
|
||
| `unknown` | нет классификации |
|
||
|
||
Важно: продуктивность домена зависит от контекста. Например, `youtube.com` для
|
||
одного подразделения может быть учебным ресурсом, а для другого -
|
||
непродуктивной активностью. В спринте 3 это фиксируется как ограничение модели.
|
||
Тонкую контекстную политику по подразделениям лучше выносить в следующий этап.
|
||
|
||
### Использование в запросах
|
||
|
||
```sql
|
||
WITH
|
||
lowerUTF8(
|
||
domain(if(position(url, '://') = 0, concat('http://', url), url))
|
||
) AS domain_name
|
||
SELECT
|
||
event_time,
|
||
host_name,
|
||
user_login,
|
||
url,
|
||
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
|
||
FROM aw_browser_events;
|
||
```
|
||
|
||
### Отчет unknown domains
|
||
|
||
```sql
|
||
WITH
|
||
lowerUTF8(
|
||
domain(if(position(url, '://') = 0, concat('http://', url), url))
|
||
) AS domain_name
|
||
SELECT
|
||
domain_name,
|
||
count() AS events,
|
||
sum(duration_sec) AS duration_sec
|
||
FROM aw_browser_events
|
||
WHERE domain_name != ''
|
||
AND dictGetStringOrDefault(
|
||
'dict_domain_category',
|
||
'category',
|
||
domain_name,
|
||
''
|
||
) = ''
|
||
GROUP BY domain_name
|
||
ORDER BY duration_sec DESC
|
||
LIMIT 100;
|
||
```
|
||
|
||
### Витрина browser productivity
|
||
|
||
```sql
|
||
WITH
|
||
lowerUTF8(
|
||
domain(if(position(url, '://') = 0, concat('http://', url), url))
|
||
) AS domain_name
|
||
SELECT
|
||
toDate(event_time) AS date,
|
||
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,
|
||
dictGetStringOrDefault(
|
||
'dict_domain_category',
|
||
'category',
|
||
domain_name,
|
||
'unknown'
|
||
) AS domain_category,
|
||
dictGetStringOrDefault(
|
||
'dict_domain_category',
|
||
'productivity_class',
|
||
domain_name,
|
||
'unknown'
|
||
) AS productivity_class,
|
||
sum(duration_sec) AS duration_sec
|
||
FROM aw_browser_events
|
||
WHERE domain_name != ''
|
||
GROUP BY
|
||
date,
|
||
department,
|
||
employee_name,
|
||
domain_category,
|
||
productivity_class
|
||
ORDER BY
|
||
date,
|
||
department,
|
||
employee_name,
|
||
duration_sec DESC;
|
||
```
|
||
|
||
### Глубинная фильтрация
|
||
|
||
Классификация только по домену не всегда достаточна.
|
||
|
||
Примеры:
|
||
|
||
- `github.com` может быть рабочим ресурсом, но конкретный path может относиться
|
||
к личным репозиториям;
|
||
- `youtube.com` может быть обучением или развлечением;
|
||
- `docs.google.com` может быть рабочим или личным ресурсом;
|
||
- `mail.ru` может быть почтой, новостями или облаком.
|
||
|
||
В спринте 3 нужно заложить второй уровень правил, но не усложнять основную
|
||
модель. Для этого создается отдельная таблица правил URL/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);
|
||
```
|
||
|
||
На первом этапе `dim_url_rule` можно не подключать как dictionary. Достаточно
|
||
вести ее как backlog правил и применять точечно в offline-отчетах или
|
||
materialized view, если появится необходимость.
|
||
|
||
### Definition of Done
|
||
|
||
Спринт 3 считается завершенным, когда:
|
||
|
||
- создана таблица `dim_domain_category`;
|
||
- создан словарь `dict_domain_category`;
|
||
- словарь находится в статусе `LOADED`;
|
||
- есть нормализация URL в `domain_name` через `domain()`;
|
||
- есть стартовая классификация top domains;
|
||
- есть отчет top unknown domains;
|
||
- есть отчет browser productivity по отделу/сотруднику;
|
||
- известные рабочие ресурсы классифицированы отдельно от неизвестных;
|
||
- risky/non-productive домены не используются как дисциплинарный вывод без
|
||
ручной проверки;
|
||
- ограничения доменной модели и необходимость path-level правил задокументированы.
|
||
|
||
### Риски спринта 3
|
||
|
||
| Риск | Митигация |
|
||
|---|---|
|
||
| URL не всегда есть в событии | использовать URL-поле, а title parsing оставить fallback |
|
||
| Домен не отражает реальное назначение страницы | добавить `dim_url_rule` для path-level правил |
|
||
| Ошибочная классификация домена | review справочника и поле `comment` |
|
||
| Большая доля unknown domains | ежедневный top unknown отчет |
|
||
| Внешние AI/cloud сервисы требуют отдельной политики | вынести `ai_service` и `cloud_storage` в отдельные категории |
|
||
| Продуктивность зависит от подразделения | оставить как ограничение спринта 3, контекстную политику вынести позже |
|
||
|
||
## 5. Спринт 4. Enterprise-масштабирование и стабильность
|
||
|
||
### Цель
|
||
|
||
Снять нагрузку с Grafana и ClickHouse при росте объема событий: dashboard'ы не
|
||
должны каждый раз агрегировать миллионы или миллиарды строк сырых событий.
|
||
|
||
Для этого создается слой materialized views, который при вставке новых событий
|
||
сразу складывает в агрегированные таблицы уже обогащенные метрики:
|
||
|
||
```text
|
||
raw AWatch-rus events
|
||
-> dictionaries lookup during INSERT
|
||
-> hourly aggregate tables
|
||
-> Grafana queries over small aggregates
|
||
```
|
||
|
||
Ключевое решение: materialized views должны записывать в агрегированную таблицу
|
||
не только технические `host_name`, `user_login`, `process_name` или `domain`, а
|
||
готовые управленческие разрезы:
|
||
|
||
- дата/час;
|
||
- филиал;
|
||
- отделение/подразделение;
|
||
- тип активности: `desktop` или `browser`;
|
||
- категория приложения или домена;
|
||
- класс продуктивности;
|
||
- сумма секунд;
|
||
- количество событий;
|
||
- счетчики unknown-зон.
|
||
|
||
Это снижает CPU-нагрузку на ClickHouse и делает Grafana предсказуемой по
|
||
времени ответа.
|
||
|
||
### Принятое архитектурное решение
|
||
|
||
Для первого промышленного слоя используется `SummingMergeTree`, потому что
|
||
базовые метрики аддитивны:
|
||
|
||
- `duration_sec`;
|
||
- `event_count`;
|
||
- `unknown_subject_events`;
|
||
- `unknown_category_events`.
|
||
|
||
`AggregatingMergeTree` нужен позже, если появятся сложные состояния:
|
||
`uniqState`, `quantileState`, percentile latency, distinct users/workstations
|
||
или rolling windows. Для Спринта 4 он не является обязательным.
|
||
|
||
Важно: dictionary lookup внутри materialized view выполняется в момент вставки.
|
||
Это хорошо для скорости Grafana, но означает, что исправление справочника не
|
||
пересчитает старые агрегаты автоматически. Для этого нужен явный backfill.
|
||
|
||
### Target table для Grafana
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS agg_workforce_productivity_hourly
|
||
(
|
||
bucket_start DateTime,
|
||
event_date Date,
|
||
|
||
branch LowCardinality(String),
|
||
department LowCardinality(String),
|
||
|
||
activity_type LowCardinality(String),
|
||
category LowCardinality(String),
|
||
productivity_class LowCardinality(String),
|
||
|
||
duration_sec UInt64,
|
||
event_count UInt64,
|
||
unknown_subject_events UInt64,
|
||
unknown_category_events UInt64
|
||
)
|
||
ENGINE = SummingMergeTree((
|
||
duration_sec,
|
||
event_count,
|
||
unknown_subject_events,
|
||
unknown_category_events
|
||
))
|
||
PARTITION BY toYYYYMM(event_date)
|
||
ORDER BY (
|
||
event_date,
|
||
bucket_start,
|
||
branch,
|
||
department,
|
||
activity_type,
|
||
productivity_class,
|
||
category
|
||
);
|
||
```
|
||
|
||
`bucket_start` хранит часовую гранулярность. Дневные отчеты строятся простой
|
||
агрегацией поверх этой таблицы, без чтения raw-событий.
|
||
|
||
### Materialized view для desktop events
|
||
|
||
```sql
|
||
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_desktop_productivity_hourly
|
||
TO agg_workforce_productivity_hourly
|
||
AS
|
||
SELECT
|
||
toStartOfHour(event_time) AS bucket_start,
|
||
toDate(event_time) AS event_date,
|
||
|
||
dictGetStringOrDefault(
|
||
'dict_workstation_user',
|
||
'branch',
|
||
(host_name, user_login),
|
||
'unknown'
|
||
) AS branch,
|
||
dictGetStringOrDefault(
|
||
'dict_workstation_user',
|
||
'department',
|
||
(host_name, user_login),
|
||
'unknown'
|
||
) AS department,
|
||
|
||
'desktop' AS activity_type,
|
||
dictGetStringOrDefault(
|
||
'dict_application_category',
|
||
'category',
|
||
process_name,
|
||
'unknown'
|
||
) AS category,
|
||
dictGetStringOrDefault(
|
||
'dict_application_category',
|
||
'productivity_class',
|
||
process_name,
|
||
'unknown'
|
||
) AS productivity_class,
|
||
|
||
toUInt64(sum(duration_sec)) AS duration_sec,
|
||
toUInt64(count()) AS event_count,
|
||
toUInt64(sum(if(
|
||
dictGetStringOrDefault(
|
||
'dict_workstation_user',
|
||
'employee_name',
|
||
(host_name, user_login),
|
||
''
|
||
) = '',
|
||
1,
|
||
0
|
||
))) AS unknown_subject_events,
|
||
toUInt64(sum(if(
|
||
dictGetStringOrDefault(
|
||
'dict_application_category',
|
||
'category',
|
||
process_name,
|
||
''
|
||
) = '',
|
||
1,
|
||
0
|
||
))) AS unknown_category_events
|
||
FROM aw_window_events
|
||
GROUP BY
|
||
bucket_start,
|
||
event_date,
|
||
branch,
|
||
department,
|
||
activity_type,
|
||
category,
|
||
productivity_class;
|
||
```
|
||
|
||
### Materialized view для browser events
|
||
|
||
```sql
|
||
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_browser_productivity_hourly
|
||
TO agg_workforce_productivity_hourly
|
||
AS
|
||
WITH
|
||
lowerUTF8(
|
||
domain(if(position(url, '://') = 0, concat('http://', url), url))
|
||
) AS domain_name
|
||
SELECT
|
||
toStartOfHour(event_time) AS bucket_start,
|
||
toDate(event_time) AS event_date,
|
||
|
||
dictGetStringOrDefault(
|
||
'dict_workstation_user',
|
||
'branch',
|
||
(host_name, user_login),
|
||
'unknown'
|
||
) AS branch,
|
||
dictGetStringOrDefault(
|
||
'dict_workstation_user',
|
||
'department',
|
||
(host_name, user_login),
|
||
'unknown'
|
||
) AS department,
|
||
|
||
'browser' AS activity_type,
|
||
dictGetStringOrDefault(
|
||
'dict_domain_category',
|
||
'category',
|
||
domain_name,
|
||
'unknown'
|
||
) AS category,
|
||
dictGetStringOrDefault(
|
||
'dict_domain_category',
|
||
'productivity_class',
|
||
domain_name,
|
||
'unknown'
|
||
) AS productivity_class,
|
||
|
||
toUInt64(sum(duration_sec)) AS duration_sec,
|
||
toUInt64(count()) AS event_count,
|
||
toUInt64(sum(if(
|
||
dictGetStringOrDefault(
|
||
'dict_workstation_user',
|
||
'employee_name',
|
||
(host_name, user_login),
|
||
''
|
||
) = '',
|
||
1,
|
||
0
|
||
))) AS unknown_subject_events,
|
||
toUInt64(sum(if(
|
||
dictGetStringOrDefault(
|
||
'dict_domain_category',
|
||
'category',
|
||
domain_name,
|
||
''
|
||
) = '',
|
||
1,
|
||
0
|
||
))) AS unknown_category_events
|
||
FROM aw_browser_events
|
||
WHERE domain_name != ''
|
||
GROUP BY
|
||
bucket_start,
|
||
event_date,
|
||
branch,
|
||
department,
|
||
activity_type,
|
||
category,
|
||
productivity_class;
|
||
```
|
||
|
||
### Базовый запрос Grafana
|
||
|
||
Grafana должна читать агрегированную таблицу, а не raw events:
|
||
|
||
```sql
|
||
SELECT
|
||
event_date,
|
||
branch,
|
||
department,
|
||
productivity_class,
|
||
sum(duration_sec) AS duration_sec
|
||
FROM agg_workforce_productivity_hourly
|
||
WHERE $__timeFilter(bucket_start)
|
||
GROUP BY
|
||
event_date,
|
||
branch,
|
||
department,
|
||
productivity_class
|
||
ORDER BY
|
||
event_date,
|
||
branch,
|
||
department,
|
||
productivity_class;
|
||
```
|
||
|
||
Для детализации по desktop/browser:
|
||
|
||
```sql
|
||
SELECT
|
||
event_date,
|
||
department,
|
||
activity_type,
|
||
category,
|
||
productivity_class,
|
||
sum(duration_sec) AS duration_sec
|
||
FROM agg_workforce_productivity_hourly
|
||
WHERE $__timeFilter(bucket_start)
|
||
GROUP BY
|
||
event_date,
|
||
department,
|
||
activity_type,
|
||
category,
|
||
productivity_class
|
||
ORDER BY duration_sec DESC;
|
||
```
|
||
|
||
### Backfill и пересчет
|
||
|
||
Так как materialized views фиксируют результат dictionary lookup в момент
|
||
вставки, для исправления старых данных нужен регламент:
|
||
|
||
1. Исправить source dimension table.
|
||
2. Выполнить `SYSTEM RELOAD DICTIONARY`.
|
||
3. Проверить `system.dictionaries`.
|
||
4. Пересобрать нужный период агрегатов из raw-таблиц.
|
||
|
||
Безопасный промышленный вариант для пересчета:
|
||
|
||
- создать новую агрегированную таблицу с тем же schema;
|
||
- выполнить `INSERT INTO new_agg SELECT ... FROM raw ... WHERE event_date ...`;
|
||
- сверить контрольные суммы и строки;
|
||
- атомарно переключить имя таблицы через `RENAME TABLE`.
|
||
|
||
Удаление и повторная вставка по partition допустимы только после отдельной
|
||
проверки на тестовом контуре.
|
||
|
||
### Definition of Done
|
||
|
||
Спринт 4 считается завершенным, когда:
|
||
|
||
- создана агрегированная таблица для Grafana;
|
||
- materialized view для desktop events пишет в агрегат;
|
||
- materialized view для browser events пишет в агрегат;
|
||
- dictionary enrichment выполняется при вставке;
|
||
- Grafana dashboard'ы читают агрегат, а не raw-события;
|
||
- есть запрос контроля unknown-долей по оргструктуре, приложениям и доменам;
|
||
- есть регламент backfill после исправления справочников;
|
||
- есть smoke-запрос, подтверждающий, что новые raw-события попали в агрегат;
|
||
- зафиксировано, что исправление справочника не пересчитывает исторические
|
||
агрегаты без backfill.
|
||
|
||
### Риски спринта 4
|
||
|
||
| Риск | Митигация |
|
||
|---|---|
|
||
| Старые агрегаты не обновляются после правки dictionary | формальный backfill-регламент |
|
||
| Grafana продолжает читать raw-события | review dashboard JSON и запрет тяжелых raw-запросов |
|
||
| Неверная гранулярность агрегата | хранить hourly, дневные отчеты строить поверх hourly |
|
||
| Слишком много cardinality в ORDER BY | не включать `employee_name` в основной агрегат, держать его в drilldown |
|
||
| Ошибочная классификация попадает в агрегат | unknown counters, review справочников, backfill |
|
||
| Разные raw-схемы desktop/browser | держать отдельные MV, но общий target aggregate |
|
||
|
||
## 6. Проверка состояния dictionaries
|
||
|
||
```sql
|
||
SELECT
|
||
name,
|
||
status,
|
||
element_count,
|
||
last_exception,
|
||
last_successful_update_time
|
||
FROM system.dictionaries
|
||
WHERE name IN (
|
||
'dict_workstation_user',
|
||
'dict_application_category',
|
||
'dict_domain_category'
|
||
);
|
||
```
|
||
|
||
Ожидаемое состояние перед включением materialized views:
|
||
|
||
- все dictionaries имеют статус `LOADED`;
|
||
- `last_exception` пустой;
|
||
- `element_count` не равен нулю для загруженных справочников;
|
||
- время `last_successful_update_time` соответствует последней загрузке.
|
||
|
||
## 7. Ручная перезагрузка
|
||
|
||
После обновления справочников:
|
||
|
||
```sql
|
||
SYSTEM RELOAD DICTIONARY dict_workstation_user;
|
||
SYSTEM RELOAD DICTIONARY dict_application_category;
|
||
SYSTEM RELOAD DICTIONARY dict_domain_category;
|
||
```
|
||
|
||
После reload обязательно проверить `system.dictionaries`. Для materialized views
|
||
reload влияет только на новые вставки и ручной backfill, а не на уже записанные
|
||
агрегаты.
|
||
|
||
## 8. Контроль качества данных
|
||
|
||
Минимальный набор контрольных запросов:
|
||
|
||
```sql
|
||
SELECT
|
||
event_date,
|
||
sum(event_count) AS events,
|
||
sum(unknown_subject_events) AS unknown_subject_events,
|
||
round(unknown_subject_events / nullIf(events, 0), 4) AS unknown_subject_ratio,
|
||
sum(unknown_category_events) AS unknown_category_events,
|
||
round(unknown_category_events / nullIf(events, 0), 4) AS unknown_category_ratio
|
||
FROM agg_workforce_productivity_hourly
|
||
GROUP BY event_date
|
||
ORDER BY event_date DESC
|
||
LIMIT 14;
|
||
```
|
||
|
||
```sql
|
||
SELECT
|
||
event_date,
|
||
activity_type,
|
||
productivity_class,
|
||
sum(duration_sec) AS duration_sec
|
||
FROM agg_workforce_productivity_hourly
|
||
GROUP BY
|
||
event_date,
|
||
activity_type,
|
||
productivity_class
|
||
ORDER BY
|
||
event_date DESC,
|
||
duration_sec DESC;
|
||
```
|
||
|
||
Эти проверки нужны не для дисциплинарных выводов, а для качества данных:
|
||
находить незаведенные рабочие станции, неизвестные процессы, неизвестные
|
||
домены и ошибки классификации.
|
||
|
||
## 9. Общие риски
|
||
|
||
| Риск | Митигация |
|
||
|---|---|
|
||
| Ошибочная привязка пользователя к оргструктуре | отчет unknown и ручная сверка источника |
|
||
| Ошибочная классификация приложения | поле `comment`, владелец справочника, review изменений |
|
||
| Ошибочная классификация домена | отдельный review доменного справочника и path-level backlog |
|
||
| Потеря историчности | на пилоте принимается current snapshot; для регламентных отчетов проектировать history отдельно |
|
||
| Unknown processes/domains растут | регулярный top unknown отчет |
|
||
| Dictionary stale | контроль `system.dictionaries` и reload после загрузки |
|
||
| Исправления справочников не попадают в старые агрегаты | формальный backfill |
|
||
| Использование классификации как наказательного инструмента | явно фиксировать аналитический, а не дисциплинарный характер метрик |
|