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.
@@ -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")