GitHub pull request and issue activity exporter — collects PR and issue metrics from the GitHub API, including Renovate Dependency Dashboard state, and pushes them via OTLP to any OpenTelemetry-compatible backend (Grafana Cloud, Prometheus with OTLP receiver, Datadog, etc.).
- Pushes PR and issue metrics via OTLP (HTTP or gRPC) as primary output
- Optional Prometheus
/metricsbridge for scrape-based setups (disabled by default) - Metrics conform to the OTel semantic convention registry and are validated by Weaver in CI
- Multi-arch Docker image published to GHCR
- Fully automated releases via semantic-release — no manual tagging
Create config.yaml:
export:
otlp:
endpoint: http://my-otel-collector:4318
targets:
- name: my-org
orgs:
- org: my-orgWith no auth block the target uses the GITHUB_TOKEN env var as a PAT.
Run the image with the config mounted at /etc/github-activity-exporter/config.yaml:
docker run --rm \
-e GITHUB_TOKEN=ghp_... \
-v "$PWD/config.yaml:/etc/github-activity-exporter/config.yaml:ro" \
ghcr.io/gjed/github-activity-exporter:latestMetrics are pushed to the OTLP endpoint after each collection cycle (default: every 5 minutes).
To also expose a Prometheus scrape endpoint, set export.prometheus.enabled: true in the config and add -p 9090:9090.
docker pull ghcr.io/gjed/github-activity-exporter:latestgo install github.com/gjed/github-activity-exporter/cmd/github-activity-exporter@latestThe exporter takes no flags other than --version. Targets, GitHub auth and export settings come from a YAML file.
The path is read from CONFIG_FILE. It defaults to config.yaml in the working directory, and to /etc/github-activity-exporter/config.yaml in the Docker image.
collection_interval: 5m # how often to collect and push metrics
export:
otlp:
endpoint: https://otlp.example.com:4318 # required; http: full URL, grpc: host:port without scheme
protocol: http # http or grpc
headers: # sent on every OTLP request; names are lower-cased
Authorization: Bearer <token>
X-Scope-OrgID: my-tenant
tls:
insecure: false # http: skip cert verification; grpc: no TLS. Not for production
prometheus:
enabled: false # optional /metrics bridge
listen_address: ":9090"
targets:
- name: my-org # unique per target
base_url: https://api.github.com # REST API root; GHES: https://<host>/api/v3
auth: # exactly one of token, token_env, or app; empty or omitted uses GITHUB_TOKEN
token_env: MY_GITHUB_TOKEN # env var holding a PAT with repo read access
# app:
# app_id: 12345
# installation_id: 67890
# private_key_path: /etc/github-activity-exporter/key.pem
# private_key_env: GITHUB_APP_KEY # base64-encoded PEM, instead of the path
orgs: # at least one org or repo is required
- org: my-org
include_repos: []
exclude_repos: []
include_archived: false
repos:
- other-owner/some-repo
filters:
prs:
include_labels: []
exclude_labels: []
states: [] # open, closed, draft, merged
issues:
exclude_title_patterns: [] # regular expressions
states: [] # open, closed| Env | Default | Description |
|---|---|---|
CONFIG_FILE |
config.yaml |
Path to the config file |
GITHUB_TOKEN |
PAT for targets with an empty or omitted auth block |
|
RENOVATE_BOT_LOGIN |
renovate[bot] |
Login of the Renovate bot that owns the Dependency Dashboard |
A target's credential is resolved in this order:
- The target's own
auth.token,auth.token_envorauth.app.GITHUB_TOKENis then ignored for that target. GITHUB_TOKEN, when the target'sauthblock is empty or omitted. Every such target shares it. If it is unset or empty, startup fails with an error naming the target.
A partially filled auth block (for example app without app_id) is a startup error; it does not fall back to GITHUB_TOKEN.
base_url drives every GitHub call for the target: REST, GraphQL, the GitHub App token exchange and the startup credential check. GraphQL goes to https://api.github.com/graphql by default and to https://<host>/api/graphql when base_url ends in /api/v3. Targets with different base_url values can run in one deployment.
Scalar config keys can be overridden with GITHUB_ACTIVITY_EXPORTER_ env vars, upper-cased with . replaced by _ (for example GITHUB_ACTIVITY_EXPORTER_EXPORT_OTLP_ENDPOINT). Per-target settings, including auth, cannot be overridden this way.
All metric names and attributes are defined in the OTel registry at registry/.
| Metric | Instrument | Description |
|---|---|---|
github.pr.count |
UpDownCounter | PR count by repo, state, and label |
github.pr.age |
Gauge | Age in seconds of the oldest open PR per repo |
github.pr.close.duration |
Histogram | Time from open to close/merge for PRs in lookback window |
github.pr.automerged |
Counter | PRs merged with no human approval |
github.pr.review_status |
Gauge | Open PR count by review decision state |
github.issue.count |
UpDownCounter | Issue count by repo, state, and label |
github.issue.age |
Gauge | Age in seconds of the oldest open issue per repo |
renovate.dashboard.queue |
Gauge | Renovate dashboard PRs by queue state |
github_exporter.scrape.duration |
Histogram | Collection cycle duration in seconds |
github_exporter.api.errors |
Counter | GitHub API errors by target and endpoint |
github_exporter.otlp.errors |
Counter | OTLP push failures |
The registry name is the stored name (#14). OTLP and the Prometheus bridge expose github.pr.count as github.pr.count: no _total or unit suffix (_seconds), no underscore escaping. Histograms add only the classic sample series <name>_bucket, <name>_sum and <name>_count, for example github_exporter.scrape.duration_bucket. CI checks both outputs against the registry.
Your backend must keep the names as sent (OTLP translation strategy NoTranslation):
- Prometheus 3.x: UTF-8 metric names (
metric_name_validation_scheme: utf8, the 3.x default). For OTLP ingestion, run with--web.enable-otlp-receiverand setotlp.translation_strategy: NoTranslation. - Mimir:
-validation.name-validation-scheme=utf8,-distributor.otel-translation-strategy=NoTranslationand-distributor.otel-metric-suffixes-enabled=false(per-tenant limitsname_validation_scheme,otel_translation_strategy,otel_metric_suffixes_enabled). - Grafana Cloud Metrics: the same Mimir per-tenant limits on your stack.
With any other setting the backend stores escaped or suffixed names (for example github_pr_count), which do not match the registry.
/metrics does not serve the OpenMetrics text format, because OpenMetrics requires counter names to end in _total. Scrapers that ask for OpenMetrics first, as Prometheus 3.x and Alloy do by default, get the Prometheus text format, which keeps the counter type.
- Go 1.26+
- golangci-lint
- OTel Weaver (for schema validation)
go build ./...go test -race ./...golangci-lint runweaver registry check -r registry/make generate| Job | Trigger | Description |
|---|---|---|
lint |
every PR + push to main |
golangci-lint |
test |
every PR + push to main |
go test -race, JUnit XML upload, coverage PR comment |
build |
every PR + push to main |
go build ./... for linux/amd64 |
docker-build |
every PR + push to main |
Multi-arch image build (linux/amd64, linux/arm64) |
check-generated |
every PR + push to main |
Fails if make generate produces uncommitted changes |
registry-validate |
every PR + push to main |
Weaver schema check + emit smoke-test (separate workflow) |
integration-test |
PRs targeting main |
Starts exporter + mock GitHub API, runs Weaver live-check |
release |
push to main |
semantic-release → version tag + GitHub Release |
image |
release published | Multi-arch image → GHCR (X.Y.Z, X.Y, X, latest) |
Releases are fully automated. Merge to main and semantic-release determines the next version from conventional commits:
| Commit prefix | Version bump |
|---|---|
feat: |
minor |
fix:, perf: |
patch |
feat!: / BREAKING CHANGE: |
major |
chore:, docs:, test: |
no release |