Files
AWatch-rus/docs/clickhouse/DICTIONARIES_IMPLEMENTATION_PLAN_RU.md
T

23 KiB
Raw Blame History

План внедрения 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. Исторические данные нужно пересчитать отдельно.

Порядок:

  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 для качества справочников

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