metrics: add Circonus backend
Peter Bourgon
7 years ago
0 | // Package circonus provides a Circonus backend for package metrics. | |
1 | // | |
2 | // Users are responsible for calling the circonusgometrics.Start method | |
3 | // themselves. Note that all Circonus metrics must have unique names, and are | |
4 | // registered in a package-global registry. Circonus metrics also don't support | |
5 | // fields, so all With methods are no-ops. | |
6 | package circonus | |
7 | ||
8 | import ( | |
9 | "github.com/circonus-labs/circonus-gometrics" | |
10 | ||
11 | "github.com/go-kit/kit/metrics" | |
12 | ) | |
13 | ||
14 | // NewCounter returns a counter backed by a Circonus counter with the given | |
15 | // name. Due to the Circonus data model, fields are not supported. | |
16 | func NewCounter(name string) metrics.Counter { | |
17 | return counter(name) | |
18 | } | |
19 | ||
20 | type counter circonusgometrics.Counter | |
21 | ||
22 | // Name implements Counter. | |
23 | func (c counter) Name() string { | |
24 | return string(c) | |
25 | } | |
26 | ||
27 | // With implements Counter, but is a no-op. | |
28 | func (c counter) With(metrics.Field) metrics.Counter { | |
29 | return c | |
30 | } | |
31 | ||
32 | // Add implements Counter. | |
33 | func (c counter) Add(delta uint64) { | |
34 | circonusgometrics.Counter(c).AddN(delta) | |
35 | } | |
36 | ||
37 | // NewGauge returns a gauge backed by a Circonus gauge with the given name. Due | |
38 | // to the Circonus data model, fields are not supported. Also, Circonus gauges | |
39 | // are defined as integers, so values are truncated. | |
40 | func NewGauge(name string) metrics.Gauge { | |
41 | return gauge(name) | |
42 | } | |
43 | ||
44 | type gauge circonusgometrics.Gauge | |
45 | ||
46 | // Name implements Gauge. | |
47 | func (g gauge) Name() string { | |
48 | return string(g) | |
49 | } | |
50 | ||
51 | // With implements Gauge, but is a no-op. | |
52 | func (g gauge) With(metrics.Field) metrics.Gauge { | |
53 | return g | |
54 | } | |
55 | ||
56 | // Set implements Gauge. | |
57 | func (g gauge) Set(value float64) { | |
58 | circonusgometrics.Gauge(g).Set(int64(value)) | |
59 | } | |
60 | ||
61 | // Add implements Gauge, but is a no-op, as Circonus gauges don't support | |
62 | // incremental (delta) mutation. | |
63 | func (g gauge) Add(float64) { | |
64 | return | |
65 | } | |
66 | ||
67 | // Get implements Gauge, but always returns zero, as there's no way to extract | |
68 | // the current value from a Circonus gauge. | |
69 | func (g gauge) Get() float64 { | |
70 | return 0.0 | |
71 | } | |
72 | ||
73 | // NewHistogram returns a histogram backed by a Circonus histogram. | |
74 | // Due to the Circonus data model, fields are not supported. | |
75 | func NewHistogram(name string) metrics.Histogram { | |
76 | return histogram{ | |
77 | h: circonusgometrics.NewHistogram(name), | |
78 | } | |
79 | } | |
80 | ||
81 | type histogram struct { | |
82 | h *circonusgometrics.Histogram | |
83 | } | |
84 | ||
85 | // Name implements Histogram. | |
86 | func (h histogram) Name() string { | |
87 | return h.h.Name() | |
88 | } | |
89 | ||
90 | // With implements Histogram, but is a no-op. | |
91 | func (h histogram) With(metrics.Field) metrics.Histogram { | |
92 | return h | |
93 | } | |
94 | ||
95 | // Observe implements Histogram. The value is converted to float64. | |
96 | func (h histogram) Observe(value int64) { | |
97 | h.h.RecordValue(float64(value)) | |
98 | } | |
99 | ||
100 | // Distribution implements Histogram, but is a no-op. | |
101 | func (h histogram) Distribution() ([]metrics.Bucket, []metrics.Quantile) { | |
102 | return []metrics.Bucket{}, []metrics.Quantile{} | |
103 | } |
0 | package circonus | |
1 | ||
2 | import ( | |
3 | "encoding/json" | |
4 | "errors" | |
5 | "io/ioutil" | |
6 | "log" | |
7 | "net/http" | |
8 | "net/http/httptest" | |
9 | "sync" | |
10 | "testing" | |
11 | "time" | |
12 | ||
13 | "github.com/circonus-labs/circonus-gometrics" | |
14 | ||
15 | "github.com/go-kit/kit/metrics" | |
16 | "github.com/go-kit/kit/metrics/teststat" | |
17 | ) | |
18 | ||
19 | var ( | |
20 | // The Circonus Start() method launches a new goroutine that cannot be | |
21 | // stopped. So, make sure we only do that once per test run. | |
22 | onceStart sync.Once | |
23 | ||
24 | // Similarly, once set, the submission interval cannot be changed. | |
25 | submissionInterval = 50 * time.Millisecond | |
26 | ) | |
27 | ||
28 | func TestCounter(t *testing.T) { | |
29 | log.SetOutput(ioutil.Discard) // Circonus logs errors directly! Bad Circonus! | |
30 | defer circonusgometrics.Reset() // Circonus has package global state! Bad Circonus! | |
31 | ||
32 | var ( | |
33 | name = "test_counter" | |
34 | value uint64 | |
35 | ) | |
36 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
37 | m := map[string]postCounter{} | |
38 | json.NewDecoder(r.Body).Decode(&m) | |
39 | value = m[name].Value | |
40 | })) | |
41 | defer s.Close() | |
42 | ||
43 | // We must set the submission URL before making observations. Circonus emits | |
44 | // using a package-global goroutine that is started with Start() but not | |
45 | // stoppable. Once it gets going, it will POST to the package-global | |
46 | // submission URL every interval. If we record observations first, and then | |
47 | // try to change the submission URL, it's possible that the observations | |
48 | // have already been submitted to the previous URL. And, at least in the | |
49 | // case of histograms, every submit, success or failure, resets the data. | |
50 | // Bad Circonus! | |
51 | ||
52 | circonusgometrics.WithSubmissionUrl(s.URL) | |
53 | circonusgometrics.WithInterval(submissionInterval) | |
54 | ||
55 | c := NewCounter(name) | |
56 | ||
57 | if want, have := name, c.Name(); want != have { | |
58 | t.Errorf("want %q, have %q", want, have) | |
59 | } | |
60 | ||
61 | c.Add(123) | |
62 | c.With(metrics.Field{Key: "this should", Value: "be ignored"}).Add(456) | |
63 | ||
64 | onceStart.Do(func() { circonusgometrics.Start() }) | |
65 | if err := within(time.Second, func() bool { | |
66 | return value > 0 | |
67 | }); err != nil { | |
68 | t.Fatalf("error collecting results: %v", err) | |
69 | } | |
70 | ||
71 | if want, have := 123+456, int(value); want != have { | |
72 | t.Errorf("want %d, have %d", want, have) | |
73 | } | |
74 | } | |
75 | ||
76 | func TestGauge(t *testing.T) { | |
77 | log.SetOutput(ioutil.Discard) // Circonus logs errors directly! Bad Circonus! | |
78 | defer circonusgometrics.Reset() // Circonus has package global state! Bad Circonus! | |
79 | ||
80 | var ( | |
81 | name = "test_gauge" | |
82 | value float64 | |
83 | ) | |
84 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
85 | m := map[string]postGauge{} | |
86 | json.NewDecoder(r.Body).Decode(&m) | |
87 | value = m[name].Value | |
88 | })) | |
89 | defer s.Close() | |
90 | ||
91 | circonusgometrics.WithSubmissionUrl(s.URL) | |
92 | circonusgometrics.WithInterval(submissionInterval) | |
93 | ||
94 | g := NewGauge(name) | |
95 | ||
96 | g.Set(123) | |
97 | g.Add(456) // is a no-op | |
98 | ||
99 | if want, have := 0.0, g.Get(); want != have { | |
100 | t.Errorf("Get should always return %.2f, but I got %.2f", want, have) | |
101 | } | |
102 | ||
103 | onceStart.Do(func() { circonusgometrics.Start() }) | |
104 | ||
105 | if err := within(time.Second, func() bool { | |
106 | return value > 0.0 | |
107 | }); err != nil { | |
108 | t.Fatalf("error collecting results: %v", err) | |
109 | } | |
110 | ||
111 | if want, have := 123.0, value; want != have { | |
112 | t.Errorf("want %.2f, have %.2f", want, have) | |
113 | } | |
114 | } | |
115 | ||
116 | func TestHistogram(t *testing.T) { | |
117 | log.SetOutput(ioutil.Discard) // Circonus logs errors directly! Bad Circonus! | |
118 | defer circonusgometrics.Reset() // Circonus has package global state! Bad Circonus! | |
119 | ||
120 | var ( | |
121 | name = "test_histogram" | |
122 | result []string | |
123 | onceDecode sync.Once | |
124 | ) | |
125 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
126 | onceDecode.Do(func() { | |
127 | m := map[string]postHistogram{} | |
128 | json.NewDecoder(r.Body).Decode(&m) | |
129 | result = m[name].Value | |
130 | }) | |
131 | })) | |
132 | defer s.Close() | |
133 | ||
134 | circonusgometrics.WithSubmissionUrl(s.URL) | |
135 | circonusgometrics.WithInterval(submissionInterval) | |
136 | ||
137 | h := NewHistogram(name) | |
138 | ||
139 | var ( | |
140 | seed = int64(123) | |
141 | mean = int64(500) | |
142 | stdev = int64(123) | |
143 | min = int64(0) | |
144 | max = 2 * mean | |
145 | ) | |
146 | teststat.PopulateNormalHistogram(t, h, seed, mean, stdev) | |
147 | ||
148 | onceStart.Do(func() { circonusgometrics.Start() }) | |
149 | ||
150 | if err := within(time.Second, func() bool { | |
151 | return len(result) > 0 | |
152 | }); err != nil { | |
153 | t.Fatalf("error collecting results: %v", err) | |
154 | } | |
155 | ||
156 | teststat.AssertCirconusNormalHistogram(t, mean, stdev, min, max, result) | |
157 | } | |
158 | ||
159 | func within(d time.Duration, f func() bool) error { | |
160 | deadline := time.Now().Add(d) | |
161 | for { | |
162 | if time.Now().After(deadline) { | |
163 | return errors.New("deadline exceeded") | |
164 | } | |
165 | if f() { | |
166 | return nil | |
167 | } | |
168 | time.Sleep(d / 10) | |
169 | } | |
170 | } | |
171 | ||
172 | // These are reverse-engineered from the POST body. | |
173 | ||
174 | type postCounter struct { | |
175 | Value uint64 `json:"_value"` | |
176 | } | |
177 | ||
178 | type postGauge struct { | |
179 | Value float64 `json:"_value"` | |
180 | } | |
181 | ||
182 | type postHistogram struct { | |
183 | Value []string `json:"_value"` | |
184 | } |
0 | package teststat | |
1 | ||
2 | import ( | |
3 | "math" | |
4 | "strconv" | |
5 | "strings" | |
6 | "testing" | |
7 | ||
8 | "github.com/codahale/hdrhistogram" | |
9 | ) | |
10 | ||
11 | // AssertCirconusNormalHistogram ensures the Circonus Histogram data captured in | |
12 | // the result slice abides a normal distribution. | |
13 | func AssertCirconusNormalHistogram(t *testing.T, mean, stdev, min, max int64, result []string) { | |
14 | if len(result) <= 0 { | |
15 | t.Fatal("no results") | |
16 | } | |
17 | ||
18 | // Circonus just dumps the raw counts. We need to do our own statistical analysis. | |
19 | h := hdrhistogram.New(min, max, 3) | |
20 | ||
21 | for _, s := range result { | |
22 | // "H[1.23e04]=123" | |
23 | toks := strings.Split(s, "=") | |
24 | if len(toks) != 2 { | |
25 | t.Fatalf("bad H value: %q", s) | |
26 | } | |
27 | ||
28 | var bucket string | |
29 | bucket = toks[0] | |
30 | bucket = bucket[2 : len(bucket)-1] // "H[1.23e04]" -> "1.23e04" | |
31 | f, err := strconv.ParseFloat(bucket, 64) | |
32 | if err != nil { | |
33 | t.Fatalf("error parsing H value: %q: %v", s, err) | |
34 | } | |
35 | ||
36 | count, err := strconv.ParseFloat(toks[1], 64) | |
37 | if err != nil { | |
38 | t.Fatalf("error parsing H count: %q: %v", s, err) | |
39 | } | |
40 | ||
41 | h.RecordValues(int64(f), int64(count)) | |
42 | } | |
43 | ||
44 | // Apparently Circonus buckets observations by dropping a sigfig, so we have | |
45 | // very coarse tolerance. | |
46 | var tolerance int64 = 20 | |
47 | for _, quantile := range []int{50, 90, 99} { | |
48 | want := normalValueAtQuantile(mean, stdev, quantile) | |
49 | have := h.ValueAtQuantile(float64(quantile)) | |
50 | if int64(math.Abs(float64(want)-float64(have))) > tolerance { | |
51 | t.Errorf("quantile %d: want %d, have %d", quantile, want, have) | |
52 | } | |
53 | } | |
54 | } |