40 KiB
План внедрения 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 внедряются как слой быстрого обогащения событий, а не как новый источник истины.
Источник истины:
исходные справочники / 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
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
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);
Использование
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;
Отчет по слепым зонам
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
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.
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);
Использование в запросах
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
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;
Пример витрины продуктивности
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:
SELECT
url,
lowerUTF8(domain(url)) AS domain_name
FROM aw_browser_events;
Если domain() недостаточно из-за строк без схемы, использовать нормализацию:
SELECT
url,
lowerUTF8(
domain(
if(
position(url, '://') = 0,
concat('http://', url),
url
)
)
) AS domain_name
FROM aw_browser_events;
На ClickHouse 24.8 в локальном scaffold проверена функция domain():
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
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().
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 это фиксируется как ограничение модели.
Тонкую контекстную политику по подразделениям лучше выносить в следующий этап.
Использование в запросах
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
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
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.
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, который при вставке новых событий сразу складывает в агрегированные таблицы уже обогащенные метрики:
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
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
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
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:
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:
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 в момент вставки, для исправления старых данных нужен регламент:
- Исправить source dimension table.
- Выполнить
SYSTEM RELOAD DICTIONARY. - Проверить
system.dictionaries. - Пересобрать нужный период агрегатов из 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
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. Ручная перезагрузка
После обновления справочников:
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. Контроль качества данных
Минимальный набор контрольных запросов:
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;
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 |
| Использование классификации как наказательного инструмента | явно фиксировать аналитический, а не дисциплинарный характер метрик |