Прийшла досить цікава задачка – побудувати в 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: час на відновлення системи у випадку її краху
Див. MKPIS – Measuring 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 просто створимо кілька графіків, і в кожному будемо виводити дані по конкретному “бакету” та кількості пул-реквестів в ньому.