Skip to content

Commit

Permalink
add a metric exposition utility package
Browse files Browse the repository at this point in the history
[#166889819]

Signed-off-by: Travis Patterson <tpatterson@pivotal.io>
  • Loading branch information
chentom88 authored and Travis Patterson committed Jun 28, 2019
1 parent c3f242a commit 375ed21
Show file tree
Hide file tree
Showing 3 changed files with 305 additions and 0 deletions.
15 changes: 15 additions & 0 deletions metrics/metrics_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package metrics_test

import (
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"log"

"testing"
)

func TestMetrics(t *testing.T) {
log.SetOutput(GinkgoWriter)
RegisterFailHandler(Fail)
RunSpecs(t, "Metrics Suite")
}
161 changes: 161 additions & 0 deletions metrics/prometheus.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package metrics

import (
"fmt"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"log"
"net"
"net/http"
"strings"
"time"
)

// The Registry keeps track of registered counters and gauges. Optionally, it can
// provide a server on a Prometheus-formatted endpoint.
type Registry struct {
port string
defaultTags map[string]string
loggr *log.Logger
}

// A cumulative metric that represents a single monotonically increasing counter
// whose value can only increase or be reset to zero on restart
type Counter interface {
Add(float64)
}

// A single numerical value that can arbitrarily go up and down.
type Gauge interface {
Add(float64)
Set(float64)
}

// Registry will register the metrics route with the default http mux but will not
// start an http server. This is intentional so that we can combine metrics with
// other things like pprof into one server. To start a server
// just for metrics, use the WithServer RegistryOption
func NewRegistry(logger *log.Logger, opts ...RegistryOption) *Registry {
pr := &Registry{
loggr: logger,
defaultTags: make(map[string]string),
}

for _, o := range opts {
o(pr)
}

http.Handle("/metrics", promhttp.Handler())
return pr
}

// Creates new counter. When a duplicate is registered, the Registry will return
// the previously created metric.
func (p *Registry) NewCounter(name string, opts ...MetricOption) Counter {
opt := p.toPromOpt(name, "counter metric", opts...)
c := prometheus.NewCounter(prometheus.CounterOpts(opt))
return p.registerCollector(name, c).(Counter)
}

// Creates new gauge. When a duplicate is registered, the Registry will return
// the previously created metric.
func (p *Registry) NewGauge(name string, opts ...MetricOption) Gauge {
opt := p.toPromOpt(name, "gauge metric", opts...)
g := prometheus.NewGauge(prometheus.GaugeOpts(opt))
return p.registerCollector(name, g).(Gauge)
}

func (p *Registry) registerCollector(name string, c prometheus.Collector) prometheus.Collector {
err := prometheus.DefaultRegisterer.Register(c)
if err != nil {
typ, ok := err.(prometheus.AlreadyRegisteredError)
if !ok {
p.loggr.Panicf("unable to create %s: %s", name, err)
}

return typ.ExistingCollector
}

return c
}

// Get the port of the running metrics server
func (p *Registry) Port() string {
return fmt.Sprint(p.port)
}

func (p *Registry) toPromOpt(name, helpText string, mOpts ...MetricOption) prometheus.Opts {
opt := prometheus.Opts{
Name: name,
Help: helpText,
ConstLabels: make(map[string]string),
}

for _, o := range mOpts {
o(&opt)
}

for k, v := range p.defaultTags {
opt.ConstLabels[k] = v
}

return opt
}

// Options for registry initialization
type RegistryOption func(r *Registry)

// Add Default tags to all gauges and counters created from this registry
func WithDefaultTags(tags map[string]string) RegistryOption {
return func(r *Registry) {
for k, v := range tags {
r.defaultTags[k] = v
}
}
}

// Starts an http server on the given port to host metrics.
func WithServer(port int) RegistryOption {
return func(r *Registry) {
r.start(port)
}
}

func (p *Registry) start(port int) {
addr := fmt.Sprintf("127.0.0.1:%d", port)
s := http.Server{
Addr: addr,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}

lis, err := net.Listen("tcp", addr)
if err != nil {
p.loggr.Fatalf("Unable to setup metrics endpoint (%s): %s", addr, err)
}
p.loggr.Printf("Metrics endpoint is listening on %s", lis.Addr().String())

parts := strings.Split(lis.Addr().String(), ":")
p.port = parts[len(parts)-1]

go s.Serve(lis)
}

// Options applied to metrics on creation
type MetricOption func(o *prometheus.Opts)

// Add these tags to the metrics
func WithMetricTags(tags map[string]string) MetricOption {
return func(o *prometheus.Opts) {
for k, v := range tags {
o.ConstLabels[k] = v
}
}
}

// Add the passed help text to the created metric
func WithHelpText(helpText string) MetricOption {
return func(o *prometheus.Opts) {
o.Help = helpText
}
}
129 changes: 129 additions & 0 deletions metrics/prometheus_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package metrics_test

import (
"code.cloudfoundry.org/go-loggregator/metrics"
"fmt"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"github.com/prometheus/client_golang/prometheus"
"io/ioutil"
"log"
"net/http"
)

var _ = Describe("PrometheusMetrics", func() {

var (
l *log.Logger
)

BeforeEach(func() {
l = log.New(GinkgoWriter, "", log.LstdFlags)

// This is needed because the prom registry will register
// the /metrics route with the default http mux which is
// global
http.DefaultServeMux = new(http.ServeMux)

// Resetting prometheus registry because we use the global
// DefaultRegisterer for the default instrumentation
r := prometheus.NewRegistry()
prometheus.DefaultRegisterer = r
prometheus.DefaultGatherer = r
})

It("serves metrics on a prometheus endpoint", func() {
r := metrics.NewRegistry(l, metrics.WithServer(0))

c := r.NewCounter(
"test_counter",
metrics.WithMetricTags(map[string]string{"foo": "bar"}),
metrics.WithHelpText("a counter help text for test_counter"),
)

g := r.NewGauge(
"test_gauge",
metrics.WithHelpText("a gauge help text for test_gauge"),
metrics.WithMetricTags(map[string]string{"bar": "baz"}),
)

c.Add(10)
g.Set(10)
g.Add(1)

Eventually(func() string { return getMetrics(r.Port()) }).Should(ContainSubstring(`test_counter{foo="bar"} 10`))
Eventually(func() string { return getMetrics(r.Port()) }).Should(ContainSubstring("a counter help text for test_counter"))
Eventually(func() string { return getMetrics(r.Port()) }).Should(ContainSubstring(`test_gauge{bar="baz"} 11`))
Eventually(func() string { return getMetrics(r.Port()) }).Should(ContainSubstring("a gauge help text for test_gauge"))
})

It("accepts custom default tags", func() {
ct := map[string]string{
"tag": "custom",
}

r := metrics.NewRegistry(l, metrics.WithDefaultTags(ct), metrics.WithServer(0))

r.NewCounter(
"test_counter",
metrics.WithHelpText("a counter help text for test_counter"),
)

r.NewGauge(
"test_gauge",
metrics.WithHelpText("a gauge help text for test_gauge"),
)

Eventually(func() string { return getMetrics(r.Port()) }).Should(ContainSubstring(`test_counter{tag="custom"} 0`))
Eventually(func() string { return getMetrics(r.Port()) }).Should(ContainSubstring(`test_gauge{tag="custom"} 0`))
})

It("returns the metric when duplicate is created", func() {
r := metrics.NewRegistry(l, metrics.WithServer(0))

c := r.NewCounter("test_counter")
c2 := r.NewCounter("test_counter")

c.Add(1)
c2.Add(2)

Eventually(func() string {
return getMetrics(r.Port())
}).Should(ContainSubstring(`test_counter 3`))

g := r.NewGauge("test_gauge")
g2 := r.NewGauge("test_gauge")

g.Add(1)
g2.Add(2)

Eventually(func() string {
return getMetrics(r.Port())
}).Should(ContainSubstring(`test_gauge 3`))
})

It("panics if the metric is invalid", func() {
r := metrics.NewRegistry(l)

Expect(func() {
r.NewCounter("test-counter")
}).To(Panic())

Expect(func() {
r.NewGauge("test-counter")
}).To(Panic())
})
})

func getMetrics(port string) string {
addr := fmt.Sprintf("http://127.0.0.1:%s/metrics", port)
resp, err := http.Get(addr)
if err != nil {
return ""
}

respBytes, err := ioutil.ReadAll(resp.Body)
Expect(err).ToNot(HaveOccurred())

return string(respBytes)
}

0 comments on commit 375ed21

Please sign in to comment.