本文转自:https://medium.com/teamzerolabs/15-steps-to-write-an-application-prometheus-exporter-in-go-9746b4520e26。仅用于学习目的,转载请注明

示例代码地址:https://github.com/teamzerolabs/mirth_channel_exporter

1 背景

exporter是prometheus监控流水线中的核心和灵魂。如果你想使用的exporter没有,则需要自己编写一个。这里将包含编写exporter需要的几个步骤。

2 目标

3 步骤

3.1 搭建Go的环境和包的依赖

go mod init my_first_exporter 
go get github.com/prometheus/client_golang 
go get github.com/joho/godotenv
--> creates go.mod file
--> Installs dependency into the go.mod file

3.2 创建入口文件并导入依赖

创建main.go文件,并写入如下代码:

package main

import (
 "github.com/joho/godotenv"
 "github.com/prometheus/client_golang/prometheus"
 "github.com/prometheus/client_golang/prometheus/promhttp"
)

3.3 写入入口函数main()

func main() {

}

3.4 添加prometheus metrics endpoint并监听服务端口

func main() {
    http.Handle("/metrics", promhttp.Handler())
    log.Fatal(http.ListenAndServe(":9101", nil))
}

3.5 使用curl命令探索外部服务的API

这里要监控的应用是MirthConnect,这里将使用两个API调用:

  1. 获取channel的统计信息
  2. 获取channel的id和名称映射
curl -k --location --request GET 'https://apihost/api/channels/statistics' \
--user admin:admin

curl -k --location --request GET 'https://apihost/api/channels/idsAndNames' \
--user admin:admin

3.6 将curl调用转换为go的HTTP调用,并处理结果解析

本步是整个过程中最难的部分,比如我的端点返回的XML文本,这意味着我需要使用encoding/xml将对内容进行反序列化。

如果转换成功意味着go程序处理结果与curl是一样的。为了完成这个转换需要使用下面的包:

你也可以使用log包将结果迭代后的值输出

3.7 声明prometheus metric description

在prometheus中,每个metric都由下面几部分组成:

例如:

# HELP promhttp_metric_handler_requests_total Total number of scrapes by HTTP status code.
# TYPE promhttp_metric_handler_requests_total counter
promhttp_metric_handler_requests_total{code=”200"} 1.829159e+06
promhttp_metric_handler_requests_total{code=”500"} 0
promhttp_metric_handler_requests_total{code=”503"} 0

对于应用的抓取器,我们会定义prometheus metric description,这包括指标名称/指标标签/指标帮助文本

messagesReceived = prometheus.NewDesc(
 prometheus.BuildFQName(namespace, "", "messages_received_total"),
 "How many messages have been received (per channel).",
 []string{"channel"}, nil,
)

3.8 使用接口声明prometheus exporter的几个部分

自定义exporter需要4个部分:

  1. A structure with member variables一个结构体
  2. A factory method that returns the structure返回结构体的工厂方法
  3. Describe function Describe函数
  4. Collect function Collect函数
type Exporter struct {
 mirthEndpoint, mirthUsername, mirthPassword string
}

func NewExporter(mirthEndpoint string, mirthUsername string, mirthPassword string) *Exporter {
 return &Exporter{
  mirthEndpoint: mirthEndpoint,
  mirthUsername: mirthUsername,
  mirthPassword: mirthPassword,
 }
}
func (e *Exporter) Describe(ch chan<- *prometheus.Desc) {
}
func (e *Exporter) Collect(ch chan<- prometheus.Metric) {
}

3.9 在Describe函数中发送3.7的metric description

func (e *Exporter) Describe(ch chan<- *prometheus.Desc) {
 ch <- up
 ch <- messagesReceived
 ch <- messagesFiltered
 ch <- messagesQueued
 ch <- messagesSent
 ch <- messagesErrored
}

3.10 将3.6步API的调用逻辑放到collect函数中

这里继续采用3.6步的逻辑。不同于3.6将内容输出到屏幕,而是将内容发送到prometheus.Metric通道:

func (e *Exporter) Collect(ch chan<- prometheus.Metric) {
 channelIdNameMap, err := e.LoadChannelIdNameMap()
 if err != nil {
  ch <- prometheus.MustNewConstMetric(
   up, prometheus.GaugeValue, 0,
  )
  log.Println(err)
  return
 }
 ch <- prometheus.MustNewConstMetric(
  up, prometheus.GaugeValue, 1,
 )

 e.HitMirthRestApisAndUpdateMetrics(channelIdNameMap, ch)
}

当调用API的时候,确保使用prometheus.MustNewConstMetric(prometheus.Desc, metric type, measurement)发送到measurements

对于需要传入外部标签的场景,按下面的做法在参数列表的末尾添加外部的标签

channelError, _ := strconv.ParseFloat(channelStatsList.Channels[i].Error, 64)
ch <- prometheus.MustNewConstMetric(
 messagesErrored, prometheus.GaugeValue, channelError, channelName,
)

3.11 在main函数中声明exporter并注册

exporter := NewExporter(mirthEndpoint, mirthUsername, mirthPassword)
prometheus.MustRegister(exporter)

到此exporter就可以使用了,每次请求metrics路由的时候,将调用API,并以prometheus文本文件的格式返回结果。下面的步骤是使得exporter的部署更加容易。

3.12 将硬编码的API路径转换为flags的形式

目前,应用的base url,metric route url,exporter端口都是采用硬编码的方式。可以使用命令行参数的方式将程序变得更加灵活

var (
listenAddress = flag.String("web.listen-address", ":9141",
 "Address to listen on for telemetry")
metricsPath = flag.String("web.telemetry-path", "/metrics",
 "Path under which to expose metrics")
)
func main() {
   flag.Parse()
   ...
   http.Handle(*metricsPath, promhttp.Handler())
   log.Fatal(http.ListenAndServe(*listenAddress, nil))
}

3.13 将加密信息移到环境变量中

如果应用的endpoint或者登录密码等发生变化怎么办?我们可以从环境变量中加载。在本示例中,使用godotenv包来帮助存储变量值:

import (
  "os"
)
func main() {
 err := godotenv.Load()
 if err != nil {
  log.Println("Error loading .env file, assume env variables are set.")
 }
 mirthEndpoint := os.Getenv("MIRTH_ENDPOINT")
 mirthUsername := os.Getenv("MIRTH_USERNAME")
 mirthPassword := os.Getenv("MIRTH_PASSWORD")
}

3.14 编写Makefile用于不同平台的快速构建

Makefile可以让你在开发过程中节省大量的输入。对于需要在多平台上构建的exporter(testing on windows/mac, running in Linux),可以使用下面的内容:

linux:
   GOOS=linux GOARCH=amd64 go build
mac:
   GOOS=darwin GOARCH=amd64 go build

因此简单的make mac或make linux就会生成不同的执行文件

3.15 编写service文件让程序在后台运行

取决于exporter的运行环境,你可以写个service文件或者是Dockerfile。下面是一个Centos 7 service文件的示例:

[Unit]
Description=mirth channel exporter
After=network.target
StartLimitIntervalSec=0
[Service]
Type=simple
Restart=always
RestartSec=1
WorkingDirectory=/mirth/mirthconnect
EnvironmentFile=/etc/sysconfig/mirth_channel_exporter
ExecStart=/mirth/mirthconnect/mirth_channel_exporter

[Install]
WantedBy=multi-user.target

到这里就全部结束了。整个过程中只有第6步比较困难,如果你直到哪些API可用,并且直到如何解析它们,剩下的步骤就水到渠成了。

完成代码如下:

// A minimal example of how to include Prometheus instrumentation.
package main

import (
	"crypto/tls"
	"encoding/xml"
	"flag"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"strconv"

	"github.com/joho/godotenv"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

/*
<map>
  <entry>
    <string>101af57f-f26c-40d3-86a3-309e74b93512</string>
    <string>Send-Email-Notification</string>
  </entry>
</map>
*/
type ChannelIdNameMap struct {
	XMLName xml.Name       `xml:"map"`
	Entries []ChannelEntry `xml:"entry"`
}
type ChannelEntry struct {
	XMLName xml.Name `xml:"entry"`
	Values  []string `xml:"string"`
}

/*
<list>
  <channelStatistics>
    <serverId>c5e6a736-0e88-46a7-bf32-5b4908c4d859</serverId>
    <channelId>101af57f-f26c-40d3-86a3-309e74b93512</channelId>
    <received>0</received>
    <sent>0</sent>
    <error>0</error>
    <filtered>0</filtered>
    <queued>0</queued>
  </channelStatistics>
</list>
*/
type ChannelStatsList struct {
	XMLName  xml.Name       `xml:"list"`
	Channels []ChannelStats `xml:"channelStatistics"`
}
type ChannelStats struct {
	XMLName   xml.Name `xml:"channelStatistics"`
	ServerId  string   `xml:"serverId"`
	ChannelId string   `xml:"channelId"`
	Received  string   `xml:"received"`
	Sent      string   `xml:"sent"`
	Error     string   `xml:"error"`
	Filtered  string   `xml:"filtered"`
	Queued    string   `xml:"queued"`
}

const namespace = "mirth"
const channelIdNameApi = "/api/channels/idsAndNames"
const channelStatsApi = "/api/channels/statistics"

var (
	tr = &http.Transport{
		TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
	}
	client = &http.Client{Transport: tr}

	listenAddress = flag.String("web.listen-address", ":9141",
		"Address to listen on for telemetry")
	metricsPath = flag.String("web.telemetry-path", "/metrics",
		"Path under which to expose metrics")

	// Metrics
	up = prometheus.NewDesc(
		prometheus.BuildFQName(namespace, "", "up"),
		"Was the last Mirth query successful.",
		nil, nil,
	)
	messagesReceived = prometheus.NewDesc(
		prometheus.BuildFQName(namespace, "", "messages_received_total"),
		"How many messages have been received (per channel).",
		[]string{"channel"}, nil,
	)
	messagesFiltered = prometheus.NewDesc(
		prometheus.BuildFQName(namespace, "", "messages_filtered_total"),
		"How many messages have been filtered (per channel).",
		[]string{"channel"}, nil,
	)
	messagesQueued = prometheus.NewDesc(
		prometheus.BuildFQName(namespace, "", "messages_queued"),
		"How many messages are currently queued (per channel).",
		[]string{"channel"}, nil,
	)
	messagesSent = prometheus.NewDesc(
		prometheus.BuildFQName(namespace, "", "messages_sent_total"),
		"How many messages have been sent (per channel).",
		[]string{"channel"}, nil,
	)
	messagesErrored = prometheus.NewDesc(
		prometheus.BuildFQName(namespace, "", "messages_errored_total"),
		"How many messages have errored (per channel).",
		[]string{"channel"}, nil,
	)
)

type Exporter struct {
	mirthEndpoint, mirthUsername, mirthPassword string
}

func NewExporter(mirthEndpoint string, mirthUsername string, mirthPassword string) *Exporter {
	return &Exporter{
		mirthEndpoint: mirthEndpoint,
		mirthUsername: mirthUsername,
		mirthPassword: mirthPassword,
	}
}

func (e *Exporter) Describe(ch chan<- *prometheus.Desc) {
	ch <- up
	ch <- messagesReceived
	ch <- messagesFiltered
	ch <- messagesQueued
	ch <- messagesSent
	ch <- messagesErrored
}

func (e *Exporter) Collect(ch chan<- prometheus.Metric) {
	channelIdNameMap, err := e.LoadChannelIdNameMap()
	if err != nil {
		ch <- prometheus.MustNewConstMetric(
			up, prometheus.GaugeValue, 0,
		)
		log.Println(err)
		return
	}
	ch <- prometheus.MustNewConstMetric(
		up, prometheus.GaugeValue, 1,
	)

	e.HitMirthRestApisAndUpdateMetrics(channelIdNameMap, ch)
}

func (e *Exporter) LoadChannelIdNameMap() (map[string]string, error) {
	// Create the map of channel id to names
	channelIdNameMap := make(map[string]string)

	req, err := http.NewRequest("GET", e.mirthEndpoint+channelIdNameApi, nil)
	if err != nil {
		return nil, err
	}

	// This one line implements the authentication required for the task.
	req.SetBasicAuth(e.mirthUsername, e.mirthPassword)
	// Make request and show output.
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}

	body, err := ioutil.ReadAll(resp.Body)
	resp.Body.Close()
	if err != nil {
		return nil, err
	}
	// fmt.Println(string(body))

	// we initialize our array
	var channelIdNameMapXML ChannelIdNameMap
	// we unmarshal our byteArray which contains our
	// xmlFiles content into 'users' which we defined above
	err = xml.Unmarshal(body, &channelIdNameMapXML)
	if err != nil {
		return nil, err
	}

	for i := 0; i < len(channelIdNameMapXML.Entries); i++ {
		channelIdNameMap[channelIdNameMapXML.Entries[i].Values[0]] = channelIdNameMapXML.Entries[i].Values[1]
	}

	return channelIdNameMap, nil
}

func (e *Exporter) HitMirthRestApisAndUpdateMetrics(channelIdNameMap map[string]string, ch chan<- prometheus.Metric) {
	// Load channel stats
	req, err := http.NewRequest("GET", e.mirthEndpoint+channelStatsApi, nil)
	if err != nil {
		log.Fatal(err)
	}

	// This one line implements the authentication required for the task.
	req.SetBasicAuth(e.mirthUsername, e.mirthPassword)
	// Make request and show output.
	resp, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}

	body, err := ioutil.ReadAll(resp.Body)
	resp.Body.Close()
	if err != nil {
		log.Fatal(err)
	}
	// fmt.Println(string(body))

	// we initialize our array
	var channelStatsList ChannelStatsList
	// we unmarshal our byteArray which contains our
	// xmlFiles content into 'users' which we defined above
	err = xml.Unmarshal(body, &channelStatsList)
	if err != nil {
		log.Fatal(err)
	}

	for i := 0; i < len(channelStatsList.Channels); i++ {
		channelName := channelIdNameMap[channelStatsList.Channels[i].ChannelId]

		channelReceived, _ := strconv.ParseFloat(channelStatsList.Channels[i].Received, 64)
		ch <- prometheus.MustNewConstMetric(
			messagesReceived, prometheus.GaugeValue, channelReceived, channelName,
		)

		channelSent, _ := strconv.ParseFloat(channelStatsList.Channels[i].Sent, 64)
		ch <- prometheus.MustNewConstMetric(
			messagesSent, prometheus.GaugeValue, channelSent, channelName,
		)

		channelError, _ := strconv.ParseFloat(channelStatsList.Channels[i].Error, 64)
		ch <- prometheus.MustNewConstMetric(
			messagesErrored, prometheus.GaugeValue, channelError, channelName,
		)

		channelFiltered, _ := strconv.ParseFloat(channelStatsList.Channels[i].Filtered, 64)
		ch <- prometheus.MustNewConstMetric(
			messagesFiltered, prometheus.GaugeValue, channelFiltered, channelName,
		)

		channelQueued, _ := strconv.ParseFloat(channelStatsList.Channels[i].Queued, 64)
		ch <- prometheus.MustNewConstMetric(
			messagesQueued, prometheus.GaugeValue, channelQueued, channelName,
		)
	}

	log.Println("Endpoint scraped")
}

func main() {
	err := godotenv.Load()
	if err != nil {
		log.Println("Error loading .env file, assume env variables are set.")
	}

	flag.Parse()

	mirthEndpoint := os.Getenv("MIRTH_ENDPOINT")
	mirthUsername := os.Getenv("MIRTH_USERNAME")
	mirthPassword := os.Getenv("MIRTH_PASSWORD")

	exporter := NewExporter(mirthEndpoint, mirthUsername, mirthPassword)
	prometheus.MustRegister(exporter)

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