Prometheus: GitHub Exporter – пишемо власний експортер для GitHub API

Автор |  01/06/2023
 

Прийшла досить цікава задачка – побудувати в Grafana дашборду, в якій би відображався статус процессу розробки, а саме – перформанс, тобто ефективність наших DevOps-процесів.

Потрібно це тому, что ми намагаємось побудувати “true continuous deployment”, щоб код автоматично потрапляв у Production, і нам важливо бачити як саме проходить процес розробки.

Загалом для оцінки ефективності процессу розробки ми придумали 5 метрик:

  • Deployment Frequency: як часто виконуються деплої
  • Lead Time for Changes: скільки часу займає доставка фічі до Production, тобто час між її першим коммітом в репозиторій до моменту, коли вона потрапляє в Production
  • PR Lead Time: час, котрий фіча “вісить” у статусі Pull Request
  • Change Failure Rate: процент деплоїв, які викликали проблеми у Production
  • Time to Restore Service: час на відновлення системи у випадку її краху

Див. MKPISMeasuring the development process for Gitflow managed projects та The 2019 Accelerate State of DevOps: Elite performance, productivity, and scaling.

Почати вирішили з метрики для PR Lead Time – будемо міряти час від створення Pull Request до його мержу в master-гілку, і виводити його на Grafana-дашборді.

Що зробимо: напишемо власний GitHub Exporter, який буде ходити до GitHub API, збирати дані, та створювати Prometheus-метрику, яку потім використаємо у Grafana. Див. Prometheus: створення Custom Prometheus Exporter на Python.

Тобто у нас будуть:

  • Grafana/Prometheus стек
  • Python
  • бібліотека PyGithub для роботи з GitHub API
  • prometheus-client для створення власних метрик

GitHub API та PyGithub

Почнемо з GitHub API. Документація – Getting started with the REST API.

GitHub token та аутентифікація

Нам знадобиться token – див. Authenticating to the REST API та Creating a personal access token.

Перевіряємо його:

[simterm]

$ curl -X GET -H "Authorization: token ghp_ys9***ilr" 'https://api.github.com/user'
{
  "login": "arseny***",
  "id": 132904972,
  "node_id": "U_kgDOB-v4DA",
  "avatar_url": "https://avatars.githubusercontent.com/u/132904972?v=4",
  "gravatar_id": "",
  "url": "https://api.github.com/users/arseny***",
...

[/simterm]

Окей, відповідь є, значить токен працює.

Бібліотека PyGithub

Встановлюємо PyGithub:

[simterm]

$ pip install PyGithub

[/simterm]

Тепер спробуємо сходити до GitHub API у коді на Python:

#!/usr/bin/env python

from github import Github

access_token = "ghp_ys9***ilr"

# connect to Gihub
github_instance = Github(access_token)
organization_name = 'OrgName'
# read org
organization = github_instance.get_organization(organization_name)
# get repos list     
repositories = organization.get_repos()

for repository in repositories:
    print(f"Repository: {repository.full_name.split('/')[1]}")

Тут створюємо github_instance, аутентифікуємось з нашим токеном, отримуємо інформацію про GitHub Organization, та всі репозиторії цієї організації.

Запускаємо:

[simterm]

$ ./test-api.py 
Repository: chatbot-automation
Repository: ***-sandbox
Repository: ***-ios
...

[/simterm]

Окей, працює.

Отримання інформації про Pull Request

Далі, спробуємо отримати інформацю про пул-реквест, а саме – час його створення та закриття.

Тут, щоб спростити та пришвидшити розробку експортеру і його тестування, будемо використовувати тільки один репозиторій і будемо вибирати закриті пул-реквести тільки за останній тиждень, а потім вже повернемо цикл, в якому будемо перебирати всі репозиторії та пул-реквести в них:

...

# get infro about a repository
repository = github_instance.get_repo("OrgName/repo-name")
# get all PRs in a given repository
pull_requests = repository.get_pulls(state='closed')

# to get PRs closed during last N days
days_ago = datetime.now() - timedelta(days=7)

for pull_request in pull_requests:
    merged_at = pull_request.closed_at
    created_at = pull_request.merged_at

    if created_at >= days_ago and created_at and merged_at:
        print(f"Pull Request: {pull_request.number} Created at: {pull_request.created_at} Merged at: {pull_request.merged_at}")

Тут у циклі для кожного PR отримуємо його атрибути merged_at та created_at, див. List pull requests – у Response schema є список всіх атрибутів, які ми можемо побачити для кожного PR.

У days_ago = datetime.now() - timedelta(days=7) отримуємо день 7 днів тому, щоб вибрати пул-реквести, створені після цієї дати, а потім для перевірки виводимо на екран інформацію про дату створення PR та дату, коли його змержили в master.

Перевіряємо:

[simterm]

$ ./test-api.py 
Pull Request: 1055 Created at: 2023-05-31 18:34:18 Merged at: 2023-06-01 08:14:49
Pull Request: 1049 Created at: 2023-05-31 10:22:16 Merged at: 2023-05-31 18:03:09
Pull Request: 1048 Created at: 2023-05-30 15:16:13 Merged at: 2023-05-31 14:17:57
...

[/simterm]

Гуд! Працює.

Тепер можемо починати думати про метрику для Prometheus.

Prometheus Client та метрики

Встановлюємо бібліотеку:

[simterm]

$ pip install prometheus_client

[/simterm]

Щоб мати більше уяви про те, що саме ми хочимо побудувати – можна почитати How to Read Lead Time Distribution, де є приклад такого графіку:

Тобто в нашому випадку будуть:

  • x-axis (горизонталь): час (години на закриття PR)
  • y-axis (вертикаль): кількість PR закриті за Х-годин

Тут я досить багато часу витратив, намагюсь зробити це з використанням різних типів метрик для Prometheus, і спочатку пробував Histogram, бо наче ж виглядає логічно – в бакети гістрограми вносити значення, по типу такого:

buckets = [1, 2, 5, 10, 20, 100, 1000]
gh_repo_lead_time = Histogram('gh_repo_lead_time', 'Time in hours between PR open and merge', buckets=buckets, labelnames=['gh_repo_name'])

Проте, з Histogram не вийшло, бо в бакет 1000 потрапляють всі значення меньше 1000, в бакет 100 – всі менше ста, і так далі, а нам потрібно в бакет 100 включати тільки дані про пул-реквести, які були закриті між 50 годин та 100 годин.

Але врешті-решт все вийшло з використанням типу Counter та лейбл repo_name та time_interval.

Див. A Deep Dive Into the Four Types of Prometheus Metrics.

Створення метрики

Спочатку створимо Python dictionary з “бакетами” – це години, на протязі яких були закриті пул-реквести:

time_intervals = [1, 2, 5, 10, 20, 50, 100, 1000]

Далі будемо отримувати кількість годин на закриття у кожному PR, перевіряти в який саме “бакет” цей PR попадає, і потім заносити дані у метрику – додавати лейблу time_interval зі значенням з бакету, в який це PR попав, та інкрементити значення каунтеру.

Створємо саму метрику pull_request_duration_count та функцію calculate_pull_request_duration(), в яку будемо передавати пул-реквест для перевірки:

...
# buckets for PRs closed during {interval}
time_intervals = [1, 2, 5, 10, 20, 50, 100, 1000]  # 1 hour, 2 hours, 5 hours
# prometheus metric to count PRs in each {interval}
pull_request_duration_count = Counter('pull_request_duration_count',
                                      'Count of Pull Requests within a time interval',
                                      labelnames=['repo_name', 'time_interval'])

def calculate_pull_request_duration(repository, pr):
    created_at = pr.created_at
    merged_at = pr.merged_at

    if created_at >= days_ago and created_at and merged_at:
        duration = (merged_at - created_at).total_seconds() / 3600

        # Increment the histogram for each time interval
        for interval in time_intervals:
            if duration <= interval:
                print(f"PR ID: {pr.number} Duration: {duration} Interval: {interval}")
                pull_request_duration_count.labels(time_interval=interval, repo_name=repository).inc()
                break
...

Тут у calculate_pull_request_duration():

  • отримуємо час створення та мержу пул-реквеста
  • перевіряємо, що PR молодший за $days_ago і має атрібути created_at та merged_at, тобто він вже змержений
  • рахуємо, скільки часу він провів до моменту його мержу в мастер-гілку, та переводимо в години – duration = (merged_at - created_at).total_seconds() / 3600
  • у циклі проходимось по “бакетах” з нашого time_intervals dictionary – шукаємо, в який з них попадає цей PR
  • і в кінці створюємо метрику pull_request_duration_count, в labels якої вносимо ім’я репозиторію та “бакет”, в який попав цей пул-реквест, і інкриментимо значення каунтера на +1:
    pull_request_duration_count.labels(time_interval=interval, repo_name=repository).inc()

Далі, описуємо функцію main() та ї виклик:

...

def main():
    # connect to Gihub
    github_instance = Github(github_token)
    organization_name = 'OrgName'
    # read org
    organization = github_instance.get_organization(organization_name)
    # get repos list 
    repositories = organization.get_repos()

    for repository in repositories:
        # to set in labels
        repository_name = repository.full_name.split('/')[1]
        pull_requests = repository.get_pulls(state='closed')

        if pull_requests.totalCount > 0:
            print(f"Checking repository: {repository_name}")
            for pr in pull_requests:
                calculate_pull_request_duration(repository_name, pr)
        else:
            print(f"Sckipping repository: {repository_name}")

    # Start Prometheus HTTP server
    start_http_server(8000)
    print("HTTP server started")
    while True:
        time.sleep(15)
        pass

if __name__ == '__main__':
    main()

Тут ми:

  • створємо об’єкт Github
  • отримуємо список репозиторіїв організацї
  • для кожного репозиторія викликаємо get_pulls(state='closed')
  • перевіряємо, що в репозиторії були пул-реквести, і по черзі відправляємо їх до функції calculate_pull_request_duration()
  • запускаємо HTTP-сервер на порту 8000, де будемо отримувати метрики

Повний код Prometheus-експортеру

Все разом тепер виходить так:

#!/usr/bin/env python

from datetime import datetime, timedelta
import time
from prometheus_client import start_http_server, Counter
from github import Github

# TODO: move to env vars
github_token = "ghp_ys9***ilr"

# to get PRs closed during last N days
days_ago = datetime.now() - timedelta(days=7)
# buckets for PRs closed during {interval}
time_intervals = [1, 2, 5, 10, 20, 50, 100, 1000]  # 1 hour, 2 hours, 5 hours
# prometheus metric to count PRs in each {interval}
pull_request_duration_count = Counter('pull_request_duration_count',
                                      'Count of Pull Requests within a time interval',
                                      labelnames=['repo_name', 'time_interval'])

def calculate_pull_request_duration(repository, pr):
    created_at = pr.created_at
    merged_at = pr.merged_at

    if created_at >= days_ago and created_at and merged_at:
        duration = (merged_at - created_at).total_seconds() / 3600

        # Increment the Counter for each time interval
        for interval in time_intervals:
            if duration <= interval:
                print(f"PR ID: {pr.number} Duration: {duration} Interval: {interval}")
                pull_request_duration_count.labels(time_interval=interval, repo_name=repository).inc()
                break

def main():
    # connect to Gihub
    github_instance = Github(github_token)
    organization_name = 'OrgNameg'
    # read org
    organization = github_instance.get_organization(organization_name)
    # get repos list 
    repositories = organization.get_repos()

    for repository in repositories:
        # to set in labels
        repository_name = repository.full_name.split('/')[1]
        pull_requests = repository.get_pulls(state='closed')

        if pull_requests.totalCount > 0:
            print(f"Checking repository: {repository_name}")
            for pr in pull_requests:
                calculate_pull_request_duration(repository_name, pr)
        else:
            print(f"Skipping repository: {repository_name}")

    # Start Prometheus HTTP server
    start_http_server(8000)
    print("HTTP server started")
    while True:
        time.sleep(15)
        pass

if __name__ == '__main__':
    main()

Запускаємо скрипт:

[simterm]

$ ./github-exporter.py
...
Skipping repository: ***-sandbox
Checking repository: ***-ios
PR ID: 1332 Duration: 5.4775 Interval: 10
PR ID: 1331 Duration: 0.32916666666666666 Interval: 1
PR ID: 1330 Duration: 20.796944444444446 Interval: 50
...

[/simterm]

Чекаємо, поки будуть перевірені всі репозиторії і запуститься http_server(), та перевіряємо метрики з curl:

[simterm]

$ curl localhost:8000
...
# HELP pull_request_duration_count_total Count of Pull Requests within a time interval
# TYPE pull_request_duration_count_total counter
pull_request_duration_count_total{repo_name="***-ios",time_interval="10"} 1.0
pull_request_duration_count_total{repo_name="***-ios",time_interval="1"} 1.0
pull_request_duration_count_total{repo_name="***-ios",time_interval="50"} 2.0
pull_request_duration_count_total{repo_name="***-ios",time_interval="100"} 1.0
pull_request_duration_count_total{repo_name="***-ios",time_interval="20"} 1.0
pull_request_duration_count_total{repo_name="***-ios",time_interval="1000"} 1.0
...

[/simterm]

Гуд! Працює.

GitHub API rate limits

Майте на увазі, що GitHub обмежує кількість запитів до API – 5,000 на годину зі звичайним юзерським токеном, та 15.000, якщо у вас Enterprise ліцензія. Див. Rate limits for requests from personal accounts.

Якщо його перевищити – отримаєте 403:

[simterm]

...
  File "/usr/local/lib/python3.11/site-packages/github/Requester.py", line 423, in __check
    raise self.__createException(status, responseHeaders, output)
github.GithubException.RateLimitExceededException: 403 {"message": "API rate limit exceeded for user ID 132904972.", "documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting"}

[/simterm]

Prometheus Server та отримання метрик

Залишилось почати збирати метрики у Prometheus, та створити Grafana dashboard.

Запуск Prometheus Exporter

Створюємо Dockerfile:

FROM python:latest

COPY github-exporter.py ./
RUN pip install prometheus_client PyGithub

CMD [ "python", "./github-exporter.py"]

Збираємо образ:

[simterm]

$ docker build -t gh-exporter .

[/simterm]

У нас Prometheus/Grafana поки що в простому Docker Compose – додаємо запуск нашого нового експортеру:

...
  gh-exporter:
    scrape_timeout: 15s
    image: gh-exporter
    ports:
      - 8000:8000
...

(токен таки краще передавати через змінну оточення з docker-compose файлу, а не хардкодити в коді)

І у файлу конфігурації самого Prometheus – описуємо нову scrape_job:

scrape_configs:
...
  - job_name: gh_exporter
    scrape_interval: 5s
    static_configs:
      - targets: ['gh-exporter:8000']
...

Запускаємо, і за хвилину перевіряємо метрики в Prometheus:

Найс!

Grafana dashboard

Останнім робимо саму борду.

Додамо змінну, щоб мати змогу відобразити дані по конкретному репозіторію/ях:

Для візуалізації я використав тип Bar gauge і такий query:

sum(pull_request_duration_count_total{repo_name=~"$repository"}) by (time_interval)

У Overrides задаємо колір для кожної колонки.

Єдине, що тут не дуже – це сортування колонок: сам Prometheus це не вміє і не хоче (див. Added sort_by_label function for sorting by label values), а Grafana сортує по першим цифрам у отриманих  з label значеннях, тобто 1, 2, 5, не враховуючи кількість 0 після цифри.

Але то вже деталі – може, таки візьмемо Victoria Metrics з її sort_by_label, або в Grafana просто створимо кілька графіків, і в кожному будемо виводити дані по конкретному “бакету” та кількості пул-реквестів в ньому.