From 1f6135d6022629919cb009a95f2ab144daf51d75 Mon Sep 17 00:00:00 2001 From: Bill Havanki Date: Thu, 12 Aug 2021 16:16:15 -0400 Subject: [PATCH] Support Datadog distribution metric type (#132) The Datadog client now has the ability to send histogram metrics as the Datadog-specific distribution metric type. The Datadog client configuration has a new `DistributionPrefixes` item which specifies the prefixes of metric names that, when reported as histograms to the stats library, are to be sent as distributions instead. For example, when the prefix list is set to `{ "dist_" }`, then any histogram metric whose name begins with "dist_" is sent as a distribution; all other histograms are sent as ordinary histograms, as before. The default configuration sends no histograms as distributions. --- datadog/client.go | 34 +++++++++++++++------- datadog/client_test.go | 23 +++++++++++++++ datadog/measure.go | 26 +++++++++++++++-- datadog/measure_test.go | 63 +++++++++++++++++++++++++++++++++++++++-- datadog/metric.go | 11 +++---- datadog/metric_test.go | 22 ++++++++++++++ 6 files changed, 158 insertions(+), 21 deletions(-) diff --git a/datadog/client.go b/datadog/client.go index 3244556..8c8cfa2 100644 --- a/datadog/client.go +++ b/datadog/client.go @@ -25,13 +25,17 @@ const ( MaxBufferSize = 65507 ) -// DefaultFilter is the default tag to filter before sending to -// datadog. Using the request path as a tag can overwhelm datadog's -// servers if there are too many unique routes due to unique IDs being a -// part of the path. Only change the default filter if there is a static -// number of routes. var ( + // DefaultFilters are the default tags to filter before sending to + // datadog. Using the request path as a tag can overwhelm datadog's + // servers if there are too many unique routes due to unique IDs being a + // part of the path. Only change the default filters if there are a static + // number of routes. DefaultFilters = []string{"http_req_path"} + + // DefaultDistributionPrefixes is the default set of name prefixes for + // metrics to be sent as distributions instead of as histograms. + DefaultDistributionPrefixes = []string{} ) // The ClientConfig type is used to configure datadog clients. @@ -44,6 +48,10 @@ type ClientConfig struct { // List of tags to filter. If left nil is set to DefaultFilters. Filters []string + + // Set of name prefixes for metrics to be sent as distributions instead of + // as histograms. + DistributionPrefixes []string } // Client represents an datadog client that implements the stats.Handler @@ -77,6 +85,10 @@ func NewClientWith(config ClientConfig) *Client { config.Filters = DefaultFilters } + if config.DistributionPrefixes == nil { + config.DistributionPrefixes = DefaultDistributionPrefixes + } + // transform filters from array to map filterMap := make(map[string]struct{}) for _, f := range config.Filters { @@ -85,7 +97,8 @@ func NewClientWith(config ClientConfig) *Client { c := &Client{ serializer: serializer{ - filters: filterMap, + filters: filterMap, + distPrefixes: config.DistributionPrefixes, }, } @@ -124,14 +137,15 @@ func (c *Client) Close() error { } type serializer struct { - conn net.Conn - bufferSize int - filters map[string]struct{} + conn net.Conn + bufferSize int + filters map[string]struct{} + distPrefixes []string } func (s *serializer) AppendMeasures(b []byte, _ time.Time, measures ...stats.Measure) []byte { for _, m := range measures { - b = AppendMeasureFiltered(b, m, s.filters) + b = AppendMeasureFiltered(b, m, s.filters, s.distPrefixes) } return b } diff --git a/datadog/client_test.go b/datadog/client_test.go index 3238140..3d9f00b 100644 --- a/datadog/client_test.go +++ b/datadog/client_test.go @@ -35,6 +35,29 @@ func TestClient(t *testing.T) { } } +func TestClientWithDistributionPrefixes(t *testing.T) { + client := NewClientWith(ClientConfig{ + Address: DefaultAddress, + DistributionPrefixes: []string{"dist_"}, + }) + + client.HandleMeasures(time.Time{}, stats.Measure{ + Name: "request", + Fields: []stats.Field{ + {Name: "count", Value: stats.ValueOf(5)}, + stats.MakeField("dist_rtt", stats.ValueOf(100*time.Millisecond), stats.Histogram), + }, + Tags: []stats.Tag{ + stats.T("answer", "42"), + stats.T("hello", "world"), + }, + }) + + if err := client.Close(); err != nil { + t.Error(err) + } +} + func TestClientWriteLargeMetrics(t *testing.T) { const data = `main.http.error.count:0|c|#http_req_content_charset:,http_req_content_endoing:,http_req_content_type:,http_req_host:localhost:3011,http_req_method:GET,http_req_protocol:HTTP/1.1,http_req_transfer_encoding:identity main.http.message.count:1|c|#http_req_content_charset:,http_req_content_endoing:,http_req_content_type:,http_req_host:localhost:3011,http_req_method:GET,http_req_protocol:HTTP/1.1,http_req_transfer_encoding:identity,operation:read,type:request diff --git a/datadog/measure.go b/datadog/measure.go index f411a55..aaf8ded 100644 --- a/datadog/measure.go +++ b/datadog/measure.go @@ -3,20 +3,24 @@ package datadog import ( "math" "strconv" + "strings" "github.com/segmentio/stats/v4" ) +// Datagram format: https://docs.datadoghq.com/developers/dogstatsd/datagram_shell + // AppendMeasure is a formatting routine to append the dogstatsd protocol // representation of a measure to a memory buffer. func AppendMeasure(b []byte, m stats.Measure) []byte { - return AppendMeasureFiltered(b, m, nil) + return AppendMeasureFiltered(b, m, nil, []string{}) } // AppendMeasureFiltered is a formatting routine to append the dogstatsd protocol // representation of a measure to a memory buffer. Tags listed in the filters map // are removed. (some tags may not be suitable for submission to DataDog) -func AppendMeasureFiltered(b []byte, m stats.Measure, filters map[string]struct{}) []byte { +func AppendMeasureFiltered(b []byte, m stats.Measure, filters map[string]struct{}, + distPrefixes []string) []byte { for _, field := range m.Fields { b = append(b, m.Name...) if len(field.Name) != 0 { @@ -50,7 +54,11 @@ func AppendMeasureFiltered(b []byte, m stats.Measure, filters map[string]struct{ case stats.Gauge: b = append(b, '|', 'g') default: - b = append(b, '|', 'h') + if sendDist(field.Name, distPrefixes) { + b = append(b, '|', 'd') + } else { + b = append(b, '|', 'h') + } } if n := len(m.Tags); n != 0 { @@ -86,3 +94,15 @@ func normalizeFloat(f float64) float64 { return f } } + +func sendDist(name string, distPrefixes []string) bool { + if distPrefixes == nil { + return false + } + for _, prefix := range distPrefixes { + if strings.HasPrefix(name, prefix) { + return true + } + } + return false +} diff --git a/datadog/measure_test.go b/datadog/measure_test.go index 883b6e0..7f0f8fd 100644 --- a/datadog/measure_test.go +++ b/datadog/measure_test.go @@ -9,8 +9,9 @@ import ( var ( testMeasures = []struct { - m stats.Measure - s string + m stats.Measure + s string + dp []string }{ { m: stats.Measure{ @@ -21,6 +22,7 @@ var ( }, s: `request.count:5|c `, + dp: []string{}, }, { @@ -38,6 +40,23 @@ var ( s: `request.count:5|c|#answer:42,hello:world request.rtt:0.1|h|#answer:42,hello:world `, + dp: []string{}, + }, + + { + m: stats.Measure{ + Name: "request", + Fields: []stats.Field{ + stats.MakeField("dist_rtt", 100*time.Millisecond, stats.Histogram), + }, + Tags: []stats.Tag{ + stats.T("answer", "42"), + stats.T("hello", "world"), + }, + }, + s: `request.dist_rtt:0.1|d|#answer:42,hello:world +`, + dp: []string{"dist_"}, }, } ) @@ -45,7 +64,7 @@ request.rtt:0.1|h|#answer:42,hello:world func TestAppendMeasure(t *testing.T) { for _, test := range testMeasures { t.Run(test.s, func(t *testing.T) { - if s := string(AppendMeasure(nil, test.m)); s != test.s { + if s := string(AppendMeasureFiltered(nil, test.m, nil, test.dp)); s != test.s { t.Error("bad metric representation:") t.Log("expected:", test.s) t.Log("found: ", s) @@ -53,3 +72,41 @@ func TestAppendMeasure(t *testing.T) { }) } } + +var ( + testDistNames = []struct { + n string + d bool + }{ + { + n: "name", + d: false, + }, + { + n: "", + d: false, + }, + { + n: "dist_name", + d: true, + }, + { + n: "distname", + d: false, + }, + } + distPrefixes = []string{"dist_"} +) + +func TestSendDist(t *testing.T) { + for _, test := range testDistNames { + t.Run(test.n, func(t *testing.T) { + a := sendDist(test.n, distPrefixes) + if a != test.d { + t.Error("distribution name detection incorrect:") + t.Log("expected:", test.d) + t.Log("found: ", a) + } + }) + } +} diff --git a/datadog/metric.go b/datadog/metric.go index 5131e37..a6bcc06 100644 --- a/datadog/metric.go +++ b/datadog/metric.go @@ -8,15 +8,16 @@ import ( ) // MetricType is an enumeration providing symbols to represent the different -// metric types upported by datadog. +// metric types supported by datadog. type MetricType string // Metric Types. const ( - Counter MetricType = "c" - Gauge MetricType = "g" - Histogram MetricType = "h" - Unknown MetricType = "?" + Counter MetricType = "c" + Gauge MetricType = "g" + Histogram MetricType = "h" + Distribution MetricType = "d" + Unknown MetricType = "?" ) // The Metric type is a representation of the metrics supported by datadog. diff --git a/datadog/metric_test.go b/datadog/metric_test.go index 76380f6..2ba8d38 100644 --- a/datadog/metric_test.go +++ b/datadog/metric_test.go @@ -101,6 +101,28 @@ var testMetrics = []struct { }, }, + { + s: "song.length:240|d|@0.5\n", + m: Metric{ + Type: Distribution, + Name: "song.length", + Value: 240, + Rate: 0.5, + Tags: nil, + }, + }, + + { + s: "users.uniques:1234|d\n", + m: Metric{ + Type: Distribution, + Name: "users.uniques", + Value: 1234, + Rate: 1, + Tags: nil, + }, + }, + { s: "users.online:1|c|#country:china\n", m: Metric{