Metrics are a big slice of the observability pie, allowing teams to gain quantitative insights into the performance of their applications. Prometheus has established itself as a standard for generating and collecting time series data in the cloud-native world.

In this tutorial, we will take a look at how to generate custom metrics and understand Prometheus metric types. We’ll wrap up by making sense of the metrics generated by visualizing.

What’s Prometheus?

Originally developed at SoundCloud in 2012 and later donated to the CNCF in May of 2016, Prometheus is an open-source monitoring and alerting system that stores metrics as time-series data. What really sets Prometheus apart is its powerful query language called PromQL, which allows users to create simple and complex queries on the metrics they export, and the interesting “pull model,” which means the Prometheus server pulls metrics from targets as opposed to the traditional push-based system where applications “push” metrics to a central server.

Prometheus Metric Types

Before we can expose our metrics, it's important to understand the various metric types that Prometheus supports:

Metric Type When to Use Example
Counter Tracks events that continually increase and never decrease. Useful for measuring total counts. Number of total requests served.
Count of successful uploads.
Gauge Represents a current value that can fluctuate up or down. Current number of active connections.
Histogram Captures the distribution of values over time. Helpful for analyzing variations in metrics like request latency or response sizes. Distribution of request processing times
Summary Similar to histograms, it also provides the total count and sum of observed values. Useful for summarizing key statistics. Summary of request latencies (including total count, and percentiles such as p50, p95).


Hopefully, this will give you a clear picture of Prometheus core metric types. With that out of the way, let’s proceed to implementation.

Prerequisites

This tutorial assumes some familiarity with Golang and Kubernetes. Additionally, you will need the following in order to proceed:

Creating a Cluster on Civo

To create a cluster using the Civo CLI, run the following command:

civo k3s create --create-firewall --nodes 1 -m --save --switch --wait exporter-demo

This would launch a one-node Kubernetes cluster in your Civo account, the -m flag would merge the kubeconfig for the new cluster with your existing kubeconfig, --switch points your kube-context to the newly created cluster.

Setting up the development environment

To make the setup process easier, we'll be working with a sample repository that already has some code in place. This will allow us to focus on the important concepts without getting bogged down in project setup.

Clone the sample repository:

git clone git@github.com:s1ntaxe770r/prometheus-http.git & cd prometheus-http

Install the dependencies:

go mod tidy

This will install the major dependencies we need, which is github.com/prometheus/client_golang

Exploring the CodeBase

Within the project directory, there is a single main.go file which serves as the entry point for the application:

package main


import (
   "encoding/json"
   "fmt"
   "math/rand"
   "net/http"
   "time"


   "log/slog"


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


var (
   RequestCounter = prometheus.NewCounter(prometheus.CounterOpts{
       Name: "http_request_count",
       Help: "Total number of HTTP requests received",
   })


   RequestDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
       Name:    "http_request_duration_seconds",
       Help:    "Duration of HTTP requests in seconds",
       Buckets: prometheus.LinearBuckets(0.001, 0.005, 10),
   })
)


type pingResponse struct {
   Message string `json:"message"`
}


func main() {
   // Seed the random number generator for better randomness


   http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
       slog.Info("handling new get request")
       startTime := time.Now()
       RequestCounter.Inc() // Increment request counter


       // Generate random number between 1 and 10
       randomNumber := rand.Intn(10) + 1


       var response pingResponse
       response.Message = "hello"


       if randomNumber < 5 {
           // Sleep for 3 seconds
           time.Sleep(3 * time.Second)
       }


       elapsed := time.Since(startTime)
       RequestDuration.Observe(elapsed.Seconds()) // Observe request duration


       // Set content type and encode response as JSON
       w.Header().Set("Content-Type", "application/json")
       err := json.NewEncoder(w).Encode(response)
       if err != nil {
           fmt.Println("Error encoding response:", err)
           return
       }
       slog.Info("processed request", "duration", elapsed)
   })


   // Register metrics handler
   prometheus.MustRegister(RequestCounter, RequestDuration)


   // Start HTTP server
   slog.Info("Starting server on port 8080")
   http.Handle("/metrics", promhttp.Handler())
   http.ListenAndServe(":8080", nil)
}


Here’s a quick rundown of what’s going on:

Defining Metrics

RequestCounter: This line creates a Prometheus counter named httprequestcount. Counters track the total number of events that have occurred and keep increasing. The Help string provides a description of what the metric represents.

RequestDuration: This line defines a Prometheus histogram named httprequestduration_seconds. Histograms track how many events fall within specific time ranges (buckets). Here, it measures the duration of HTTP requests in seconds. The Buckets option defines the range and number of buckets used for recording durations (0.001 seconds to 0.005 seconds with 10 buckets).

Handling Requests and Recording Metrics

The http.HandleFunc registers a function to handle requests for the ping endpoint.

Inside the handler function:

RequestCounter.Inc(): Whenever a request arrives, this line increments the httprequestcount counter by 1, signifying one more request received.

Simulating Request Duration: The snippet above generates a random number and sleeps for 3 seconds if the number is less than 5. This simulates requests with varying durations.

RequestDuration.Observe(elapsed.Seconds()): After processing the request, the elapsed time since the request started (startTime) is calculated. This duration is then passed to the Observe function of the RequestDuration histogram. This records the request duration in the appropriate bucket based on its value.

Exposing Metrics to Prometheus

prometheus.MustRegister(RequestCounter, RequestDuration): This line registers both the counter and histogram metrics with Prometheus. This makes them accessible for scraping by the Prometheus server.

Starting the Server

http.Handle("/metrics", promhttp.Handler()): This line tells the server to handle requests for the /metrics endpoint using the promhttp.Handler(). This handler is specifically designed by Prometheus to serve metrics data in the format Prometheus expects.

http.ListenAndServe(":8080", nil): Finally, the server starts listening for incoming connections on port 8080.

Containerizing the Exporter

Now that you understand what the code does, let's build a container image.

In this demo, we would be using ttl.sh, an ephemeral container registry that doesn’t require authentication, which makes it easy to use in demos such as these. In production, you’d probably want to use an internal registry or something like DockerHub to store your images.

Within the project directory, run the following commands to build and push the image:

Store the image name as an environment variable:

export IMAGE_NAME=ttl.sh/exporter-demo:1h

Build and Push the image:

docker build --push -t ttl.sh/${IMAGE_NAME}:1h .
⚠️ If you’re on an M series Mac, you might want to add the environment variable below to ensure the image architecture is linux/amd64:
export DOCKER_DEFAULT_PLATFORM="linux/amd64"

Deploying the Exporter

With the image built, we can finally deploy the exporter and run the following command to deploy the exporter:

cat <<EOF | kubectl apply -f -
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: custom-exporter
spec:
  replicas: 3
  selector:
    matchLabels:
    app: custom-exporter
  template:
    metadata:
    labels:
        app: custom-exporter
    spec:
    containers:
    - name: custom-exporter
        image: ttl.sh/exporter-demo:1h
        ports:
        - name: web
        containerPort: 8080
---
kind: Service
apiVersion: v1
metadata:
  name: custom-exporter
  labels:
    app: custom-exporter
  annotations:
    prometheus.io/port: "web"
    prometheus.io/scrape: "true"
spec:
  selector:
    app: custom-exporter
  ports:
  - name: web
    port: 8080
EOF

For the most part, this is a standard Kubernetes deployment and service with a few key differences:

Deployment: The ports section within the container definition specifies a named port. Here, it's named "web" and maps the container port (8080) to a port on the pod (which might be dynamically assigned by Kubernetes).

In Service: The ports section within the Service definition also uses the same name, "web." This defines a Service port that maps to the container port through the pods selected by the Service.

The annotations on the Service tell Prometheus that the "web" port on the pods exposes the metrics endpoint, and Prometheus should scrape metrics from this Service.

Deploying The Prometheus Operator

The Prometheus operator was created to simplify the deployment of Prometheus on Kubernetes. For our use case, we can simplify the creation of Prometheus targets using the ServiceMonitor Custom resource definition(CRD).

Clone the Kube-Prometheus repo, which includes the manifests for the Prometheus operator, custom resource definitions, and Grafana for visualization.

git clone git@github.com:prometheus-operator/kube-prometheus.git  && cd kube-prometheus/

To use the version of the Prometheus operator used in this tutorial, run the following command checkout the following commit:

git checkout  65922b9fd8c3869c06686b44f5f3aa9f96560666

Create a monitoring namespace:

kubectl create ns monitoring

Deploy the Operator:

kubectl apply --server-side -f manifests/setup
kubectl wait \
    --for condition=Established \
    --all CustomResourceDefinition \
    --namespace=monitoring
kubectl apply -f manifests/

Verify the CRDs are installed:

k get crds | grep servicemonitor 

The output is similar to:

servicemonitors.monitoring.coreos.com      2024-05-26T17:40:30Z

Creating a ServiceMonitor

As mentioned earlier, ServiceMonitors ensures the Prometheus operator collects metrics from the target service. Apply the following manifest to create a service monitor that scrapes metrics from our custom exporter:

cat <<EOF | kubectl apply -f -
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: custom-exporter
  labels:
    app: custom-exporter
spec:
  selector:
    matchLabels:
    app: custom-exporter
  endpoints:
  - port: web
EOF

Using spec.selector, we tell the service monitor to scrape pods with the label app=custom-exporter.

Testing the exporter

Before querying the metrics we have, we’ll need to send some traffic to the exporter, beginning by port-forwarding the exporter using kubectl:

kubectl port-forward svc/custom-exporter 8080:8080

Next, use the following command to send a couple of requests to the service:

 for i in $(seq 50); do curl http://localhost:8080/ping; done

This will send 50 requests to the service, which is enough for the next phase, which is visualization.

You might also notice that making a request to /metrics would return a bunch of metrics that we didn’t define in the code.

Example Output:

# HELP promhttp_metric_handler_requests_in_flight Current number of scrapes being served.
# TYPE promhttp_metric_handler_requests_in_flight gauge
promhttp_metric_handler_requests_in_flight 1
# 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"} 1308
promhttp_metric_handler_requests_total{code="500"} 0

The help text provides meaningful information on what each metric is for.

You can verify that Prometheus is scraping metrics from our app by viewing the targets section within the dashboard. To do this, expose Prometheus using kubectl:

kubectl port-forward svc/prometheus-k8s 9090:9090 -n monitoring

Navigate to localhost:9090/targets in your browser, and you should see the following:

Creating a Prometheus Exporter using Go Testing the Exporter 1

Visualizing the Metrics

Now we have some metrics collected, and it’s time to visualize. Earlier on, we deployed the Prometheus operator, which also includes Grafana, which we can use to quickly visualize metrics.

Expose the Grafana dashboard:

kubectl port-forward svc/grafana 3000:3000 -n monitoring

Navigate to http://localhost:3000 in your browser, you should be greeted with a login page:

Exposing the Grafana dashboard

By default, the username and password is admin, soon after, you will be prompted to change it.

Creating a new dashboard

To speed things up, we will be using a dashboard that is created ahead of time. To do this, click on the hamburger menu in the top left corner of the dashboard:

Creating a new Grafana dashboard

Click on the dashboard, and you will be greeted with a new menu:

Grafana Dashboard Menu

Click on New > Import. This should open up a new menu where you can paste or upload a JSON file:

Grafana Dashboard Upload JSON file

There’s a dashboard.json file in the root of the project we cloned earlier. Run the following command to copy it to your clipboard:

On Mac:

cat dashboard.json | pbcopy 

For Linux users:

cat dashboard.json | xclip -selection clipboard

Now paste the contents of the JSON file and click on load. Your dashboard should look something like this:

Final Grafana Dashboard Screen

Upon completing this tutorial, you might want to clean up some of the resources you provided.

Within the kube-prometheus directory, run the following command:

kubectl delete --ignore-not-found=true -f manifests/ -f manifests/setup

Deleting the cluster:

civo k3s rm exporter-demo

Wrapping Up

Metrics remain an indispensable part of the observability stack. In this tutorial, we went over how we can create custom Prometheus metrics and visualize them using Grafana and the Prometheus operator. Looking to learn more about Prometheus and Grafana? Here are some suggestions:

Check out this guide on node monitoring using Prometheus and Grafana. Grafana can also be used for end-to-end tracing. Learn how to do that in this guide.