Monitoring Instance Metrics in a Golang program

Aviral Jain
5 min readJul 1, 2024

--

Monitoring in Golang

Have you ever felt the need to measure your machine’s metrics while executing some piece of code? You might be running a resource-heavy program or simply executing some recursive algorithm. Today, we will see how we can report metrics of our machine during execution of a piece of code in golang.

We would write down all our monitoring code in a separate package called metrics . Let us create a new file named metrics/monitor.go .

First, let’s start by defining Metric :

package metrics

// monitor.go

// Metrics is an interface that defines the methods that a metric should implement
// Record() - records the metric
// GetValue() - returns the value of the metric
// GetUnit() - returns the unit of the metric
// GetName() - returns the name of the metric
// GetPeak() - returns the peak value of the metric
type Metric interface {
Record() error
GetValues() []float64
GetUnit() string
GetName() string
GetPeak() float64
}

A Metric is simply an interface that implements the above functions. If you think about it, this is all you need from a Metric. Feel free to extend the functionality by adding your own functions :)

With the above picture of Metric in head, let’s define the main monitoring function:

 // monitor.go

// MonitorInstanceMetricsEachInterval monitors the metrics of the queriedMetrics each interval and returns the recorded metrics
// when done is closed.
func MonitorInstanceMetricsEachInterval(interval time.Duration, done chan bool, queriedMetrics []Metric) MetricsMap {
metricRecordTicker := time.NewTicker(interval)
defer metricRecordTicker.Stop()

for {
select {
case <-metricRecordTicker.C:
for _, metric := range queriedMetrics {
metric.Record()
}
case <-done:
recordedMetrics := make(MetricsMap, len(queriedMetrics))
for _, metric := range queriedMetrics {
recordedMetrics[metric.GetName()] = metric
}
return recordedMetrics
}
}
}

The above function takes in 3 arguments:

  1. interval: Cycle duration after which a metric needs to be measured again
  2. done: signal bool channel that tells the function to stop measuring and return the measured metrics
  3. queriedMetrics: specifies the metrics that need to be measured

The execution of the function is quite straightforward. We initialise a ticker metricRecordTicker with the interval value provided. Every time it is triggered, we call metric.Record() for each metric to be measured. Finally, when done signal is received, we return the map of metrics — MetricsMap

// monitor.go

// MetricsMap is a map of metrics name to the metrics object
type MetricsMap map[string]Metric

Now, that the entrypoint functions are ready, let us define a MetricType object that implements the Metric interface.

In this example, we will define the CPUUtilizationMetric , which as evident, measures the cpu utilisation of the instance.

We will create another go package under metrics/recorder where we can add files that define the MetricTypes. We create a file metrics/recorder/cpu.go :

package recorders

// cpu.go

type CPUUtilizationMetric struct {
name string
unit string
utilizationPercentage []float64
}

func GetCPUUtilizationMetricRecorder() *CPUUtilizationMetric {
return &CPUUtilizationMetric{
name: "CPU Utilization",
unit: "%",
utilizationPercentage: []float64{},
}
}

Here we define our CPUUtilizationMetric struct with private fields (fields start with smallcase). We also define a public function GetCPUUtilizationMetricRecorder() that returns the metric object.

Now, using these fields, we can simply define the interface functions. The most important function is the Record() function.

package recorders

// cpu.go
import (
"github.com/shirou/gopsutil/cpu"
"fmt"
)

func (c *CPUUtilizationMetric) Record() error {
// record the cpu utilization
cpuPercent, err := cpu.Percent(0, false)
if err != nil {
return fmt.Errorf("error recording cpu utilization percentage: %v", err)
}
c.utilizationPercentage = append(c.utilizationPercentage, cpuPercent[0])
return nil
}

We use an external library github.com/shirou/gopsutil to record the metrics. You can check out their documentation for more available features.

The Record() function measures the cpu utilization at that instant and appends the reading to the array of the recorder object.

The other function definitions are pretty straightforward:

// cpu.go
func (c *CPUUtilizationMetric) GetValues() []float64 {
return c.utilizationPercentage
}

func (c *CPUUtilizationMetric) GetUnit() string {
return c.unit
}

func (c *CPUUtilizationMetric) GetName() string {
return c.name
}

func (c *CPUUtilizationMetric) GetPeak() float64 {
var peak float64
for _, value := range c.utilizationPercentage {
if value > peak {
peak = value
}
}
return peak
}

With all the functions defined, CPUUtilizationMetric implements the Metric interface.

We can also define another MetricType like the example below:

package recorders

// memory.go
import (
"fmt"
"github.com/shirou/gopsutil/mem"
)

type MemoryUtilizationMetric struct {
name string
unit string
utilizationPercentage []float64
}

func GetMemoryUtilizationMetricRecorder() *MemoryUtilizationMetric {
return &MemoryUtilizationMetric{
name: "Memory Utilization",
unit: "%",
utilizationPercentage: []float64{},
}
}

func (m *MemoryUtilizationMetric) Record() error {
// record the memory utilization
memoryUsage, err := mem.VirtualMemory()
if err != nil {
return fmt.Errorf("error recording memory utilization percentage: %v", err)
}
m.utilizationPercentage = append(m.utilizationPercentage, memoryUsage.UsedPercent)
return nil
}

func (m *MemoryUtilizationMetric) GetValues() []float64 {
return m.utilizationPercentage
}

func (m *MemoryUtilizationMetric) GetUnit() string {
return m.unit
}

func (m *MemoryUtilizationMetric) GetName() string {
return m.name
}

func (m *MemoryUtilizationMetric) GetPeak() float64 {
var peak float64
for _, value := range m.utilizationPercentage {
if value > peak {
peak = value
}
}
return peak
}

Now we can call the MonitorInstanceMetricsEachInterval function and query the defined metrics. Let us take an example of usage in main.go :

package main

import (
"time"
"<root-module>/metrics"
"<root-module>/metrics/recorders"
)

func main(){
// some code execution

// start measuring metrics in a goroutine
metricsMeasureDone := make(chan bool)
go func(done chan bool){
metricsToRecord := []metrics.Metric{
recorders.GetCPUUtilizationMetricRecorder(),
recorders.GetMemoryUtilizationMetricRecorder(),
}
receivedMetrics := metrics.MonitorInstanceMetricsEachInterval(time.Second, done, metricsToRecord)
// report(receivedMetrics)
for metricName, metricValue := range receivedMetrics {
metricReport := fmt.Sprintf("Metric Name: %s,Metric Unit: %s, Metric Values: %v, Peak Value: %v", metricName, metricValue.GetUnit(), metricValue.GetValues(), metricValue.GetPeak())
fmt.Println(metricReport)
}
}(metricsMeasureDone)

someResourceHeavyFunction()
done <- true
close(done)
}

This should give you an output like:

[Metric Name: CPU Utilization,Metric Unit: %, Metric Values: [6.782230061191921 10.651629077039253 9.204758923766095 11.208515968235524 9.58046336496363 12.303829253252173 7.196495620964323 30.006238300935557 13.498452007358791 1.7532874141157708], Peak Value: 30.006238300935557]
[Metric Name: Memory Utilization,Metric Unit: %, Metric Values: [10.561164884229342 10.573267640772166 10.5658647474341 10.564354946819101 10.507871357682115 10.71623601836356 10.685443391304402 10.82320052161176 10.540356422527443 10.519243565540263], Peak Value: 10.82320052161176]

You can extend the functionality by adding more metrics, or define more functions in the Metric interface.

Also, make sure to add a properly formatted report() function to clearly visualise the metrics and send them to a remote place if required. Hope, this article could give you an idea of how to implement an extensible MetricRecorder for your golang program :)

Until next time

Burnerlee

--

--