Skip to content
Closed
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,21 @@ It is designed to be simple to deploy and can run either:
| `promgithub_event_processing_duration_seconds` | Histogram | `event_type` | Duration of async webhook event processing |
| `promgithub_duplicate_deliveries_seen_total` | Counter | `event_type` | Duplicate webhook deliveries observed |
| `promgithub_duplicate_deliveries_dropped_total` | Counter | `event_type` | Duplicate webhook deliveries dropped |
| `promgithub_event_filtered_total` | Counter | `event_type`, `reason` | Webhook events dropped by the configured label policy |

## Metric model

The exporter focuses on repository and workflow health signals while avoiding noisy per-entity labels such as runner names, job names, commit author identities, and pull request authors.

This keeps the default metric set compact and practical for Prometheus while still preserving the `branch` label for branch-specific workflow and job visibility.

Operators can further bound series growth without a code change:

- Filter repositories, branches, and workflows with allowlists, denylists, and regular expressions.
- Optionally normalize branch labels into `default`, `release`, and `feature` classes.

These controls are off by default so existing scrapes keep raw branch names. See [Usage documentation](./docs/usage.md#label-normalization-and-event-filtering) for recommended production settings.

## Redis-backed multi-instance mode

When Redis is configured, `promgithub` uses it for:
Expand Down
45 changes: 45 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,50 @@ The service supports the following environment variables:
- `PROMGITHUB_REDIS_DELIVERY_TTL` (optional): TTL for webhook delivery dedupe keys, default `24h`.
- `PROMGITHUB_EVENT_WORKERS` (optional): Number of async webhook processing workers, default `4`.
- `PROMGITHUB_EVENT_QUEUE_SIZE` (optional): Bounded async webhook queue size, default `256`.
- `PROMGITHUB_REPO_ALLOWLIST` (optional): Comma-separated `owner/repo` list. When set, only these repositories are recorded.
- `PROMGITHUB_REPO_DENYLIST` (optional): Comma-separated `owner/repo` list. Matching repositories are dropped.
- `PROMGITHUB_BRANCH_ALLOW_REGEX` (optional): If set, only events whose branch matches this regular expression are recorded.
- `PROMGITHUB_BRANCH_DENY_REGEX` (optional): If set, events whose branch matches this regular expression are dropped.
- `PROMGITHUB_WORKFLOW_ALLOW_REGEX` (optional): If set, only workflow and job events whose workflow name matches are recorded. Push and pull request events skip this filter.
- `PROMGITHUB_WORKFLOW_DENY_REGEX` (optional): If set, matching workflow and job events are dropped.
- `PROMGITHUB_NORMALIZE_BRANCHES` (optional): When `true`, replace raw branch labels with `default`, `release`, or `feature`. Default `false`.
- `PROMGITHUB_DEFAULT_BRANCHES` (optional): Comma-separated branch names classified as `default` when normalization is enabled. Default `main,master`.
- `PROMGITHUB_RELEASE_BRANCH_REGEX` (optional): Branches matching this expression are classified as `release` when normalization is enabled. Default `^(release/|hotfix/).+`.

If Redis is configured, the service stores delivery and run state in Redis.

### Label normalization and event filtering

Filtered events are still signature-checked, deduplicated, and acknowledged so GitHub does not retry them. They do not update business metrics or run state. Drops are counted on `promgithub_event_filtered_total{event_type,reason}` with `reason` of `repository`, `branch`, or `workflow`.

Branch filters apply to:

- workflow and job `head_branch`
- push refs after stripping `refs/heads/` or `refs/tags/`
- pull request `base` refs

Allowlists and denylists can be combined. A repository must be in the allowlist when one is set, and must not be in the denylist.

Enabling `PROMGITHUB_NORMALIZE_BRANCHES` changes the `branch` and `base_branch` label values:

| Class | Default match |
| --- | --- |
| `default` | `main` or `master`, or names in `PROMGITHUB_DEFAULT_BRANCHES` |
| `release` | `release/*` or `hotfix/*`, or `PROMGITHUB_RELEASE_BRANCH_REGEX` |
| `feature` | every other non-empty branch |

This is a scrape-breaking change for existing dashboards. Enable it on new deployments, or expect old raw-branch series to go stale.

Recommended production starting point for a multi-repo organization:

```bash
PROMGITHUB_REPO_ALLOWLIST="acme/api,acme/web,acme/worker"
PROMGITHUB_BRANCH_DENY_REGEX="^(dependabot/|renovate/)"
PROMGITHUB_NORMALIZE_BRANCHES="true"
```

Keep `PROMGITHUB_NORMALIZE_BRANCHES=false` when you still need per-branch workflow health. In that case, prefer `PROMGITHUB_BRANCH_ALLOW_REGEX` such as `^(main|release/.+)$` so feature-branch series do not accumulate.

### Async processing and backpressure

Webhook requests are acknowledged after signature validation, duplicate-delivery recording, and enqueueing into the bounded async processor.
Expand Down Expand Up @@ -160,6 +201,10 @@ promgithub:
db: 0
keyPrefix: promgithub
deliveryTTL: 24h
labelPolicy:
repoAllowlist: "acme/api,acme/web"
branchDenyRegex: "^(dependabot/|renovate/)"
normalizeBranches: true
```

When `redis.enabled=true`, the chart deploys Redis as a dependency and configures `promgithub` to connect to it automatically.
Expand Down
20 changes: 20 additions & 0 deletions helm/promgithub/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ spec:
- name: PROMGITHUB_REDIS_DELIVERY_TTL
value: "{{ .Values.redisConfig.deliveryTTL | default "24h" }}"
{{- end }}
{{- with .Values.labelPolicy }}
- name: PROMGITHUB_REPO_ALLOWLIST
value: "{{ .repoAllowlist }}"
- name: PROMGITHUB_REPO_DENYLIST
value: "{{ .repoDenylist }}"
- name: PROMGITHUB_BRANCH_ALLOW_REGEX
value: {{ .branchAllowRegex | quote }}
- name: PROMGITHUB_BRANCH_DENY_REGEX
value: {{ .branchDenyRegex | quote }}
- name: PROMGITHUB_WORKFLOW_ALLOW_REGEX
value: {{ .workflowAllowRegex | quote }}
- name: PROMGITHUB_WORKFLOW_DENY_REGEX
value: {{ .workflowDenyRegex | quote }}
- name: PROMGITHUB_NORMALIZE_BRANCHES
value: "{{ .normalizeBranches }}"
- name: PROMGITHUB_DEFAULT_BRANCHES
value: "{{ .defaultBranches | default "main,master" }}"
- name: PROMGITHUB_RELEASE_BRANCH_REGEX
value: {{ .releaseBranchRegex | default "^(release/|hotfix/).+" | quote }}
{{- end }}
envFrom:
- secretRef:
name: "{{ include "promgithub.fullname" . }}"
Expand Down
12 changes: 12 additions & 0 deletions helm/promgithub/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ redisConfig:
keyPrefix: promgithub
deliveryTTL: 24h

# Optional controls for metric cardinality. Empty filters keep current behavior.
labelPolicy:
repoAllowlist: ""
repoDenylist: ""
branchAllowRegex: ""
branchDenyRegex: ""
workflowAllowRegex: ""
workflowDenyRegex: ""
normalizeBranches: false
defaultBranches: "main,master"
releaseBranchRegex: "^(release/|hotfix/).+"

# This is for setting up the promgithub service
service:
# This sets the service type
Expand Down
17 changes: 17 additions & 0 deletions src/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
)

Expand Down Expand Up @@ -43,3 +44,19 @@ func parseEnvDuration(key string, defaultValue time.Duration) (time.Duration, er

return parsed, nil
}

func parseEnvBool(key string, defaultValue bool) (bool, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return defaultValue, nil
}

switch strings.ToLower(value) {
case "1", "true", "yes", "on":
return true, nil
case "0", "false", "no", "off":
return false, nil
default:
return false, fmt.Errorf("parse %s: invalid boolean %q", key, value)
}
}
49 changes: 41 additions & 8 deletions src/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,22 @@ func updateWorkflowMetrics(ctx context.Context, body []byte) {
return
}

labels, ok := applyLabelPolicy(githubEventWorkflowRun, eventLabels{
Repository: payload.Workflow.Repository.FullName,
Branch: payload.Workflow.Branch,
Workflow: payload.Workflow.Name,
})
if !ok {
return
}

updateTrackedRunMetrics(
ctx,
payload.Workflow.RunID,
runMetricDetails{
repository: payload.Workflow.Repository.FullName,
branch: payload.Workflow.Branch,
name: payload.Workflow.Name,
repository: labels.Repository,
branch: labels.Branch,
name: labels.Workflow,
status: payload.Workflow.Status,
conclusion: payload.Workflow.Conclusion,
startedAt: payload.Workflow.CreatedAt,
Expand All @@ -247,13 +256,22 @@ func updateJobMetrics(ctx context.Context, body []byte) {
return
}

labels, ok := applyLabelPolicy(githubEventWorkflowJob, eventLabels{
Repository: payload.Job.Repository.FullName,
Branch: payload.Job.Branch,
Workflow: payload.Job.WorkflowName,
})
if !ok {
return
}

updateTrackedRunMetrics(
ctx,
payload.Job.ID,
runMetricDetails{
repository: payload.Job.Repository.FullName,
branch: payload.Job.Branch,
name: payload.Job.WorkflowName,
repository: labels.Repository,
branch: labels.Branch,
name: labels.Workflow,
status: payload.Job.Status,
conclusion: payload.Job.Conclusion,
startedAt: payload.Job.StartedAt,
Expand All @@ -273,6 +291,13 @@ func updateCommitMetrics(body []byte) {
return
}

if _, ok := applyLabelPolicy(githubEventPush, eventLabels{
Repository: payload.Repository.FullName,
Branch: branchFromRef(payload.Ref),
}); !ok {
return
}

for range payload.Commits {
defaultMetricRecorder.RecordCommitPushed(payload.Repository.FullName)
}
Expand All @@ -286,9 +311,17 @@ func updatePullRequestMetrics(body []byte) {
return
}

labels, ok := applyLabelPolicy(githubEventPullRequest, eventLabels{
Repository: payload.Repository.FullName,
Branch: payload.PullRequest.Base.Ref,
})
if !ok {
return
}

defaultMetricRecorder.RecordPullRequest(
payload.Repository.FullName,
payload.PullRequest.Base.Ref,
labels.Repository,
labels.Branch,
payload.Action,
)
}
2 changes: 2 additions & 0 deletions src/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ func resetWebhookTestState() {
asyncProcessingDurationHistogram.Reset()
duplicateDeliveriesSeenCounter.Reset()
duplicateDeliveriesDroppedCounter.Reset()
filteredEventsCounter.Reset()
defaultLabelPolicy = labelPolicy{}
asyncQueueDepthGauge.Set(0)
asyncQueueCapacityGauge.Set(0)
asyncWorkerCountGauge.Set(0)
Expand Down
40 changes: 40 additions & 0 deletions src/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,44 @@ func TestIntegrationWebhookMetrics(t *testing.T) {
}
}

func TestIntegrationLabelPolicyFiltersDeniedRepository(t *testing.T) {
server := newIntegrationTestServer(t)
defer server.Close()
useLabelPolicy(t, testLabelPolicy(t, func(policy *labelPolicy) {
policy.repoDeny = parseSetList("user/repo")
}))

body := mustReadFixture(t, "workflow_run.json")
resp := sendWebhookRequest(t, server.URL, githubEventWorkflowRun, body, "delivery-filtered")
assertResponseStatus(t, resp, http.StatusAccepted)

metrics := waitForMetricsSubstring(t, server.URL, `promgithub_event_filtered_total{event_type="workflow_run",reason="repository"} 1`)
if !strings.Contains(metrics, `promgithub_event_filtered_total{event_type="workflow_run",reason="repository"} 1`) {
t.Fatalf("expected filtered event metric, got:\n%s", metrics)
}
if strings.Contains(metrics, `promgithub_workflow_status{branch="main",conclusion="success",repository="user/repo",workflow_name="CI",workflow_status="completed"} 1`) {
t.Fatalf("denied repository should not record workflow metrics:\n%s", metrics)
}
}

func TestIntegrationLabelPolicyNormalizesDefaultBranch(t *testing.T) {
server := newIntegrationTestServer(t)
defer server.Close()
useLabelPolicy(t, testLabelPolicy(t, func(policy *labelPolicy) {
policy.normalizeBranches = true
}))

body := mustReadFixture(t, "workflow_run.json")
resp := sendWebhookRequest(t, server.URL, githubEventWorkflowRun, body, "delivery-normalized")
assertResponseStatus(t, resp, http.StatusAccepted)

expected := `promgithub_workflow_status{branch="default",conclusion="success",repository="user/repo",workflow_name="CI",workflow_status="completed"} 1`
metrics := waitForMetricsSubstring(t, server.URL, expected)
if !strings.Contains(metrics, expected) {
t.Fatalf("expected normalized branch label, got:\n%s", metrics)
}
}

func TestIntegrationWebhookInvalidSignature(t *testing.T) {
server := newIntegrationTestServer(t)
defer server.Close()
Expand Down Expand Up @@ -462,6 +500,8 @@ func resetIntegrationTestMetrics() {
asyncProcessingDurationHistogram.Reset()
duplicateDeliveriesSeenCounter.Reset()
duplicateDeliveriesDroppedCounter.Reset()
filteredEventsCounter.Reset()
defaultLabelPolicy = labelPolicy{}
defaultServiceMetrics.apiCallsCounter.Reset()
defaultServiceMetrics.requestDurationHistogram.Reset()
asyncQueueDepthGauge.Set(0)
Expand Down
Loading