How to make a simple Prometheus exporter in Go - Part 1

4 minute read Published: 2019-07-20

In this post I want to show how easy can be to write a custom Prometheus exporter in Go.

When someone says you should you should write your own metrics exporter for Prometheus your first question should be "WHY?". There are so many great exporters available today that you need to have a pretty good reason to tackle this problem. Fortunately, "because we can" is a pretty good reason to do almost anything, including write our own metrics exporter.

Just to be clear, I'm not trying to argue that you should I just want to show you that you can do it. Writing an exporter isn't a thing reserved to just a selected few, anyone can do it if they really want it.

First, if you are serious about writing a custom exporter you should read this.

Prometheus expects to receive three data points to every metric. The most important is the metric itself. This include the metric name, its associated labels and the current value. The basic format for a metric is the following:

metric_name{label_name="label_value"} 1

The next two data points are optionals, but keep in mind that Prometheus expects this information to come before the actual metric data. The first optional information is the metric description.

# HELP metric_name some description about the metric

This only exists to give some reference for the users of this metric.

The last piece of information is the metric type, if you don't send this data Prometheus will classify your metric as "untyped".

# TYPE metric_name counter

For more information on metric types you can read this page and for more information on the exposition format you can read this page.

Now that we already covered the basic theory we can start to write some code. In this post we will just create some simple functions that will allow us to generate the specific format that Prometheus expects.

The ideia behind our first implementation is that we will have some function that receives the data and returns an bytes.Buffer that will give us the string we want. This will give us a nice plattform to build some abstractions in the future and will be really easy tom implement right now.

The first step is to create some functions to generate the "# HELP" and "# TYPE" lines:

func CreateHelp(metricName, metricHelp string) *bytes.Buffer {
	str := "# HELP " + metricName + " " + metricHelp + "\n"
	return bytes.NewBufferString(str)
}

func CreateType(metricName, metricType string) *bytes.Buffer {
	str := "# TYPE " + metricName + " " + metricType + "\n"
	return bytes.NewBufferString(str)
}

The most notable flaw in this code the fact that metricType in a string. This can cause a lot o trouble if we aren't careful enogh. One better solution we could adopt would be to use an iota to represent the metric type and make a switch on it. As we are going for the simplest possible solution, we will be skipping this step right now.

Next we need to generate the actual metric line:

func CreateMetric(
	metricName string,
	metricLabels map[string]string,
	metricValue float64) *bytes.Buffer {
	data := ""

	lblString := ""
	for k, v := range metricLabels {
		lblString += "\"" + k + "\"=\"" + v + "\","
	}

	// remove last comma
	lblString = lblString[:len(lblString)-1]

	valueString := strconv.FormatFloat(metricValue, 'E', -1, 64)
	data += metricName + "{" + lblString + "} " + valueString + "\n"
	return bytes.NewBufferString(data)
}

We cut a lot of corners with this implementation, but this should be enough so you can better understand the problem.

With this we now have everything we need in place to start exporting data to a prometheus server. The last piece of code we need is something to actually serve this metrics over HTTP. The following is a really simple implementation:

func main() {
	http.HandleFunc("/metrics", metrics)
	log.Fatal(http.ListenAndServe(":8888", nil))
}

func metrics(w http.ResponseWriter, r *http.Request) {
	name := "first_metric"
	desc := "this is our first metric"
	metricType := "gauge"
	lbls := make(map[string]string)
	lbls["author"] = "Nicolas Zachow"
	lbls["blog"] = "words.zachow.dev"
	value := 1.0

	h := CreateHelp(name, desc)
	w.Write(h.Bytes())
	t := CreateType(name, metricType)
	w.Write(t.Bytes())
	m := CreateMetric(name, lbls, value)
	w.Write(m.Bytes())
}

The final result should look like this:

# HELP first_metric this is our first metric
# TYPE first_metric gauge
first_metric{"author"="Nicolas Zachow","blog"="words.zachow.dev"} 1E+00

What we created so far works but is really cumbersome to use in any project larger than a simple Hello World. For this reason we will create some abstractions on top of this code in the near future.