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



