2017-02-17 14:29:37 +00:00
|
|
|
// Copyright 2017 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.
|
|
|
|
|
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bufio"
|
2017-03-17 15:42:54 +00:00
|
|
|
"bytes"
|
|
|
|
"errors"
|
2017-02-17 14:29:37 +00:00
|
|
|
"flag"
|
2017-03-17 15:42:54 +00:00
|
|
|
"fmt"
|
2017-02-17 14:29:37 +00:00
|
|
|
"io"
|
|
|
|
"log"
|
|
|
|
"net"
|
|
|
|
"net/http"
|
|
|
|
"os"
|
|
|
|
"regexp"
|
|
|
|
"strconv"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
postfixUpDesc = prometheus.NewDesc(
|
2017-02-17 14:50:47 +00:00
|
|
|
prometheus.BuildFQName("postfix", "", "up"),
|
2017-02-17 14:29:37 +00:00
|
|
|
"Whether scraping Postfix's metrics was successful.",
|
|
|
|
nil, nil)
|
|
|
|
)
|
|
|
|
|
2017-02-17 14:31:04 +00:00
|
|
|
// Parses the output of Postfix's 'showq' command and turns it into metrics.
|
|
|
|
//
|
2017-03-17 15:42:54 +00:00
|
|
|
// The output format of this command depends on the version of Postfix
|
|
|
|
// used. Postfix 2.x uses a textual format, identical to the output of
|
|
|
|
// the 'mailq' command. Postfix 3.x uses a binary format, where entries
|
|
|
|
// are terminated using null bytes. Auto-detect the format by scanning
|
|
|
|
// for null bytes in the first 128 bytes of output.
|
2017-02-17 14:29:37 +00:00
|
|
|
func CollectShowqFromReader(file io.Reader, ch chan<- prometheus.Metric) error {
|
2017-03-17 15:42:54 +00:00
|
|
|
reader := bufio.NewReader(file)
|
|
|
|
buf, _ := reader.Peek(128)
|
|
|
|
if bytes.IndexByte(buf, 0) >= 0 {
|
|
|
|
return CollectBinaryShowqFromReader(reader, ch)
|
|
|
|
} else {
|
|
|
|
return CollectTextualShowqFromReader(reader, ch)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Parses Postfix's textual showq output.
|
|
|
|
func CollectTextualShowqFromReader(file io.Reader, ch chan<- prometheus.Metric) error {
|
2017-02-17 14:29:37 +00:00
|
|
|
scanner := bufio.NewScanner(file)
|
|
|
|
scanner.Split(bufio.ScanLines)
|
|
|
|
|
|
|
|
// Regular expression for matching postqueue's output. Example:
|
|
|
|
// "A07A81514 5156 Tue Feb 14 13:13:54 MAILER-DAEMON"
|
2017-04-18 14:03:53 +00:00
|
|
|
messageLine := regexp.MustCompile("^[0-9A-F]+([\\*!]?) +(\\d+) (\\w{3} \\w{3} +\\d+ +\\d+:\\d{2}:\\d{2}) +")
|
2017-02-17 14:29:37 +00:00
|
|
|
|
|
|
|
// Histograms tracking the messages by size and age.
|
2017-04-18 14:03:53 +00:00
|
|
|
sizeHistogram := prometheus.NewHistogramVec(
|
2017-02-17 14:29:37 +00:00
|
|
|
prometheus.HistogramOpts{
|
2017-02-17 14:50:47 +00:00
|
|
|
Namespace: "postfix",
|
2017-02-17 14:29:37 +00:00
|
|
|
Name: "queue_message_size_bytes",
|
|
|
|
Help: "Size of messages in Postfix's message queue, in bytes",
|
|
|
|
Buckets: []float64{1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9},
|
2017-04-18 14:03:53 +00:00
|
|
|
},
|
|
|
|
[]string{"queue"})
|
|
|
|
ageHistogram := prometheus.NewHistogramVec(
|
2017-02-17 14:29:37 +00:00
|
|
|
prometheus.HistogramOpts{
|
2017-02-17 14:50:47 +00:00
|
|
|
Namespace: "postfix",
|
2017-02-17 14:29:37 +00:00
|
|
|
Name: "queue_message_age_seconds",
|
|
|
|
Help: "Age of messages in Postfix's message queue, in seconds",
|
|
|
|
Buckets: []float64{1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8},
|
2017-04-18 14:03:53 +00:00
|
|
|
},
|
|
|
|
[]string{"queue"})
|
2017-02-17 14:29:37 +00:00
|
|
|
|
|
|
|
now := time.Now()
|
|
|
|
for scanner.Scan() {
|
|
|
|
matches := messageLine.FindStringSubmatch(scanner.Text())
|
|
|
|
if matches != nil {
|
2017-04-18 14:03:53 +00:00
|
|
|
// Derive the name of the message queue.
|
|
|
|
queue := "other"
|
|
|
|
if matches[1] == "*" {
|
|
|
|
queue = "active"
|
|
|
|
} else if matches[1] == "!" {
|
|
|
|
queue = "hold"
|
|
|
|
}
|
|
|
|
|
2017-02-17 14:29:37 +00:00
|
|
|
// Parse the message size.
|
2017-04-18 14:03:53 +00:00
|
|
|
size, err := strconv.ParseFloat(matches[2], 64)
|
2017-02-17 14:29:37 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Parse the message date. Unfortunately, the
|
|
|
|
// output contains no year number. Assume it
|
|
|
|
// applies to the last year for which the
|
|
|
|
// message date doesn't exceed time.Now().
|
2017-04-18 14:03:53 +00:00
|
|
|
date, err := time.Parse("Mon Jan 2 15:04:05", matches[3])
|
2017-02-17 14:29:37 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
date = date.AddDate(now.Year(), 0, 0)
|
|
|
|
if date.After(now) {
|
2017-02-17 14:47:45 +00:00
|
|
|
date = date.AddDate(-1, 0, 0)
|
2017-02-17 14:29:37 +00:00
|
|
|
}
|
|
|
|
|
2017-04-18 14:03:53 +00:00
|
|
|
sizeHistogram.WithLabelValues(queue).Observe(size)
|
|
|
|
ageHistogram.WithLabelValues(queue).Observe(now.Sub(date).Seconds())
|
2017-02-17 14:29:37 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-04-18 14:03:53 +00:00
|
|
|
sizeHistogram.Collect(ch)
|
|
|
|
ageHistogram.Collect(ch)
|
2017-02-17 14:29:37 +00:00
|
|
|
return scanner.Err()
|
|
|
|
}
|
|
|
|
|
2017-03-17 15:42:54 +00:00
|
|
|
// Splitting function for bufio.Scanner to split entries by null bytes.
|
|
|
|
func ScanNullTerminatedEntries(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
|
|
|
if i := bytes.IndexByte(data, 0); i >= 0 {
|
|
|
|
// Valid record found.
|
|
|
|
return i + 1, data[0:i], nil
|
|
|
|
} else if atEOF && len(data) != 0 {
|
|
|
|
// Data at the end of the file without a null terminator.
|
|
|
|
return 0, nil, errors.New("Expected null byte terminator")
|
|
|
|
} else {
|
|
|
|
// Request more data.
|
|
|
|
return 0, nil, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Parses Postfix's binary format.
|
|
|
|
func CollectBinaryShowqFromReader(file io.Reader, ch chan<- prometheus.Metric) error {
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
|
|
scanner.Split(ScanNullTerminatedEntries)
|
|
|
|
|
|
|
|
// Histograms tracking the messages by size and age.
|
2017-04-18 14:03:53 +00:00
|
|
|
sizeHistogram := prometheus.NewHistogramVec(
|
2017-03-17 15:42:54 +00:00
|
|
|
prometheus.HistogramOpts{
|
|
|
|
Namespace: "postfix",
|
|
|
|
Name: "queue_message_size_bytes",
|
|
|
|
Help: "Size of messages in Postfix's message queue, in bytes",
|
|
|
|
Buckets: []float64{1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9},
|
2017-04-18 14:03:53 +00:00
|
|
|
},
|
|
|
|
[]string{"queue"})
|
|
|
|
ageHistogram := prometheus.NewHistogramVec(
|
2017-03-17 15:42:54 +00:00
|
|
|
prometheus.HistogramOpts{
|
|
|
|
Namespace: "postfix",
|
|
|
|
Name: "queue_message_age_seconds",
|
|
|
|
Help: "Age of messages in Postfix's message queue, in seconds",
|
|
|
|
Buckets: []float64{1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8},
|
2017-04-18 14:03:53 +00:00
|
|
|
},
|
|
|
|
[]string{"queue"})
|
2017-03-17 15:42:54 +00:00
|
|
|
|
|
|
|
now := float64(time.Now().UnixNano()) / 1e9
|
2017-04-18 14:03:53 +00:00
|
|
|
queue := "unknown"
|
2017-03-17 15:42:54 +00:00
|
|
|
for scanner.Scan() {
|
|
|
|
// Parse a key/value entry.
|
|
|
|
key := scanner.Text()
|
|
|
|
if len(key) == 0 {
|
2017-04-18 14:03:53 +00:00
|
|
|
// Empty key means a record separator.
|
|
|
|
queue := "unknown"
|
2017-03-17 15:42:54 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
if !scanner.Scan() {
|
|
|
|
return fmt.Errorf("Key %q does not have a value\n", key)
|
|
|
|
}
|
|
|
|
value := scanner.Text()
|
|
|
|
|
2017-04-18 14:03:53 +00:00
|
|
|
if key == "queue_name" {
|
|
|
|
// The name of the message queue.
|
|
|
|
queue = value
|
|
|
|
} else if key == "size" {
|
2017-03-17 15:42:54 +00:00
|
|
|
// Message size in bytes.
|
|
|
|
size, err := strconv.ParseFloat(value, 64)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2017-04-18 14:03:53 +00:00
|
|
|
sizeHistogram.WithLabelValues(queue).Observe(size)
|
2017-03-17 15:42:54 +00:00
|
|
|
} else if key == "time" {
|
|
|
|
// Message time as a UNIX timestamp.
|
|
|
|
time, err := strconv.ParseFloat(value, 64)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2017-04-18 14:03:53 +00:00
|
|
|
ageHistogram.WithLabelValues(queue).Observe(now - time)
|
2017-03-17 15:42:54 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-04-18 14:03:53 +00:00
|
|
|
sizeHistogram.Collect(ch)
|
|
|
|
ageHistogram.Collect(ch)
|
2017-03-17 15:42:54 +00:00
|
|
|
return scanner.Err()
|
|
|
|
}
|
|
|
|
|
2017-02-17 14:29:37 +00:00
|
|
|
func CollectShowqFromFile(path string, ch chan<- prometheus.Metric) error {
|
|
|
|
conn, err := os.Open(path)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return CollectShowqFromReader(conn, ch)
|
|
|
|
}
|
|
|
|
|
|
|
|
func CollectShowqFromSocket(path string, ch chan<- prometheus.Metric) error {
|
|
|
|
conn, err := net.Dial("unix", path)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return CollectShowqFromReader(conn, ch)
|
|
|
|
}
|
|
|
|
|
|
|
|
type PostfixExporter struct {
|
|
|
|
showqPath string
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewPostfixExporter(showqPath string) (*PostfixExporter, error) {
|
|
|
|
return &PostfixExporter{
|
|
|
|
showqPath: showqPath,
|
|
|
|
}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (e *PostfixExporter) Describe(ch chan<- *prometheus.Desc) {
|
|
|
|
ch <- postfixUpDesc
|
|
|
|
}
|
|
|
|
|
|
|
|
func (e *PostfixExporter) Collect(ch chan<- prometheus.Metric) {
|
|
|
|
err := CollectShowqFromSocket(e.showqPath, ch)
|
|
|
|
if err == nil {
|
|
|
|
ch <- prometheus.MustNewConstMetric(
|
|
|
|
postfixUpDesc,
|
|
|
|
prometheus.GaugeValue,
|
|
|
|
1.0)
|
|
|
|
} else {
|
|
|
|
log.Printf("Failed to scrape showq socket: %s", err)
|
|
|
|
ch <- prometheus.MustNewConstMetric(
|
|
|
|
postfixUpDesc,
|
|
|
|
prometheus.GaugeValue,
|
|
|
|
0.0)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func main() {
|
|
|
|
var (
|
2017-02-17 14:53:56 +00:00
|
|
|
listenAddress = flag.String("web.listen-address", ":9154", "Address to listen on for web interface and telemetry.")
|
2017-02-17 14:29:37 +00:00
|
|
|
metricsPath = flag.String("web.telemetry-path", "/metrics", "Path under which to expose metrics.")
|
|
|
|
postfixShowqPath = flag.String("postfix.showq_path", "/var/spool/postfix/public/showq", "Path at which Postfix places its showq socket.")
|
|
|
|
)
|
|
|
|
flag.Parse()
|
|
|
|
|
|
|
|
exporter, err := NewPostfixExporter(*postfixShowqPath)
|
|
|
|
if err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
prometheus.MustRegister(exporter)
|
|
|
|
|
|
|
|
http.Handle(*metricsPath, prometheus.Handler())
|
|
|
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
w.Write([]byte(`
|
|
|
|
<html>
|
|
|
|
<head><title>Postfix Exporter</title></head>
|
|
|
|
<body>
|
|
|
|
<h1>Postfix Exporter</h1>
|
|
|
|
<p><a href='` + *metricsPath + `'>Metrics</a></p>
|
|
|
|
</body>
|
|
|
|
</html>`))
|
|
|
|
})
|
|
|
|
log.Fatal(http.ListenAndServe(*listenAddress, nil))
|
|
|
|
}
|