github-actions-ingester 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- github_actions_ingester/__init__.py +9 -0
- github_actions_ingester/__main__.py +330 -0
- github_actions_ingester/app_manifest.py +97 -0
- github_actions_ingester/collector.py +338 -0
- github_actions_ingester/config.py +290 -0
- github_actions_ingester/github.py +585 -0
- github_actions_ingester/metrics.py +184 -0
- github_actions_ingester/migrations/0001_initial.sql +140 -0
- github_actions_ingester/ratelimit.py +39 -0
- github_actions_ingester/server.py +115 -0
- github_actions_ingester/store.py +580 -0
- github_actions_ingester/workflow_schedule.py +83 -0
- github_actions_ingester-0.1.0.dist-info/METADATA +522 -0
- github_actions_ingester-0.1.0.dist-info/RECORD +17 -0
- github_actions_ingester-0.1.0.dist-info/WHEEL +4 -0
- github_actions_ingester-0.1.0.dist-info/entry_points.txt +2 -0
- github_actions_ingester-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Prometheus metric definitions.
|
|
2
|
+
|
|
3
|
+
Everything is registered on an explicit ``CollectorRegistry`` (plus the
|
|
4
|
+
standard process/platform/GC collectors) so ``/metrics`` renders exactly
|
|
5
|
+
what this ingester publishes.
|
|
6
|
+
|
|
7
|
+
Two families:
|
|
8
|
+
|
|
9
|
+
``gha_ingester_*`` — introspection: cycle timing, API budget, rows
|
|
10
|
+
written, errors. Alert on these to know the
|
|
11
|
+
ingester itself is healthy.
|
|
12
|
+
``gha_scheduled_workflow_*`` — one series per workflow with a cron
|
|
13
|
+
``on.schedule``: when it last ran and how often
|
|
14
|
+
it should. Alert on these to catch a scheduled
|
|
15
|
+
workflow that silently stopped firing.
|
|
16
|
+
|
|
17
|
+
The analytical data (minutes, success rate, queue time) lives in
|
|
18
|
+
PostgreSQL, not here: Grafana reads it with the SQL datasource.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from prometheus_client import (
|
|
24
|
+
GC_COLLECTOR,
|
|
25
|
+
PLATFORM_COLLECTOR,
|
|
26
|
+
PROCESS_COLLECTOR,
|
|
27
|
+
CollectorRegistry,
|
|
28
|
+
Counter,
|
|
29
|
+
Gauge,
|
|
30
|
+
Histogram,
|
|
31
|
+
Info,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
NAMESPACE = "gha_ingester"
|
|
35
|
+
|
|
36
|
+
WORKFLOW_LABELS = ("repository", "workflow", "workflow_name")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Metrics:
|
|
40
|
+
def __init__(self, registry: CollectorRegistry | None = None) -> None:
|
|
41
|
+
self.registry = registry or CollectorRegistry(auto_describe=True)
|
|
42
|
+
self.registry.register(PROCESS_COLLECTOR)
|
|
43
|
+
self.registry.register(PLATFORM_COLLECTOR)
|
|
44
|
+
self.registry.register(GC_COLLECTOR)
|
|
45
|
+
|
|
46
|
+
# ---- Liveness of the ingester itself ----
|
|
47
|
+
self.build_info = Info(
|
|
48
|
+
f"{NAMESPACE}_build",
|
|
49
|
+
"Build information of the running ingester.",
|
|
50
|
+
registry=self.registry,
|
|
51
|
+
)
|
|
52
|
+
self.up = Gauge(
|
|
53
|
+
f"{NAMESPACE}_up",
|
|
54
|
+
"1 when the last ingestion cycle finished without a fatal error, else 0.",
|
|
55
|
+
registry=self.registry,
|
|
56
|
+
)
|
|
57
|
+
self.ready = Gauge(
|
|
58
|
+
f"{NAMESPACE}_ready",
|
|
59
|
+
"1 once the database schema is bootstrapped and the first cycle completed.",
|
|
60
|
+
registry=self.registry,
|
|
61
|
+
)
|
|
62
|
+
self.cycles_total = Counter(
|
|
63
|
+
f"{NAMESPACE}_cycles_total",
|
|
64
|
+
"Ingestion cycles, by outcome.",
|
|
65
|
+
("result",),
|
|
66
|
+
registry=self.registry,
|
|
67
|
+
)
|
|
68
|
+
self.cycle_duration_seconds = Histogram(
|
|
69
|
+
f"{NAMESPACE}_cycle_duration_seconds",
|
|
70
|
+
"Wall-clock duration of an ingestion cycle.",
|
|
71
|
+
registry=self.registry,
|
|
72
|
+
buckets=(1, 5, 15, 30, 60, 120, 300, 600, 1800),
|
|
73
|
+
)
|
|
74
|
+
self.last_cycle_timestamp_seconds = Gauge(
|
|
75
|
+
f"{NAMESPACE}_last_cycle_timestamp_seconds",
|
|
76
|
+
"Unix time of the end of the last cycle (any outcome).",
|
|
77
|
+
registry=self.registry,
|
|
78
|
+
)
|
|
79
|
+
self.last_success_timestamp_seconds = Gauge(
|
|
80
|
+
f"{NAMESPACE}_last_success_timestamp_seconds",
|
|
81
|
+
"Unix time of the end of the last SUCCESSFUL cycle.",
|
|
82
|
+
registry=self.registry,
|
|
83
|
+
)
|
|
84
|
+
self.errors_total = Counter(
|
|
85
|
+
f"{NAMESPACE}_errors_total",
|
|
86
|
+
"Errors during ingestion, by stage.",
|
|
87
|
+
("stage",),
|
|
88
|
+
registry=self.registry,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# ---- GitHub API budget ----
|
|
92
|
+
self.github_requests_total = Counter(
|
|
93
|
+
f"{NAMESPACE}_github_requests_total",
|
|
94
|
+
"Requests sent to the GitHub REST API, by HTTP status.",
|
|
95
|
+
("status",),
|
|
96
|
+
registry=self.registry,
|
|
97
|
+
)
|
|
98
|
+
self.github_rate_limit_remaining = Gauge(
|
|
99
|
+
f"{NAMESPACE}_github_rate_limit_remaining",
|
|
100
|
+
"Requests left in the current primary rate-limit window "
|
|
101
|
+
"(X-RateLimit-Remaining of the last response).",
|
|
102
|
+
registry=self.registry,
|
|
103
|
+
)
|
|
104
|
+
self.github_rate_limit_limit = Gauge(
|
|
105
|
+
f"{NAMESPACE}_github_rate_limit_limit",
|
|
106
|
+
"Size of the primary rate-limit window (X-RateLimit-Limit).",
|
|
107
|
+
registry=self.registry,
|
|
108
|
+
)
|
|
109
|
+
self.github_rate_limit_reset_timestamp_seconds = Gauge(
|
|
110
|
+
f"{NAMESPACE}_github_rate_limit_reset_timestamp_seconds",
|
|
111
|
+
"Unix time when the primary rate-limit window resets.",
|
|
112
|
+
registry=self.registry,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# ---- What was ingested ----
|
|
116
|
+
self.repositories = Gauge(
|
|
117
|
+
f"{NAMESPACE}_repositories",
|
|
118
|
+
"Repositories currently in scope (after exclusions).",
|
|
119
|
+
registry=self.registry,
|
|
120
|
+
)
|
|
121
|
+
self.workflows = Gauge(
|
|
122
|
+
f"{NAMESPACE}_workflows",
|
|
123
|
+
"Workflow files known across all repositories.",
|
|
124
|
+
registry=self.registry,
|
|
125
|
+
)
|
|
126
|
+
self.runs_upserted_total = Counter(
|
|
127
|
+
f"{NAMESPACE}_runs_upserted_total",
|
|
128
|
+
"Workflow runs written (inserted or updated), by repository.",
|
|
129
|
+
("repository",),
|
|
130
|
+
registry=self.registry,
|
|
131
|
+
)
|
|
132
|
+
self.jobs_upserted_total = Counter(
|
|
133
|
+
f"{NAMESPACE}_jobs_upserted_total",
|
|
134
|
+
"Workflow jobs written (inserted or updated), by repository.",
|
|
135
|
+
("repository",),
|
|
136
|
+
registry=self.registry,
|
|
137
|
+
)
|
|
138
|
+
self.open_runs = Gauge(
|
|
139
|
+
f"{NAMESPACE}_open_runs",
|
|
140
|
+
"Runs stored that are not yet completed (queued / in progress).",
|
|
141
|
+
registry=self.registry,
|
|
142
|
+
)
|
|
143
|
+
self.stored_runs = Gauge(
|
|
144
|
+
f"{NAMESPACE}_stored_runs",
|
|
145
|
+
"Total workflow runs stored in the database.",
|
|
146
|
+
registry=self.registry,
|
|
147
|
+
)
|
|
148
|
+
self.stored_jobs = Gauge(
|
|
149
|
+
f"{NAMESPACE}_stored_jobs",
|
|
150
|
+
"Total workflow jobs stored in the database.",
|
|
151
|
+
registry=self.registry,
|
|
152
|
+
)
|
|
153
|
+
self.repository_cycle_duration_seconds = Histogram(
|
|
154
|
+
f"{NAMESPACE}_repository_cycle_duration_seconds",
|
|
155
|
+
"Time spent ingesting one repository within a cycle.",
|
|
156
|
+
registry=self.registry,
|
|
157
|
+
buckets=(0.5, 1, 2, 5, 10, 30, 60, 300),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# ---- Scheduled workflow liveness ----
|
|
161
|
+
self.scheduled_last_run_timestamp_seconds = Gauge(
|
|
162
|
+
"gha_scheduled_workflow_last_run_timestamp_seconds",
|
|
163
|
+
"Unix time of the most recent run triggered by `schedule` for the workflow.",
|
|
164
|
+
WORKFLOW_LABELS,
|
|
165
|
+
registry=self.registry,
|
|
166
|
+
)
|
|
167
|
+
self.scheduled_interval_seconds = Gauge(
|
|
168
|
+
"gha_scheduled_workflow_interval_seconds",
|
|
169
|
+
"Longest gap between two consecutive cron fires of the workflow (all schedules merged).",
|
|
170
|
+
WORKFLOW_LABELS,
|
|
171
|
+
registry=self.registry,
|
|
172
|
+
)
|
|
173
|
+
self.scheduled_last_conclusion = Gauge(
|
|
174
|
+
"gha_scheduled_workflow_last_conclusion",
|
|
175
|
+
"1 for the conclusion of the last completed scheduled run "
|
|
176
|
+
"(success/failure/cancelled/...), 0 for the others.",
|
|
177
|
+
(*WORKFLOW_LABELS, "conclusion"),
|
|
178
|
+
registry=self.registry,
|
|
179
|
+
)
|
|
180
|
+
self.scheduled_workflows = Gauge(
|
|
181
|
+
"gha_scheduled_workflows",
|
|
182
|
+
"Active workflows that declare at least one cron schedule.",
|
|
183
|
+
registry=self.registry,
|
|
184
|
+
)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
-- 0001_initial: core tables, indexes and the compatibility views.
|
|
2
|
+
--
|
|
3
|
+
-- Every statement runs inside the schema selected by GHA_DATABASE_SCHEMA
|
|
4
|
+
-- (search_path is set by the migration runner), so nothing here is
|
|
5
|
+
-- schema-qualified.
|
|
6
|
+
|
|
7
|
+
CREATE TABLE IF NOT EXISTS repositories (
|
|
8
|
+
id BIGINT PRIMARY KEY,
|
|
9
|
+
owner TEXT NOT NULL,
|
|
10
|
+
name TEXT NOT NULL,
|
|
11
|
+
full_name TEXT NOT NULL UNIQUE,
|
|
12
|
+
default_branch TEXT NOT NULL DEFAULT 'main',
|
|
13
|
+
private BOOLEAN NOT NULL DEFAULT FALSE,
|
|
14
|
+
archived BOOLEAN NOT NULL DEFAULT FALSE,
|
|
15
|
+
html_url TEXT NOT NULL DEFAULT '',
|
|
16
|
+
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
17
|
+
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE IF NOT EXISTS workflows (
|
|
21
|
+
id BIGINT PRIMARY KEY,
|
|
22
|
+
repository_id BIGINT NOT NULL REFERENCES repositories (id) ON DELETE CASCADE,
|
|
23
|
+
name TEXT NOT NULL,
|
|
24
|
+
path TEXT NOT NULL,
|
|
25
|
+
state TEXT NOT NULL DEFAULT '',
|
|
26
|
+
html_url TEXT NOT NULL DEFAULT '',
|
|
27
|
+
schedules TEXT[] NOT NULL DEFAULT '{}',
|
|
28
|
+
-- Longest gap between two consecutive scheduled firings (all crons merged).
|
|
29
|
+
schedule_interval_seconds DOUBLE PRECISION,
|
|
30
|
+
schedules_synced_at TIMESTAMPTZ,
|
|
31
|
+
created_at TIMESTAMPTZ,
|
|
32
|
+
updated_at TIMESTAMPTZ,
|
|
33
|
+
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
34
|
+
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS workflows_repository_idx ON workflows (repository_id);
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS workflow_runs (
|
|
39
|
+
id BIGINT PRIMARY KEY,
|
|
40
|
+
repository_id BIGINT NOT NULL REFERENCES repositories (id) ON DELETE CASCADE,
|
|
41
|
+
workflow_id BIGINT NOT NULL,
|
|
42
|
+
run_number INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
run_attempt INTEGER NOT NULL DEFAULT 1,
|
|
44
|
+
name TEXT NOT NULL DEFAULT '',
|
|
45
|
+
display_title TEXT NOT NULL DEFAULT '',
|
|
46
|
+
event TEXT NOT NULL DEFAULT '',
|
|
47
|
+
status TEXT NOT NULL DEFAULT '',
|
|
48
|
+
conclusion TEXT,
|
|
49
|
+
head_branch TEXT,
|
|
50
|
+
head_sha TEXT NOT NULL DEFAULT '',
|
|
51
|
+
actor TEXT NOT NULL DEFAULT '',
|
|
52
|
+
triggering_actor TEXT NOT NULL DEFAULT '',
|
|
53
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
54
|
+
updated_at TIMESTAMPTZ,
|
|
55
|
+
run_started_at TIMESTAMPTZ,
|
|
56
|
+
-- Derived on write: MAX(jobs.completed_at) once every job finished, or
|
|
57
|
+
-- updated_at when the run is completed and no job reported a time.
|
|
58
|
+
completed_at TIMESTAMPTZ,
|
|
59
|
+
html_url TEXT NOT NULL DEFAULT '',
|
|
60
|
+
jobs_synced_at TIMESTAMPTZ,
|
|
61
|
+
ingested_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
62
|
+
);
|
|
63
|
+
CREATE INDEX IF NOT EXISTS workflow_runs_repo_created_idx
|
|
64
|
+
ON workflow_runs (repository_id, created_at DESC);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS workflow_runs_workflow_created_idx
|
|
66
|
+
ON workflow_runs (workflow_id, created_at DESC);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS workflow_runs_created_idx ON workflow_runs (created_at DESC);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS workflow_runs_open_idx
|
|
69
|
+
ON workflow_runs (repository_id, created_at) WHERE status <> 'completed';
|
|
70
|
+
|
|
71
|
+
CREATE TABLE IF NOT EXISTS workflow_jobs (
|
|
72
|
+
id BIGINT PRIMARY KEY,
|
|
73
|
+
run_id BIGINT NOT NULL REFERENCES workflow_runs (id) ON DELETE CASCADE,
|
|
74
|
+
repository_id BIGINT NOT NULL,
|
|
75
|
+
run_attempt INTEGER NOT NULL DEFAULT 1,
|
|
76
|
+
name TEXT NOT NULL DEFAULT '',
|
|
77
|
+
status TEXT NOT NULL DEFAULT '',
|
|
78
|
+
conclusion TEXT,
|
|
79
|
+
runner_name TEXT,
|
|
80
|
+
runner_group_name TEXT,
|
|
81
|
+
labels TEXT[] NOT NULL DEFAULT '{}',
|
|
82
|
+
created_at TIMESTAMPTZ,
|
|
83
|
+
started_at TIMESTAMPTZ,
|
|
84
|
+
completed_at TIMESTAMPTZ,
|
|
85
|
+
steps INTEGER NOT NULL DEFAULT 0,
|
|
86
|
+
html_url TEXT NOT NULL DEFAULT '',
|
|
87
|
+
ingested_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
88
|
+
);
|
|
89
|
+
CREATE INDEX IF NOT EXISTS workflow_jobs_run_idx ON workflow_jobs (run_id);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS workflow_jobs_repo_started_idx
|
|
91
|
+
ON workflow_jobs (repository_id, started_at DESC);
|
|
92
|
+
|
|
93
|
+
-- One row per repository: where the incremental listing resumes from.
|
|
94
|
+
CREATE TABLE IF NOT EXISTS ingest_cursors (
|
|
95
|
+
repository_id BIGINT PRIMARY KEY REFERENCES repositories (id) ON DELETE CASCADE,
|
|
96
|
+
runs_created_since TIMESTAMPTZ NOT NULL,
|
|
97
|
+
last_cycle_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
98
|
+
last_cycle_runs INTEGER NOT NULL DEFAULT 0
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
-- ---------------------------------------------------------------------------
|
|
102
|
+
-- Compatibility views: the column set expected by Grafana dashboard 24157
|
|
103
|
+
-- ("GitHub Actions insights"). Point its Postgres datasource at this schema
|
|
104
|
+
-- and every panel works unchanged.
|
|
105
|
+
-- ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
CREATE OR REPLACE VIEW minion_repositories AS
|
|
108
|
+
SELECT id, full_name, owner, name, archived
|
|
109
|
+
FROM repositories;
|
|
110
|
+
|
|
111
|
+
CREATE OR REPLACE VIEW minion_workflow_files AS
|
|
112
|
+
SELECT id, repository_id, name, path
|
|
113
|
+
FROM workflows;
|
|
114
|
+
|
|
115
|
+
CREATE OR REPLACE VIEW minion_workflow_runs AS
|
|
116
|
+
SELECT
|
|
117
|
+
r.id,
|
|
118
|
+
r.repository_id,
|
|
119
|
+
r.workflow_id AS workflow_file_id,
|
|
120
|
+
r.event,
|
|
121
|
+
r.status,
|
|
122
|
+
r.conclusion,
|
|
123
|
+
r.head_branch,
|
|
124
|
+
r.created_at,
|
|
125
|
+
COALESCE(r.run_started_at, r.created_at) AS started_at,
|
|
126
|
+
r.completed_at
|
|
127
|
+
FROM workflow_runs r;
|
|
128
|
+
|
|
129
|
+
CREATE OR REPLACE VIEW minion_workflow_jobs AS
|
|
130
|
+
SELECT
|
|
131
|
+
j.id,
|
|
132
|
+
j.run_id,
|
|
133
|
+
j.name,
|
|
134
|
+
j.status,
|
|
135
|
+
j.conclusion,
|
|
136
|
+
j.runner_name,
|
|
137
|
+
j.created_at,
|
|
138
|
+
j.started_at,
|
|
139
|
+
j.completed_at
|
|
140
|
+
FROM workflow_jobs j;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Client-side pacing for GitHub API calls.
|
|
2
|
+
|
|
3
|
+
Leaky bucket: each ``acquire()`` blocks until at least ``1/rps`` seconds
|
|
4
|
+
have passed since the previous acquire. GitHub enforces a primary limit
|
|
5
|
+
(5000 req/h for Apps and PATs) and secondary limits on bursts; pacing at
|
|
6
|
+
a few requests per second keeps the ingester well inside both while the
|
|
7
|
+
primary-limit guard in the client handles the rest.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RateLimiter:
|
|
17
|
+
"""Thread-safe leaky-bucket pacing helper."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, rps: float) -> None:
|
|
20
|
+
if rps <= 0:
|
|
21
|
+
raise ValueError("rps must be > 0")
|
|
22
|
+
self._min_interval = 1.0 / rps
|
|
23
|
+
self._last = 0.0
|
|
24
|
+
self._lock = threading.Lock()
|
|
25
|
+
|
|
26
|
+
def acquire(self) -> None:
|
|
27
|
+
"""Block until the next request slot is allowed.
|
|
28
|
+
|
|
29
|
+
The lock guards the slot reservation; the wait happens outside it
|
|
30
|
+
so concurrent acquirers queue up in order instead of serializing
|
|
31
|
+
on the lock for the whole sleep.
|
|
32
|
+
"""
|
|
33
|
+
with self._lock:
|
|
34
|
+
now = time.monotonic()
|
|
35
|
+
scheduled_at = max(now, self._last + self._min_interval)
|
|
36
|
+
self._last = scheduled_at
|
|
37
|
+
wait = scheduled_at - time.monotonic()
|
|
38
|
+
if wait > 0:
|
|
39
|
+
time.sleep(wait)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""HTTP server — Prometheus /metrics + health endpoints.
|
|
2
|
+
|
|
3
|
+
stdlib ``http.server`` only: the ingester serves one scraper and a couple
|
|
4
|
+
of probes, nothing that justifies an async stack.
|
|
5
|
+
|
|
6
|
+
/metrics Prometheus exposition
|
|
7
|
+
/healthz liveness — 200 while the HTTP server answers
|
|
8
|
+
/readyz readiness — 200 once the schema is bootstrapped and the first
|
|
9
|
+
cycle finished; 503 before that so a rollout waits for the
|
|
10
|
+
database bootstrap instead of declaring victory early
|
|
11
|
+
/ index
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import http.server
|
|
17
|
+
import threading
|
|
18
|
+
import urllib.parse
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
import structlog
|
|
23
|
+
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from prometheus_client import CollectorRegistry
|
|
27
|
+
|
|
28
|
+
logger = structlog.get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
_INDEX_HTML = """\
|
|
31
|
+
<!doctype html>
|
|
32
|
+
<html lang="en">
|
|
33
|
+
<head><title>github-actions-ingester</title></head>
|
|
34
|
+
<body>
|
|
35
|
+
<h1>github-actions-ingester</h1>
|
|
36
|
+
<p>Ingests GitHub Actions workflow runs and jobs into PostgreSQL and
|
|
37
|
+
exposes ingester health as Prometheus metrics.</p>
|
|
38
|
+
<ul>
|
|
39
|
+
<li><a href="/metrics">/metrics</a> — Prometheus exposition
|
|
40
|
+
(<code>gha_ingester_*</code>, <code>gha_scheduled_workflow_*</code>)</li>
|
|
41
|
+
<li><a href="/healthz">/healthz</a> — liveness</li>
|
|
42
|
+
<li><a href="/readyz">/readyz</a> — readiness (503 until the schema is
|
|
43
|
+
bootstrapped and the first cycle completed)</li>
|
|
44
|
+
</ul>
|
|
45
|
+
<p>The analytical data (minutes, success rate, queue wait) is in the
|
|
46
|
+
database; query it with the Grafana PostgreSQL datasource.</p>
|
|
47
|
+
<p><a href="https://github.com/danielgines/github-actions-ingester">github.com/danielgines/github-actions-ingester</a></p>
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def make_handler(
|
|
54
|
+
registry: CollectorRegistry, is_ready: Callable[[], bool]
|
|
55
|
+
) -> type[http.server.BaseHTTPRequestHandler]:
|
|
56
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
57
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
def _send(self, code: int, body: bytes, content_type: str) -> None:
|
|
61
|
+
self.send_response(code)
|
|
62
|
+
self.send_header("Content-Type", content_type)
|
|
63
|
+
self.send_header("Content-Length", str(len(body)))
|
|
64
|
+
self.end_headers()
|
|
65
|
+
self.wfile.write(body)
|
|
66
|
+
|
|
67
|
+
def do_GET(self) -> None:
|
|
68
|
+
path = urllib.parse.urlparse(self.path).path
|
|
69
|
+
if path == "/metrics":
|
|
70
|
+
self._send(200, generate_latest(registry), CONTENT_TYPE_LATEST)
|
|
71
|
+
return
|
|
72
|
+
if path == "/healthz":
|
|
73
|
+
self._send(200, b"ok\n", "text/plain; charset=utf-8")
|
|
74
|
+
return
|
|
75
|
+
if path == "/readyz":
|
|
76
|
+
if is_ready():
|
|
77
|
+
self._send(200, b"ready\n", "text/plain; charset=utf-8")
|
|
78
|
+
else:
|
|
79
|
+
self._send(503, b"not ready\n", "text/plain; charset=utf-8")
|
|
80
|
+
return
|
|
81
|
+
if path in ("/", "/index.html"):
|
|
82
|
+
self._send(200, _INDEX_HTML.encode(), "text/html; charset=utf-8")
|
|
83
|
+
return
|
|
84
|
+
self._send(404, b"not found\n", "text/plain; charset=utf-8")
|
|
85
|
+
|
|
86
|
+
return Handler
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class MetricsServer:
|
|
90
|
+
"""Threaded HTTP server with a controllable lifetime."""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self, host: str, port: int, registry: CollectorRegistry, is_ready: Callable[[], bool]
|
|
94
|
+
) -> None:
|
|
95
|
+
self._httpd = http.server.ThreadingHTTPServer(
|
|
96
|
+
(host, port), make_handler(registry, is_ready)
|
|
97
|
+
)
|
|
98
|
+
self._thread = threading.Thread(
|
|
99
|
+
target=self._httpd.serve_forever, name="github-actions-ingester-http", daemon=True
|
|
100
|
+
)
|
|
101
|
+
self._host = host
|
|
102
|
+
self._port = port
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def port(self) -> int:
|
|
106
|
+
return int(self._httpd.server_address[1])
|
|
107
|
+
|
|
108
|
+
def start(self) -> None:
|
|
109
|
+
self._thread.start()
|
|
110
|
+
logger.info("http.listening", host=self._host, port=self.port)
|
|
111
|
+
|
|
112
|
+
def stop(self) -> None:
|
|
113
|
+
self._httpd.shutdown()
|
|
114
|
+
self._httpd.server_close()
|
|
115
|
+
logger.info("http.stopped")
|