dovecot_exporter.go 6.45 KB
Newer Older
Ed Schouten's avatar
Ed Schouten committed
1
2
3
4
5
6
7
8
9
10
11
12
13
// Copyright 2016 Kumina, https://kumina.nl/
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
	"bufio"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"os"
	"strconv"
	"strings"

	"github.com/prometheus/client_golang/prometheus"
28
	"gopkg.in/alecthomas/kingpin.v2"
29
30
)

Nick Groenen's avatar
Nick Groenen committed
31
32
33
34
35
var dovecotUpDesc = prometheus.NewDesc(
	prometheus.BuildFQName("dovecot", "", "up"),
	"Whether scraping Dovecot's metrics was successful.",
	[]string{"scope"},
	nil)
36

37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
// CollectFromReader converts the output of Dovecot's EXPORT command to metrics.
func CollectFromReader(file io.Reader, scope string, ch chan<- prometheus.Metric) error {
	if scope == "global" {
		return collectGlobalMetricsFromReader(file, scope, ch)
	}
	return collectDetailMetricsFromReader(file, scope, ch)
}

// CollectFromFile collects dovecot statistics from the given file
func CollectFromFile(path string, scope string, ch chan<- prometheus.Metric) error {
	conn, err := os.Open(path)
	if err != nil {
		return err
	}
	return CollectFromReader(conn, scope, ch)
}

// CollectFromSocket collects statistics from dovecot's stats socket.
func CollectFromSocket(path string, scope string, ch chan<- prometheus.Metric) error {
	conn, err := net.Dial("unix", path)
	if err != nil {
		return err
	}
	_, err = conn.Write([]byte("EXPORT\t" + scope + "\n"))
	if err != nil {
		return err
	}
	return CollectFromReader(conn, scope, ch)
}

// collectGlobalMetricsFromReader collects dovecot "global" scope metrics from
// the supplied reader.
func collectGlobalMetricsFromReader(reader io.Reader, scope string, ch chan<- prometheus.Metric) error {
	scanner := bufio.NewScanner(reader)
	scanner.Split(bufio.ScanLines)

	// Read first line of input, containing the aggregation and column names.
	if !scanner.Scan() {
		return fmt.Errorf("Failed to extract columns from input")
	}
	columnNames := strings.Fields(scanner.Text())
	if len(columnNames) < 1 {
		return fmt.Errorf("Input does not provide any columns")
	}

	columns := []*prometheus.Desc{}
	for _, columnName := range columnNames {
		columns = append(columns, prometheus.NewDesc(
			prometheus.BuildFQName("dovecot", scope, columnName),
			"Help text not provided by this exporter.",
			[]string{},
			nil))
	}

	// Global metrics only have a single row containing values following the
	// line with column names
	if !scanner.Scan() {
		return scanner.Err()
	}
	values := strings.Fields(scanner.Text())

	if len(values) != len(columns) {
		return fmt.Errorf("error while parsing row: value count does not match column count")
	}

	for i, value := range values {
		f, err := strconv.ParseFloat(value, 64)
		if err != nil {
			return err
		}
		ch <- prometheus.MustNewConstMetric(
			columns[i],
			prometheus.UntypedValue,
			f,
		)
	}
	return scanner.Err()
}

// collectGlobalMetricsFromReader collects dovecot "non-global" scope metrics
// from the supplied reader.
func collectDetailMetricsFromReader(reader io.Reader, scope string, ch chan<- prometheus.Metric) error {
	scanner := bufio.NewScanner(reader)
120
121
122
123
124
125
126
	scanner.Split(bufio.ScanLines)

	// Read first line of input, containing the aggregation and column names.
	if !scanner.Scan() {
		return fmt.Errorf("Failed to extract columns from input")
	}
	columnNames := strings.Fields(scanner.Text())
127
128
129
	if len(columnNames) < 2 {
		return fmt.Errorf("Input does not provide any columns")
	}
130

131
132
	columns := []*prometheus.Desc{}
	for _, columnName := range columnNames[1:] {
Ed Schouten's avatar
Ed Schouten committed
133
134
135
136
137
		columns = append(columns, prometheus.NewDesc(
			prometheus.BuildFQName("dovecot", columnNames[0], columnName),
			"Help text not provided by this exporter.",
			[]string{columnNames[0]},
			nil))
138
139
140
141
	}

	// Read successive lines, containing the values.
	for scanner.Scan() {
142
143
		row := scanner.Text()
		if strings.TrimSpace(row) == "" {
144
145
			break
		}
146
147
148
149
150
151

		values := strings.Fields(row)
		if len(values) != len(columns)+1 {
			return fmt.Errorf("error while parsing rows: value count does not match column count")
		}

152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
		for i, value := range values[1:] {
			f, err := strconv.ParseFloat(value, 64)
			if err != nil {
				return err
			}
			ch <- prometheus.MustNewConstMetric(
				columns[i],
				prometheus.UntypedValue,
				f,
				values[0])
		}
	}
	return scanner.Err()
}

type DovecotExporter struct {
Nick Groenen's avatar
Nick Groenen committed
168
	scopes     []string
169
170
171
	socketPath string
}

Nick Groenen's avatar
Nick Groenen committed
172
func NewDovecotExporter(socketPath string, scopes []string) *DovecotExporter {
173
	return &DovecotExporter{
Nick Groenen's avatar
Nick Groenen committed
174
		scopes:     scopes,
175
176
177
178
179
180
181
182
183
		socketPath: socketPath,
	}
}

func (e *DovecotExporter) Describe(ch chan<- *prometheus.Desc) {
	ch <- dovecotUpDesc
}

func (e *DovecotExporter) Collect(ch chan<- prometheus.Metric) {
Nick Groenen's avatar
Nick Groenen committed
184
	for _, scope := range e.scopes {
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
		err := CollectFromSocket(e.socketPath, scope, ch)
		if err == nil {
			ch <- prometheus.MustNewConstMetric(
				dovecotUpDesc,
				prometheus.GaugeValue,
				1.0,
				scope)
		} else {
			log.Printf("Failed to scrape socket: %s", err)
			ch <- prometheus.MustNewConstMetric(
				dovecotUpDesc,
				prometheus.GaugeValue,
				0.0,
				scope)
		}
	}
}

func main() {
	var (
205
206
207
208
		app           = kingpin.New("dovecot_exporter", "Prometheus metrics exporter for Dovecot")
		listenAddress = app.Flag("web.listen-address", "Address to listen on for web interface and telemetry.").Default(":9166").String()
		metricsPath   = app.Flag("web.telemetry-path", "Path under which to expose metrics.").Default("/metrics").String()
		socketPath    = app.Flag("dovecot.socket-path", "Path under which to expose metrics.").Default("/var/run/dovecot/stats").String()
209
		dovecotScopes = app.Flag("dovecot.scopes", "Stats scopes to query (comma separated)").Default("user").String()
210
	)
211
	kingpin.MustParse(app.Parse(os.Args[1:]))
212

Nick Groenen's avatar
Nick Groenen committed
213
	exporter := NewDovecotExporter(*socketPath, strings.Split(*dovecotScopes, ","))
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
	prometheus.MustRegister(exporter)

	http.Handle(*metricsPath, prometheus.Handler())
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`
			<html>
			<head><title>Dovecot Exporter</title></head>
			<body>
			<h1>Dovecot Exporter</h1>
			<p><a href='` + *metricsPath + `'>Metrics</a></p>
			</body>
			</html>`))
	})
	log.Fatal(http.ListenAndServe(*listenAddress, nil))
}