From 375ed21d3b1f19d2b708e90eafb6404489395de7 Mon Sep 17 00:00:00 2001 From: Tom Chen Date: Fri, 28 Jun 2019 15:05:37 -0600 Subject: [PATCH] add a metric exposition utility package [#166889819] Signed-off-by: Travis Patterson --- metrics/metrics_suite_test.go | 15 ++++ metrics/prometheus.go | 161 ++++++++++++++++++++++++++++++++++ metrics/prometheus_test.go | 129 +++++++++++++++++++++++++++ 3 files changed, 305 insertions(+) create mode 100644 metrics/metrics_suite_test.go create mode 100644 metrics/prometheus.go create mode 100644 metrics/prometheus_test.go diff --git a/metrics/metrics_suite_test.go b/metrics/metrics_suite_test.go new file mode 100644 index 0000000..74cad4a --- /dev/null +++ b/metrics/metrics_suite_test.go @@ -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") +} diff --git a/metrics/prometheus.go b/metrics/prometheus.go new file mode 100644 index 0000000..71106b4 --- /dev/null +++ b/metrics/prometheus.go @@ -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 + } +} diff --git a/metrics/prometheus_test.go b/metrics/prometheus_test.go new file mode 100644 index 0000000..cbc4e54 --- /dev/null +++ b/metrics/prometheus_test.go @@ -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) +}