DEV Community

Elton Minetto
Elton Minetto

Posted on • Updated on

Using Prometheus to collect metrics from Golang applications

This text is part of a series of posts I am doing with examples of applications using Clean Architecture. The other posts that are part of this series are:

In this post, I will talk about a very important feature in the complex projects we work with daily: metrics. Among the various solutions on the market for this purpose, one that has gained more prominence is the duo Prometheus + Grafana.

According to the description found on Wikipedia:

Prometheus is a free software application used for event monitoring and alerting. It records real-time metrics in a time series database built using a HTTP pull model, with flexible queries and real-time alerting.

And we can describe Grafana as:

Grafana is a multi-platform open source analytics and interactive visualization software available since 2014. It provides charts, graphs, and alerts for the web when connected to supported data sources.

In short, Prometheus collects data and thanks to Grafana we can create beautiful graphics and dashboards to facilitate the visualization of information.

Creating the Use Cases layer

To make use of this functionality, we need to adapt our codes so they can provide the data that Prometheus will collect and process. As we are using Clean Architecture, the first step we will do is to create a new package in our UseCases layer. Based on the repository: https://github.com/eminetto/clean-architecture-go we started by creating the pkg/metric/interface file .go, whose content is:

package metric

import "time"

//CLI define a CLI app
type CLI struct {
    Name       string
    StartedAt  time.Time
    FinishedAt time.Time
    Duration   float64
}

// NewCLI create a new CLI app
func NewCLI(name string) *CLI {
    return &CLI{
        Name: name,
    }
}

//Started start monitoring the app
func (c *CLI) Started() {
    c.StartedAt = time.Now()
}

// Finished app finished
func (c *CLI) Finished() {
    c.FinishedAt = time.Now()
    c.Duration = time.Since(c.StartedAt).Seconds()
}

//HTTP application
type HTTP struct {
    Handler    string
    Method     string
    StatusCode string
    StartedAt  time.Time
    FinishedAt time.Time
    Duration   float64
}

//NewHTTP create a new HTTP app
func NewHTTP(handler string, method string) *HTTP {
    return &HTTP{
        Handler: handler,
        Method:  method,
    }
}

//Started start monitoring the app
func (h *HTTP) Started() {
    h.StartedAt = time.Now()
}

// Finished app finished
func (h *HTTP) Finished() {
    h.FinishedAt = time.Now()
    h.Duration = time.Since(h.StartedAt).Seconds()
}

//UseCase definition
type UseCase interface {
    SaveCLI(c *CLI) error
    SaveHTTP(h *HTTP)
}
Enter fullscreen mode Exit fullscreen mode

In this file we define two important structures, CLI and HTTP, which are the data we want to collect from our command line applications and our API. We also defined the UseCase interface, which we will implement later, and functions that initialize the structures: NewCLI and NewHTTP. As I mentioned in previous posts, this Clean Architecture tactic allows us to abstract the details of the metrics collection to the other layers of the application. If at any point we change Prometheus’ metric collection solution to any other, we will have no problems, as the other layers expect to receive something that implements the UseCase interface.

We will now implement the interface, creating the file pkg/metric/prometheus.go:

package metric

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/push"
    "github.com/eminetto/clean-architecture-go/config"
)

//Service implements UseCase interface
type Service struct {
    pHistogram           *prometheus.HistogramVec
    httpRequestHistogram *prometheus.HistogramVec
}

//NewPrometheusService create a new prometheus service
func NewPrometheusService() (*Service, error) {
    cli := prometheus.NewHistogramVec(prometheus.HistogramOpts{
        Namespace: "pushgateway",
        Name:      "cmd_duration_seconds",
        Help:      "CLI application execution in seconds",
        Buckets:   prometheus.DefBuckets,
    }, []string{"name"})
    http := prometheus.NewHistogramVec(prometheus.HistogramOpts{
        Namespace: "http",
        Name:      "request_duration_seconds",
        Help:      "The latency of the HTTP requests.",
        Buckets:   prometheus.DefBuckets,
    }, []string{"handler", "method", "code"})

    s := &Service{
        pHistogram:           cli,
        httpRequestHistogram: http,
    }
    err := prometheus.Register(s.pHistogram)
    if err != nil && err.Error() != "duplicate metrics collector registration attempted" {
        return nil, err
    }
    err = prometheus.Register(s.httpRequestHistogram)
    if err != nil && err.Error() != "duplicate metrics collector registration attempted" {
        return nil, err
    }
    return s, nil
}

//SaveCLI send metrics to server
func (s *Service) SaveCLI(c *CLI) error {
    gatewayURL := config.PROMETHEUS_PUSHGATEWAY
    s.pHistogram.WithLabelValues(c.Name).Observe(c.Duration)
    return push.New(gatewayURL, "cmd_job").Collector(s.pHistogram).Push()
}

//SaveHTTP send metrics to server
func (s *Service) SaveHTTP(h *HTTP) {
    s.httpRequestHistogram.WithLabelValues(h.Handler, h.Method, h.StatusCode).Observe(h.Duration)
}
Enter fullscreen mode Exit fullscreen mode

In this file, using the NewPrometheusService function, we have an implementation of theUseCase interface, which we will use in the next steps. We can find the details of each function used in the documentation of the official Go client.

Another important point of this file is the line gatewayURL: = config.PROMETHEUS_PUSHGATEWAY which is inside the functionSaveCLI. Prometheus works as a metric collector, so we need to have a way to store the data in memory, until it does the collection. When we are talking about an application that keeps running, such as an API, this data remains in memory. But with a CLI application, which quits after execution, we have to store this data somewhere. The Prometheus project has a solution for this, the PushGateway. It is a small application that we must run on some server and that will store the data until Prometheus collect it. I will talk about PushGateway again when we configure the docker-compose.yml of the application. In this configuration, we have the address of the PushGateway. I included this variable in the files: config / config_testing.go, config / config_staging.go, config / config_prod.go andconfig / config_dev.go. Check out this post to understand the reason for the existence of these files. For example, the file config / config_dev.go contains:

// +build dev

package config

const (
    MONGODB_HOST            = "mongodb://127.0.0.1:27017"
    MONGODB_DATABASE        = "bookmark"
    MONGODB_CONNECTION_POOL = 5
    API_PORT                = 8080
    PROMETHEUS_PUSHGATEWAY = "http://localhost:9091/"
)
Enter fullscreen mode Exit fullscreen mode

Collecting metrics from CLI applications

We will now start using the service to collect metrics from our CLI application. This is the new code of cmd/main.go file:

package main

import (
    "errors"
    "fmt"
    "github.com/eminetto/clean-architecture-go/pkg/metric"
    "log"
    "os"

    "github.com/eminetto/clean-architecture-go/config"
    "github.com/eminetto/clean-architecture-go/pkg/bookmark"
    "github.com/eminetto/clean-architecture-go/pkg/entity"
    "github.com/juju/mgosession"
    mgo "gopkg.in/mgo.v2"
)

func handleParams() (string, error) {
    if len(os.Args) < 2 {
        return "", errors.New("Invalid query")
    }
    return os.Args[1], nil
}

func main() {
    metricService, err := metric.NewPrometheusService()
    if err != nil {
        log.Fatal(err.Error())
    }
    appMetric := metric.NewCLI("search")
    appMetric.Started()
    query, err := handleParams()
    if err != nil {
        log.Fatal(err.Error())
    }

    session, err := mgo.Dial(config.MONGODB_HOST)
    if err != nil {
        log.Fatal(err.Error())
    }
    defer session.Close()

    mPool := mgosession.NewPool(nil, session, config.MONGODB_CONNECTION_POOL)
    defer mPool.Close()

    bookmarkRepo := bookmark.NewMongoRepository(mPool, config.MONGODB_DATABASE)
    bookmarkService := bookmark.NewService(bookmarkRepo)
    all, err := bookmarkService.Search(query)
    if err != nil {
        log.Fatal(err)
    }
    if len(all) == 0 {
        log.Fatal(entity.ErrNotFound.Error())
    }
    for _, j := range all {
        fmt.Printf("%s %s %v \n", j.Name, j.Link, j.Tags)
    }
    appMetric.Finished()
    err = metricService.SaveCLI(appMetric)
    if err != nil {
        log.Fatal(err)
    }
}

Enter fullscreen mode Exit fullscreen mode

At the beginning of the main function, we create the service with the implementation for Prometheus:

metricService, err := metric.NewPrometheusService()
if err != nil {
    log.Fatal(err.Error())
}
Enter fullscreen mode Exit fullscreen mode

After that, we started the data collecting, giving a name to our application, which we will use in the visualization in Grafana:

appMetric := metric.NewCLI("search")
appMetric.Started()
Enter fullscreen mode Exit fullscreen mode

And at the end of the file, we complete the collection and send the data to the PushGateway:

appMetric.Finished()
err = metricService.SaveCLI(appMetric)
if err != nil {
    log.Fatal(err)
}
Enter fullscreen mode Exit fullscreen mode

Collecting API metrics

Now let’s collect our API metrics. As we want to collect metrics from all endpoints, we can make use of the concept of middlewares. So, we will create the file pkg/middleware/metrics.go:

package middleware

import (
    "net/http"
    "strconv"

    "github.com/eminetto/clean-architecture-go/pkg/metric"

    "github.com/codegangsta/negroni"
)

//Metrics to prometheus
func Metrics(mService metric.UseCase) negroni.HandlerFunc {
   return func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
      appMetric := metric.NewHTTP(r.URL.Path, r.Method)
      appMetric.Started()
      next(w, r)
      res := w.(negroni.ResponseWriter)
      appMetric.Finished()
      appMetric.StatusCode = strconv.Itoa(res.Status())
      mService.SaveHTTP(appMetric)
   }
}
Enter fullscreen mode Exit fullscreen mode

This middleware will receive an implementation of the metric. UseCase interface, start the collection of the request details (execution time and status code) and save the data for future collection. As we are talking about an API, it does this storage in memory, until Prometheus collects and processes it.

We now need to change the main.go of our API, to make use of the new middleware and to create the endpoint that Prometheus will use to collect the metrics. I changed file api/main.go to this:

package main

import (
   "github.com/prometheus/client_golang/prometheus/promhttp"
   "log"
   "net/http"
   "os"
   "strconv"
   "time"

   "github.com/codegangsta/negroni"
   "github.com/eminetto/clean-architecture-go/api/handler"
   "github.com/eminetto/clean-architecture-go/config"
   "github.com/eminetto/clean-architecture-go/pkg/bookmark"
   "github.com/eminetto/clean-architecture-go/pkg/middleware"
   "github.com/eminetto/clean-architecture-go/pkg/metric"
   "github.com/gorilla/context"
   "github.com/gorilla/mux"
   "github.com/juju/mgosession"
   mgo "gopkg.in/mgo.v2"
)

func main() {
   session, err := mgo.Dial(config.MONGODB_HOST)
   if err != nil {
      log.Fatal(err.Error())
   }
   defer session.Close()

   r := mux.NewRouter()

   mPool := mgosession.NewPool(nil, session, config.MONGODB_CONNECTION_POOL)
   defer mPool.Close()

   bookmarkRepo := bookmark.NewMongoRepository(mPool, config.MONGODB_DATABASE)
   bookmarkService := bookmark.NewService(bookmarkRepo)

   metricService, err := metric.NewPrometheusService()
   if err != nil {
      log.Fatal(err.Error())
   }

   //handlers
   n := negroni.New(
      negroni.HandlerFunc(middleware.Cors),
      negroni.HandlerFunc(middleware.Metrics(metricService)),
      negroni.NewLogger(),
   )
   //bookmark
   handler.MakeBookmarkHandlers(r, *n, bookmarkService)

   http.Handle("/", r)
   http.Handle("/metrics", promhttp.Handler())
   r.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
      w.WriteHeader(http.StatusOK)
   })

   logger := log.New(os.Stderr, "logger: ", log.Lshortfile)
   srv := &http.Server{
      ReadTimeout:  5 * time.Second,
      WriteTimeout: 10 * time.Second,
      Addr:         ":" + strconv.Itoa(config.API_PORT),
      Handler:      context.ClearHandler(http.DefaultServeMux),
      ErrorLog:     logger,
   }
   err = srv.ListenAndServe()
   if err != nil {
      log.Fatal(err.Error())
   }
}
Enter fullscreen mode Exit fullscreen mode

The first important change, besides the imports and service startup (as we did in the CLI), was the inclusion of our new middleware in the execution stack, in the lines:

n := negroni.New(
  negroni.HandlerFunc(middleware.Cors),
  negroni.HandlerFunc(middleware.Metrics(metricService)),
  negroni.NewLogger(),
)
Enter fullscreen mode Exit fullscreen mode

And the second change was the creation of an endpoint that Prometheus will use to collect the data:

http.Handle("/metrics", promhttp.Handler())
Enter fullscreen mode Exit fullscreen mode

This is all the changes we need to do in our application to generate the data that Prometheus will use. We will now set up a local environment to facilitate testing.

Configuring Grafana

We will now use Grafana to create visualizations of the data collected by Prometheus.

Accessing the link http://localhost:3000/login we will login with the user admin and the password admin (and generate a new password, as requested by the interface).

After login it is necessary to create a new data source, using the option in the interface. When selecting the Prometheus option, it is necessary to fill in the information:

datasource

In the option Dashboards we need to import the standard dashboards:

datasource_dashboard

Now let's create our first dashboard:

dashboard

Selecting the option Add query we will fill with the data:

dashboard_dados

In the query field we use:

http_request_duration_seconds_count{job="bookmark"} > 0

And in the Legend field we put the information we want to show:

{{handler}} - {{method}} - {{code}}

In this way we will also see what is the method and status code, besides the URL accessed.

In the General option we will give a name for our visualization:

dashboard_title

As we will not create alerts, we can click to go back (a button with an arrow, on the top of the page) to view our updated dashboard.

Now let's add a new panel, with the information from our CLI:

new_panel

And let's create a new query:

dashboard_cli

In the query we put the value:

pushgateway_cmd_duration_seconds_sum
Enter fullscreen mode Exit fullscreen mode

And as a Legend we use:

{{name}}

We can give a name to our new panel, in the General option and return to the dashboard, which now looks like this:

dashboard_final

As the apps collect the metrics, it will update the data on the dashboard. It is possible to add other panels, with more advanced queries. There are more advanced examples in the Prometheus and Grafana documentation.

Conclusion

In this post my goal was to show how relatively simple it is to add the metrics feature to Go applications. An extra point is the fact that we are using Clean Architecture, which allows us to move from Prometheus to another solution by creating a new implementation of the metric.UseCase and just change few configuration lines. These metrics have helped us to better understand the behavior of our application and have facilitated some implementation decisions and improvements. I hope this post can be useful,  so that more projects also have these benefits.

All the codes presented in this post are in the repository https://github.com/eminetto/clean-architecture-go

Top comments (0)