Skip to content

Commit

Permalink
metrics: add Circonus backend
Browse files Browse the repository at this point in the history
  • Loading branch information
peterbourgon committed May 26, 2016
1 parent 7de0f49 commit c8beefd
Show file tree
Hide file tree
Showing 3 changed files with 344 additions and 0 deletions.
104 changes: 104 additions & 0 deletions metrics/circonus/circonus.go
@@ -0,0 +1,104 @@
// Package circonus provides a Circonus backend for package metrics.
//
// Users are responsible for calling the circonusgometrics.Start method
// themselves. Note that all Circonus metrics must have unique names, and are
// registered in a package-global registry. Circonus metrics also don't support
// fields, so all With methods are no-ops.
package circonus

import (
"github.com/circonus-labs/circonus-gometrics"

"github.com/go-kit/kit/metrics"
)

// NewCounter returns a counter backed by a Circonus counter with the given
// name. Due to the Circonus data model, fields are not supported.
func NewCounter(name string) metrics.Counter {
return counter(name)
}

type counter circonusgometrics.Counter

// Name implements Counter.
func (c counter) Name() string {
return string(c)
}

// With implements Counter, but is a no-op.
func (c counter) With(metrics.Field) metrics.Counter {
return c
}

// Add implements Counter.
func (c counter) Add(delta uint64) {
circonusgometrics.Counter(c).AddN(delta)
}

// NewGauge returns a gauge backed by a Circonus gauge with the given name. Due
// to the Circonus data model, fields are not supported. Also, Circonus gauges
// are defined as integers, so values are truncated.
func NewGauge(name string) metrics.Gauge {
return gauge(name)
}

type gauge circonusgometrics.Gauge

// Name implements Gauge.
func (g gauge) Name() string {
return string(g)
}

// With implements Gauge, but is a no-op.
func (g gauge) With(metrics.Field) metrics.Gauge {
return g
}

// Set implements Gauge.
func (g gauge) Set(value float64) {
circonusgometrics.Gauge(g).Set(int64(value))
}

// Add implements Gauge, but is a no-op, as Circonus gauges don't support
// incremental (delta) mutation.
func (g gauge) Add(float64) {
return
}

// Get implements Gauge, but always returns zero, as there's no way to extract
// the current value from a Circonus gauge.
func (g gauge) Get() float64 {
return 0.0
}

// NewHistogram returns a histogram backed by a Circonus histogram.
// Due to the Circonus data model, fields are not supported.
func NewHistogram(name string) metrics.Histogram {
return histogram{
h: circonusgometrics.NewHistogram(name),
}
}

type histogram struct {
h *circonusgometrics.Histogram
}

// Name implements Histogram.
func (h histogram) Name() string {
return h.h.Name()
}

// With implements Histogram, but is a no-op.
func (h histogram) With(metrics.Field) metrics.Histogram {
return h
}

// Observe implements Histogram. The value is converted to float64.
func (h histogram) Observe(value int64) {
h.h.RecordValue(float64(value))
}

// Distribution implements Histogram, but is a no-op.
func (h histogram) Distribution() ([]metrics.Bucket, []metrics.Quantile) {
return []metrics.Bucket{}, []metrics.Quantile{}
}
185 changes: 185 additions & 0 deletions metrics/circonus/circonus_test.go
@@ -0,0 +1,185 @@
package circonus

import (
"encoding/json"
"errors"
"io/ioutil"
"log"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"

"github.com/circonus-labs/circonus-gometrics"

"github.com/go-kit/kit/metrics"
"github.com/go-kit/kit/metrics/teststat"
)

var (
// The Circonus Start() method launches a new goroutine that cannot be
// stopped. So, make sure we only do that once per test run.
onceStart sync.Once

// Similarly, once set, the submission interval cannot be changed.
submissionInterval = 50 * time.Millisecond
)

func TestCounter(t *testing.T) {
log.SetOutput(ioutil.Discard) // Circonus logs errors directly! Bad Circonus!
defer circonusgometrics.Reset() // Circonus has package global state! Bad Circonus!

var (
name = "test_counter"
value uint64
)
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m := map[string]postCounter{}
json.NewDecoder(r.Body).Decode(&m)
value = m[name].Value
}))
defer s.Close()

// We must set the submission URL before making observations. Circonus emits
// using a package-global goroutine that is started with Start() but not
// stoppable. Once it gets going, it will POST to the package-global
// submission URL every interval. If we record observations first, and then
// try to change the submission URL, it's possible that the observations
// have already been submitted to the previous URL. And, at least in the
// case of histograms, every submit, success or failure, resets the data.
// Bad Circonus!

circonusgometrics.WithSubmissionUrl(s.URL)
circonusgometrics.WithInterval(submissionInterval)

c := NewCounter(name)

if want, have := name, c.Name(); want != have {
t.Errorf("want %q, have %q", want, have)
}

c.Add(123)
c.With(metrics.Field{Key: "this should", Value: "be ignored"}).Add(456)

onceStart.Do(func() { circonusgometrics.Start() })
if err := within(time.Second, func() bool {
return value > 0
}); err != nil {
t.Fatalf("error collecting results: %v", err)
}

if want, have := 123+456, int(value); want != have {
t.Errorf("want %d, have %d", want, have)
}
}

func TestGauge(t *testing.T) {
log.SetOutput(ioutil.Discard) // Circonus logs errors directly! Bad Circonus!
defer circonusgometrics.Reset() // Circonus has package global state! Bad Circonus!

var (
name = "test_gauge"
value float64
)
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m := map[string]postGauge{}
json.NewDecoder(r.Body).Decode(&m)
value = m[name].Value
}))
defer s.Close()

circonusgometrics.WithSubmissionUrl(s.URL)
circonusgometrics.WithInterval(submissionInterval)

g := NewGauge(name)

g.Set(123)
g.Add(456) // is a no-op

if want, have := 0.0, g.Get(); want != have {
t.Errorf("Get should always return %.2f, but I got %.2f", want, have)
}

onceStart.Do(func() { circonusgometrics.Start() })

if err := within(time.Second, func() bool {
return value > 0.0
}); err != nil {
t.Fatalf("error collecting results: %v", err)
}

if want, have := 123.0, value; want != have {
t.Errorf("want %.2f, have %.2f", want, have)
}
}

func TestHistogram(t *testing.T) {
log.SetOutput(ioutil.Discard) // Circonus logs errors directly! Bad Circonus!
defer circonusgometrics.Reset() // Circonus has package global state! Bad Circonus!

var (
name = "test_histogram"
result []string
onceDecode sync.Once
)
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
onceDecode.Do(func() {
m := map[string]postHistogram{}
json.NewDecoder(r.Body).Decode(&m)
result = m[name].Value
})
}))
defer s.Close()

circonusgometrics.WithSubmissionUrl(s.URL)
circonusgometrics.WithInterval(submissionInterval)

h := NewHistogram(name)

var (
seed = int64(123)
mean = int64(500)
stdev = int64(123)
min = int64(0)
max = 2 * mean
)
teststat.PopulateNormalHistogram(t, h, seed, mean, stdev)

onceStart.Do(func() { circonusgometrics.Start() })

if err := within(time.Second, func() bool {
return len(result) > 0
}); err != nil {
t.Fatalf("error collecting results: %v", err)
}

teststat.AssertCirconusNormalHistogram(t, mean, stdev, min, max, result)
}

func within(d time.Duration, f func() bool) error {
deadline := time.Now().Add(d)
for {
if time.Now().After(deadline) {
return errors.New("deadline exceeded")
}
if f() {
return nil
}
time.Sleep(d / 10)
}
}

// These are reverse-engineered from the POST body.

type postCounter struct {
Value uint64 `json:"_value"`
}

type postGauge struct {
Value float64 `json:"_value"`
}

type postHistogram struct {
Value []string `json:"_value"`
}
55 changes: 55 additions & 0 deletions metrics/teststat/circonus.go
@@ -0,0 +1,55 @@
package teststat

import (
"math"
"strconv"
"strings"
"testing"

"github.com/codahale/hdrhistogram"
)

// AssertCirconusNormalHistogram ensures the Circonus Histogram data captured in
// the result slice abides a normal distribution.
func AssertCirconusNormalHistogram(t *testing.T, mean, stdev, min, max int64, result []string) {
if len(result) <= 0 {
t.Fatal("no results")
}

// Circonus just dumps the raw counts. We need to do our own statistical analysis.
h := hdrhistogram.New(min, max, 3)

for _, s := range result {
// "H[1.23e04]=123"
toks := strings.Split(s, "=")
if len(toks) != 2 {
t.Fatalf("bad H value: %q", s)
}

var bucket string
bucket = toks[0]
bucket = bucket[2 : len(bucket)-1] // "H[1.23e04]" -> "1.23e04"
f, err := strconv.ParseFloat(bucket, 64)
if err != nil {
t.Fatalf("error parsing H value: %q: %v", s, err)
}

count, err := strconv.ParseFloat(toks[1], 64)
if err != nil {
t.Fatalf("error parsing H count: %q: %v", s, err)
}

h.RecordValues(int64(f), int64(count))
}

// Apparently Circonus buckets observations by dropping a sigfig, so we have
// very coarse tolerance.
var tolerance int64 = 20
for _, quantile := range []int{50, 90, 99} {
want := normalValueAtQuantile(mean, stdev, quantile)
have := h.ValueAtQuantile(float64(quantile))
if int64(math.Abs(float64(want)-float64(have))) > tolerance {
t.Errorf("quantile %d: want %d, have %d", quantile, want, have)
}
}
}

0 comments on commit c8beefd

Please sign in to comment.