Типичные грабли аналитики на примере данных из Git

Типичные грабли аналитики
на примере данных из Git

Алексей Миловидов

Эпизод 1

Взять данные из Git для анализа процессов разработки.

На примере этой задачи я расскажу про:

— тривиальные факты
— и прописные истины,
  которые все и так уже знают.

Что такое Git

Чем отличается Git и GitHub?

Git — распределённая система контроля версий.

GitHub — централизованный хостинг Git репозиториев
+ социальная сеть для разработчиков.

Что хотим получить

— файлы с максимальным и минимальным количеством авторов;
— самые старые строчки кода в репозитории;
— файлы с самой длинной историей;
— любимые файлы для каждого автора;
— большие файлы с маленьким количеством авторов;
— в какие дни недели лучше писать код,
  чтобы у него было меньше шансов быть удалённым;
— файлы отсортированные по возрасту кода;
— кто написал больше всего кода;
— кто написал больше всего кода, который не удалили;
— код каких авторов чаще всего переписывают;
— найти пары авторов, что один пишет код, а другой удаляет их код;
— медиана времени удаления строчек кода;
— какой код больше всего переписывали;
— кто больше любит писать комментарии или тесты;


https://xkcd.com/1597/

Модель данных Git

Из чего состоит Git репозиторий?

Репозиторий состоит из blob-ов, деревьев и коммитов.

Всё имеет хэш, уникально идентифицирующий содержимое.

Blob — содержимое файла.

Tree — слепок содержимого директории, ссылается на tree и blob-ы.

Commit — ссылка на tree и родительские коммиты.

Коммиты ссылаются друг на друга и образуют граф.

Если у коммита больше одного родителя — это merge.

Модель данных Git

Важные замечания:

В git не хранится diff между коммитами.
Хранится слепок содержимого файлов, а diff вычисляется налету.

В git не хранится авторство строчек кода. Оно вычисляется налету просмотром истории подряд, пока не найдётся нужный diff.

В git нет линейной истории. Нет возможности представить репозиторий в виде последовательности patch, сохраняя авторство.

git log — лишь один из способов обхода графа коммитов

Что с этим делать?

Засунуть всё в реляционную БД* и гонять SELECT запросы.

Но модель данных Git не является реляционной
— придётся идти на компромиссы.

* конечно же в ClickHouse.

Способ 1

Взять текущее состояние репозитория.

Вычислить для каждого файла blame (git blame)
— авторство и время коммита каждой строчки.

Каждую строчку каждого файла положить в таблицу.

Объём данных — это просто LOC.

ClickHouse:      732 048 строк (без contrib)
LLVM:          9 809 148 строк
Chromium:     15 063 229 строк (без third_party)
Linux Kernel: 21 645 046 строк

Внутренний репозиторий Яндекса: ??? строк.

Способ 1

— файлы с максимальным и минимальным количеством авторов;
— самые старые строчки кода в репозитории;
— файлы с самой длинной историей;
— любимые файлы для каждого автора;
— большие файлы с маленьким количеством авторов;
— в какие дни недели лучше писать код,
  чтобы у него было меньше шансов быть удалённым;
— файлы отсортированные по возрасту кода;
— кто написал больше всего кода;
— кто написал больше всего кода, который не удалили;
— код каких авторов чаще всего переписывают;
— найти пары авторов, что один пишет код, а другой удаляет их код;
— медиана времени удаления строчек кода;
— какой код больше всего переписывали;
— кто больше любит писать комментарии или тесты;

Способ 2

Взять все коммиты.
Для каждого коммита взять состояние репозитория.

Объём данных, оценка сверху:
число строк сейчас * число коммитов / 2

ClickHouse — 732 048 строк * 51 055 коммитов / 2 = 18.6 млрд.
Linux Kernel — 21 645 046 строк * 950 019 коммитов / 2 = 10 трлн.

Всё можно посчитать, но данных слишком много :(

Способ 3

Взять все коммиты.

У каждого коммита получить diff (git show).

Получим добавленные и удалённые строчки кода.

Запишем всё в базу!

Объём данных — в несколько раз больше LOC.
... смотря сколько раз в среднем код переписывался.

Способ 3

Недостатки:

Для merge коммитов diff нетривиален,
так как показывает изменения относительно нескольких родителей.

Решение: просто пропустим merge коммиты.

Изменения для разрешения конфликтов не попадут в статистику.

Статистика будет неточной... ну и ладно.

Зато есть история изменений и всё можно посчитать!

Определяем модель данных

Три таблицы:

commits
  hash String,
  author LowCardinality(String),
  time DateTime,
  message String,
  ...
file_changes
  change_type Enum('Add' = 1, 'Delete' = 2, 'Modify' = 3, 'Rename' = 4...),
  path LowCardinality(String),
  old_path LowCardinality(String),
  file_extension LowCardinality(String),
  ...
line_changes
  sign Int8,
  line_number_old UInt32,
  line_number_new UInt32,
  line LowCardinality(String),
  indent UInt8,
  line_type Enum('Empty' = 0, 'Comment' = 1, 'Punct' = 2, 'Code' = 3),
  ...

Определяем модель данных

Принципы:

1. Денормализация.
В каждую строчку file_changes продублируем все свойства коммита.
В каждую строчку line_changes продублируем все свойства file_changes и двух коммитов — текущего и предыдущего из blame.

2. Обогащение статистикой.
В каждую строчку commits также добавим:
- количество добавленных и удалённых файлов;
- количество добавленных и удалённых строк;

3. Предвычисление отдельных деталей.
- вычислим уровень отступа строчки и положим в отдельный столбец;
- вычислим, является ли строчка пустой, комментарием, кодом...;
- расширение файла тоже сохраним в отдельный столбец.

Определяем модель данных

Принципы:

1. Денормализация.

2. Обогащение статистикой.

3. Предвычисление отдельных деталей.

Эти принципы противопоказаны для транзакционных БД
  и очень хороши для аналитических БД.

Определяем модель данных

CREATE TABLE git.line_changes
(
    sign Int8,
    line_number_old UInt32,
    line_number_new UInt32,
    hunk_num UInt32,
    hunk_start_line_number_old UInt32,
    hunk_start_line_number_new UInt32,
    hunk_lines_added UInt32,
    hunk_lines_deleted UInt32,
    hunk_context LowCardinality(String),
    line LowCardinality(String),
    indent UInt8,
    line_type Enum('Empty' = 0, 'Comment' = 1, 'Punct' = 2, 'Code' = 3),

    prev_commit_hash String,
    prev_author LowCardinality(String),
    prev_time DateTime,

    file_change_type Enum('Add' = 1, 'Delete' = 2, 'Modify' = 3, 'Rename' = 4, 'Copy' = 5, 'Type' = 6),
    path LowCardinality(String),
    old_path LowCardinality(String),
    file_extension LowCardinality(String),
    file_lines_added UInt32,
    file_lines_deleted UInt32,
    file_hunks_added UInt32,
    file_hunks_removed UInt32,
    file_hunks_changed UInt32,

    commit_hash String,
    author LowCardinality(String),
    time DateTime,
    commit_message String,
    commit_files_added UInt32,
    commit_files_deleted UInt32,
    commit_files_renamed UInt32,
    commit_files_modified UInt32,
    commit_lines_added UInt32,
    commit_lines_deleted UInt32,
    commit_hunks_added UInt32,
    commit_hunks_removed UInt32,
    commit_hunks_changed UInt32
) ENGINE = MergeTree ORDER BY time;

Пишем скрипт

Я попытался написать скрипт на bash, но потерпел крах.

Написал скрипт на C++ :) (а мог бы на Python).

Это не является рекомендацией.
Вы — аналитики. Пишите на чём угодно.

https://github.com/ClickHouse/ClickHouse/pull/14471

Запускаем скрипт

clickhouse-git-import --help

Обработка репозитория и размер таблиц в ClickHouse:

- ClickHouse:  31 сек;    6 млн строк;  122 МБ;
- LLVM:         8 мин;   62 млн строк;  1.2 ГБ;
- Linux:       12 мин;   85 млн строк;  1.7 ГБ;
- Chromium:    67 мин;  343 млн строк;  6.8 ГБ.

Копеечный объём данных! Можно анализировать на ноутбуке.

Анализируем данные

Количество авторов, которые написали больше всего строк кода.

LOC — самая тупая метрика из всех :(

Варианты:
— всё, что написали или только те строчки, которые не удалены?
— только добавленные строчки кода или удаление кода тоже важно?
— как учитывать код, тесты, документацию?

SELECT author AS k, count() AS c FROM line_changes WHERE file_extension IN ('h', 'cpp') GROUP BY k ORDER BY c DESC LIMIT 20 ┌─k────────────────────┬───────c─┐ │ Alexey Milovidov │ 1061697 │ │ proller │ 200704 │ <-- коммиты third-party кода │ Nikolai Kochetov │ 183370 │ │ Alexey Arno │ 107018 │ │ Vitaly Baranov │ 93296 │ │ Andrey Mironov │ 92973 │ │ Guillaume Tassery │ 89530 │ <-- коммит и удаление │ alesapin │ 86999 │ автогенерированных файлов │ Vitaliy Lyudvichenko │ 85609 │ │ Michael Kolupaev │ 69178 │ │ CurtizJ │ 62607 │ │ chertus │ 53425 │ │ Alexey Zatelepin │ 49331 │ │ zhang2014 │ 48352 │ │ Alexander Tokmakov │ 46371 │ │ alexey-milovidov │ 41518 │ <-- дубликат │ peshkurov │ 36525 │ <-- испорчена история git, │ Nikita Mikhaylov │ 30226 │ закоммичен snapshot без parent. │ Nikita Vasilev │ 28182 │ │ Artem Zuikov │ 25176 │ └──────────────────────┴─────────┘

Предостережения

Запрос выполнен за 0.009 сек. А чтобы разобраться, почему результат является мусором, потребуются часы разбирательств.

Нельзя считать производительность разработчиков
по физическим свойствам репозитория.

Количество строк кода — очень плохая метрика.

Если вы агрегируете данные и считаете топ
— вы будете видеть, в первую очередь, выбросы данных.

В выбросах придётся разбираться, а данные придётся чистить.

Один агрегированный отчёт не является результатом.

Что-то более интересное

Авторы и доля их кода, удалённая другими авторами:

SELECT k, written_code.c, removed_code.c, round(removed_code.c * 100 / written_code.c) AS remove_ratio FROM ( SELECT author AS k, count() AS c FROM line_changes WHERE sign = 1 AND file_extension IN ('h', 'cpp') AND line_type NOT IN ('Punct', 'Empty') GROUP BY k ) AS written_code INNER JOIN ( SELECT prev_author AS k, count() AS c FROM line_changes WHERE sign = -1 AND file_extension IN ('h', 'cpp') AND line_type NOT IN ('Punct', 'Empty') AND author != prev_author GROUP BY k ) AS removed_code USING (k) WHERE written_code.c > 1000 ORDER BY c DESC LIMIT 500

Что-то более интересное

Авторы и доля их кода, удалённая другими авторами:

┌─k───────────────────────┬──────c─┬─removed_code.c─┬─remove_ratio─┐ │ Alexey Milovidov │ 426656 │ 113306 │ 27 │ │ Nikolai Kochetov │ 83479 │ 16999 │ 20 │ │ Andrey Mironov │ 55097 │ 43989 │ 80 │ │ Alexey Arno │ 49474 │ 22664 │ 46 │ │ Vitaliy Lyudvichenko │ 47603 │ 31890 │ 67 │ │ Vitaly Baranov │ 44032 │ 3010 │ 7 │ │ alesapin │ 40617 │ 8505 │ 21 │ │ proller │ 36638 │ 19471 │ 53 │ │ Michael Kolupaev │ 31523 │ 21182 │ 67 │ │ peshkurov │ 25442 │ 24556 │ 97 │ │ chertus │ 24013 │ 6961 │ 29 │ │ CurtizJ │ 23684 │ 7604 │ 32 │ │ Alexey Zatelepin │ 22892 │ 11105 │ 49 │ │ Alexander Tokmakov │ 21147 │ 2524 │ 12 │ │ zhang2014 │ 20476 │ 3900 │ 19 │ │ alexey-milovidov │ 16513 │ 10415 │ 63 │ │ Nikita Vasilev │ 14099 │ 2633 │ 19 │ │ Guillaume Tassery │ 12431 │ 1199 │ 10 │ │ Nikita Mikhaylov │ 11673 │ 1447 │ 12 │

Выводы

Полученный инструмент является хорошей игрушкой.

Метрики из этих данных считать можно...
но слепо ориентироваться на них — нет.

Инструмент можно давать в руки человеку, который будет
сомневаться, думать и перепроверять данные.

Эпизод 2

GitHub — централизованный хостинг Git репозиториев;
+ социальная сеть для разработчиков;
+ инструменты совместной работы.

В данных Git репозитория нет:
— issues (таск трекера);
— pull requests;
— code review, звёзд, лайков и комментариев.

Это реализует сам GitHub.

Я хочу все данные достать и проанализировать!

Импорт данных GitHub

— GitHub API;

— GH Archive: https://www.gharchive.org/.

Данные о событиях, которые происходили в репозиториях.

GH Archive — обновляемый архив данных о всех репозиториях GitHub!

Импорт данных GitHub

Типы событий GitHub API:

— CommitCommentEvent;
— CreateEvent;
— DeleteEvent;
— ForkEvent;
— GollumEvent;
— IssueCommentEvent;
— IssuesEvent;
— MemberEvent;
— PublicEvent;
— PullRequestEvent;
— PullRequestReviewCommentEvent;
— PushEvent;
— ReleaseEvent;
— SponsorshipEvent;
— WatchEvent;

Импорт данных GitHub

wget --continue \
https://data.gharchive.org/{2015..2020}-{01..12}-{01..31}-{0..23}.json.gz

Все данные открыты и вы можете их скачать!

Просто ждём несколько дней пока скачается
84 264 файла размером 1.2 TB.

Можно ускорить, разбив по серверам, но зачем...
когда можно просто оставить на выходные и забыть.

Анализ данных GitHub

Как анализировать 1.2 TB .json.gz файлов?

Способ 1. Прямо как есть. Никуда не загружаем.
Анализируем с помощью clickhouse-local:

clickhouse-local --query " SELECT count() FROM file('*.json.gz', TSV, 'data String') WHERE JSONExtractString(data, 'actor', 'login') = 'alexey-milovidov'"

Запрос упирается в диск и выполняется ~30 минут.
Узкое место — чтение всех имеющихся данных.

Это не оптимально!

Анализ данных GitHub

Как анализировать 1.2 TB .json.gz файлов?

Способ 2. Переложить в таблицу в ClickHouse.

Но у данных очень сложная структура.
— 15 разных событий, у каждого много своих атрибутов.

Решение: упростить структуру!

CREATE TABLE github_events
(
    event_type Enum('CommitCommentEvent' = 1, 'CreateEvent' = 2, 'DeleteEvent' = 3, 'ForkEvent' = 4,
                    'GollumEvent' = 5, 'IssueCommentEvent' = 6, 'IssuesEvent' = 7, 'MemberEvent' = 8,
                    'PublicEvent' = 9, 'PullRequestEvent' = 10, 'PullRequestReviewCommentEvent' = 11,
                    'PushEvent' = 12, 'ReleaseEvent' = 13, 'SponsorshipEvent' = 14, 'WatchEvent' = 15,
                    'GistEvent' = 16, 'FollowEvent' = 17, 'DownloadEvent' = 18, 'PullRequestReviewEvent' = 19),
    actor_login LowCardinality(String),
    repo_name LowCardinality(String),
    created_at DateTime,
    updated_at DateTime,
    action Enum('none' = 0, 'created' = 1, 'added' = 2, 'edited' = 3, 'deleted' = 4, 'opened' = 5, 'closed' = 6,
        'reopened' = 7, 'assigned' = 8, 'unassigned' = 9, 'labeled' = 10, 'unlabeled' = 11, 'review_requested' = 12,
        'review_request_removed' = 13, 'synchronize' = 14, 'started' = 15, 'published' = 16),
    comment_id UInt64,
    body String,
    path String,
    position UInt32,
    line UInt32,
    ref LowCardinality(String),
    ref_type Enum('none' = 0, 'branch' = 1, 'tag' = 2, 'repository' = 3),
    creator_user_login LowCardinality(String),
    number UInt32,
    title String,
    labels Array(LowCardinality(String)),
    state Enum('none' = 0, 'open' = 1, 'closed' = 2),
    locked UInt8,
    assignee LowCardinality(String),
    assignees Array(LowCardinality(String)),
    comments UInt32,
    author_association Enum('NONE' = 0, 'CONTRIBUTOR' = 1, 'OWNER' = 2, 'COLLABORATOR' = 3, 'MEMBER' = 4),
    closed_at DateTime,
    merged_at DateTime,
    merge_commit_sha String,
    requested_reviewers Array(LowCardinality(String)),
    requested_teams Array(LowCardinality(String)),
    head_ref LowCardinality(String),
    head_sha String,
    base_ref LowCardinality(String),
    base_sha String,
    merged UInt8,
    mergeable UInt8,
    rebaseable UInt8,
    mergeable_state Enum('unknown' = 0, 'dirty' = 1, 'clean' = 2, 'unstable' = 3, 'draft' = 4),
    merged_by LowCardinality(String),
    review_comments UInt32,
    maintainer_can_modify UInt8,
    ...
) ENGINE = MergeTree ORDER BY (event_type, repo_name, created_at);
find . -name '*.json.gz' | xargs -P16 -I{} bash -c "
gzip -cd {} | jq -c '
[
    .type,
    .actor.login,
    .repo.name,
    .created_at,
    .payload.updated_at // .payload.comment.updated_at // .payload.issue.updated_at // .payload.pull_request.updated_at,
    .payload.action,
    .payload.comment.id,
    .payload.review.body // .payload.comment.body // .payload.issue.body // .payload.pull_request.body // .payload.release.body,
    .payload.comment.path,
    .payload.comment.position,
    .payload.comment.line,
    .payload.ref,
    .payload.ref_type,
    .payload.comment.user.login // .payload.issue.user.login // .payload.pull_request.user.login,
    .payload.issue.number // .payload.pull_request.number,
    .payload.issue.title // .payload.pull_request.title,
    [.payload.issue.labels[]?.name // .payload.pull_request.labels[]?.name],
    .payload.issue.state // .payload.pull_request.state,
    .payload.issue.locked // .payload.pull_request.locked,
    .payload.issue.assignee.login // .payload.pull_request.assignee.login,
    [.payload.issue.assignees[]?.login // .payload.pull_request.assignees[]?.login],
    .payload.issue.comments // .payload.pull_request.comments,
    .payload.review.author_association // .payload.issue.author_association // .payload.pull_request.author_association,
    .payload.issue.closed_at // .payload.pull_request.closed_at,
    .payload.pull_request.merged_at,
    .payload.pull_request.merge_commit_sha,
    [.payload.pull_request.requested_reviewers[]?.login],
    [.payload.pull_request.requested_teams[]?.name],
    ...
    .payload.pull_request.additions,
    .payload.pull_request.deletions,
    .payload.pull_request.changed_files,
    .payload.comment.diff_hunk,
    .payload.comment.original_position,
    .payload.comment.commit_id,
    .payload.comment.original_commit_id,
    .payload.size,
    .payload.distinct_size,
    .payload.member.login,
    .payload.release.tag_name,
    .payload.release.name,
    .payload.review.state
]' | clickhouse-client --input_format_null_as_default 1 --date_time_input_format best_effort --query '
    INSERT INTO github_events FORMAT JSONCompactEachRow' || echo 'File {} has issues'
"

Загрузка данных GitHub в ClickHouse

Что получили:

Одна плоская таблица со всеми типами событий.

Много столбцов — разные свойства разных событий.

Легко анализировать разные события без JOIN.

SELECT
    sum(event_type = 'WatchEvent') AS stars,
    sum(event_type = 'PullRequestEvent'
        AND action = 'opened') AS prs
FROM github_events
GROUP BY repo_name

Загрузка данных GitHub в ClickHouse

Что получили:

— 2.5 млрд строк;
— 150 ГБ в сжатом виде;
— 140 млн репозиториев;
— запросы выполняются за секунды.

Вывод:

Все события за всю историю во всех репозиториях на GitHub:
— это не «Big Data»;
— это «Fits on Your Laptop» Data.

Данные GitHub в ClickHouse

Могу быстро ответить почти на любой вопрос
про Open-Source экосистему*:

— топ репозиториев по звёздам, за всё время, за этот год;
— самые выросшие за квартал или стагнирующие из крупных репозиториев;
— графики числа звёзд, issues, contributors для любого репозитория;
— наиболее плодовитые авторы кода;
— кто больше всего проводит code review;
— репозитории с наибольшей активностью в комментариях;
— все репозитории связанные с ClickHouse;
— все мои комментарии... и чужие тоже.

* про её часть, доступную на GitHub.

Выводы

Чтобы анализировать данные, надо:

— изучить исходную модель и природу данных;
— выбрать правильное представление для их анализа;
— выполнить подготовку и очистку данных;
— срезать углы где можно;
— уметь написать простой скрипт на любом языке;
— никогда не бояться больших данных;
— исследовать выбросы и внутренности данных;
— быть готовым всё выкинуть и начать сначала;
— никогда не останавливаться на одном отчёте;

... и ещё понадобится отвага.

.