diff --git a/.travis.yml b/.travis.yml index 1a0ae8e1..7cf2b652 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,10 +3,17 @@ language: go go: - "1.14" +before_script: + # For mixin + - go install github.com/jsonnet-bundler/jsonnet-bundler/cmd/jb + - go install github.com/monitoring-mixins/mixtool/cmd/mixtool + - go install github.com/google/go-jsonnet/cmd/jsonnetfmt + script: - diff -u <(echo -n) <(gofmt -s -d ./) - diff -u <(echo -n) <(go vet ./...) - go test -v ./... + - make -C github-mixin install lint build env: - GO111MODULE=on diff --git a/exporter/metrics.go b/exporter/metrics.go index 3bd3c161..679b2a0b 100644 --- a/exporter/metrics.go +++ b/exporter/metrics.go @@ -1,7 +1,10 @@ package exporter -import "github.com/prometheus/client_golang/prometheus" -import "strconv" +import ( + "strconv" + + "github.com/prometheus/client_golang/prometheus" +) // AddMetrics - Add's all of the metrics to a map of strings, returns the map. func AddMetrics() map[string]*prometheus.Desc { @@ -21,7 +24,7 @@ func AddMetrics() map[string]*prometheus.Desc { APIMetrics["PullRequestCount"] = prometheus.NewDesc( prometheus.BuildFQName("github", "repo", "pull_request_count"), "Total number of pull requests for given repository", - []string{"repo"}, nil, + []string{"repo", "user"}, nil, ) APIMetrics["Watchers"] = prometheus.NewDesc( prometheus.BuildFQName("github", "repo", "watchers"), @@ -85,7 +88,7 @@ func (e *Exporter) processMetrics(data []*Datum, rates *RateLimits, ch chan<- pr ch <- prometheus.MustNewConstMetric(e.APIMetrics["OpenIssues"], prometheus.GaugeValue, (x.OpenIssues - float64(prCount)), x.Name, x.Owner.Login, strconv.FormatBool(x.Private), strconv.FormatBool(x.Fork), strconv.FormatBool(x.Archived), x.License.Key, x.Language) // prCount - ch <- prometheus.MustNewConstMetric(e.APIMetrics["PullRequestCount"], prometheus.GaugeValue, float64(prCount), x.Name) + ch <- prometheus.MustNewConstMetric(e.APIMetrics["PullRequestCount"], prometheus.GaugeValue, float64(prCount), x.Name, x.Owner.Login) } // Set Rate limit stats diff --git a/github-mixin/.gitignore b/github-mixin/.gitignore new file mode 100644 index 00000000..ac3d221c --- /dev/null +++ b/github-mixin/.gitignore @@ -0,0 +1,5 @@ +/alerts.yaml +/rules.yaml +dashboards_out +vendor +jsonnetfile.lock.json diff --git a/github-mixin/Makefile b/github-mixin/Makefile new file mode 100644 index 00000000..66cb8d89 --- /dev/null +++ b/github-mixin/Makefile @@ -0,0 +1,26 @@ +JSONNET_FMT := jsonnetfmt -n 2 --max-blank-lines 2 --string-style s --comment-style s + +default: build + +all: install fmt lint build + +fmt: + find . -name 'vendor' -prune -o -name '*.libsonnet' -print -o -name '*.jsonnet' -print | \ + xargs -n 1 -- $(JSONNET_FMT) -i + +lint: + find . -name 'vendor' -prune -o -name '*.libsonnet' -print -o -name '*.jsonnet' -print | \ + while read f; do \ + $(JSONNET_FMT) "$$f" | diff -u "$$f" -; \ + done + + mixtool lint mixin.libsonnet + +install: + jb install + +build: + mixtool generate dashboards mixin.libsonnet -d dashboards_out + +clean: + rm -rf dashboards_out diff --git a/github-mixin/README.md b/github-mixin/README.md new file mode 100644 index 00000000..a06dfb7e --- /dev/null +++ b/github-mixin/README.md @@ -0,0 +1,27 @@ +# GitHub Mixin + +## Overview +Mixins are a collection of configurable, reusable Prometheus rules, alerts and/or Grafana dashboards for a particular system, usually created by experts in that system. By applying them to Prometheus and Grafana, you can quickly set up appropriate monitoring for your systems. + +The GitHub mixin currently provides simple dashboards for visualizing GitHub metrics emitted by the exporter. + +To use them, you need to have `jb`, `mixtool` and `jsonnetfmt` installed. If you have a working Go development environment, it's easiest to run the following: +```bash +$ go get github.com/jsonnet-bundler/jsonnet-bundler/cmd/jb +$ go get github.com/monitoring-mixins/mixtool/cmd/mixtool +$ go get github.com/google/go-jsonnet/cmd/jsonnetfmt +``` + +You can then build a directory `dashboard_out` with the JSON dashboard files for Grafana: +```bash +$ make all +``` + +For more advanced uses of mixins, see https://github.com/monitoring-mixins/docs. + +## Dashboards +* GitHub Repository Stats - Graphs GitHub metrics for a given repository. Any repository monitored by the exporter can be selected on this dashboard. +* GitHub API Usage - GitHub enforces rate limiting on the API used by the exporter. This dashboard can be used to monitor if the exporter is running out of requests. + +## Future Development +The mixin can be extended with recording and alerting rules for Prometheus. diff --git a/github-mixin/dashboards/api-usage.libsonnet b/github-mixin/dashboards/api-usage.libsonnet new file mode 100644 index 00000000..3312bed9 --- /dev/null +++ b/github-mixin/dashboards/api-usage.libsonnet @@ -0,0 +1,41 @@ +local common = import 'common.libsonnet'; +local grafana = import 'github.com/grafana/grafonnet-lib/grafonnet/grafana.libsonnet'; + +grafana.dashboard.new('GitHub API Usage', uid='github-api-usage', editable=true) +.addTemplate( + grafana.template.datasource( + 'datasource', + 'prometheus', + 'Prometheus' + ) +) +.addPanels( + [ + grafana.text.new('GitHub Request Limits', content=||| + GitHub metrics are generated by calling the GitHub API, which will throttle if too many requests are made in an + hour. When this happens, gaps will appear for the metrics. + + This dashboard monitors the API usage, so you can tell if you are running out of requests. If this does + become a problem, consider reducing the set of repositories being monitored or the number of metrics being + generated, in order to reduce the request rate. + |||) + + { gridPos: { x: 0, y: 0, w: 24, h: 4 } }, + + grafana.graphPanel.new( + 'API Usage', + min=0, + ) + .addTarget(grafana.prometheus.target('github_rate_remaining', legendFormat='Remaining Requests')) + .addTarget(grafana.prometheus.target('github_rate_limit', legendFormat='Max Requests')) + + { gridPos: { x: 8, y: 4, w: 16, h: 10 } }, + + common.latestSingleStatPanel('Current Remaining Requests in Time Window') + .addTarget(grafana.prometheus.target('github_rate_remaining')) + + { gridPos: { x: 0, y: 4, w: 8, h: 5 } }, + + common.latestSingleStatPanel('Current Max Requests Per Hour') + .addTarget(grafana.prometheus.target('github_rate_limit')) + + { gridPos: { x: 0, y: 9, w: 8, h: 5 } }, + + ] +) diff --git a/github-mixin/dashboards/common.libsonnet b/github-mixin/dashboards/common.libsonnet new file mode 100644 index 00000000..569ffb8a --- /dev/null +++ b/github-mixin/dashboards/common.libsonnet @@ -0,0 +1,17 @@ +local grafana = import 'github.com/grafana/grafonnet-lib/grafonnet/grafana.libsonnet'; + +{ + latestSingleStatPanel(title, format='none'):: + grafana.statPanel.new(title, reducerFunction='last', graphMode='none') + + { + fieldConfig: { + defaults: { + thresholds: { + mode: 'absolute', + steps: [], + }, + unit: format, + }, + }, + }, +} diff --git a/github-mixin/dashboards/repository-stats.libsonnet b/github-mixin/dashboards/repository-stats.libsonnet new file mode 100644 index 00000000..697bafbd --- /dev/null +++ b/github-mixin/dashboards/repository-stats.libsonnet @@ -0,0 +1,96 @@ +local common = import 'common.libsonnet'; +local grafana = import 'github.com/grafana/grafonnet-lib/grafonnet/grafana.libsonnet'; + +local dashboardWidth = 24; + +local metric(metric_name, title, format='none') = { + name: metric_name, + title: title, + format: format, +}; + +local latestRepoStatPanel(metric) = + common.latestSingleStatPanel(metric.title, metric.format) + .addTarget(grafana.prometheus.target(metric.name + '{user=~"$user",repo=~"$repo"}')); + +local graphPanel(metric) = + grafana.graphPanel.new( + metric.title, + min=0, + legend_show=false, + format=metric.format + ) + .addTarget(grafana.prometheus.target(metric.name + '{user=~"$user",repo=~"$repo"}')); + +// Calculates positions of an array of panels which have the same dimensions and +// should be displayed together. +// Assumes the area above startY has been "filled in" - Grafana moves panels up +// automatically if there is empty space. +local setGridPos(panels, startY, panelWidth, panelHeight) = + if panelWidth > dashboardWidth then + error 'panelWidth cannot be larger than dashboardWidth' + else + local panelsPerRow = std.floor(dashboardWidth / panelWidth); + local calculate(index) = { + gridPos: { + x: (index % panelsPerRow) * panelWidth, + y: startY + (std.floor(index / panelsPerRow) * panelHeight), + w: panelWidth, + h: panelHeight, + }, + }; + + std.mapWithIndex(function(index, panel) panel + calculate(index), panels); + +local maxY(panels) = std.foldl(std.max, [p.gridPos.y + p.gridPos.h for p in panels], 0); + +local repoPanels(metrics) = + local statPanels = std.map(latestRepoStatPanel, metrics); + local statPanelsWithGridPos = setGridPos(statPanels, 0, 4, 4); + + local statPanelsMaxY = maxY(statPanelsWithGridPos); + + local graphRowPanel = { title: 'Graphs', type: 'row' }; + local graphRowPanelWithGridPos = setGridPos([graphRowPanel], statPanelsMaxY, dashboardWidth, 1); + + local graphPanels = std.map(graphPanel, metrics); + local graphPanelsWithGridPos = setGridPos(graphPanels, statPanelsMaxY + 1, 8, 8); + + std.flattenArrays([statPanelsWithGridPos, graphRowPanelWithGridPos, graphPanelsWithGridPos]); + +grafana.dashboard.new('GitHub Repository Stats', uid='github-repo-stats', editable=true) +.addTemplate( + grafana.template.datasource( + 'datasource', + 'prometheus', + 'Prometheus' + ) +) +.addTemplate( + grafana.template.new( + 'user', + '$datasource', + 'label_values(user)', + refresh='load' + ) +) +.addTemplate( + grafana.template.new( + 'repo', + '$datasource', + 'label_values(github_repo_open_issues{user="$user"}, repo)', + refresh='load' + ) +) +.addPanels( + repoPanels( + [ + metric('github_repo_open_issues', 'Open Issues'), + metric('github_repo_pull_request_count', 'Open Pull Requests'), + metric('github_repo_forks', 'Forks'), + metric('github_repo_stars', 'Stars'), + metric('github_repo_watchers', 'Watchers'), + metric('github_repo_size_kb', 'Repository Size', format='deckbytes'), + ] + ) +) diff --git a/github-mixin/jsonnetfile.json b/github-mixin/jsonnetfile.json new file mode 100644 index 00000000..93f3316e --- /dev/null +++ b/github-mixin/jsonnetfile.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "dependencies": [ + { + "source": { + "git": { + "remote": "https://github.com/grafana/grafonnet-lib.git", + "subdir": "grafonnet" + } + }, + "version": "master" + } + ], + "legacyImports": true +} diff --git a/github-mixin/mixin.libsonnet b/github-mixin/mixin.libsonnet new file mode 100644 index 00000000..3c60a926 --- /dev/null +++ b/github-mixin/mixin.libsonnet @@ -0,0 +1,6 @@ +{ + grafanaDashboards: { + 'api-usage.json': (import 'dashboards/api-usage.libsonnet'), + 'repository-stats.json': (import 'dashboards/repository-stats.libsonnet'), + }, +} diff --git a/test/github_exporter_test.go b/test/github_exporter_test.go index e3d0fd1e..9e3f1e5c 100644 --- a/test/github_exporter_test.go +++ b/test/github_exporter_test.go @@ -2,16 +2,17 @@ package test import ( "fmt" - "github.com/infinityworks/github-exporter/config" - "github.com/infinityworks/github-exporter/exporter" - web "github.com/infinityworks/github-exporter/http" - "github.com/prometheus/client_golang/prometheus" - "github.com/steinfletcher/apitest" "io/ioutil" "net/http" "os" "strings" "testing" + + "github.com/infinityworks/github-exporter/config" + "github.com/infinityworks/github-exporter/exporter" + web "github.com/infinityworks/github-exporter/http" + "github.com/prometheus/client_golang/prometheus" + "github.com/steinfletcher/apitest" ) func TestHomepage(t *testing.T) { @@ -41,7 +42,7 @@ func TestGithubExporter(t *testing.T) { Assert(bodyContains(`github_rate_remaining 60`)). Assert(bodyContains(`github_rate_reset 1.566853865e+09`)). Assert(bodyContains(`github_repo_forks{archived="false",fork="false",language="Go",license="mit",private="false",repo="myRepo",user="myOrg"} 10`)). - Assert(bodyContains(`github_repo_pull_request_count{repo="myRepo"} 3`)). + Assert(bodyContains(`github_repo_pull_request_count{repo="myRepo",user="myOrg"} 3`)). Assert(bodyContains(`github_repo_open_issues{archived="false",fork="false",language="Go",license="mit",private="false",repo="myRepo",user="myOrg"} 2`)). Assert(bodyContains(`github_repo_size_kb{archived="false",fork="false",language="Go",license="mit",private="false",repo="myRepo",user="myOrg"} 946`)). Assert(bodyContains(`github_repo_stars{archived="false",fork="false",language="Go",license="mit",private="false",repo="myRepo",user="myOrg"} 120`)).