From df5134ff767584b3969ae65873ec77f23411de4d Mon Sep 17 00:00:00 2001 From: duhow Date: Wed, 8 Feb 2023 16:31:48 +0100 Subject: [PATCH 1/8] feat: Handle and call supported events only --- setup.cfg | 2 +- src/app.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/setup.cfg b/setup.cfg index 292cb39..89890a4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = github-workflows-monitoring -version = 0.1 +version = 0.1.1 license-file = LICENSE [options] diff --git a/src/app.py b/src/app.py index 9fde501..a9393bd 100644 --- a/src/app.py +++ b/src/app.py @@ -99,14 +99,19 @@ def process_workflow_job(): return True +allowed_events = { + "workflow_job": process_workflow_job +} + + @app.route("/github-webhook", methods=["POST"]) def github_webhook_process(): event = request.headers.get(GithubHeaders.EVENT.value) - command = f"process_{event}" - if command == "process_workflow_job": - app.logger.debug(f"Calling function {command}") - process_workflow_job() + if event in allowed_events: + app.logger.debug(f"Calling function to process {event=}") + func = allowed_events.get(event) + func() return "OK" app.logger.error(f"Unknown event type {event}, can't handle") From 8333678d8443fba5aaa93bbce440721b8beeef97 Mon Sep 17 00:00:00 2001 From: duhow Date: Wed, 8 Feb 2023 16:34:57 +0100 Subject: [PATCH 2/8] Ignore coverage tests file --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c18dd8d..c1e64c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ __pycache__/ +.coverage From 3032ae201541c0f650221700a80b136f8525322a Mon Sep 17 00:00:00 2001 From: duhow Date: Wed, 8 Feb 2023 17:05:06 +0100 Subject: [PATCH 3/8] change: Define workflow data as a dict and use dict_to_logfmt --- src/app.py | 29 +++++++++++++---------------- src/utils.py | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/app.py b/src/app.py index a9393bd..390fea4 100644 --- a/src/app.py +++ b/src/app.py @@ -7,7 +7,7 @@ from const import GithubHeaders, LOGGING_CONFIG -from utils import parse_datetime +from utils import parse_datetime, dict_to_logfmt dictConfig(LOGGING_CONFIG) @@ -53,13 +53,16 @@ def process_workflow_job(): repository = job["repository"]["full_name"] action = job["action"] + context_details = { + "action": action, + "repository": repository, + "job_id": job_id, + "workflow": workflow, + } + if action == "queued": # add to memory as timestamp jobs[job_id] = int(time_start.timestamp()) - msg = ( - f"action={action} repository={repository} job_id={job_id}" - f' workflow="{workflow}"' - ) elif action == "in_progress": job_requested = jobs.get(job_id) @@ -68,10 +71,7 @@ def process_workflow_job(): time_to_start = 0 else: time_to_start = (time_start - datetime.fromtimestamp(job_requested)).seconds - msg = ( - f"action={action} repository={repository} job_id={job_id}" - f' workflow="{workflow}" time_to_start={time_to_start}' - ) + context_details["time_to_start"] = time_to_start elif action == "completed": job_requested = jobs.get(job_id) @@ -84,18 +84,15 @@ def process_workflow_job(): ).seconds # delete from memory del jobs[job_id] - msg = ( - f"action={action} repository={repository} job_id={job_id}" - f' workflow="{workflow}" time_to_finish={time_to_finish}' - ) + context_details["time_to_finish"] = time_to_finish else: app.logger.warning(f"Unknown action {action}, removing from memory") if job_id in jobs: del jobs[job_id] - msg = None + context_details = None - if msg: - app.logger.info(msg) + if context_details: + app.logger.info(dict_to_logfmt(context_details)) return True diff --git a/src/utils.py b/src/utils.py index c71da7c..635fdc5 100644 --- a/src/utils.py +++ b/src/utils.py @@ -5,3 +5,21 @@ def parse_datetime(date: str) -> datetime: """Parse GitHub date to object""" exp = "%Y-%m-%dT%H:%M:%SZ" return datetime.strptime(date, exp) + + +def dict_to_logfmt(data: dict) -> str: + """Convert a dict to logfmt string""" + outstr = list() + for k, v in data.items(): + if v is None: + outstr.append(f"{k}=") + continue + if isinstance(v, bool): + v = "true" if v else "false" + elif isinstance(v, (dict, object, int)): + v = str(v) + + if " " in v: + v = '"%s"' % v.replace('"', '\\"') + outstr.append(f"{k}={v}") + return " ".join(outstr) From 29bea217e5972b4f54d722141157c27df805c243 Mon Sep 17 00:00:00 2001 From: duhow Date: Wed, 8 Feb 2023 17:19:31 +0100 Subject: [PATCH 4/8] feat: Add more information from workflow execution --- src/app.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/app.py b/src/app.py index 390fea4..ec001b3 100644 --- a/src/app.py +++ b/src/app.py @@ -51,13 +51,20 @@ def process_workflow_job(): workflow = job["workflow_job"]["workflow_name"] time_start = parse_datetime(job["workflow_job"]["started_at"]) repository = job["repository"]["full_name"] + repository_private = job["repository"]["private"] action = job["action"] + conclusion = job["workflow_job"].get("conclusion") + requestor = job.get("sender", {}).get("login") + runner_name = job["workflow_job"]["runner_name"] + runner_group_name = job["workflow_job"]["runner_group_name"] + runner_public = (runner_group_name == "GitHub Actions") context_details = { "action": action, "repository": repository, "job_id": job_id, "workflow": workflow, + "requestor": requestor, } if action == "queued": @@ -71,7 +78,14 @@ def process_workflow_job(): time_to_start = 0 else: time_to_start = (time_start - datetime.fromtimestamp(job_requested)).seconds - context_details["time_to_start"] = time_to_start + + extra_data = { + "time_to_start": time_to_start, + "runner_name": runner_name, + "runner_public": runner_public, + "repository_private": repository_private + } + context_details = {**context_details, **extra_data} elif action == "completed": job_requested = jobs.get(job_id) @@ -84,7 +98,12 @@ def process_workflow_job(): ).seconds # delete from memory del jobs[job_id] - context_details["time_to_finish"] = time_to_finish + + extra_data = { + "time_to_finish": time_to_finish, + "conclusion": conclusion + } + context_details = {**context_details, **extra_data} else: app.logger.warning(f"Unknown action {action}, removing from memory") if job_id in jobs: From 1b47bfbfc86e18ab53d1e30dadb580716cb23367 Mon Sep 17 00:00:00 2001 From: duhow Date: Wed, 8 Feb 2023 17:35:50 +0100 Subject: [PATCH 5/8] Fix tests --- .github/workflows/tests.yaml | 1 + tests/tests.py | 32 +++++++++++++++++++++++++------- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index ca8f4f0..98103c8 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -10,6 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python-version: ["3.10", "3.11"] diff --git a/tests/tests.py b/tests/tests.py index 70db745..e4fc72c 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -11,9 +11,22 @@ "id": 0, "workflow_name": "CI", "started_at": "2023-01-27T14:00:00Z", + "conclusion": None, + "labels": [], + "runner_id": None, + "runner_name": None, + "runner_group_id": None, + "runner_group_name": None, }, "repository": { + "name": "foo", "full_name": "foo/foo", + "private": False, + }, + "sender": { + "login": "testerbot", + "id": 1, + "type": "User", }, } @@ -53,7 +66,8 @@ def test_started_job_not_stored(client, caplog): assert response.status_code == 200 assert caplog.messages == [ "Job 2 is in_progress but not stored!", - 'action=in_progress repository=foo/foo job_id=2 workflow="CI" time_to_start=0', + 'action=in_progress repository=foo/foo job_id=2 workflow=CI requestor=testerbot time_to_start=0 ' + 'runner_name= runner_public=false repository_private=false', ] @@ -65,7 +79,7 @@ def test_finished_job_not_stored(client, caplog): assert response.status_code == 200 assert caplog.messages == [ "Job 3 is completed but not stored!", - 'action=completed repository=foo/foo job_id=3 workflow="CI" time_to_finish=0', + 'action=completed repository=foo/foo job_id=3 workflow=CI requestor=testerbot time_to_finish=0 conclusion=', ] @@ -79,7 +93,7 @@ def test_unknown_action(client, caplog): response = client.post("/github-webhook", headers=HEADERS, json=body_failed) assert response.status_code == 200 assert caplog.messages == [ - 'action=queued repository=foo/foo job_id=4 workflow="CI"', + 'action=queued repository=foo/foo job_id=4 workflow=CI requestor=testerbot', "Unknown action failed, removing from memory", ] @@ -91,7 +105,7 @@ def test_queued_job(client, caplog): response = client.post("/github-webhook", headers=HEADERS, json=body_queued) assert response.status_code == 200 assert caplog.messages == [ - 'action=queued repository=foo/foo job_id=1 workflow="CI"' + 'action=queued repository=foo/foo job_id=1 workflow=CI requestor=testerbot' ] @@ -103,7 +117,7 @@ def test_logging_flow(client, caplog): response = client.post("/github-webhook", headers=HEADERS, json=body_queued) assert response.status_code == 200 assert ( - caplog.messages[0] == 'action=queued repository=foo/foo job_id=5 workflow="CI"' + caplog.messages[0] == 'action=queued repository=foo/foo job_id=5 workflow=CI requestor=testerbot' ) body_started = BODY.copy() @@ -113,15 +127,19 @@ def test_logging_flow(client, caplog): assert response.status_code == 200 assert ( caplog.messages[1] - == 'action=in_progress repository=foo/foo job_id=5 workflow="CI" time_to_start=5' + == 'action=in_progress repository=foo/foo job_id=5 workflow=CI requestor=testerbot time_to_start=5 ' + 'runner_name= runner_public=false repository_private=false' + ) body_completed = BODY.copy() body_completed["action"] = "completed" + body_completed["workflow_job"]["conclusion"] = "success" body_completed["workflow_job"]["completed_at"] = "2023-01-27T14:05:00Z" response = client.post("/github-webhook", headers=HEADERS, json=body_completed) assert response.status_code == 200 assert ( caplog.messages[2] - == 'action=completed repository=foo/foo job_id=5 workflow="CI" time_to_finish=295' + == 'action=completed repository=foo/foo job_id=5 workflow=CI requestor=testerbot ' + 'time_to_finish=295 conclusion=success' ) From ecb82564ff94733a53054ff3b8ea041dc409c53a Mon Sep 17 00:00:00 2001 From: duhow Date: Wed, 8 Feb 2023 17:58:30 +0100 Subject: [PATCH 6/8] Format using pre-commit hooks --- .pre-commit-config.yaml | 27 +++++++++++++++++++++++++++ setup.cfg | 2 +- src/utils.py | 4 ++-- 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0bae781 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: check-docstring-first + - id: check-json + - id: pretty-format-json + args: [--autofix, --no-sort-keys] + - id: check-added-large-files + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer +- repo: https://github.com/myint/docformatter + rev: v1.5.1 + hooks: + - id: docformatter + args: [--in-place] +- repo: https://github.com/asottile/pyupgrade + rev: v3.3.1 + hooks: + - id: pyupgrade + args: [--py38-plus] +- repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 + hooks: + - id: flake8 diff --git a/setup.cfg b/setup.cfg index 89890a4..a7a6339 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,7 +6,7 @@ license-file = LICENSE [options] python_requires = >=3.8 packages = find: -install_requires = +install_requires = Flask>=2.2,<3 [flake8] diff --git a/src/utils.py b/src/utils.py index 635fdc5..0b61add 100644 --- a/src/utils.py +++ b/src/utils.py @@ -2,13 +2,13 @@ def parse_datetime(date: str) -> datetime: - """Parse GitHub date to object""" + """Parse GitHub date to object.""" exp = "%Y-%m-%dT%H:%M:%SZ" return datetime.strptime(date, exp) def dict_to_logfmt(data: dict) -> str: - """Convert a dict to logfmt string""" + """Convert a dict to logfmt string.""" outstr = list() for k, v in data.items(): if v is None: From e0229be99da6764c61f1cd2cbf2cd1104a5f8927 Mon Sep 17 00:00:00 2001 From: duhow Date: Wed, 8 Feb 2023 18:30:21 +0100 Subject: [PATCH 7/8] Update README --- README.md | 33 ++++++++++++++++++++++++++++----- media/github_setup.png | Bin 0 -> 27751 bytes 2 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 media/github_setup.png diff --git a/README.md b/README.md index 0cf6fd0..0de6dc6 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,41 @@ -# github-workflows-monitoring +# GitHub Workflows Monitoring [![Tests](https://github.com/midokura/github-workflows-monitoring/actions/workflows/tests.yaml/badge.svg)](https://github.com/midokura/github-workflows-monitoring/actions/workflows/tests.yaml) ## About -Github Workflow Monitoring is a small Flask-based web server that connects to Github using websockets to monitor Github Actions workflows. It tracks each workflow's state (queued, in_progress, completed) and calculates the time spent in each state. The metrics are logged in logfmt format for easy consumption by Grafana. +Github Workflows Monitoring is a small Python (Flask-based) application that processes [GitHub webhook calls] and logs them. +It tracks each workflow's state (`queued`, `in_progress`, `completed`) and calculates the time spent in each state. + +This application can be very useful to gather information about Organization Runners: +- How much time is spent before a job starts processing? +- What repositories are triggering lots of jobs? + +The metrics are logged in `logfmt` format to simplify querying them (eg. with Grafana). + +[GitHub webhook calls]: https://docs.github.com/en/developers/webhooks-and-events/webhooks/creating-webhooks + +## Setup + +Go to your **GitHub Organization** >> **Settings** >> **Webhooks** >> **Add new webhook**. + +Expose your application to Internet (ngrok, Load Balancer, etc), and **use endpoint** `/github-webhook`. + +![Example of Webhook configuration](media/github_setup.png) + +The **events** that are currently supported are: +- Workflow jobs ## Testing Into a virtual environment, install the requirements: - pip install -r tests/requirements.txt - +```sh +pip install -r tests/requirements.txt +``` To run the tests: - pytest --cov=src +```sh +pytest --cov=src +``` diff --git a/media/github_setup.png b/media/github_setup.png new file mode 100644 index 0000000000000000000000000000000000000000..fc21fc35e7474c400bb3e4df673917e115c00029 GIT binary patch literal 27751 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 Date: Thu, 9 Feb 2023 11:27:40 +0100 Subject: [PATCH 8/8] Merge context_details dict with new values --- src/app.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app.py b/src/app.py index ec001b3..0c134b4 100644 --- a/src/app.py +++ b/src/app.py @@ -79,13 +79,13 @@ def process_workflow_job(): else: time_to_start = (time_start - datetime.fromtimestamp(job_requested)).seconds - extra_data = { + context_details = { + **context_details, "time_to_start": time_to_start, "runner_name": runner_name, "runner_public": runner_public, "repository_private": repository_private } - context_details = {**context_details, **extra_data} elif action == "completed": job_requested = jobs.get(job_id) @@ -99,11 +99,12 @@ def process_workflow_job(): # delete from memory del jobs[job_id] - extra_data = { + context_details = { + **context_details, "time_to_finish": time_to_finish, "conclusion": conclusion } - context_details = {**context_details, **extra_data} + else: app.logger.warning(f"Unknown action {action}, removing from memory") if job_id in jobs: