Metrics interface and Prometheus implementation (#1929)

* Metrics interface

* Prometheus implementation

* NoOp implementation

Co-authored-by: chris <chris@Profanity.local>
This commit is contained in:
Prawn 2020-08-18 19:27:50 +12:00 committed by GitHub
parent e1248f90f4
commit da4159513e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 577 additions and 1 deletions

2
go.mod
View File

@ -52,6 +52,7 @@ require (
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.7.0
github.com/soheilhy/cmux v0.1.4 // indirect
github.com/stretchr/testify v1.5.1
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf
@ -69,4 +70,3 @@ require (
)
replace google.golang.org/grpc => google.golang.org/grpc v1.26.0

5
go.sum
View File

@ -74,6 +74,7 @@ github.com/cenkalti/backoff/v4 v4.0.0 h1:6VeaLF9aI+MAUQ95106HwWzYZgJJpZ4stumjj6R
github.com/cenkalti/backoff/v4 v4.0.0/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg=
github.com/census-instrumentation/opencensus-proto v0.2.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE=
github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ=
@ -407,17 +408,20 @@ github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.1.0 h1:BQ53HtBmfOitExawJ6LokA4x8ov/z0SYYb0+HxJfRI8=
github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
github.com/prometheus/client_golang v1.7.0 h1:wCi7urQOGBsYcQROHqpUUX4ct84xp40t9R9JX0FuA/U=
github.com/prometheus/client_golang v1.7.0/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.6.0 h1:kRhiuYSXR3+uv2IbVbZhUxK5zVD/2pp3Gd2PpvPkpEo=
github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc=
github.com/prometheus/common v0.10.0 h1:RyRA7RzGXQZiW+tGMr7sxa85G1z0yOpM1qq5c8lNawc=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
@ -425,6 +429,7 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT
github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
github.com/prometheus/procfs v0.0.5 h1:3+auTFlqw+ZaQYJARz6ArODtkaIwtvBTx3N2NehQlL8=
github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
github.com/prometheus/procfs v0.1.3 h1:F0+tqvhOksq22sc6iCHF5WGlWjdwj92p0udFh1VFBS8=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2/go.mod h1:7tZKcyumwBO6qip7RNQ5r77yrssm9bfCowcLEBcU5IA=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=

22
metrics/README.md Normal file
View File

@ -0,0 +1,22 @@
metrics
=======
The metrics package provides a simple metrics "Reporter" interface which allows the user to submit counters, gauges and timings (along with key/value tags).
Implementations
---------------
* Prometheus (pull): will be first
* Prometheus (push): certainly achievable
* InfluxDB: could quite easily be done
* Telegraf: almost identical to the InfluxDB implementation
* Micro: Could we provide metrics over Micro's server interface?
Todo
----
* Include a handler middleware which uses the Reporter interface to generate per-request level metrics
- Throughput
- Errors
- Duration

37
metrics/noop/reporter.go Normal file
View File

@ -0,0 +1,37 @@
package noop
import (
"time"
log "github.com/micro/go-micro/v3/logger"
"github.com/micro/go-micro/v3/metrics"
)
// Reporter is an implementation of metrics.Reporter:
type Reporter struct {
options metrics.Options
}
// New returns a configured noop reporter:
func New(opts ...metrics.Option) *Reporter {
log.Info("Metrics/NoOp - not doing anything")
return &Reporter{
options: metrics.NewOptions(opts...),
}
}
// Count implements the metrics.Reporter interface Count method:
func (r *Reporter) Count(metricName string, value int64, tags metrics.Tags) error {
return nil
}
// Gauge implements the metrics.Reporter interface Gauge method:
func (r *Reporter) Gauge(metricName string, value float64, tags metrics.Tags) error {
return nil
}
// Timing implements the metrics.Reporter interface Timing method:
func (r *Reporter) Timing(metricName string, value time.Duration, tags metrics.Tags) error {
return nil
}

View File

@ -0,0 +1,20 @@
package noop
import (
"testing"
"github.com/micro/go-micro/v3/metrics"
"github.com/stretchr/testify/assert"
)
func TestNoopReporter(t *testing.T) {
// Make a Reporter:
reporter := New(metrics.Path("/noop"))
assert.NotNil(t, reporter)
assert.Equal(t, "/noop", reporter.options.Path)
// Check that our implementation is valid:
assert.Implements(t, new(metrics.Reporter), reporter)
}

65
metrics/options.go Normal file
View File

@ -0,0 +1,65 @@
package metrics
var (
// The Prometheus metrics will be made available on this port:
defaultPrometheusListenAddress = ":9000"
// This is the endpoint where the Prometheus metrics will be made available ("/metrics" is the default with Prometheus):
defaultPath = "/metrics"
// timingObjectives is the default spread of stats we maintain for timings / histograms:
defaultTimingObjectives = map[float64]float64{0.0: 0, 0.5: 0.05, 0.75: 0.04, 0.90: 0.03, 0.95: 0.02, 0.98: 0.001, 1: 0}
)
// Option powers the configuration for metrics implementations:
type Option func(*Options)
// Options for metrics implementations:
type Options struct {
Address string
Path string
DefaultTags Tags
TimingObjectives map[float64]float64
}
// NewOptions prepares a set of options:
func NewOptions(opt ...Option) Options {
opts := Options{
Address: defaultPrometheusListenAddress,
DefaultTags: make(Tags),
Path: defaultPath,
TimingObjectives: defaultTimingObjectives,
}
for _, o := range opt {
o(&opts)
}
return opts
}
// Path used to serve metrics over HTTP:
func Path(value string) Option {
return func(o *Options) {
o.Path = value
}
}
// Address is the listen address to serve metrics on:
func Address(value string) Option {
return func(o *Options) {
o.Address = value
}
}
// DefaultTags will be added to every metric:
func DefaultTags(value Tags) Option {
return func(o *Options) {
o.DefaultTags = value
}
}
// TimingObjectives defines the desired spread of statistics for histogram / timing metrics:
func TimingObjectives(value map[float64]float64) Option {
return func(o *Options) {
o.TimingObjectives = value
}
}

18
metrics/options_test.go Normal file
View File

@ -0,0 +1,18 @@
package metrics
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestOptions(t *testing.T) {
// Make some new options:
options := NewOptions(Path("/prometheus"), DefaultTags(map[string]string{"service": "prometheus-test"}))
// Check that the defaults and overrides were accepted:
assert.Equal(t, ":9000", options.Address)
assert.Equal(t, "/prometheus", options.Path)
assert.Equal(t, "prometheus-test", options.DefaultTags["service"])
}

View File

@ -0,0 +1,26 @@
Prometheus
==========
A Prometheus "pull" based implementation of the metrics Reporter interface.
Capabilities
------------
* Go runtime metrics are handled natively by the Prometheus client library (CPU / MEM / GC / GoRoutines etc).
* User-defined metrics are registered in the Prometheus client dynamically (they must be pre-registered, hence all of the faffing around in metric_family.go).
* The metrics are made available on a Prometheus-compatible HTTP endpoint, which can be scraped at any time. This means that the user can very easily access stats even running locally as a standalone binary.
* Requires a micro.Server parameter (from which it gathers the service name and version). These are included as tags with every metric.
Usage
-----
```golang
prometheusReporter := metrics.New(server)
tags := metrics.Tags{"greeter": "Janos"}
err := prometheusReporter.Count("hellos", 1, tags)
if err != nil {
fmt.Printf("Error setting a Count metric: %v", err)
}
```

View File

@ -0,0 +1,109 @@
package prometheus
import (
"sync"
"github.com/prometheus/client_golang/prometheus"
)
// metricFamily stores our cached metrics:
type metricFamily struct {
counters map[string]*prometheus.CounterVec
gauges map[string]*prometheus.GaugeVec
timings map[string]*prometheus.SummaryVec
defaultLabels prometheus.Labels
mutex sync.Mutex
prometheusRegistry *prometheus.Registry
timingObjectives map[float64]float64
}
// newMetricFamily returns a new metricFamily (useful in case we want to change the structure later):
func (r *Reporter) newMetricFamily() metricFamily {
return metricFamily{
counters: make(map[string]*prometheus.CounterVec),
gauges: make(map[string]*prometheus.GaugeVec),
timings: make(map[string]*prometheus.SummaryVec),
defaultLabels: r.convertTags(r.options.DefaultTags),
prometheusRegistry: r.prometheusRegistry,
timingObjectives: r.options.TimingObjectives,
}
}
// getCounter either gets a counter, or makes a new one:
func (mf *metricFamily) getCounter(name string, labelNames []string) *prometheus.CounterVec {
mf.mutex.Lock()
defer mf.mutex.Unlock()
// See if we already have this counter:
counter, ok := mf.counters[name]
if !ok {
// Make a new counter:
counter = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: name,
ConstLabels: mf.defaultLabels,
},
labelNames,
)
// Register it and add it to our list:
mf.prometheusRegistry.MustRegister(counter)
mf.counters[name] = counter
}
return counter
}
// getGauge either gets a gauge, or makes a new one:
func (mf *metricFamily) getGauge(name string, labelNames []string) *prometheus.GaugeVec {
mf.mutex.Lock()
defer mf.mutex.Unlock()
// See if we already have this gauge:
gauge, ok := mf.gauges[name]
if !ok {
// Make a new gauge:
gauge = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: name,
ConstLabels: mf.defaultLabels,
},
labelNames,
)
// Register it and add it to our list:
mf.prometheusRegistry.MustRegister(gauge)
mf.gauges[name] = gauge
}
return gauge
}
// getTiming either gets a timing, or makes a new one:
func (mf *metricFamily) getTiming(name string, labelNames []string) *prometheus.SummaryVec {
mf.mutex.Lock()
defer mf.mutex.Unlock()
// See if we already have this timing:
timing, ok := mf.timings[name]
if !ok {
// Make a new timing:
timing = prometheus.NewSummaryVec(
prometheus.SummaryOpts{
Name: name,
ConstLabels: mf.defaultLabels,
Objectives: mf.timingObjectives,
},
labelNames,
)
// Register it and add it to our list:
mf.prometheusRegistry.MustRegister(timing)
mf.timings[name] = timing
}
return timing
}

View File

@ -0,0 +1,68 @@
package prometheus
import (
"errors"
"time"
"github.com/micro/go-micro/v3/metrics"
)
// ErrPrometheusPanic is a catch-all for the panics which can be thrown by the Prometheus client:
var ErrPrometheusPanic = errors.New("The Prometheus client panicked. Did you do something like change the tag cardinality or the type of a metric?")
// Count is a counter with key/value tags:
// New values are added to any previous one (eg "number of hits")
func (r *Reporter) Count(name string, value int64, tags metrics.Tags) (err error) {
defer func() {
if r := recover(); r != nil {
err = ErrPrometheusPanic
}
}()
counter := r.metrics.getCounter(r.stripUnsupportedCharacters(name), r.listTagKeys(tags))
metric, err := counter.GetMetricWith(r.convertTags(tags))
if err != nil {
return err
}
metric.Add(float64(value))
return err
}
// Gauge is a register with key/value tags:
// New values simply override any previous one (eg "current connections")
func (r *Reporter) Gauge(name string, value float64, tags metrics.Tags) (err error) {
defer func() {
if r := recover(); r != nil {
err = ErrPrometheusPanic
}
}()
gauge := r.metrics.getGauge(r.stripUnsupportedCharacters(name), r.listTagKeys(tags))
metric, err := gauge.GetMetricWith(r.convertTags(tags))
if err != nil {
return err
}
metric.Set(value)
return err
}
// Timing is a histogram with key/valye tags:
// New values are added into a series of aggregations
func (r *Reporter) Timing(name string, value time.Duration, tags metrics.Tags) (err error) {
defer func() {
if r := recover(); r != nil {
err = ErrPrometheusPanic
}
}()
timing := r.metrics.getTiming(r.stripUnsupportedCharacters(name), r.listTagKeys(tags))
metric, err := timing.GetMetricWith(r.convertTags(tags))
if err != nil {
return err
}
metric.Observe(value.Seconds())
return err
}

View File

@ -0,0 +1,69 @@
package prometheus
import (
"net/http"
"strings"
log "github.com/micro/go-micro/v3/logger"
"github.com/micro/go-micro/v3/metrics"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// Reporter is an implementation of metrics.Reporter:
type Reporter struct {
options metrics.Options
prometheusRegistry *prometheus.Registry
metrics metricFamily
}
// New returns a configured prometheus reporter:
func New(opts ...metrics.Option) (*Reporter, error) {
options := metrics.NewOptions(opts...)
// Make a prometheus registry (this keeps track of any metrics we generate):
prometheusRegistry := prometheus.NewRegistry()
prometheusRegistry.Register(prometheus.NewGoCollector())
prometheusRegistry.Register(prometheus.NewProcessCollector(prometheus.ProcessCollectorOpts{Namespace: "goruntime"}))
// Make a new Reporter:
newReporter := &Reporter{
options: options,
prometheusRegistry: prometheusRegistry,
}
// Add metrics families for each type:
newReporter.metrics = newReporter.newMetricFamily()
// Handle the metrics endpoint with prometheus:
log.Infof("Metrics/Prometheus [http] Listening on %s%s", options.Address, options.Path)
http.Handle(options.Path, promhttp.HandlerFor(prometheusRegistry, promhttp.HandlerOpts{ErrorHandling: promhttp.ContinueOnError}))
go http.ListenAndServe(options.Address, nil)
return newReporter, nil
}
// convertTags turns Tags into prometheus labels:
func (r *Reporter) convertTags(tags metrics.Tags) prometheus.Labels {
labels := prometheus.Labels{}
for key, value := range tags {
labels[key] = r.stripUnsupportedCharacters(value)
}
return labels
}
// listTagKeys returns a list of tag keys (we need to provide this to the Prometheus client):
func (r *Reporter) listTagKeys(tags metrics.Tags) (labelKeys []string) {
for key := range tags {
labelKeys = append(labelKeys, key)
}
return
}
// stripUnsupportedCharacters cleans up a metrics key or value:
func (r *Reporter) stripUnsupportedCharacters(metricName string) string {
valueWithoutDots := strings.Replace(metricName, ".", "_", -1)
valueWithoutCommas := strings.Replace(valueWithoutDots, ",", "_", -1)
valueWIthoutSpaces := strings.Replace(valueWithoutCommas, " ", "", -1)
return valueWIthoutSpaces
}

View File

@ -0,0 +1,73 @@
package prometheus
import (
"testing"
"time"
"github.com/micro/go-micro/v3/metrics"
"github.com/stretchr/testify/assert"
)
func TestPrometheusReporter(t *testing.T) {
// Make a Reporter:
reporter, err := New(metrics.Path("/prometheus"), metrics.DefaultTags(map[string]string{"service": "prometheus-test"}))
assert.NoError(t, err)
assert.NotNil(t, reporter)
assert.Equal(t, "prometheus-test", reporter.options.DefaultTags["service"])
assert.Equal(t, ":9000", reporter.options.Address)
assert.Equal(t, "/prometheus", reporter.options.Path)
// Check that our implementation is valid:
assert.Implements(t, new(metrics.Reporter), reporter)
// Test tag conversion:
tags := metrics.Tags{
"tag1": "false",
"tag2": "true",
}
convertedTags := reporter.convertTags(tags)
assert.Equal(t, "false", convertedTags["tag1"])
assert.Equal(t, "true", convertedTags["tag2"])
// Test tag enumeration:
listedTags := reporter.listTagKeys(tags)
assert.Contains(t, listedTags, "tag1")
assert.Contains(t, listedTags, "tag2")
// Test string cleaning:
preparedMetricName := reporter.stripUnsupportedCharacters("some.kind,of tag")
assert.Equal(t, "some_kind_oftag", preparedMetricName)
// Test MetricFamilies:
metricFamily := reporter.newMetricFamily()
// Counters:
assert.NotNil(t, metricFamily.getCounter("testCounter", []string{"test", "counter"}))
assert.Len(t, metricFamily.counters, 1)
// Gauges:
assert.NotNil(t, metricFamily.getGauge("testGauge", []string{"test", "gauge"}))
assert.Len(t, metricFamily.gauges, 1)
// Timings:
assert.NotNil(t, metricFamily.getTiming("testTiming", []string{"test", "timing"}))
assert.Len(t, metricFamily.timings, 1)
// Test submitting metrics through the interface methods:
assert.NoError(t, reporter.Count("test.counter.1", 6, tags))
assert.NoError(t, reporter.Count("test.counter.2", 19, tags))
assert.NoError(t, reporter.Count("test.counter.1", 5, tags))
assert.NoError(t, reporter.Gauge("test.gauge.1", 99, tags))
assert.NoError(t, reporter.Gauge("test.gauge.2", 55, tags))
assert.NoError(t, reporter.Gauge("test.gauge.1", 98, tags))
assert.NoError(t, reporter.Timing("test.timing.1", time.Second, tags))
assert.NoError(t, reporter.Timing("test.timing.2", time.Minute, tags))
assert.Len(t, reporter.metrics.counters, 2)
assert.Len(t, reporter.metrics.gauges, 2)
assert.Len(t, reporter.metrics.timings, 2)
// Test reading back the metrics:
// This could be done by hitting the /metrics endpoint
}

13
metrics/reporter.go Normal file
View File

@ -0,0 +1,13 @@
package metrics
import "time"
// Tags is a map of fields to add to a metric:
type Tags map[string]string
// Reporter is the standard metrics interface:
type Reporter interface {
Count(metricName string, value int64, tags Tags) error
Gauge(metricName string, value float64, tags Tags) error
Timing(metricName string, value time.Duration, tags Tags) error
}

View File

@ -0,0 +1,51 @@
package wrapper
import (
"time"
"context"
"github.com/micro/go-micro/v3/metrics"
"github.com/micro/go-micro/v3/server"
)
// Wrapper provides a HandlerFunc for metrics.Reporter implementations:
type Wrapper struct {
reporter metrics.Reporter
}
// New returns a *Wrapper configured with the given metrics.Reporter:
func New(reporter metrics.Reporter) *Wrapper {
return &Wrapper{
reporter: reporter,
}
}
// HandlerFunc instruments handlers registered to a service:
func (w *Wrapper) HandlerFunc(handlerFunction server.HandlerFunc) server.HandlerFunc {
return func(ctx context.Context, req server.Request, rsp interface{}) error {
// Build some tags to describe the call:
tags := metrics.Tags{
"method": req.Method(),
}
// Start the clock:
callTime := time.Now()
// Run the handlerFunction:
err := handlerFunction(ctx, req, rsp)
// Add a result tag:
if err != nil {
tags["result"] = "failure"
} else {
tags["result"] = "failure"
}
// Instrument the result (if the DefaultClient has been configured):
w.reporter.Timing("service.handler", time.Since(callTime), tags)
return err
}
}