Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/partials/metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,36 @@ github_runner_repo_busy{owner, id, name, os, status}
github_runner_repo_online{owner, id, name, os, status}
: Static metrics of runner is online or not

github_status_actions_up{}
: Current health status of Actions on githubstatus.com

github_status_api_requests_up{}
: Current health status of API Requests on githubstatus.com

github_status_codespaces_up{}
: Current health status of Codespaces on githubstatus.com

github_status_copilot_up{}
: Current health status of Copilot on githubstatus.com

github_status_git_operations_up{}
: Current health status of Git Operations on githubstatus.com

github_status_issues_up{}
: Current health status of Issues on githubstatus.com

github_status_packages_up{}
: Current health status of Packages on githubstatus.com

github_status_pages_up{}
: Current health status of Pages on githubstatus.com

github_status_pull_requests_up{}
: Current health status of Pull Requests on githubstatus.com

github_status_webhooks_up{}
: Current health status of Webhooks on githubstatus.com

github_workflow_job_created_timestamp{owner, repo, name, title, branch, sha, identifier, run_id, run_attempt, labels, runner_id, runner_name, runner_group_id, runner_group_name, workflow_name, conclusion}
: Timestamp when the workflow job have been created

Expand Down
5 changes: 5 additions & 0 deletions hack/generate-metrics-docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ func main() {
exporter.NewWorkflowJobCollector(slog.Default(), nil, nil, nil, nil, cfg).Metrics()...,
)

collectors = append(
collectors,
exporter.NewStatusCollector(slog.Default(), nil, nil, nil, nil, cfg).Metrics()...,
)

metrics := make([]metric, 0)

metrics = append(metrics, metric{
Expand Down
13 changes: 13 additions & 0 deletions pkg/action/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,19 @@ func handler(cfg *config.Config, db store.Store, logger *slog.Logger, client *gi
))
}

if cfg.Collector.Status {
logger.Debug("Status collector registered")

registry.MustRegister(exporter.NewStatusCollector(
logger,
client,
db,
requestFailures,
requestDuration,
cfg.Target,
))
}

reg := promhttp.HandlerFor(
registry,
promhttp.HandlerOpts{
Expand Down
7 changes: 7 additions & 0 deletions pkg/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,13 @@ func RootFlags(cfg *config.Config) []cli.Flag {
Sources: cli.EnvVars("GITHUB_EXPORTER_COLLECTOR_RUNNERS"),
Destination: &cfg.Collector.Runners,
},
&cli.BoolFlag{
Name: "collector.status",
Value: false,
Usage: "Enable collector for github.com service status",
Sources: cli.EnvVars("GITHUB_EXPORTER_COLLECTOR_STATUS"),
Destination: &cfg.Collector.Status,
},
&cli.StringSliceFlag{
Name: "collector.runners.labels",
Value: config.RunnerLabels(),
Expand Down
1 change: 1 addition & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ type Collector struct {
WorkflowRuns bool
WorkflowJobs bool
Runners bool
Status bool
}

// Database defines the database specific configuration.
Expand Down
300 changes: 300 additions & 0 deletions pkg/exporter/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
package exporter

import (
"encoding/json"
"io"
"log/slog"
"net/http"
"strings"
"time"

"github.com/google/go-github/v74/github"
"github.com/prometheus/client_golang/prometheus"
"github.com/promhippie/github_exporter/pkg/config"
"github.com/promhippie/github_exporter/pkg/store"
)

// statusComponent represents a GitHub.com service shown on githubstatus.com.
type statusComponent string

const (
compGitOperations statusComponent = "Git Operations"
compWebhooks statusComponent = "Webhooks"
compAPIRequests statusComponent = "API Requests"
compIssues statusComponent = "Issues"
compPullRequests statusComponent = "Pull Requests"
compActions statusComponent = "Actions"
compPackages statusComponent = "Packages"
compPages statusComponent = "Pages"
compCodespaces statusComponent = "Codespaces"
compCopilot statusComponent = "Copilot"
)

// statusComponents defines the ordered list of services we expose as gauges.
var statusComponents = []statusComponent{
compGitOperations,
compWebhooks,
compAPIRequests,
compIssues,
compPullRequests,
compActions,
compPackages,
compPages,
compCodespaces,
compCopilot,
}

func isStatusComponent(name string) bool {
trimmed := strings.TrimSpace(name)
for _, c := range statusComponents {
if string(c) == trimmed {
return true
}
}
return false
}

// StatusCollector exposes gauges for GitHub component status.
type StatusCollector struct {
client *github.Client
logger *slog.Logger
db store.Store
failures *prometheus.CounterVec
duration *prometheus.HistogramVec
config config.Target

GitOperationsUp *prometheus.Desc
WebhooksUp *prometheus.Desc
APIRequestsUp *prometheus.Desc
IssuesUp *prometheus.Desc
PullRequestsUp *prometheus.Desc
ActionsUp *prometheus.Desc
PackagesUp *prometheus.Desc
PagesUp *prometheus.Desc
CodespacesUp *prometheus.Desc
CopilotUp *prometheus.Desc
}

// NewStatusCollector returns a new StatusCollector with metric descriptors only.
func NewStatusCollector(logger *slog.Logger, client *github.Client, db store.Store, failures *prometheus.CounterVec, duration *prometheus.HistogramVec, cfg config.Target) *StatusCollector {
if failures != nil {
failures.WithLabelValues("status").Add(0)
}

labels := []string{}
return &StatusCollector{
client: client,
logger: logger.With("collector", "status"),
db: db,
failures: failures,
duration: duration,
config: cfg,

GitOperationsUp: prometheus.NewDesc(
"github_status_git_operations_up",
"Current health status of Git Operations on githubstatus.com",
labels,
nil,
),
WebhooksUp: prometheus.NewDesc(
"github_status_webhooks_up",
"Current health status of Webhooks on githubstatus.com",
labels,
nil,
),
APIRequestsUp: prometheus.NewDesc(
"github_status_api_requests_up",
"Current health status of API Requests on githubstatus.com",
labels,
nil,
),
IssuesUp: prometheus.NewDesc(
"github_status_issues_up",
"Current health status of Issues on githubstatus.com",
labels,
nil,
),
PullRequestsUp: prometheus.NewDesc(
"github_status_pull_requests_up",
"Current health status of Pull Requests on githubstatus.com",
labels,
nil,
),
ActionsUp: prometheus.NewDesc(
"github_status_actions_up",
"Current health status of Actions on githubstatus.com",
labels,
nil,
),
PackagesUp: prometheus.NewDesc(
"github_status_packages_up",
"Current health status of Packages on githubstatus.com",
labels,
nil,
),
PagesUp: prometheus.NewDesc(
"github_status_pages_up",
"Current health status of Pages on githubstatus.com",
labels,
nil,
),
CodespacesUp: prometheus.NewDesc(
"github_status_codespaces_up",
"Current health status of Codespaces on githubstatus.com",
labels,
nil,
),
CopilotUp: prometheus.NewDesc(
"github_status_copilot_up",
"Current health status of Copilot on githubstatus.com",
labels,
nil,
),
}
}

// Metrics returns descriptors for documentation generation.
func (c *StatusCollector) Metrics() []*prometheus.Desc {
return []*prometheus.Desc{
c.GitOperationsUp,
c.WebhooksUp,
c.APIRequestsUp,
c.IssuesUp,
c.PullRequestsUp,
c.ActionsUp,
c.PackagesUp,
c.PagesUp,
c.CodespacesUp,
c.CopilotUp,
}
}

// Describe sends all possible descriptors.
func (c *StatusCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.GitOperationsUp
ch <- c.WebhooksUp
ch <- c.APIRequestsUp
ch <- c.IssuesUp
ch <- c.PullRequestsUp
ch <- c.ActionsUp
ch <- c.PackagesUp
ch <- c.PagesUp
ch <- c.CodespacesUp
ch <- c.CopilotUp
}

// Collect gathers component status metrics from githubstatus.com and sends them
// to the provided channel.
func (c *StatusCollector) Collect(ch chan<- prometheus.Metric) {
// Perform a single scrape of the status JSON and populate all gauges.
// Treat "operational" as up (1), everything else as down (0).
client := &http.Client{Timeout: c.config.Timeout}

now := time.Now()
req, err := http.NewRequest("GET", "https://www.githubstatus.com/api/v2/summary.json", nil)
if err != nil {
c.logger.Error("Failed to build status summary request", "err", err)
if c.failures != nil {
c.failures.WithLabelValues("status").Inc()
}
return
}

resp, err := client.Do(req)
if err != nil {
c.logger.Error("Failed to fetch status summary", "err", err)
if c.failures != nil {
c.failures.WithLabelValues("status").Inc()
}
return
}
defer func() { _ = resp.Body.Close() }()

if c.duration != nil {
c.duration.WithLabelValues("status").Observe(time.Since(now).Seconds())
}

bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
c.logger.Error("Failed to read status summary", "err", err)
if c.failures != nil {
c.failures.WithLabelValues("status").Inc()
}
return
}

statuses := extractStatusFromJSON(bodyBytes)

// Prepare the set of components to check → metric descriptors mapping.
components := []struct {
name statusComponent
desc *prometheus.Desc
}{
{compGitOperations, c.GitOperationsUp},
{compWebhooks, c.WebhooksUp},
{compAPIRequests, c.APIRequestsUp},
{compIssues, c.IssuesUp},
{compPullRequests, c.PullRequestsUp},
{compActions, c.ActionsUp},
{compPackages, c.PackagesUp},
{compPages, c.PagesUp},
{compCodespaces, c.CodespacesUp},
{compCopilot, c.CopilotUp},
}

// No labels for these metrics.
labels := []string{}

// Emit metrics for each component.
for _, comp := range components {
var up float64
if ok, exists := statuses[string(comp.name)]; exists {
if ok {
up = 1.0
} else {
up = 0.0
}
} else {
// If not found at all, consider as down and log for visibility.
c.logger.Warn("Component status not found in status summary", "component", comp.name)
up = 0.0
}
c.logger.Debug("Component status scraped", "component", string(comp.name), "up", up)
ch <- prometheus.MustNewConstMetric(
comp.desc,
prometheus.GaugeValue,
up,
labels...,
)
}
}

// extractStatusFromJSON parses the GitHub Status summary JSON and returns
// a map of component name -> up (true if operational).
func extractStatusFromJSON(data []byte) map[string]bool {
type component struct {
Name string `json:"name"`
Status string `json:"status"`
}
type summary struct {
Components []component `json:"components"`
}

result := make(map[string]bool)

var s summary
if err := json.Unmarshal(data, &s); err != nil {
return result
}

for _, c := range s.Components {
name := strings.TrimSpace(c.Name)
if name == "" || !isStatusComponent(name) {
continue
}
statusText := strings.ToLower(strings.TrimSpace(c.Status))
result[name] = statusText == "operational"
}

return result
}
Loading