Skip to content

Commit 3c51d57

Browse files
keremispirlimblaschke
authored andcommitted
Exporting WorkItem query results as metrics (#16)
* Added query-related entities Gets query path+project id as parameter Implements collection based on metrics_agentpool.go Needs to pass projectId to AzureDevopsClient.rest() Change webdevops/azure-devops-exporter to current repository Add collectorQueryList and ProjectName Get Number of Work Items from query * Getting workitem details Query each work item and its data for prometheus * Renamed expected environment variable AZURE_DEVOPS_QUERY to AZURE_DEVOPS_QUERIES * Parse multiple query entries coming from command line Parse multiple project whitelist and blacklist entries coming from command line Uncomment default metrics * Aligned import statements with rest of the repo * Fix azure-devops-client package references * Updated README.md * Removed updating projects for Query collectors golint: Renamed projectId to projectID Unexported ensureSplitBySpace function * Added collecting scrape durations of Query collectors * Added ScrapeTimeQuery defaulting to ScrapeTime (30m) Removed setting ScrapeTimeLive to ScrapeTime if not set; it's impossible since ScrapeTimeLive has a default of 30s. * Changed long option for Query parameter to 'list.query' Removed ensureSplitBySpace function since it was not a bug; lists should be passed by repeating same option for every item
1 parent 1a4c154 commit 3c51d57

File tree

9 files changed

+338
-7
lines changed

9 files changed

+338
-7
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
/vendor/
2-
/azure-devops-exporter
2+
*.exe

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Normally no configuration is needed but can be customized using environment vari
3131
| `AZURE_DEVOPS_FILTER_PROJECT` | none | Whitelist project uuids |
3232
| `AZURE_DEVOPS_BLACKLIST_PROJECT` | none | Blacklist project uuids |
3333
| `AZURE_DEVOPS_FILTER_AGENTPOOL` | none | Whitelist for agentpool metrics |
34+
| `AZURE_DEVOPS_QUERIES` | none | Whitelist query and project uuids |
3435
| `AZURE_DEVOPS_APIVERSION` | fixed version | API version used to query for Azure DevOps |
3536
| `REQUEST_CONCURRENCY` | `10` | API request concurrency (number of calls at the same time) |
3637
| `REQUEST_RETRIES` | `3` | API request retries in case of failure |
@@ -71,6 +72,7 @@ Metrics
7172
| `azure_devops_repository_stats` | repository | Repository stats |
7273
| `azure_devops_repository_commits` | repository | Repository commit counter |
7374
| `azure_devops_repository_pushes` | repository | Repository push counter |
75+
| `azure_devops_query_result` | live | Latest results of given queries |
7476
| `azure_devops_deployment_info` | deployment | Release deployment informations |
7577
| `azure_devops_deployment_status` | deployment | Release deployment status informations |
7678
| `azure_devops_stats_agentpool_builds` | stats | Number of buildsper agentpool, project and result (counter) |
@@ -106,6 +108,7 @@ Application Options:
106108
--whitelist.project= Filter projects (UUIDs) [$AZURE_DEVOPS_FILTER_PROJECT]
107109
--blacklist.project= Filter projects (UUIDs) [$AZURE_DEVOPS_BLACKLIST_PROJECT]
108110
--whitelist.agentpool= Filter of agent pool (IDs) [$AZURE_DEVOPS_FILTER_AGENTPOOL]
111+
--whitelist.queries= Pairs of query and project UUIDs in the form: '<queryId>@<projectId>' [%AZURE_DEVOPS_QUERIES%]
109112
--azuredevops.url= Azure DevOps url (empty if hosted by microsoft) [$AZURE_DEVOPS_URL]
110113
--azuredevops.access-token= Azure DevOps access token [$AZURE_DEVOPS_ACCESS_TOKEN]
111114
--azuredevops.organisation= Azure DevOps organization [$AZURE_DEVOPS_ORGANISATION]
@@ -136,7 +139,7 @@ Next shift
136139
bottomk(1,
137140
min by (userName, time) (
138141
pagerduty_schedule_final_entry{scheduleID="$SCHEDULEID",type="startTime"}
139-
* on (userID) group_left(userName) (pagerduty_user_info)
142+
* on (userID) group_left(userName) (pagerduty_user_info)
140143
) - time() > 0
141144
)
142145
```

azure-devops-client/query.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package AzureDevopsClient
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/url"
7+
)
8+
9+
type Query struct {
10+
Path string `json:"path"`
11+
}
12+
13+
type WorkItemInfoList struct {
14+
List []WorkItemInfo `json:"workItems"`
15+
}
16+
17+
type WorkItemInfo struct {
18+
Id int `json:"id"`
19+
Url string `json:"url"`
20+
}
21+
22+
func (c *AzureDevopsClient) QueryWorkItems(queryPath, projectId string) (list WorkItemInfoList, error error) {
23+
defer c.concurrencyUnlock()
24+
c.concurrencyLock()
25+
26+
url := fmt.Sprintf(
27+
"%v/_apis/wit/wiql/%v?api-version=%v",
28+
projectId,
29+
queryPath,
30+
url.QueryEscape(c.ApiVersion),
31+
)
32+
response, err := c.rest().R().Get(url)
33+
if err := c.checkResponse(response, err); err != nil {
34+
error = err
35+
return
36+
}
37+
38+
err = json.Unmarshal(response.Body(), &list)
39+
if err != nil {
40+
error = err
41+
}
42+
43+
return
44+
}

azure-devops-client/workitem.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package AzureDevopsClient
2+
3+
import (
4+
"encoding/json"
5+
)
6+
7+
type WorkItem struct {
8+
Id int64 `json:"id"`
9+
Fields WorkItemFields `json:"fields"`
10+
}
11+
12+
type WorkItemFields struct {
13+
Title string `json:"System.Title"`
14+
Path string `json:"System.AreaPath"`
15+
CreatedDate string `json:"System.CreatedDate"`
16+
AcceptedDate string `json:"Microsoft.VSTS.CodeReview.AcceptedDate"`
17+
ResolvedDate string `json:"Microsoft.VSTS.Common.ResolvedDate"`
18+
ClosedDate string `json:"Microsoft.VSTS.Common.ClosedDate"`
19+
}
20+
21+
func (c *AzureDevopsClient) GetWorkItem(workItemUrl string) (workItem WorkItem, error error) {
22+
defer c.concurrencyUnlock()
23+
c.concurrencyLock()
24+
25+
response, err := c.rest().R().Get(workItemUrl)
26+
if err := c.checkResponse(response, err); err != nil {
27+
error = err
28+
return
29+
}
30+
31+
err = json.Unmarshal(response.Body(), &workItem)
32+
if err != nil {
33+
error = err
34+
}
35+
36+
return
37+
}

collector_processor_query.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package main
2+
3+
import (
4+
"context"
5+
)
6+
7+
type CollectorProcessorQueryInterface interface {
8+
Setup(collector *CollectorQuery)
9+
Reset()
10+
Collect(ctx context.Context, callback chan<- func())
11+
}
12+
13+
type CollectorProcessorQuery struct {
14+
CollectorProcessorQueryInterface
15+
CollectorReference *CollectorQuery
16+
}
17+
18+
func NewCollectorQuery(name string, processor CollectorProcessorQueryInterface) *CollectorQuery {
19+
collector := CollectorQuery{
20+
CollectorBase: CollectorBase{
21+
Name: name,
22+
},
23+
Processor: processor,
24+
}
25+
26+
return &collector
27+
}

collector_query.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"sync"
6+
)
7+
8+
type CollectorQuery struct {
9+
CollectorBase
10+
11+
Processor CollectorProcessorQueryInterface
12+
QueryList []string
13+
}
14+
15+
func (c *CollectorQuery) Run() {
16+
c.Processor.Setup(c)
17+
go func() {
18+
for {
19+
go func() {
20+
c.Collect()
21+
}()
22+
c.sleepUntilNextCollection()
23+
}
24+
}()
25+
}
26+
27+
func (c *CollectorQuery) Collect() {
28+
var wg sync.WaitGroup
29+
var wgCallback sync.WaitGroup
30+
31+
ctx := context.Background()
32+
33+
callbackChannel := make(chan func())
34+
35+
c.collectionStart()
36+
37+
wg.Add(1)
38+
go func(ctx context.Context, callback chan<- func()) {
39+
defer wg.Done()
40+
c.Processor.Collect(ctx, callbackChannel)
41+
}(ctx, callbackChannel)
42+
43+
// collect metrics (callbacks) and process them
44+
wgCallback.Add(1)
45+
go func() {
46+
defer wgCallback.Done()
47+
var callbackList []func()
48+
for callback := range callbackChannel {
49+
callbackList = append(callbackList, callback)
50+
}
51+
52+
// reset metric values
53+
c.Processor.Reset()
54+
55+
// process callbacks (set metrics)
56+
for _, callback := range callbackList {
57+
callback()
58+
}
59+
}()
60+
61+
// wait for all funcs
62+
wg.Wait()
63+
close(callbackChannel)
64+
wgCallback.Wait()
65+
66+
c.collectionFinish()
67+
}

main.go

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"log"
99
"net/http"
1010
"os"
11+
"strings"
1112
"time"
1213
)
1314

@@ -26,6 +27,7 @@ var (
2627
collectorGeneralList map[string]*CollectorGeneral
2728
collectorProjectList map[string]*CollectorProject
2829
collectorAgentPoolList map[string]*CollectorAgentPool
30+
collectorQueryList map[string]*CollectorQuery
2931
)
3032

3133
var opts struct {
@@ -45,6 +47,7 @@ var opts struct {
4547
ScrapeTimePullRequest *time.Duration `long:"scrape.time.pullrequest" env:"SCRAPE_TIME_PULLREQUEST" description:"Scrape time for pullrequest metrics (time.duration)"`
4648
ScrapeTimeStats *time.Duration `long:"scrape.time.stats" env:"SCRAPE_TIME_STATS" description:"Scrape time for stats metrics (time.duration)"`
4749
ScrapeTimeResourceUsage *time.Duration `long:"scrape.time.resourceusage" env:"SCRAPE_TIME_RESOURCEUSAGE" description:"Scrape time for resourceusage metrics (time.duration)"`
50+
ScrapeTimeQuery *time.Duration `long:"scrape.time.query" env:"SCRAPE_TIME_QUERY" description:"Scrape time for query results (time.duration)"`
4851
ScrapeTimeLive *time.Duration `long:"scrape.time.live" env:"SCRAPE_TIME_LIVE" description:"Scrape time for live metrics (time.duration)" default:"30s"`
4952

5053
// summary options
@@ -55,6 +58,9 @@ var opts struct {
5558
AzureDevopsBlacklistProjects []string `long:"blacklist.project" env:"AZURE_DEVOPS_BLACKLIST_PROJECT" env-delim:" " description:"Filter projects (UUIDs)"`
5659
AzureDevopsFilterAgentPoolId []int64 `long:"whitelist.agentpool" env:"AZURE_DEVOPS_FILTER_AGENTPOOL" env-delim:" " description:"Filter of agent pool (IDs)"`
5760

61+
// query settings
62+
QueriesWithProjects []string `long:"list.query" env:"AZURE_DEVOPS_QUERIES" env-delim:" " description:"Pairs of query and project UUIDs in the form: '<queryId>@<projectId>'"`
63+
5864
// azure settings
5965
AzureDevopsUrl *string `long:"azuredevops.url" env:"AZURE_DEVOPS_URL" description:"Azure DevOps url (empty if hosted by microsoft)"`
6066
AzureDevopsAccessToken string `long:"azuredevops.access-token" env:"AZURE_DEVOPS_ACCESS_TOKEN" description:"Azure DevOps access token" required:"true"`
@@ -95,6 +101,7 @@ func main() {
95101
Logger.Infof("set scape interval[Deployment]: %v", scrapeIntervalStatus(opts.ScrapeTimeDeployment))
96102
Logger.Infof("set scape interval[Stats]: %v", scrapeIntervalStatus(opts.ScrapeTimeStats))
97103
Logger.Infof("set scape interval[ResourceUsage]: %v", scrapeIntervalStatus(opts.ScrapeTimeResourceUsage))
104+
Logger.Infof("set scape interval[Queries]: %v", scrapeIntervalStatus(opts.ScrapeTimeQuery))
98105
initMetricCollector()
99106

100107
Logger.Infof("Starting http server on %s", opts.ServerBind)
@@ -118,6 +125,20 @@ func initArgparser() {
118125
}
119126
}
120127

128+
// ensure query paths and projects are splitted by '@'
129+
if opts.QueriesWithProjects != nil {
130+
queryError := false
131+
for _, query := range opts.QueriesWithProjects {
132+
if strings.Count(query, "@") != 1 {
133+
fmt.Println("Query path '", query, "' is malformed; should be '<query UUID>@<project UUID>'")
134+
queryError = true
135+
}
136+
}
137+
if queryError {
138+
os.Exit(1)
139+
}
140+
}
141+
121142
// use default scrape time if null
122143
if opts.ScrapeTimeProjects == nil {
123144
opts.ScrapeTimeProjects = &opts.ScrapeTime
@@ -151,13 +172,13 @@ func initArgparser() {
151172
opts.ScrapeTimeResourceUsage = &opts.ScrapeTime
152173
}
153174

154-
if opts.ScrapeTimeLive == nil {
155-
opts.ScrapeTimeLive = &opts.ScrapeTime
156-
}
157-
158175
if opts.StatsSummaryMaxAge == nil {
159176
opts.StatsSummaryMaxAge = opts.ScrapeTimeStats
160177
}
178+
179+
if opts.ScrapeTimeQuery == nil {
180+
opts.ScrapeTimeQuery = &opts.ScrapeTime
181+
}
161182
}
162183

163184
// Init and build Azure authorzier
@@ -225,6 +246,7 @@ func initMetricCollector() {
225246
collectorGeneralList = map[string]*CollectorGeneral{}
226247
collectorProjectList = map[string]*CollectorProject{}
227248
collectorAgentPoolList = map[string]*CollectorAgentPool{}
249+
collectorQueryList = map[string]*CollectorQuery{}
228250

229251
projectList := getAzureDevOpsProjects()
230252

@@ -328,6 +350,16 @@ func initMetricCollector() {
328350
Logger.Infof("collector[%s]: disabled", collectorName)
329351
}
330352

353+
collectorName = "Query"
354+
if opts.ScrapeTimeQuery.Seconds() > 0 {
355+
collectorQueryList[collectorName] = NewCollectorQuery(collectorName, &MetricsCollectorQuery{})
356+
collectorQueryList[collectorName].SetAzureProjects(&projectList)
357+
collectorQueryList[collectorName].QueryList = opts.QueriesWithProjects
358+
collectorQueryList[collectorName].SetScrapeTime(*opts.ScrapeTimeLive)
359+
} else {
360+
Logger.Infof("collector[%s]: disabled", collectorName)
361+
}
362+
331363
for _, collector := range collectorGeneralList {
332364
collector.Run()
333365
}
@@ -340,6 +372,10 @@ func initMetricCollector() {
340372
collector.Run()
341373
}
342374

375+
for _, collector := range collectorQueryList {
376+
collector.Run()
377+
}
378+
343379
// background auto update of projects
344380
if opts.ScrapeTimeProjects.Seconds() > 0 {
345381
go func() {
@@ -350,6 +386,7 @@ func initMetricCollector() {
350386
Logger.Info("daemon: updating project list")
351387

352388
projectList := getAzureDevOpsProjects()
389+
Logger.Infof("daemon: found %v projects", projectList.Count)
353390

354391
for _, collector := range collectorGeneralList {
355392
collector.SetAzureProjects(&projectList)
@@ -363,7 +400,7 @@ func initMetricCollector() {
363400
collector.SetAzureProjects(&projectList)
364401
}
365402

366-
Logger.Infof("daemon: found %v projects", projectList.Count)
403+
Logger.Info("daemon: skipping Query collectors; they don't use projects")
367404
time.Sleep(*opts.ScrapeTimeProjects)
368405
}
369406
}()

metrics_general.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ func (m *MetricsCollectorGeneral) collectCollectorStats(ctx context.Context, cal
7777
}, *collector.LastScrapeDuration)
7878
}
7979
}
80+
8081
for _, collector := range collectorProjectList {
8182
if collector.LastScrapeDuration != nil {
8283
statsMetrics.AddDuration(prometheus.Labels{
@@ -86,6 +87,15 @@ func (m *MetricsCollectorGeneral) collectCollectorStats(ctx context.Context, cal
8687
}
8788
}
8889

90+
for _, collector := range collectorQueryList {
91+
if collector.LastScrapeDuration != nil {
92+
statsMetrics.AddDuration(prometheus.Labels{
93+
"name": collector.Name,
94+
"type": "collectorDuration",
95+
}, *collector.LastScrapeDuration)
96+
}
97+
}
98+
8999
callback <- func() {
90100
statsMetrics.GaugeSet(m.prometheus.stats)
91101
}

0 commit comments

Comments
 (0)