docs(clickhouse): plan dictionaries and aggregate sprints
This commit is contained in:
@@ -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` |
|
||||
Reference in New Issue
Block a user