Skip to content

Commit c9d58a9

Browse files
Metrics package
Added metrics package which provides tools useful for gathering and exposing system metrics for external monitoring tools.
1 parent 9fbd0b9 commit c9d58a9

6 files changed

Lines changed: 308 additions & 0 deletions

File tree

pkg/metrics/gauge.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package metrics
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"sync"
7+
"time"
8+
)
9+
10+
// Gauge is a metric type that represents a single numerical value that can
11+
// arbitrarily go up and down.
12+
type Gauge struct {
13+
name string
14+
labels map[string]string
15+
16+
value float64
17+
timestamp int64
18+
mutex sync.RWMutex
19+
}
20+
21+
// Set allows setting the gauge to an arbitrary value.
22+
func (g *Gauge) Set(value float64) {
23+
g.mutex.Lock()
24+
defer g.mutex.Unlock()
25+
26+
g.value = value
27+
g.timestamp = time.Now().UnixNano() / int64(time.Millisecond)
28+
}
29+
30+
// Exposes the gauge in the text-based exposition format.
31+
func (g *Gauge) expose() string {
32+
g.mutex.RLock()
33+
defer g.mutex.RUnlock()
34+
35+
typeLine := fmt.Sprintf("# TYPE %v %v", g.name, "gauge")
36+
37+
labelsStrings := make([]string, 0)
38+
for name, value := range g.labels {
39+
labelsStrings = append(
40+
labelsStrings,
41+
fmt.Sprintf("%v=\"%v\"", name, value),
42+
)
43+
}
44+
labels := strings.Join(labelsStrings, ",")
45+
46+
body := fmt.Sprintf("%v{%v} %v %v", g.name, labels, g.value, g.timestamp)
47+
48+
return fmt.Sprintf("%v\n%v", typeLine, body)
49+
}

pkg/metrics/gauge_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package metrics
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestGaugeSet(t *testing.T) {
8+
gauge := &Gauge{
9+
name: "test_gauge",
10+
labels: map[string]string{"label": "value"},
11+
}
12+
13+
if gauge.value != 0 {
14+
t.Fatal("incorrect gauge initial value")
15+
}
16+
17+
if gauge.timestamp != 0 {
18+
t.Fatal("incorrect gauge initial timestamp")
19+
}
20+
21+
newGaugeValue := float64(500)
22+
23+
gauge.Set(newGaugeValue)
24+
25+
if gauge.value != newGaugeValue {
26+
t.Fatalf(
27+
"incorrect gauge value:\n"+
28+
"expected: [%v]\n"+
29+
"actual: [%v]",
30+
newGaugeValue,
31+
gauge.value,
32+
)
33+
}
34+
35+
if gauge.timestamp == 0 {
36+
t.Fatal("timestamp should be set")
37+
}
38+
}
39+
40+
func TestGaugeExpose(t *testing.T) {
41+
gauge := &Gauge{
42+
name: "test_gauge",
43+
labels: map[string]string{"label": "value"},
44+
value: 500,
45+
timestamp: 1000,
46+
}
47+
48+
actualText := gauge.expose()
49+
50+
expectedText := "# TYPE test_gauge gauge\ntest_gauge{label=\"value\"} 500 1000"
51+
52+
if actualText != expectedText {
53+
t.Fatalf(
54+
"incorrect gauge expose text:\n"+
55+
"expected: [%v]\n"+
56+
"actual: [%v]",
57+
expectedText,
58+
actualText,
59+
)
60+
}
61+
}

pkg/metrics/observer.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package metrics
2+
3+
import (
4+
"context"
5+
"time"
6+
)
7+
8+
// Source defines a source of metric data.
9+
type Source func() float64
10+
11+
// Sink defines a destination of collected metric data.
12+
type Sink interface {
13+
Set(value float64)
14+
}
15+
16+
// Observe triggers a cyclic metric observation goroutine.
17+
func Observe(
18+
ctx context.Context,
19+
source Source,
20+
sink Sink,
21+
tick time.Duration,
22+
) {
23+
go func() {
24+
ticker := time.NewTicker(tick)
25+
defer ticker.Stop()
26+
27+
for {
28+
select {
29+
case <-ticker.C:
30+
sink.Set(source())
31+
case <-ctx.Done():
32+
return
33+
}
34+
}
35+
}()
36+
}

pkg/metrics/observer_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package metrics
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
)
8+
9+
func TestObserve(t *testing.T) {
10+
gauge := &Gauge{}
11+
ctx, _ := context.WithTimeout(context.Background(), 5*time.Millisecond)
12+
13+
Observe(
14+
ctx,
15+
func() float64 {
16+
return 5000
17+
},
18+
gauge,
19+
1*time.Millisecond,
20+
)
21+
22+
<-ctx.Done()
23+
24+
expectedGaugeValue := float64(5000)
25+
if gauge.value != expectedGaugeValue {
26+
t.Fatalf(
27+
"incorrect gauge value:\n"+
28+
"expected: [%v]\n"+
29+
"actual: [%v]",
30+
expectedGaugeValue,
31+
gauge.value,
32+
)
33+
}
34+
35+
if gauge.timestamp == 0 {
36+
t.Fatal("timestamp should be set")
37+
}
38+
}

pkg/metrics/registry.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Package `metrics` provides some tools useful for gathering and
2+
// exposing system metrics for external monitoring tools.
3+
//
4+
// Currently, this package is intended to use with Prometheus but
5+
// can be easily extended if needed. Also, not all Prometheus metric
6+
// types are implemented. The main motivation of creating a custom
7+
// package was a need to avoid usage of external unaudited dependencies.
8+
//
9+
// Following specifications was used as reference:
10+
// - https://prometheus.io/docs/instrumenting/writing_clientlibs/
11+
// - https://prometheus.io/docs/instrumenting/exposition_formats/
12+
package metrics
13+
14+
import (
15+
"fmt"
16+
"io"
17+
"net/http"
18+
"strconv"
19+
"strings"
20+
"sync"
21+
22+
"github.com/ipfs/go-log"
23+
)
24+
25+
var logger = log.Logger("keep-metrics")
26+
27+
type metric interface {
28+
expose() string
29+
}
30+
31+
// Registry performs all management of metrics. Specifically, it allows
32+
// to registering new metrics and exposing them through the metrics server.
33+
type Registry struct {
34+
application string
35+
identifier string
36+
37+
metrics map[string]metric
38+
metricsMutex sync.RWMutex
39+
}
40+
41+
// NewRegistry creates a new metrics registry.
42+
func NewRegistry(application, identifier string) *Registry {
43+
return &Registry{
44+
application: application,
45+
identifier: identifier,
46+
metrics: make(map[string]metric),
47+
}
48+
}
49+
50+
// EnableServer enables the metrics server on the given port. Data will
51+
// be exposed on `/metrics` path.
52+
func (r *Registry) EnableServer(port int) {
53+
server := &http.Server{Addr: ":" + strconv.Itoa(port)}
54+
55+
http.HandleFunc("/metrics", func(response http.ResponseWriter, _ *http.Request) {
56+
if _, err := io.WriteString(response, r.exposeMetrics()); err != nil {
57+
logger.Errorf("could not write response: [%v]", err)
58+
}
59+
})
60+
61+
go func() {
62+
if err := server.ListenAndServe(); err != http.ErrServerClosed {
63+
logger.Errorf("metrics server error: [%v]", err)
64+
}
65+
}()
66+
}
67+
68+
// Exposes all registered metrics in their text format.
69+
func (r *Registry) exposeMetrics() string {
70+
r.metricsMutex.RLock()
71+
defer r.metricsMutex.RUnlock()
72+
73+
metrics := make([]string, 0)
74+
for _, metric := range r.metrics {
75+
metrics = append(metrics, metric.expose())
76+
}
77+
78+
return strings.Join(metrics, "\n\n")
79+
}
80+
81+
// NewGauge creates and registers a new gauge metric which will be exposed
82+
// through the metrics server. In case a metric already exists, an error
83+
// will be returned.
84+
func (r *Registry) NewGauge(name string) (*Gauge, error) {
85+
r.metricsMutex.Lock()
86+
defer r.metricsMutex.Unlock()
87+
88+
if _, exists := r.metrics[name]; exists {
89+
return nil, fmt.Errorf("gauge [%v] already exists", name)
90+
}
91+
92+
gauge := &Gauge{
93+
name: name,
94+
labels: map[string]string{
95+
"application": r.application,
96+
"identifier": r.identifier,
97+
},
98+
}
99+
100+
r.metrics[name] = gauge
101+
return gauge, nil
102+
}

pkg/metrics/registry_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package metrics
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestRegistryNewGauge(t *testing.T) {
8+
registry := NewRegistry("test-app", "test-id")
9+
10+
gauge, err := registry.NewGauge("test-gauge")
11+
if err != nil {
12+
t.Fatal(err)
13+
}
14+
15+
if _, err = registry.NewGauge("test-gauge"); err == nil {
16+
t.Fatalf("should fail when creating gauge with the same name")
17+
}
18+
19+
if _, exists := registry.metrics[gauge.name]; !exists {
20+
t.Fatalf("metric with name [%v] should exist in the registry", gauge.name)
21+
}
22+
}

0 commit comments

Comments
 (0)