ClickHouse ETL: разбор `events.custom` в пары атрибут–значение
Задача
В Komrad SIEM поле komrad_events.events.custom хранит “плоский” JSON‑словарь пользовательских атрибутов (key → value), где значение может быть строкой, числом, булевым, массивом/объектом или строкой, внутри которой лежит JSON (string‑marshalled JSON).
Для OLAP‑аналитики (агрегации, фильтры, корреляции, BI‑дашборды) часто удобнее хранить custom в нормализованном виде: одна строка на один атрибут:
(event_key, tenant_id, attr, value_raw, value/typed_value…).
Этот пакет даёт два варианта:
- Ad‑hoc SELECT: “взрываем”
customв пары прямо запросом. - Витрина (таблица + Materialized View): складываем пары в отдельную MergeTree‑таблицу для быстрых OLAP‑запросов.
Исходные данные
Таблица событий: komrad_events.events (движок MergeTree).
Идентификатор события в Komrad — композиция (key_time, key_roller, key_stream):
key_time Int64— Unix timestamp (seconds)key_roller UInt32key_stream UInt32
Рекомендуется всегда переносить эти три поля (и tenant_id) в витрину с парами custom, чтобы в дальнейшем можно было:
- восстанавливать событие (
JOINобратно кevents); - группировать/агрегировать пары в рамках одного события;
- строить “wide” представления и индексы под конкретные use‑case’ы.
Особый случай: Fingerprint.UserAgentData
custom['Fingerprint.UserAgentData'] — это JSON‑строка, внутри которой лежит JSON‑объект (пример: строка вида "{\"brands\":[...],\"mobile\":false,...}").
Мы поддерживаем два режима:
- flat: сохраняем
Fingerprint.UserAgentDataкак один атрибут (строковое значение). - expanded: раскрываем внутренний объект в пары
Fingerprint.UserAgentData.<inner_key>.
По умолчанию мы раскрываем только Fingerprint.UserAgentData. Это сделано намеренно: “универсальное” рекурсивное раскрытие всех строк‑JSON часто приводит к непредсказуемому росту объёма данных и стоимости записи.
1) Ad‑hoc запросы (без витрин)
- Плоское разложение
customв пары:sql/01_select_custom_pairs_flat.sql - Плоское разложение + раскрытие
Fingerprint.UserAgentData:sql/02_select_custom_pairs_expand_fingerprint_useragentdata.sql
Выходные колонки:
attr— имя атрибута (ключ JSON)value_raw— JSON‑литерал (например:"abc",123,true,{"k":"v"},[1,2])value— нормализованная строка (JSON‑строки разэкраниваются, остальные типы остаются как есть)
2) Витрины (таблицы + MV) для OLAP
Доступно 4 варианта хранения (flat/expanded × single/typed):
| Вариант | Что даёт | Когда выбирать |
|---|---|---|
flat + single-value | минимум CPU/диска, простая модель | стартовый вариант для high‑EPS |
flat + typed | типизированные колонки (string/bool/int/float) | когда важны быстрые числовые агрегации/фильтры |
expanded + single-value | раскрывает Fingerprint.UserAgentData.<k> | когда UA‑аналитика важна и объём приемлем |
expanded + typed | раскрытие + типизация | максимально удобно для OLAP, но дороже по ресурсам |
DDL таблиц:
sql/10_ddl_kv_flat_single.sqlsql/11_ddl_kv_flat_typed.sqlsql/12_ddl_kv_expanded_single.sqlsql/13_ddl_kv_expanded_typed.sql
Безопасное включение MV без POPULATE (рекомендуется)
Для SIEM‑нагрузок (особенно 10k–100k EPS) использовать POPULATE не рекомендуется: он создаёт пиковую нагрузку и может негативно повлиять на ingestion/latency.
Безопасная схема включения:
1) Зафиксируйте "границу" включения:
SELECT now() AS mv_start;
2) Создайте целевую таблицу (один из sql/10..13_ddl_*.sql).
3) Создайте Materialized View с отсечкой только для новых данных:
sql/20_mv_kv_flat_single_safe.sqlsql/21_mv_kv_flat_typed_safe.sqlsql/22_mv_kv_expanded_single_safe.sqlsql/23_mv_kv_expanded_typed_safe.sql
В каждом файле нужно заменить дефолт:
WITH toDateTime('2099-01-01 00:00:00') AS mv_start
на фактическое значение mv_start, полученное на шаге 1.
4) Заполните "исторические" данные backfill'ом для w_time < mv_start.
Backfill скрипт (партиционно, с возобновлением, throttling)
Скрипт: scripts/backfill_custom_kv.sh
Ключевые свойства:
- идёт по партициям исходной таблицы (
system.parts); - пишет прогресс в state‑файл (можно перезапускать);
- умеет ограничивать диапазон партиций (
--from-partition/--to-partition); - регулируется по нагрузке (
--max-threads,--max-insert-threads,--sleep).
Пример:
./scripts/backfill_custom_kv.sh \
--database komrad_events \
--source-table events \
--mode flat_single \
--cutoff-wtime '2025-12-25 12:34:56' \
--max-threads 4 \
--max-insert-threads 2 \
--sleep 1
Важно, чтобы "окно" MV и backfill не пересекались:
- MV:
w_time >= mv_start - backfill:
w_time < mv_start
Если поменять условия или использовать POPULATE одновременно с backfill, возможны дубли.
Диагностика и оценка стоимости
Перед включением витрин полезно оценить размер/ширину custom:
sql/00_profile_custom.sql
Практическая оценка объёма витрины:
- количество строк ≈
events × avg(keys(custom)) expandedможет добавлять строки дляFingerprint.UserAgentData.<k>typedдобавляет несколько колонок, но может сильно ускорить OLAP‑фильтры/агрегации
Точки расширения
Если потребуется:
- раскрывать дополнительные ключи со строкой‑JSON (по whitelist);
- нормализовать ключи (
attr) или ограничивать набор атрибутов; - сделать отдельные витрины под конкретные домены (Auth/Network/EDR) и агрегаты;
лучше делать это осознанно (по требованиям запросов и бюджету ресурсов), так как стоимость записи и объём данных при SIEM‑нагрузках растут очень быстро.