Skip to content

Commit

Permalink
Add support for querying multiple Jira servers. (#2)
Browse files Browse the repository at this point in the history
* First pass at supporting multiple Jira sites

* Improved Dockerfile. Added value checking to collector. Removed JQL array length checking as its optional

* Update README.  Bump username warning length.

* Some comments removed, some added.

* Better error handling. Remove unwanted comments. Improve readme around JQL and multiple Jira servers.
  • Loading branch information
sjiveson authored and jwholdsworth committed Nov 20, 2018
1 parent d520dab commit 34f06da
Show file tree
Hide file tree
Showing 5 changed files with 116 additions and 59 deletions.
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
FROM golang:1.10-alpine
LABEL maintainer "James W Holdsworth"
LABEL source "https://github.com/jwholdsworth/jira-cloud-exporter"

WORKDIR /go/src/app
COPY . .

RUN apk add --update git
RUN apk add --no-cache --update --verbose git

RUN go get -d -v ./...
RUN go install -v ./...
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# jira-cloud-exporter

Exposes basic JIRA metrics to a Prometheus compatible endpoint.
Exposes basic [JIRA](https://www.atlassian.com/software/jira) metrics (from one or more servers) to a [Prometheus](https://prometheus.io) compatible endpoint using the metric name: *jira_cloud_exporter*.

## Configuration

Configuration is provided in the form of environment variables.
Configuration is provided in the form of environment variables. If multiple Jira servers are being queried, each variable value should be a comma separated list.

### Required

Expand All @@ -13,7 +13,11 @@ Configuration is provided in the form of environment variables.
* `JIRA_URL` is the URL to your organisation's JIRA application.

### Optional
* `JIRA_JQL` is the JIRA query language search filter (defaults to empty, so you'll get everything)

* `JIRA_JQL` is the JIRA query language search filter (defaults to empty, so you'll get everything). If querying multiple Jira servers this variable is *not* optional. You must delimit null values if not specifying a filter for a server. For instance, if querying two servers:
* Filter for first, no filter for second: `JIRA_JQL="project=test,"`
* No filter for first, filter for second: `JIRA_JQL=",project=test"`
* No filter for either: `JIRA_JQL=","`
* `METRICS_PATH` is the endpoint Prometheus should scrape on this exporter. Defaults to `/metrics`
* `LISTEN_ADDRESS` is the IP and port to bind this exporter to. Defaults to `:9800`.

Expand Down
87 changes: 59 additions & 28 deletions collector/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import (
"fmt"
"io/ioutil"
"net/http"
"strings"
"time"

"github.com/jwholdsworth/jira-cloud-exporter/config"
"github.com/prometheus/client_golang/prometheus"
log "github.com/sirupsen/logrus"
)

// JiraCollector initiates the collection of metrics from the JIRA instance
// JiraCollector initiates the collection of metrics from a JIRA instance
func JiraCollector() *JiraMetrics {
return &JiraMetrics{
jiraIssues: prometheus.NewDesc(prometheus.BuildFQName("jira", "cloud", "exporter"),
Expand All @@ -27,10 +28,14 @@ func (collector *JiraMetrics) Describe(ch chan<- *prometheus.Desc) {
ch <- collector.jiraIssues
}

//Collect implements required collect function for all promehteus collectors
//Collect implements required collect function for all prometheus collectors
func (collector *JiraMetrics) Collect(ch chan<- prometheus.Metric) {

collectedIssues := fetchJiraIssues()
collectedIssues, err := fetchJiraIssues()
if err != nil {
log.Error(err)
return
}

for _, issue := range collectedIssues.Issues {
createdTimestamp := convertToUnixTime(issue.Fields.Created)
Expand All @@ -49,38 +54,64 @@ func convertToUnixTime(timestamp string) float64 {
return float64(dateTime.Unix())
}

func fetchJiraIssues() JiraIssues {
// DI this
cfg := config.Init()
var jiraIssues JiraIssues
func fetchJiraIssues() (JiraIssues, error) {

client := http.Client{}
url := fmt.Sprintf("%s/rest/api/2/search?jql=%s", cfg.JiraURL, cfg.JiraJql)
req, err := http.NewRequest(http.MethodGet, url, nil)
cfgs, err := config.Init()
if err != nil {
log.Error(err)
return jiraIssues
}
req.Header.Set("User-Agent", "jira-cloud-exporter")
req.SetBasicAuth(cfg.JiraUsername, cfg.JiraToken)
log.Info(fmt.Sprintf("Sending request to %s", url))
res, err := client.Do(req)
var AllIssues JiraIssues

if err != nil {
log.Error(err)
return jiraIssues
}
for _, cfg := range cfgs {
var jiraIssues JiraIssues

body, readErr := ioutil.ReadAll(res.Body)
if readErr != nil {
log.Error(readErr)
return jiraIssues
}
// Confirm the Jira URL begins with the http:// or https:// scheme specification
// Also emit a warning if HTTPS isn't being used
if !strings.HasPrefix(cfg.JiraURL, "http") {
err := fmt.Errorf("The Jira URL: %s does not begin with 'http'", cfg.JiraURL)
return jiraIssues, err
} else if !strings.HasPrefix(cfg.JiraURL, "https://") {
log.Warn("The Jira URL: ", cfg.JiraURL, " is insecure, your API token is being sent in clear text")
}
if len(cfg.JiraUsername) < 6 {
log.Warn("The Jira username has fewer than 6 characters, are you sure it is valid?")
}
if len(cfg.JiraToken) < 10 {
log.Warn("The Jira token has fewer than 10 characters, are you sure it is valid?")
}

client := http.Client{}
url := fmt.Sprintf("%s/rest/api/2/search?jql=%s", cfg.JiraURL, cfg.JiraJql)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return jiraIssues, err
}
req.Header.Set("User-Agent", "jira-cloud-exporter")
req.SetBasicAuth(cfg.JiraUsername, cfg.JiraToken)
log.Info(fmt.Sprintf("Sending request to %s", url))
res, err := client.Do(req)

if err != nil {
return jiraIssues, err
}

body, readErr := ioutil.ReadAll(res.Body)
if readErr != nil {
return jiraIssues, err
}

jsonError := json.Unmarshal(body, &jiraIssues)
if jsonError != nil {
return jiraIssues, err
}

AllIssues.Issues = append(AllIssues.Issues, jiraIssues.Issues...)

jsonError := json.Unmarshal(body, &jiraIssues)
if jsonError != nil {
log.Error(jsonError)
}

return jiraIssues
return AllIssues, nil
}

type error interface {
Error() string
}
49 changes: 30 additions & 19 deletions config/config.go
Original file line number Diff line number Diff line change
@@ -1,36 +1,47 @@
package config

import "os"
import (
"fmt"
"os"
"strings"
)

type Config struct {
JiraToken string
JiraUsername string
JiraJql string
JiraURL string
MetricsPath string
ListenAddress string
JiraToken string
JiraUsername string
JiraJql string
JiraURL string
}

// Init populates the Config struct based on environmental runtime configuration
func Init() Config {
func Init() ([]Config, error) {

jiraToken := getEnv("JIRA_TOKEN", "")
jiraUsername := getEnv("JIRA_USERNAME", "")
jiraJql := getEnv("JIRA_JQL", "")
jiraURL := getEnv("JIRA_URL", "")
metricsPath := getEnv("METRICS_PATH", "/metrics")
listenAddress := getEnv("LISTEN_ADDRESS", ":9800")

appConfig := Config{
jiraToken,
jiraUsername,
jiraJql,
jiraURL,
metricsPath,
listenAddress,

tokens := strings.Split(jiraToken, ",")
usernames := strings.Split(jiraUsername, ",")
jqls := strings.Split(jiraJql, ",")
urls := strings.Split(jiraURL, ",")

appConfig := make([]Config, 0)

if len(urls) != len(usernames) {
err := fmt.Errorf("The number of Jira URLs doesn't match the number of Jira Usernames")
return appConfig, err
} else if len(usernames) != len(tokens) {
err := fmt.Errorf("The number of Jira Usernames doesn't match the number of Jira Tokens")
return appConfig, err
}

for i, items := range urls {
temp := Config{tokens[i], usernames[i], jqls[i], items}
appConfig = append(appConfig, temp)
}

return appConfig
return appConfig, nil
}

func getEnv(environmentVariable, defaultValue string) string {
Expand Down
25 changes: 17 additions & 8 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,33 @@ package main
import (
"fmt"
"net/http"
"os"

"github.com/jwholdsworth/jira-cloud-exporter/collector"
"github.com/jwholdsworth/jira-cloud-exporter/config"
"github.com/prometheus/client_golang/prometheus"

"github.com/prometheus/client_golang/prometheus/promhttp"
log "github.com/sirupsen/logrus"
)

func main() {
cfg := config.Init()
metricsPath := getEnv("METRICS_PATH", "/metrics")
listenAddress := getEnv("LISTEN_ADDRESS", ":9800")
jiraCollector := collector.JiraCollector()
prometheus.MustRegister(jiraCollector)

http.Handle(cfg.MetricsPath, promhttp.Handler())
if cfg.MetricsPath != "/" {
http.Handle("/", http.RedirectHandler(cfg.MetricsPath, http.StatusMovedPermanently))
http.Handle(metricsPath, promhttp.Handler())
if metricsPath != "/" {
http.Handle("/", http.RedirectHandler(metricsPath, http.StatusMovedPermanently))
}
log.Info(fmt.Sprintf("Listening on %s", cfg.ListenAddress))
log.Fatal(http.ListenAndServe(cfg.ListenAddress, nil))
log.Info(fmt.Sprintf("Listening on %s", listenAddress))
log.Fatal(http.ListenAndServe(listenAddress, nil))
}

func getEnv(environmentVariable, defaultValue string) string {
envVar := os.Getenv(environmentVariable)
if len(envVar) == 0 {
return defaultValue
}

return envVar
}

0 comments on commit 34f06da

Please sign in to comment.