├── .github └── workflows │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── Makefile ├── README.md ├── buffers.go ├── client.go ├── client_test.go ├── go.mod ├── loops.go ├── options.go ├── statsd.go ├── tags.go └── tags_test.go /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [ main ] 4 | pull_request: 5 | branches: [ main ] 6 | 7 | permissions: 8 | contents: read 9 | 10 | name: Lint 11 | 12 | jobs: 13 | golangci: 14 | name: lint 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-go@v5 19 | with: 20 | go-version: stable 21 | - name: golangci-lint 22 | uses: golangci/golangci-lint-action@v6 23 | with: 24 | version: v1.61 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [ main ] 4 | pull_request: 5 | branches: [ main ] 6 | 7 | name: Test 8 | jobs: 9 | test: 10 | strategy: 11 | matrix: 12 | go-version: [1.22.x, 1.23.x] 13 | os: [ubuntu-latest] 14 | runs-on: ${{ matrix.os }} 15 | steps: 16 | - name: Install Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: ${{ matrix.go-version }} 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | - name: Test 23 | run: go test -v ./... 24 | - name: Test Race 25 | run: go test -v -race ./... 26 | - name: Bench 27 | run: go test -v -bench . -benchmem -run nothing ./... 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | coverage.txt 27 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - gofmt 4 | - gosec 5 | - unconvert 6 | - misspell 7 | - goimports 8 | - megacheck 9 | - staticcheck 10 | - unused 11 | - typecheck 12 | - ineffassign 13 | - revive 14 | - stylecheck 15 | - unparam 16 | - nakedret 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Andrey Smirnov 4 | Copyright (c) 2015 Alexandre Cesaro 5 | Copyright (c) 2014 Lorenzo Alberton 6 | Copyright (c) 2013 Armon Dadgar 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy of 9 | this software and associated documentation files (the "Software"), to deal in 10 | the Software without restriction, including without limitation the rights to 11 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 12 | the Software, and to permit persons to whom the Software is furnished to do so, 13 | subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 20 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 21 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 22 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 23 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: test check bench 2 | 3 | .PHONY: test 4 | test: 5 | go test -race -v -coverprofile=coverage.txt -covermode=atomic 6 | 7 | .PHONY: bench 8 | bench: 9 | go test -v -bench . -benchmem -run nothing ./... 10 | 11 | .PHONY: check 12 | check: 13 | golangci-lint run 14 | 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/smira/go-statsd.svg?branch=master)](https://travis-ci.org/smira/go-statsd) 2 | [![Documentation](https://godoc.org/github.com/smira/go-statsd?status.svg)](http://godoc.org/github.com/smira/go-statsd) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/smira/go-statsd)](https://goreportcard.com/report/github.com/smira/go-statsd) 4 | [![codecov](https://codecov.io/gh/smira/go-statsd/branch/master/graph/badge.svg)](https://codecov.io/gh/smira/go-statsd) 5 | [![License](https://img.shields.io/github/license/smira/go-statsd.svg?maxAge=2592000)](https://github.com/smira/go-statsd/LICENSE) 6 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fsmira%2Fgo-statsd.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fsmira%2Fgo-statsd?ref=badge_shield) 7 | 8 | Go statsd client library with zero allocation overhead, great performance and automatic 9 | reconnects. 10 | 11 | Client has zero memory allocation per metric sent: 12 | 13 | * ring of buffers, each buffer is UDP packet 14 | * buffer is taken from the pool, filled with metrics, passed on to the network delivery and 15 | returned to the pool 16 | * buffer is flushed either when it is full or when flush period comes (e.g. every 100ms) 17 | * separate goroutines handle network operations: sending UDP packets and reconnecting UDP socket 18 | * when metric is serialized, zero allocation operations are used to avoid `reflect` and temporary buffers 19 | 20 | ## Zero memory allocation 21 | 22 | As metrics could be sent by the application at very high rate (e.g. hundreds of metrics per one request), 23 | it is important that sending metrics doesn't cause any additional GC or CPU pressure. `go-statsd` is using 24 | buffer pools and it tries to avoid allocations while building statsd packets. 25 | 26 | ## Reconnecting to statsd 27 | 28 | With modern container-based platforms with dynamic DNS statsd server might change its address when container 29 | gets rescheduled. As statsd packets are delivered over UDP, there's no easy way for the client to figure out 30 | that packets are going nowhere. `go-statsd` supports configurable reconnect interval which forces DNS resolution. 31 | 32 | While client is reconnecting, metrics are still processed and buffered. 33 | 34 | ## Dropping metrics 35 | 36 | When buffer pool is exhausted, `go-statsd` starts dropping packets. Number of dropped packets is reported via 37 | `Client.GetLostPackets()` and every minute logged using `log.Printf()`. Usually packets should never be dropped, 38 | if that happens it's usually signal of enormous metric volume. 39 | 40 | ## Stastd server 41 | 42 | Any statsd-compatible server should work well with `go-statsd`, [statsite](https://github.com/statsite/statsite) works 43 | exceptionally well as it has great performance and low memory footprint even with huge number of metrics. 44 | 45 | ## Usage 46 | 47 | Initialize client instance with options, one client per application is usually enough: 48 | 49 | ```go 50 | client := statsd.NewClient("localhost:8125", 51 | statsd.MaxPacketSize(1400), 52 | statsd.MetricPrefix("web.")) 53 | ``` 54 | 55 | Send metrics as events happen in the application, metrics will be packed together and 56 | delivered to statsd server: 57 | 58 | ```go 59 | start := time.Now() 60 | client.Incr("requests.http", 1) 61 | // ... 62 | client.PrecisionTiming("requests.route.api.latency", time.Since(start)) 63 | ``` 64 | 65 | Shutdown client during application shutdown to flush all the pending metrics: 66 | 67 | ```go 68 | client.Close() 69 | ``` 70 | 71 | ## Tagging 72 | 73 | Metrics could be tagged to support aggregation on TSDB side. go-statsd supports 74 | tags in [InfluxDB](https://github.com/influxdata/telegraf/tree/master/plugins/inputs/statsd) 75 | , [Datadog](https://docs.datadoghq.com/developers/dogstatsd/#datagram-format) 76 | and [Graphite](https://graphite.readthedocs.io/en/latest/tags.html) formats. 77 | Format and default tags (applied to every metric) are passed as options 78 | to the client initialization: 79 | 80 | ```go 81 | client := statsd.NewClient("localhost:8125", 82 | statsd.TagStyle(TagFormatDatadog), 83 | statsd.DefaultTags(statsd.StringTag("app", "billing"))) 84 | ``` 85 | 86 | For every metric sent, tags could be added as the last argument(s) to the function 87 | call: 88 | 89 | ```go 90 | client.Incr("request", 1, 91 | statsd.StringTag("procotol", "http"), statsd.IntTag("port", 80)) 92 | ``` 93 | 94 | 95 | ## Benchmark 96 | 97 | [Benchmark](https://github.com/smira/go-statsd-benchmark) comparing several clients: 98 | 99 | * https://github.com/alexcesaro/statsd/ (`Alexcesaro`) 100 | * this client (`GoStatsd`) 101 | * https://github.com/cactus/go-statsd-client (`Cactus`) 102 | * https://github.com/peterbourgon/g2s (`G2s`) 103 | * https://github.com/quipo/statsd (`Quipo`) 104 | * https://github.com/Unix4ever/statsd (`Unix4ever`) 105 | 106 | Benchmark results: 107 | 108 | BenchmarkAlexcesaro-12 5000000 333 ns/op 0 B/op 0 allocs/op 109 | BenchmarkGoStatsd-12 10000000 230 ns/op 23 B/op 0 allocs/op 110 | BenchmarkCactus-12 3000000 604 ns/op 5 B/op 0 allocs/op 111 | BenchmarkG2s-12 200000 7499 ns/op 576 B/op 21 allocs/op 112 | BenchmarkQuipo-12 1000000 1048 ns/op 384 B/op 7 allocs/op 113 | BenchmarkUnix4ever-12 1000000 1695 ns/op 408 B/op 18 allocs/op 114 | 115 | ## Origins 116 | 117 | Ideas were borrowed from the following stastd clients: 118 | 119 | * https://github.com/quipo/statsd (MIT License, https://github.com/quipo/statsd/blob/master/LICENSE) 120 | * https://github.com/Unix4ever/statsd (MIT License, https://github.com/Unix4ever/statsd/blob/master/LICENSE) 121 | * https://github.com/alexcesaro/statsd/ (MIT License, https://github.com/alexcesaro/statsd/blob/master/LICENSE) 122 | * https://github.com/armon/go-metrics (MIT License, https://github.com/armon/go-metrics/blob/master/LICENSE) 123 | 124 | ## Talks 125 | 126 | I gave a talk about design and optimizations which went into go-statsd at 127 | [Gophercon Russia 2018](https://www.gophercon-russia.ru/): 128 | [slides](https://talks.godoc.org/github.com/smira/gopherconru2018/go-statsd.slide), 129 | [source](https://github.com/smira/gopherconru2018). 130 | 131 | ## License 132 | 133 | License is [MIT License](LICENSE). 134 | 135 | 136 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fsmira%2Fgo-statsd.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fsmira%2Fgo-statsd?ref=badge_large) 137 | -------------------------------------------------------------------------------- /buffers.go: -------------------------------------------------------------------------------- 1 | package statsd 2 | 3 | /* 4 | 5 | Copyright (c) 2017 Andrey Smirnov 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | 25 | */ 26 | 27 | import "sync/atomic" 28 | 29 | // checkBuf checks current buffer for overflow, and flushes buffer up to lastLen bytes on overflow 30 | // 31 | // overflow part is preserved in flushBuf 32 | func (t *transport) checkBuf(lastLen int) { 33 | if len(t.buf) > t.maxPacketSize { 34 | t.flushBuf(lastLen) 35 | } 36 | } 37 | 38 | // flushBuf sends buffer to the queue and initializes new buffer 39 | func (t *transport) flushBuf(length int) { 40 | sendBuf := t.buf[0:length] 41 | tail := t.buf[length:len(t.buf)] 42 | 43 | // get new buffer 44 | select { 45 | case t.buf = <-t.bufPool: 46 | t.buf = t.buf[0:0] 47 | default: 48 | t.buf = make([]byte, 0, t.bufSize) 49 | } 50 | 51 | // copy tail to the new buffer 52 | t.buf = append(t.buf, tail...) 53 | 54 | // flush current buffer 55 | select { 56 | case t.sendQueue <- sendBuf: 57 | default: 58 | // flush failed, we lost some data 59 | atomic.AddInt64(&t.lostPacketsPeriod, 1) 60 | atomic.AddInt64(&t.lostPacketsOverall, 1) 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package statsd 2 | 3 | /* 4 | 5 | Copyright (c) 2017 Andrey Smirnov 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | 25 | */ 26 | 27 | import ( 28 | "log" 29 | "os" 30 | "strconv" 31 | "sync" 32 | "sync/atomic" 33 | "time" 34 | ) 35 | 36 | // Client implements statsd client 37 | type Client struct { 38 | trans *transport 39 | metricPrefix string 40 | defaultTags []Tag 41 | } 42 | 43 | type transport struct { 44 | // these fields are updated with atomic operations, 45 | // so they should be at the top for proper alignment 46 | lostPacketsPeriod int64 47 | lostPacketsOverall int64 48 | 49 | maxPacketSize int 50 | tagFormat *TagFormat 51 | 52 | bufPool chan []byte 53 | buf []byte 54 | bufSize int 55 | bufLock sync.Mutex 56 | sendQueue chan []byte 57 | 58 | shutdown chan struct{} 59 | shutdownOnce sync.Once 60 | shutdownWg sync.WaitGroup 61 | } 62 | 63 | // NewClient creates new statsd client and starts background processing 64 | // 65 | // Client connects to statsd server at addr ("host:port") 66 | // 67 | // Client settings could be controlled via functions of type Option 68 | func NewClient(addr string, options ...Option) *Client { 69 | opts := ClientOptions{ 70 | Addr: addr, 71 | AddrNetwork: DefaultNetwork, 72 | MetricPrefix: DefaultMetricPrefix, 73 | MaxPacketSize: DefaultMaxPacketSize, 74 | FlushInterval: DefaultFlushInterval, 75 | ReconnectInterval: DefaultReconnectInterval, 76 | ReportInterval: DefaultReportInterval, 77 | RetryTimeout: DefaultRetryTimeout, 78 | Logger: log.New(os.Stderr, DefaultLogPrefix, log.LstdFlags), 79 | BufPoolCapacity: DefaultBufPoolCapacity, 80 | SendQueueCapacity: DefaultSendQueueCapacity, 81 | SendLoopCount: DefaultSendLoopCount, 82 | TagFormat: TagFormatInfluxDB, 83 | } 84 | 85 | c := &Client{ 86 | trans: &transport{ 87 | shutdown: make(chan struct{}), 88 | }, 89 | } 90 | // 1024 is room for overflow metric 91 | c.trans.bufSize = opts.MaxPacketSize + 1024 92 | 93 | for _, option := range options { 94 | option(&opts) 95 | } 96 | 97 | c.metricPrefix = opts.MetricPrefix 98 | c.defaultTags = opts.DefaultTags 99 | 100 | c.trans.tagFormat = opts.TagFormat 101 | c.trans.maxPacketSize = opts.MaxPacketSize 102 | c.trans.buf = make([]byte, 0, c.trans.bufSize) 103 | c.trans.bufPool = make(chan []byte, opts.BufPoolCapacity) 104 | c.trans.sendQueue = make(chan []byte, opts.SendQueueCapacity) 105 | 106 | go c.trans.flushLoop(opts.FlushInterval) 107 | 108 | for i := 0; i < opts.SendLoopCount; i++ { 109 | c.trans.shutdownWg.Add(1) 110 | go c.trans.sendLoop(opts.Addr, opts.AddrNetwork, opts.ReconnectInterval, opts.RetryTimeout, opts.Logger) 111 | } 112 | 113 | if opts.ReportInterval > 0 { 114 | c.trans.shutdownWg.Add(1) 115 | go c.trans.reportLoop(opts.ReportInterval, opts.Logger) 116 | } 117 | 118 | return c 119 | } 120 | 121 | // Close stops the client and all its clones. Calling it on a clone has the 122 | // same effect as calling it on the original client - it is stopped with all 123 | // its clones. 124 | func (c *Client) Close() error { 125 | c.trans.close() 126 | return nil 127 | } 128 | 129 | func (t *transport) close() { 130 | t.shutdownOnce.Do(func() { 131 | close(t.shutdown) 132 | }) 133 | t.shutdownWg.Wait() 134 | } 135 | 136 | // CloneWithPrefix returns a clone of the original client with different metricPrefix. 137 | func (c *Client) CloneWithPrefix(prefix string) *Client { 138 | clone := *c 139 | clone.metricPrefix = prefix 140 | return &clone 141 | } 142 | 143 | // CloneWithPrefixExtension returns a clone of the original client with the 144 | // original prefixed extended with the specified string. 145 | func (c *Client) CloneWithPrefixExtension(extension string) *Client { 146 | clone := *c 147 | clone.metricPrefix = clone.metricPrefix + extension 148 | return &clone 149 | } 150 | 151 | // GetLostPackets returns number of packets lost during client lifecycle 152 | func (c *Client) GetLostPackets() int64 { 153 | return atomic.LoadInt64(&c.trans.lostPacketsOverall) 154 | } 155 | 156 | // Incr increments a counter metric 157 | // 158 | // Often used to note a particular event, for example incoming web request. 159 | func (c *Client) Incr(stat string, count int64, tags ...Tag) { 160 | if count != 0 { 161 | c.trans.bufLock.Lock() 162 | lastLen := len(c.trans.buf) 163 | 164 | c.trans.buf = append(c.trans.buf, []byte(c.metricPrefix)...) 165 | c.trans.buf = append(c.trans.buf, []byte(stat)...) 166 | if c.trans.tagFormat.Placement == TagPlacementName { 167 | c.trans.buf = c.formatTags(c.trans.buf, tags) 168 | } 169 | c.trans.buf = append(c.trans.buf, ':') 170 | c.trans.buf = strconv.AppendInt(c.trans.buf, count, 10) 171 | c.trans.buf = append(c.trans.buf, []byte("|c")...) 172 | if c.trans.tagFormat.Placement == TagPlacementSuffix { 173 | c.trans.buf = c.formatTags(c.trans.buf, tags) 174 | } 175 | c.trans.buf = append(c.trans.buf, '\n') 176 | 177 | c.trans.checkBuf(lastLen) 178 | c.trans.bufLock.Unlock() 179 | } 180 | } 181 | 182 | // Decr decrements a counter metri 183 | // 184 | // Often used to note a particular event 185 | func (c *Client) Decr(stat string, count int64, tags ...Tag) { 186 | c.Incr(stat, -count, tags...) 187 | } 188 | 189 | // FIncr increments a float counter metric 190 | func (c *Client) FIncr(stat string, count float64, tags ...Tag) { 191 | if count != 0 { 192 | c.trans.bufLock.Lock() 193 | lastLen := len(c.trans.buf) 194 | 195 | c.trans.buf = append(c.trans.buf, []byte(c.metricPrefix)...) 196 | c.trans.buf = append(c.trans.buf, []byte(stat)...) 197 | if c.trans.tagFormat.Placement == TagPlacementName { 198 | c.trans.buf = c.formatTags(c.trans.buf, tags) 199 | } 200 | c.trans.buf = append(c.trans.buf, ':') 201 | c.trans.buf = strconv.AppendFloat(c.trans.buf, count, 'f', -1, 64) 202 | c.trans.buf = append(c.trans.buf, []byte("|c")...) 203 | if c.trans.tagFormat.Placement == TagPlacementSuffix { 204 | c.trans.buf = c.formatTags(c.trans.buf, tags) 205 | } 206 | c.trans.buf = append(c.trans.buf, '\n') 207 | 208 | c.trans.checkBuf(lastLen) 209 | c.trans.bufLock.Unlock() 210 | } 211 | } 212 | 213 | // FDecr decrements a float counter metric 214 | func (c *Client) FDecr(stat string, count float64, tags ...Tag) { 215 | c.FIncr(stat, -count, tags...) 216 | } 217 | 218 | // Timing tracks a duration event, the time delta must be given in milliseconds 219 | func (c *Client) Timing(stat string, delta int64, tags ...Tag) { 220 | c.trans.bufLock.Lock() 221 | lastLen := len(c.trans.buf) 222 | 223 | c.trans.buf = append(c.trans.buf, []byte(c.metricPrefix)...) 224 | c.trans.buf = append(c.trans.buf, []byte(stat)...) 225 | if c.trans.tagFormat.Placement == TagPlacementName { 226 | c.trans.buf = c.formatTags(c.trans.buf, tags) 227 | } 228 | c.trans.buf = append(c.trans.buf, ':') 229 | c.trans.buf = strconv.AppendInt(c.trans.buf, delta, 10) 230 | c.trans.buf = append(c.trans.buf, []byte("|ms")...) 231 | if c.trans.tagFormat.Placement == TagPlacementSuffix { 232 | c.trans.buf = c.formatTags(c.trans.buf, tags) 233 | } 234 | c.trans.buf = append(c.trans.buf, '\n') 235 | 236 | c.trans.checkBuf(lastLen) 237 | c.trans.bufLock.Unlock() 238 | } 239 | 240 | // PrecisionTiming track a duration event, the time delta has to be a duration 241 | // 242 | // Usually request processing time, time to run database query, etc. are used with 243 | // this metric type. 244 | func (c *Client) PrecisionTiming(stat string, delta time.Duration, tags ...Tag) { 245 | c.trans.bufLock.Lock() 246 | lastLen := len(c.trans.buf) 247 | 248 | c.trans.buf = append(c.trans.buf, []byte(c.metricPrefix)...) 249 | c.trans.buf = append(c.trans.buf, []byte(stat)...) 250 | if c.trans.tagFormat.Placement == TagPlacementName { 251 | c.trans.buf = c.formatTags(c.trans.buf, tags) 252 | } 253 | c.trans.buf = append(c.trans.buf, ':') 254 | c.trans.buf = strconv.AppendFloat(c.trans.buf, float64(delta)/float64(time.Millisecond), 'f', -1, 64) 255 | c.trans.buf = append(c.trans.buf, []byte("|ms")...) 256 | if c.trans.tagFormat.Placement == TagPlacementSuffix { 257 | c.trans.buf = c.formatTags(c.trans.buf, tags) 258 | } 259 | c.trans.buf = append(c.trans.buf, '\n') 260 | 261 | c.trans.checkBuf(lastLen) 262 | c.trans.bufLock.Unlock() 263 | } 264 | 265 | func (c *Client) igauge(stat string, sign []byte, value int64, tags ...Tag) { 266 | c.trans.bufLock.Lock() 267 | lastLen := len(c.trans.buf) 268 | 269 | c.trans.buf = append(c.trans.buf, []byte(c.metricPrefix)...) 270 | c.trans.buf = append(c.trans.buf, []byte(stat)...) 271 | if c.trans.tagFormat.Placement == TagPlacementName { 272 | c.trans.buf = c.formatTags(c.trans.buf, tags) 273 | } 274 | c.trans.buf = append(c.trans.buf, ':') 275 | c.trans.buf = append(c.trans.buf, sign...) 276 | c.trans.buf = strconv.AppendInt(c.trans.buf, value, 10) 277 | c.trans.buf = append(c.trans.buf, []byte("|g")...) 278 | if c.trans.tagFormat.Placement == TagPlacementSuffix { 279 | c.trans.buf = c.formatTags(c.trans.buf, tags) 280 | } 281 | c.trans.buf = append(c.trans.buf, '\n') 282 | 283 | c.trans.checkBuf(lastLen) 284 | c.trans.bufLock.Unlock() 285 | } 286 | 287 | // Gauge sets or updates constant value for the interval 288 | // 289 | // Gauges are a constant data type. They are not subject to averaging, 290 | // and they don’t change unless you change them. That is, once you set a gauge value, 291 | // it will be a flat line on the graph until you change it again. If you specify 292 | // delta to be true, that specifies that the gauge should be updated, not set. Due to the 293 | // underlying protocol, you can't explicitly set a gauge to a negative number without 294 | // first setting it to zero. 295 | func (c *Client) Gauge(stat string, value int64, tags ...Tag) { 296 | if value < 0 { 297 | c.igauge(stat, nil, 0, tags...) 298 | } 299 | 300 | c.igauge(stat, nil, value, tags...) 301 | } 302 | 303 | // GaugeDelta sends a change for a gauge 304 | func (c *Client) GaugeDelta(stat string, value int64, tags ...Tag) { 305 | // Gauge Deltas are always sent with a leading '+' or '-'. The '-' takes care of itself but the '+' must added by hand 306 | if value < 0 { 307 | c.igauge(stat, nil, value, tags...) 308 | } else { 309 | c.igauge(stat, []byte{'+'}, value, tags...) 310 | } 311 | } 312 | 313 | func (c *Client) fgauge(stat string, sign []byte, value float64, tags ...Tag) { 314 | c.trans.bufLock.Lock() 315 | lastLen := len(c.trans.buf) 316 | 317 | c.trans.buf = append(c.trans.buf, []byte(c.metricPrefix)...) 318 | c.trans.buf = append(c.trans.buf, []byte(stat)...) 319 | if c.trans.tagFormat.Placement == TagPlacementName { 320 | c.trans.buf = c.formatTags(c.trans.buf, tags) 321 | } 322 | c.trans.buf = append(c.trans.buf, ':') 323 | c.trans.buf = append(c.trans.buf, sign...) 324 | c.trans.buf = strconv.AppendFloat(c.trans.buf, value, 'f', -1, 64) 325 | c.trans.buf = append(c.trans.buf, []byte("|g")...) 326 | if c.trans.tagFormat.Placement == TagPlacementSuffix { 327 | c.trans.buf = c.formatTags(c.trans.buf, tags) 328 | } 329 | c.trans.buf = append(c.trans.buf, '\n') 330 | 331 | c.trans.checkBuf(lastLen) 332 | c.trans.bufLock.Unlock() 333 | } 334 | 335 | // FGauge sends a floating point value for a gauge 336 | func (c *Client) FGauge(stat string, value float64, tags ...Tag) { 337 | if value < 0 { 338 | c.igauge(stat, nil, 0, tags...) 339 | } 340 | 341 | c.fgauge(stat, nil, value, tags...) 342 | } 343 | 344 | // FGaugeDelta sends a floating point change for a gauge 345 | func (c *Client) FGaugeDelta(stat string, value float64, tags ...Tag) { 346 | if value < 0 { 347 | c.fgauge(stat, nil, value, tags...) 348 | } else { 349 | c.fgauge(stat, []byte{'+'}, value, tags...) 350 | } 351 | } 352 | 353 | // SetAdd adds unique element to a set 354 | // 355 | // Statsd server will provide cardinality of the set over aggregation period. 356 | func (c *Client) SetAdd(stat string, value string, tags ...Tag) { 357 | c.trans.bufLock.Lock() 358 | lastLen := len(c.trans.buf) 359 | 360 | c.trans.buf = append(c.trans.buf, []byte(c.metricPrefix)...) 361 | c.trans.buf = append(c.trans.buf, []byte(stat)...) 362 | if c.trans.tagFormat.Placement == TagPlacementName { 363 | c.trans.buf = c.formatTags(c.trans.buf, tags) 364 | } 365 | c.trans.buf = append(c.trans.buf, ':') 366 | c.trans.buf = append(c.trans.buf, []byte(value)...) 367 | c.trans.buf = append(c.trans.buf, []byte("|s")...) 368 | if c.trans.tagFormat.Placement == TagPlacementSuffix { 369 | c.trans.buf = c.formatTags(c.trans.buf, tags) 370 | } 371 | c.trans.buf = append(c.trans.buf, '\n') 372 | 373 | c.trans.checkBuf(lastLen) 374 | c.trans.bufLock.Unlock() 375 | } 376 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package statsd 2 | 3 | /* 4 | 5 | Copyright (c) 2017 Andrey Smirnov 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | 25 | */ 26 | 27 | import ( 28 | "fmt" 29 | "math/rand" 30 | "net" 31 | "strconv" 32 | "strings" 33 | "sync" 34 | "sync/atomic" 35 | "testing" 36 | "time" 37 | ) 38 | 39 | func setupListener(t *testing.T) (*net.UDPConn, chan []byte) { 40 | inSocket, err := net.ListenUDP("udp4", &net.UDPAddr{ 41 | IP: net.IPv4(127, 0, 0, 1), 42 | }) 43 | if err != nil { 44 | t.Error(err) 45 | } 46 | 47 | received := make(chan []byte, 1024) 48 | 49 | go func() { 50 | for { 51 | buf := make([]byte, 1500) 52 | 53 | n, err := inSocket.Read(buf) 54 | if err != nil { 55 | return 56 | } 57 | 58 | received <- buf[0:n] 59 | } 60 | 61 | }() 62 | 63 | return inSocket, received 64 | } 65 | 66 | func TestWrongAddress(t *testing.T) { 67 | client := NewClient("BOOM:BOOM") 68 | if err := client.Close(); err != nil { 69 | t.Errorf("error from close: %v", err) 70 | } 71 | } 72 | 73 | func TestCommands(t *testing.T) { 74 | inSocket, received := setupListener(t) 75 | 76 | client := NewClient(inSocket.LocalAddr().String(), 77 | MetricPrefix("foo."), 78 | MaxPacketSize(1400), 79 | ReconnectInterval(10*time.Second)) 80 | clientTagged := NewClient(inSocket.LocalAddr().String(), 81 | TagStyle(TagFormatDatadog), 82 | DefaultTags(StringTag("host", "example.com"), Int64Tag("weight", 38))) 83 | 84 | compareOutput := func(actions func(), expected []string) func(*testing.T) { 85 | return func(t *testing.T) { 86 | actions() 87 | 88 | for _, exp := range expected { 89 | buf := <-received 90 | 91 | if string(buf) != exp { 92 | t.Errorf("unexpected part received: %#v != %#v", string(buf), exp) 93 | } 94 | } 95 | } 96 | } 97 | 98 | t.Run("Incr", compareOutput( 99 | func() { client.Incr("req.count", 30) }, 100 | []string{"foo.req.count:30|c"})) 101 | 102 | t.Run("IncrTaggedInflux", compareOutput( 103 | func() { client.Incr("req.count", 30, StringTag("app", "service"), IntTag("port", 80)) }, 104 | []string{"foo.req.count,app=service,port=80:30|c"})) 105 | 106 | t.Run("IncrTaggedDatadog", compareOutput( 107 | func() { clientTagged.Incr("req.count", 30, StringTag("app", "service"), IntTag("port", 80)) }, 108 | []string{"req.count:30|c|#host:example.com,weight:38,app:service,port:80"})) 109 | 110 | t.Run("Decr", compareOutput( 111 | func() { client.Decr("req.count", 30) }, 112 | []string{"foo.req.count:-30|c"})) 113 | 114 | t.Run("FIncr", compareOutput( 115 | func() { client.FIncr("req.count", 0.3) }, 116 | []string{"foo.req.count:0.3|c"})) 117 | 118 | t.Run("FDecr", compareOutput( 119 | func() { client.FDecr("req.count", 0.3) }, 120 | []string{"foo.req.count:-0.3|c"})) 121 | 122 | t.Run("Timing", compareOutput( 123 | func() { client.Timing("req.duration", 100) }, 124 | []string{"foo.req.duration:100|ms"})) 125 | 126 | t.Run("TimingTaggedInflux", compareOutput( 127 | func() { client.Timing("req.duration", 100, StringTag("app", "service"), IntTag("port", 80)) }, 128 | []string{"foo.req.duration,app=service,port=80:100|ms"})) 129 | 130 | t.Run("TimingTaggedDatadog", compareOutput( 131 | func() { clientTagged.Timing("req.duration", 100, StringTag("app", "service"), IntTag("port", 80)) }, 132 | []string{"req.duration:100|ms|#host:example.com,weight:38,app:service,port:80"})) 133 | 134 | t.Run("PrecisionTiming", compareOutput( 135 | func() { client.PrecisionTiming("req.duration", 157356*time.Microsecond) }, 136 | []string{"foo.req.duration:157.356|ms"})) 137 | 138 | t.Run("PrecisionTimingTaggedInflux", compareOutput( 139 | func() { 140 | client.PrecisionTiming("req.duration", 157356*time.Microsecond, StringTag("app", "service"), IntTag("port", 80)) 141 | }, 142 | []string{"foo.req.duration,app=service,port=80:157.356|ms"})) 143 | 144 | t.Run("PrecisionTimingTaggedDatadog", compareOutput( 145 | func() { 146 | clientTagged.PrecisionTiming("req.duration", 157356*time.Microsecond, StringTag("app", "service"), IntTag("port", 80)) 147 | }, 148 | []string{"req.duration:157.356|ms|#host:example.com,weight:38,app:service,port:80"})) 149 | 150 | t.Run("Gauge", compareOutput( 151 | func() { client.Gauge("req.clients", 33); client.Gauge("req.clients", -533) }, 152 | []string{"foo.req.clients:33|g\nfoo.req.clients:0|g\nfoo.req.clients:-533|g"})) 153 | 154 | t.Run("GaugeTaggedInflux", compareOutput( 155 | func() { 156 | client.Gauge("req.clients", 33, StringTag("app", "service"), IntTag("port", 80)) 157 | client.Gauge("req.clients", -533, StringTag("app", "service"), IntTag("port", 80)) 158 | }, 159 | []string{"foo.req.clients,app=service,port=80:33|g\nfoo.req.clients,app=service,port=80:0|g\nfoo.req.clients,app=service,port=80:-533|g"})) 160 | 161 | t.Run("GaugeDelta", compareOutput( 162 | func() { client.GaugeDelta("req.clients", 33); client.GaugeDelta("req.clients", -533) }, 163 | []string{"foo.req.clients:+33|g\nfoo.req.clients:-533|g"})) 164 | 165 | t.Run("GaugeDeltaTaggedDatadog", compareOutput( 166 | func() { clientTagged.GaugeDelta("req.clients", 33); clientTagged.GaugeDelta("req.clients", -533) }, 167 | []string{"req.clients:+33|g|#host:example.com,weight:38\nreq.clients:-533|g|#host:example.com,weight:38"})) 168 | 169 | t.Run("FGauge", compareOutput( 170 | func() { client.FGauge("req.clients", 33.5); client.FGauge("req.clients", -533.3) }, 171 | []string{"foo.req.clients:33.5|g\nfoo.req.clients:0|g\nfoo.req.clients:-533.3|g"})) 172 | 173 | t.Run("FGaugeTaggedInflux", compareOutput( 174 | func() { 175 | client.FGauge("req.clients", 33.5, StringTag("app", "service"), IntTag("port", 80)) 176 | client.FGauge("req.clients", -533.3, StringTag("app", "service"), IntTag("port", 80)) 177 | }, 178 | []string{"foo.req.clients,app=service,port=80:33.5|g\nfoo.req.clients,app=service,port=80:0|g\nfoo.req.clients,app=service,port=80:-533.3|g"})) 179 | 180 | t.Run("FGaugeDelta", compareOutput( 181 | func() { client.FGaugeDelta("req.clients", 33.5); client.FGaugeDelta("req.clients", -533.3) }, 182 | []string{"foo.req.clients:+33.5|g\nfoo.req.clients:-533.3|g"})) 183 | 184 | t.Run("FGaugeDeltaTaggedDatadog", compareOutput( 185 | func() { clientTagged.FGaugeDelta("req.clients", 33.5); clientTagged.FGaugeDelta("req.clients", -533.3) }, 186 | []string{"req.clients:+33.5|g|#host:example.com,weight:38\nreq.clients:-533.3|g|#host:example.com,weight:38"})) 187 | 188 | t.Run("SetAdd", compareOutput( 189 | func() { client.SetAdd("req.user", "bob") }, 190 | []string{"foo.req.user:bob|s"})) 191 | 192 | t.Run("SetAddTaggedInflux", compareOutput( 193 | func() { client.SetAdd("req.user", "bob", StringTag("app", "service"), IntTag("port", 80)) }, 194 | []string{"foo.req.user,app=service,port=80:bob|s"})) 195 | 196 | t.Run("SetAddTaggedDatadog", compareOutput( 197 | func() { clientTagged.SetAdd("req.user", "bob", StringTag("app", "service"), IntTag("port", 80)) }, 198 | []string{"req.user:bob|s|#host:example.com,weight:38,app:service,port:80"})) 199 | 200 | t.Run("FlushedIncr", compareOutput( 201 | func() { 202 | client.Incr("req.count", 40) 203 | client.Incr("req.count", 20) 204 | time.Sleep(150 * time.Millisecond) 205 | client.Incr("req.count", 10) 206 | }, 207 | []string{"foo.req.count:40|c\nfoo.req.count:20|c", "foo.req.count:10|c"})) 208 | 209 | t.Run("SplitIncr", compareOutput( 210 | func() { 211 | for i := 0; i < 100; i++ { 212 | client.Incr("req.count", 30) 213 | } 214 | }, 215 | []string{ 216 | "foo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c", 217 | "foo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c\nfoo.req.count:30|c", 218 | })) 219 | 220 | _ = client.Close() 221 | _ = inSocket.Close() 222 | close(received) 223 | } 224 | 225 | func TestClones(t *testing.T) { 226 | inSocket, received := setupListener(t) 227 | 228 | client := NewClient(inSocket.LocalAddr().String(), 229 | MetricPrefix("foo."), 230 | MaxPacketSize(1400), 231 | ReconnectInterval(10*time.Second)) 232 | client2 := client.CloneWithPrefix("bar.") 233 | client3 := client2.CloneWithPrefixExtension("blah.") 234 | 235 | compareOutput := func(actions func(), expected []string) func(*testing.T) { 236 | return func(t *testing.T) { 237 | actions() 238 | 239 | for _, exp := range expected { 240 | var buf []byte 241 | select { 242 | case buf = <-received: 243 | case <-time.After(time.Second): 244 | t.Errorf("timeout waiting for %v", exp) 245 | return 246 | } 247 | 248 | if string(buf) != exp { 249 | t.Errorf("unexpected part received: %#v != %#v", string(buf), exp) 250 | } 251 | } 252 | } 253 | } 254 | 255 | t.Run("Original", compareOutput( 256 | func() { client.Incr("req.count", 30) }, 257 | []string{"foo.req.count:30|c"})) 258 | 259 | t.Run("CloneWithPrefix", compareOutput( 260 | func() { client2.Incr("req.count", 30) }, 261 | []string{"bar.req.count:30|c"})) 262 | 263 | t.Run("CloneWithPrefixExtension", compareOutput( 264 | func() { client3.Incr("req.count", 30) }, 265 | []string{"bar.blah.req.count:30|c"})) 266 | 267 | _ = client.Close() 268 | _ = client2.Close() 269 | _ = client3.Close() 270 | _ = inSocket.Close() 271 | close(received) 272 | } 273 | 274 | func TestConcurrent(t *testing.T) { 275 | inSocket, received := setupListener(t) 276 | 277 | client := NewClient(inSocket.LocalAddr().String(), MetricPrefix("foo."), SendLoopCount(3)) 278 | 279 | var totalSent, totalReceived int64 280 | 281 | var wg1, wg2 sync.WaitGroup 282 | 283 | wg1.Add(1) 284 | 285 | go func() { 286 | for buf := range received { 287 | for _, part := range strings.Split(string(buf), "\n") { 288 | i1 := strings.Index(part, ":") 289 | i2 := strings.Index(part, "|") 290 | 291 | if i1 == -1 || i2 == -1 { 292 | t.Logf("non-parsable part: %#v", part) 293 | continue 294 | } 295 | 296 | count, err := strconv.ParseInt(part[i1+1:i2], 10, 64) 297 | if err != nil { 298 | t.Log(err) 299 | continue 300 | } 301 | 302 | atomic.AddInt64(&totalReceived, count) 303 | } 304 | } 305 | 306 | wg1.Done() 307 | }() 308 | 309 | workers := 16 310 | count := 1024 311 | 312 | for i := 0; i < workers; i++ { 313 | wg2.Add(1) 314 | 315 | go func(i int) { 316 | for j := 0; j < count; j++ { 317 | // to simulate real load, sleep a bit in between the stats calls 318 | time.Sleep(time.Duration(rand.ExpFloat64() * float64(time.Microsecond))) 319 | 320 | increment := i + j 321 | client.Incr("some.counter", int64(increment)) 322 | 323 | atomic.AddInt64(&totalSent, int64(increment)) 324 | } 325 | 326 | wg2.Done() 327 | }(i) 328 | } 329 | 330 | wg2.Wait() 331 | 332 | if client.GetLostPackets() > 0 { 333 | t.Errorf("some packets were lost during the test, results are not valid: %d", client.GetLostPackets()) 334 | } 335 | 336 | _ = client.Close() 337 | 338 | // wait for 30 seconds for all the packets to be received 339 | for i := 0; i < 30; i++ { 340 | if atomic.LoadInt64(&totalSent) == atomic.LoadInt64(&totalReceived) { 341 | break 342 | } 343 | 344 | time.Sleep(time.Second) 345 | } 346 | 347 | _ = inSocket.Close() 348 | close(received) 349 | 350 | wg1.Wait() 351 | 352 | if atomic.LoadInt64(&totalSent) != atomic.LoadInt64(&totalReceived) { 353 | t.Errorf("sent != received: %v != %v", totalSent, totalReceived) 354 | } 355 | } 356 | 357 | func BenchmarkSimple(b *testing.B) { 358 | inSocket, err := net.ListenUDP("udp4", &net.UDPAddr{ 359 | IP: net.IPv4(127, 0, 0, 1), 360 | }) 361 | if err != nil { 362 | b.Error(err) 363 | } 364 | 365 | go func() { 366 | buf := make([]byte, 1500) 367 | for { 368 | _, err := inSocket.Read(buf) 369 | if err != nil { 370 | return 371 | } 372 | } 373 | 374 | }() 375 | 376 | c := NewClient(inSocket.LocalAddr().String(), MetricPrefix("metricPrefix"), MaxPacketSize(1432), 377 | FlushInterval(100*time.Millisecond), SendLoopCount(2)) 378 | 379 | b.ResetTimer() 380 | 381 | for i := 0; i < b.N; i++ { 382 | c.Incr("foo.bar.counter", 1) 383 | c.Gauge("foo.bar.gauge", 42) 384 | c.PrecisionTiming("foo.bar.timing", 153*time.Millisecond) 385 | } 386 | _ = c.Close() 387 | _ = inSocket.Close() 388 | } 389 | 390 | func BenchmarkSimpleUnixSocket(b *testing.B) { 391 | socket := fmt.Sprintf("/tmp/go-statsd-%d", time.Now().UnixNano()) 392 | inSocket, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: socket, Net: "unixgram"}) 393 | if err != nil { 394 | b.Error(err) 395 | return 396 | } 397 | if err := inSocket.SetReadBuffer(1024_000); err != nil { 398 | b.Error(err) 399 | return 400 | } 401 | go func() { 402 | buf := make([]byte, 1500) 403 | for { 404 | _, err := inSocket.Read(buf) 405 | if err != nil { 406 | return 407 | } 408 | } 409 | }() 410 | 411 | c := NewClient(socket, Network("unixgram"), MetricPrefix("metricPrefix"), 412 | MaxPacketSize(1432), FlushInterval(100*time.Millisecond), SendLoopCount(1)) 413 | 414 | b.ResetTimer() 415 | 416 | for i := 0; i < b.N; i++ { 417 | c.Incr("foo.bar.counter", 1) 418 | c.Gauge("foo.bar.gauge", 42) 419 | c.PrecisionTiming("foo.bar.timing", 153*time.Millisecond) 420 | } 421 | time.Sleep(1 * time.Millisecond) 422 | _ = c.Close() 423 | _ = inSocket.Close() 424 | } 425 | 426 | func BenchmarkComplexDelivery(b *testing.B) { 427 | inSocket, err := net.ListenUDP("udp4", &net.UDPAddr{ 428 | IP: net.IPv4(127, 0, 0, 1), 429 | }) 430 | if err != nil { 431 | b.Error(err) 432 | } 433 | 434 | go func() { 435 | buf := make([]byte, 1500) 436 | for { 437 | _, err := inSocket.Read(buf) 438 | if err != nil { 439 | return 440 | } 441 | } 442 | 443 | }() 444 | 445 | client := NewClient(inSocket.LocalAddr().String(), MetricPrefix("foo.")) 446 | 447 | b.ResetTimer() 448 | 449 | for i := 0; i < b.N; i++ { 450 | client.Incr("number.requests", 33) 451 | client.Timing("another.value", 157) 452 | client.PrecisionTiming("response.time.for.some.api", 150*time.Millisecond) 453 | client.PrecisionTiming("response.time.for.some.api.case1", 150*time.Millisecond) 454 | } 455 | 456 | _ = client.Close() 457 | _ = inSocket.Close() 458 | } 459 | 460 | func BenchmarkTagged(b *testing.B) { 461 | inSocket, err := net.ListenUDP("udp4", &net.UDPAddr{ 462 | IP: net.IPv4(127, 0, 0, 1), 463 | }) 464 | if err != nil { 465 | b.Error(err) 466 | } 467 | 468 | go func() { 469 | buf := make([]byte, 1500) 470 | for { 471 | _, err := inSocket.Read(buf) 472 | if err != nil { 473 | return 474 | } 475 | } 476 | 477 | }() 478 | 479 | client := NewClient(inSocket.LocalAddr().String(), MetricPrefix("metricPrefix"), MaxPacketSize(1432), 480 | FlushInterval(100*time.Millisecond), SendLoopCount(2), DefaultTags(StringTag("host", "foo")), 481 | SendQueueCapacity(10), BufPoolCapacity(40)) 482 | 483 | b.ResetTimer() 484 | 485 | for i := 0; i < b.N; i++ { 486 | client.Incr("foo.bar.counter", 1, StringTag("route", "api.one"), IntTag("status", 200)) 487 | client.Timing("another.value", 157, StringTag("service", "db")) 488 | client.PrecisionTiming("response.time.for.some.api", 150*time.Millisecond, IntTag("status", 404)) 489 | client.PrecisionTiming("response.time.for.some.api.case1", 150*time.Millisecond, StringTag("service", "db"), IntTag("status", 200)) 490 | } 491 | _ = client.Close() 492 | _ = inSocket.Close() 493 | } 494 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/smira/go-statsd 2 | 3 | go 1.20 4 | -------------------------------------------------------------------------------- /loops.go: -------------------------------------------------------------------------------- 1 | package statsd 2 | 3 | /* 4 | 5 | Copyright (c) 2017 Andrey Smirnov 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | 25 | */ 26 | 27 | import ( 28 | "context" 29 | "net" 30 | "sync/atomic" 31 | "time" 32 | ) 33 | 34 | // flushLoop makes sure metrics are flushed every flushInterval 35 | func (t *transport) flushLoop(flushInterval time.Duration) { 36 | var flushC <-chan time.Time 37 | 38 | if flushInterval > 0 { 39 | flushTicker := time.NewTicker(flushInterval) 40 | defer flushTicker.Stop() 41 | flushC = flushTicker.C 42 | } 43 | 44 | for { 45 | select { 46 | case <-t.shutdown: 47 | t.bufLock.Lock() 48 | if len(t.buf) > 0 { 49 | t.flushBuf(len(t.buf)) 50 | } 51 | t.bufLock.Unlock() 52 | 53 | close(t.sendQueue) 54 | return 55 | case <-flushC: 56 | t.bufLock.Lock() 57 | if len(t.buf) > 0 { 58 | t.flushBuf(len(t.buf)) 59 | } 60 | t.bufLock.Unlock() 61 | } 62 | } 63 | } 64 | 65 | // sendLoop handles packet delivery over UDP and periodic reconnects 66 | func (t *transport) sendLoop(addr string, network string, reconnectInterval, retryTimeout time.Duration, log SomeLogger) { 67 | var ( 68 | sock net.Conn 69 | err error 70 | reconnectC <-chan time.Time 71 | ) 72 | 73 | defer t.shutdownWg.Done() 74 | 75 | if reconnectInterval > 0 { 76 | reconnectTicker := time.NewTicker(reconnectInterval) 77 | defer reconnectTicker.Stop() 78 | reconnectC = reconnectTicker.C 79 | } 80 | 81 | RECONNECT: 82 | // Attempt to connect 83 | sock, err = func() (net.Conn, error) { 84 | // Dial with context which is aborted when client is shut down 85 | ctx, ctxCancel := context.WithCancel(context.Background()) 86 | defer ctxCancel() 87 | 88 | go func() { 89 | select { 90 | case <-t.shutdown: 91 | ctxCancel() 92 | case <-ctx.Done(): 93 | } 94 | }() 95 | 96 | var d net.Dialer 97 | return d.DialContext(ctx, network, addr) 98 | }() 99 | 100 | if err != nil { 101 | log.Printf("[STATSD] Error connecting to server: %s", err) 102 | goto WAIT 103 | } 104 | 105 | for { 106 | select { 107 | case buf, ok := <-t.sendQueue: 108 | // Get a buffer from the queue 109 | if !ok { 110 | _ = sock.Close() // nolint: gosec 111 | return 112 | } 113 | 114 | if len(buf) > 0 { 115 | // cut off \n in the end 116 | _, err := sock.Write(buf[0 : len(buf)-1]) 117 | if err != nil { 118 | log.Printf("[STATSD] Error writing to socket: %s", err) 119 | _ = sock.Close() // nolint: gosec 120 | goto WAIT 121 | } 122 | } 123 | 124 | // return buffer to the pool 125 | select { 126 | case t.bufPool <- buf: 127 | default: 128 | // pool is full, let GC handle the buf 129 | } 130 | case <-reconnectC: 131 | _ = sock.Close() // nolint: gosec 132 | goto RECONNECT 133 | } 134 | } 135 | 136 | WAIT: 137 | // Wait for a while 138 | select { 139 | case <-time.After(retryTimeout): 140 | goto RECONNECT 141 | case <-t.shutdown: 142 | } 143 | 144 | // drain send queue waiting for flush loops to terminate 145 | for range t.sendQueue { //nolint:revive 146 | } 147 | } 148 | 149 | // reportLoop reports periodically number of packets lost 150 | func (t *transport) reportLoop(reportInterval time.Duration, log SomeLogger) { 151 | defer t.shutdownWg.Done() 152 | 153 | reportTicker := time.NewTicker(reportInterval) 154 | defer reportTicker.Stop() 155 | 156 | for { 157 | select { 158 | case <-t.shutdown: 159 | return 160 | case <-reportTicker.C: 161 | lostPeriod := atomic.SwapInt64(&t.lostPacketsPeriod, 0) 162 | if lostPeriod > 0 { 163 | log.Printf("[STATSD] %d packets lost (overflow)", lostPeriod) 164 | } 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package statsd 2 | 3 | /* 4 | 5 | Copyright (c) 2017 Andrey Smirnov 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | 25 | */ 26 | 27 | import ( 28 | "time" 29 | ) 30 | 31 | // Default settings 32 | const ( 33 | DefaultMaxPacketSize = 1432 34 | DefaultMetricPrefix = "" 35 | DefaultFlushInterval = 100 * time.Millisecond 36 | DefaultReconnectInterval = time.Duration(0) 37 | DefaultReportInterval = time.Minute 38 | DefaultRetryTimeout = 5 * time.Second 39 | DefaultLogPrefix = "[STATSD] " 40 | DefaultBufPoolCapacity = 20 41 | DefaultSendQueueCapacity = 10 42 | DefaultSendLoopCount = 1 43 | DefaultNetwork = "udp" 44 | ) 45 | 46 | // SomeLogger defines logging interface that allows using 3rd party loggers 47 | // (e.g. github.com/sirupsen/logrus) with this Statsd client. 48 | type SomeLogger interface { 49 | Printf(fmt string, args ...interface{}) 50 | } 51 | 52 | // ClientOptions are statsd client settings 53 | type ClientOptions struct { 54 | // Addr is statsd server address in "host:port" format 55 | Addr string 56 | 57 | // AddrNetwork is network type for the address. Defaults to udp. 58 | AddrNetwork string 59 | 60 | // MetricPrefix is metricPrefix to prepend to every metric being sent 61 | // 62 | // If not set defaults to empty string 63 | MetricPrefix string 64 | 65 | // MaxPacketSize is maximum UDP packet size 66 | // 67 | // Safe value is 1432 bytes, if your network supports jumbo frames, 68 | // this value could be raised up to 8960 bytes 69 | MaxPacketSize int 70 | 71 | // FlushInterval controls flushing incomplete UDP packets which makes 72 | // sure metric is not delayed longer than FlushInterval 73 | // 74 | // Default value is 100ms, setting FlushInterval to zero disables flushing 75 | FlushInterval time.Duration 76 | 77 | // ReconnectInterval controls UDP socket reconnects 78 | // 79 | // Reconnecting is important to follow DNS changes, e.g. in 80 | // dynamic container environments like K8s where statsd server 81 | // instance might be relocated leading to new IP address. 82 | // 83 | // By default reconnects are disabled 84 | ReconnectInterval time.Duration 85 | 86 | // RetryTimeout controls how often client should attempt reconnecting 87 | // to statsd server on failure 88 | // 89 | // Default value is 5 seconds 90 | RetryTimeout time.Duration 91 | 92 | // ReportInterval instructs client to report number of packets lost 93 | // each interval via Logger 94 | // 95 | // By default lost packets are reported every minute, setting to zero 96 | // disables reporting 97 | ReportInterval time.Duration 98 | 99 | // Logger is used by statsd client to report errors and lost packets 100 | // 101 | // If not set, default logger to stderr with metricPrefix `[STATSD] ` is being used 102 | Logger SomeLogger 103 | 104 | // BufPoolCapacity controls size of pre-allocated buffer cache 105 | // 106 | // Each buffer is MaxPacketSize. Cache allows to avoid allocating 107 | // new buffers during high load 108 | // 109 | // Default value is DefaultBufPoolCapacity 110 | BufPoolCapacity int 111 | 112 | // SendQueueCapacity controls length of the queue of packet ready to be sent 113 | // 114 | // Packets might stay in the queue during short load bursts or while 115 | // client is reconnecting to statsd 116 | // 117 | // Default value is DefaultSendQueueCapacity 118 | SendQueueCapacity int 119 | 120 | // SendLoopCount controls number of goroutines sending UDP packets 121 | // 122 | // Default value is 1, so packets are sent from single goroutine, this 123 | // value might need to be bumped under high load 124 | SendLoopCount int 125 | 126 | // TagFormat controls formatting of StatsD tags 127 | // 128 | // If tags are not used, value of this setting isn't used. 129 | // 130 | // There are two predefined formats: for InfluxDB and Datadog, default 131 | // format is InfluxDB tag format. 132 | TagFormat *TagFormat 133 | 134 | // DefaultTags is a list of tags to be applied to every metric 135 | DefaultTags []Tag 136 | } 137 | 138 | // Option is type for option transport 139 | type Option func(c *ClientOptions) 140 | 141 | // MetricPrefix is metricPrefix to prepend to every metric being sent 142 | // 143 | // Usually metrics are prefixed with app name, e.g. `app.`. 144 | // To avoid providing this metricPrefix for every metric being collected, 145 | // and to enable shared libraries to collect metric under app name, 146 | // use MetricPrefix to set global metricPrefix for all the app metrics, 147 | // e.g. `MetricPrefix("app.")`. 148 | // 149 | // If not set defaults to empty string 150 | func MetricPrefix(prefix string) Option { 151 | return func(c *ClientOptions) { 152 | c.MetricPrefix = prefix 153 | } 154 | } 155 | 156 | // MaxPacketSize control maximum UDP packet size 157 | // 158 | // Default value is DefaultMaxPacketSize 159 | func MaxPacketSize(packetSize int) Option { 160 | return func(c *ClientOptions) { 161 | c.MaxPacketSize = packetSize 162 | } 163 | } 164 | 165 | // FlushInterval controls flushing incomplete UDP packets which makes 166 | // sure metric is not delayed longer than FlushInterval 167 | // 168 | // Default value is 100ms, setting FlushInterval to zero disables flushing 169 | func FlushInterval(interval time.Duration) Option { 170 | return func(c *ClientOptions) { 171 | c.FlushInterval = interval 172 | } 173 | } 174 | 175 | // ReconnectInterval controls UDP socket reconnects 176 | // 177 | // Reconnecting is important to follow DNS changes, e.g. in 178 | // dynamic container environments like K8s where statsd server 179 | // instance might be relocated leading to new IP address. 180 | // 181 | // By default reconnects are disabled 182 | func ReconnectInterval(interval time.Duration) Option { 183 | return func(c *ClientOptions) { 184 | c.ReconnectInterval = interval 185 | } 186 | } 187 | 188 | // RetryTimeout controls how often client should attempt reconnecting 189 | // to statsd server on failure 190 | // 191 | // Default value is 5 seconds 192 | func RetryTimeout(timeout time.Duration) Option { 193 | return func(c *ClientOptions) { 194 | c.RetryTimeout = timeout 195 | } 196 | } 197 | 198 | // ReportInterval instructs client to report number of packets lost 199 | // each interval via Logger 200 | // 201 | // By default lost packets are reported every minute, setting to zero 202 | // disables reporting 203 | func ReportInterval(interval time.Duration) Option { 204 | return func(c *ClientOptions) { 205 | c.ReportInterval = interval 206 | } 207 | } 208 | 209 | // Logger is used by statsd client to report errors and lost packets 210 | // 211 | // If not set, default logger to stderr with metricPrefix `[STATSD] ` is being used 212 | func Logger(logger SomeLogger) Option { 213 | return func(c *ClientOptions) { 214 | c.Logger = logger 215 | } 216 | } 217 | 218 | // BufPoolCapacity controls size of pre-allocated buffer cache 219 | // 220 | // Each buffer is MaxPacketSize. Cache allows to avoid allocating 221 | // new buffers during high load 222 | // 223 | // Default value is DefaultBufPoolCapacity 224 | func BufPoolCapacity(capacity int) Option { 225 | return func(c *ClientOptions) { 226 | c.BufPoolCapacity = capacity 227 | } 228 | } 229 | 230 | // SendQueueCapacity controls length of the queue of packet ready to be sent 231 | // 232 | // Packets might stay in the queue during short load bursts or while 233 | // client is reconnecting to statsd 234 | // 235 | // Default value is DefaultSendQueueCapacity 236 | func SendQueueCapacity(capacity int) Option { 237 | return func(c *ClientOptions) { 238 | c.SendQueueCapacity = capacity 239 | } 240 | } 241 | 242 | // SendLoopCount controls number of goroutines sending UDP packets 243 | // 244 | // Default value is 1, so packets are sent from single goroutine, this 245 | // value might need to be bumped under high load 246 | func SendLoopCount(threads int) Option { 247 | return func(c *ClientOptions) { 248 | c.SendLoopCount = threads 249 | } 250 | } 251 | 252 | // TagStyle controls formatting of StatsD tags 253 | // 254 | // There are two predefined formats: for InfluxDB and Datadog, default 255 | // format is InfluxDB tag format. 256 | func TagStyle(style *TagFormat) Option { 257 | return func(c *ClientOptions) { 258 | c.TagFormat = style 259 | } 260 | } 261 | 262 | // DefaultTags defines a list of tags to be applied to every metric 263 | func DefaultTags(tags ...Tag) Option { 264 | return func(c *ClientOptions) { 265 | c.DefaultTags = tags 266 | } 267 | } 268 | 269 | // Network sets the network to use Dialing the statsd server 270 | func Network(network string) Option { 271 | return func(c *ClientOptions) { 272 | c.AddrNetwork = network 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /statsd.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package statsd implements high-performance, zero-allocation statsd client. 3 | 4 | Go statsd client library with zero allocation overhead, great performance and automatic 5 | reconnects. 6 | 7 | With statsd architecture aggregation is performed on statsd server side (e.g. using 8 | high-performance servers like statsite), so application emits many metrics per user action. 9 | Performance of statsd client library is critical to introduce as little overhead as possible. 10 | 11 | Client has zero memory allocation per metric being sent, architecture is the following: 12 | 13 | - there's ring of buffers, each buffer is UDP packet 14 | - buffer is taken from the pool, filled with metrics, passed on to the network delivery and 15 | passed back to the pool 16 | - buffer is flushed either when it is full or when flush period comes (e.g. every 100ms) 17 | - separate goroutine is handling network operations: sending UDP packets and reconnecting UDP socket 18 | (to handle statsd DNS address change) 19 | - when metric is serialized, zero allocation methods are used to avoid `reflect` and memory allocation 20 | 21 | Ideas were borrowed from the following stastd clients: 22 | 23 | - https://github.com/quipo/statsd 24 | - https://github.com/Unix4ever/statsd 25 | - https://github.com/alexcesaro/statsd/ 26 | - https://github.com/armon/go-metrics 27 | 28 | # Usage 29 | 30 | Initialize client instance with options, one client per application is usually enough: 31 | 32 | client := statsd.NewClient("localhost:8125", 33 | statsd.MaxPacketSize(1400), 34 | statsd.MetricPrefix("web.")) 35 | 36 | Send metrics as events happen in the application, metrics will be packed together and 37 | delivered to statsd server: 38 | 39 | start := time.Now() 40 | client.Incr("requests.http", 1) 41 | ... 42 | client.PrecisionTiming("requests.route.api.latency", time.Since(start)) 43 | 44 | Shutdown client during application shutdown to flush all the pending metrics: 45 | 46 | client.Close() 47 | 48 | # Tagging 49 | 50 | Metrics could be tagged to support aggregation on TSDB side. go-statsd supports 51 | tags in InfluxDB and Datadog formats. Format and default tags (applied to every 52 | metric) are passed as options to the client initialization: 53 | 54 | client := statsd.NewClient("localhost:8125", 55 | statsd.TagStyle(TagFormatDatadog), 56 | statsd.DefaultTags(statsd.StringTag("app", "billing"))) 57 | 58 | For every metric sent, tags could be added as the last argument(s) to the function 59 | call: 60 | 61 | client.Incr("request", 1, 62 | statsd.StringTag("protocol", "http"), statsd.IntTag("port", 80)) 63 | */ 64 | package statsd 65 | 66 | /* 67 | 68 | Copyright (c) 2017 Andrey Smirnov 69 | 70 | Permission is hereby granted, free of charge, to any person obtaining a copy 71 | of this software and associated documentation files (the "Software"), to deal 72 | in the Software without restriction, including without limitation the rights 73 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 74 | copies of the Software, and to permit persons to whom the Software is 75 | furnished to do so, subject to the following conditions: 76 | 77 | The above copyright notice and this permission notice shall be included in all 78 | copies or substantial portions of the Software. 79 | 80 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 81 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 82 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 83 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 84 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 85 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 86 | SOFTWARE. 87 | 88 | */ 89 | -------------------------------------------------------------------------------- /tags.go: -------------------------------------------------------------------------------- 1 | package statsd 2 | 3 | /* 4 | 5 | Copyright (c) 2018 Andrey Smirnov 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | 25 | */ 26 | 27 | import "strconv" 28 | 29 | // Tag placement constants 30 | const ( 31 | TagPlacementName = iota 32 | TagPlacementSuffix 33 | ) 34 | 35 | // TagFormat controls tag formatting style 36 | type TagFormat struct { 37 | // FirstSeparator is put after metric name and before first tag 38 | FirstSeparator string 39 | // Placement specifies part of the message to append tags to 40 | Placement byte 41 | // OtherSeparator separates 2nd and subsequent tags from each other 42 | OtherSeparator byte 43 | // KeyValueSeparator separates tag name and tag value 44 | KeyValueSeparator []byte 45 | } 46 | 47 | // Tag types 48 | const ( 49 | typeString = iota 50 | typeInt64 51 | ) 52 | 53 | // Tag is metric-specific tag 54 | type Tag struct { 55 | name string 56 | strvalue string 57 | intvalue int64 58 | typ byte 59 | } 60 | 61 | // Append formats tag and appends it to the buffer 62 | func (tag Tag) Append(buf []byte, style *TagFormat) []byte { 63 | buf = append(buf, []byte(tag.name)...) 64 | buf = append(buf, style.KeyValueSeparator...) 65 | if tag.typ == typeString { 66 | return append(buf, []byte(tag.strvalue)...) 67 | } 68 | return strconv.AppendInt(buf, tag.intvalue, 10) 69 | } 70 | 71 | // StringTag creates Tag with string value 72 | func StringTag(name, value string) Tag { 73 | return Tag{name: name, strvalue: value, typ: typeString} 74 | } 75 | 76 | // IntTag creates Tag with integer value 77 | func IntTag(name string, value int) Tag { 78 | return Tag{name: name, intvalue: int64(value), typ: typeInt64} 79 | } 80 | 81 | // Int64Tag creates Tag with integer value 82 | func Int64Tag(name string, value int64) Tag { 83 | return Tag{name: name, intvalue: value, typ: typeInt64} 84 | } 85 | 86 | func (c *Client) formatTags(buf []byte, tags []Tag) []byte { 87 | tagsLen := len(c.defaultTags) + len(tags) 88 | if tagsLen == 0 { 89 | return buf 90 | } 91 | 92 | buf = append(buf, []byte(c.trans.tagFormat.FirstSeparator)...) 93 | for i := range c.defaultTags { 94 | buf = c.defaultTags[i].Append(buf, c.trans.tagFormat) 95 | if i != tagsLen-1 { 96 | buf = append(buf, c.trans.tagFormat.OtherSeparator) 97 | } 98 | } 99 | 100 | for i := range tags { 101 | buf = tags[i].Append(buf, c.trans.tagFormat) 102 | if i+len(c.defaultTags) != tagsLen-1 { 103 | buf = append(buf, c.trans.tagFormat.OtherSeparator) 104 | } 105 | } 106 | 107 | return buf 108 | } 109 | 110 | var ( 111 | // TagFormatInfluxDB is format for InfluxDB StatsD telegraf plugin 112 | // 113 | // Docs: https://github.com/influxdata/telegraf/tree/master/plugins/inputs/statsd 114 | TagFormatInfluxDB = &TagFormat{ 115 | Placement: TagPlacementName, 116 | FirstSeparator: ",", 117 | OtherSeparator: ',', 118 | KeyValueSeparator: []byte{'='}, 119 | } 120 | 121 | // TagFormatDatadog is format for DogStatsD (Datadog Agent) 122 | // 123 | // Docs: https://docs.datadoghq.com/developers/dogstatsd/#datagram-format 124 | TagFormatDatadog = &TagFormat{ 125 | Placement: TagPlacementSuffix, 126 | FirstSeparator: "|#", 127 | OtherSeparator: ',', 128 | KeyValueSeparator: []byte{':'}, 129 | } 130 | 131 | // TagFormatGraphite is format for Graphite 132 | // 133 | // Docs: https://graphite.readthedocs.io/en/latest/tags.html 134 | TagFormatGraphite = &TagFormat{ 135 | Placement: TagPlacementName, 136 | FirstSeparator: ";", 137 | OtherSeparator: ';', 138 | KeyValueSeparator: []byte{'='}, 139 | } 140 | 141 | // TagFormatOkmeter is format for Okmeter agent 142 | // 143 | // Docs: https://okmeter.io/misc/docs#statsd-plugin-config 144 | TagFormatOkmeter = &TagFormat{ 145 | Placement: TagPlacementName, 146 | FirstSeparator: ".", 147 | OtherSeparator: '.', 148 | KeyValueSeparator: []byte("_is_"), 149 | } 150 | ) 151 | -------------------------------------------------------------------------------- /tags_test.go: -------------------------------------------------------------------------------- 1 | package statsd 2 | 3 | /* 4 | 5 | Copyright (c) 2017 Andrey Smirnov 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | 25 | */ 26 | 27 | import "testing" 28 | 29 | func TestTags(t *testing.T) { 30 | compare := func(tag Tag, style *TagFormat, expected string) func(*testing.T) { 31 | return func(t *testing.T) { 32 | buf := tag.Append([]byte{}, style) 33 | 34 | if string(buf) != expected { 35 | t.Errorf("unexpected tag format: %#v != %#v", string(buf), expected) 36 | } 37 | } 38 | } 39 | 40 | t.Run("StringDatadog", 41 | compare(StringTag("name", "value"), TagFormatDatadog, "name:value")) 42 | t.Run("StringInflux", 43 | compare(StringTag("name", "value"), TagFormatInfluxDB, "name=value")) 44 | t.Run("StringGraphite", 45 | compare(StringTag("name", "value"), TagFormatInfluxDB, "name=value")) 46 | t.Run("IntDatadog", 47 | compare(IntTag("foo", -33), TagFormatDatadog, "foo:-33")) 48 | t.Run("IntInflux", 49 | compare(IntTag("foo", -33), TagFormatInfluxDB, "foo=-33")) 50 | t.Run("IntGraphite", 51 | compare(IntTag("foo", -33), TagFormatInfluxDB, "foo=-33")) 52 | t.Run("Int64Datadog", 53 | compare(Int64Tag("foo", 1024*1024*1024*1024), TagFormatDatadog, "foo:1099511627776")) 54 | t.Run("Int64Influx", 55 | compare(Int64Tag("foo", 1024*1024*1024*1024), TagFormatInfluxDB, "foo=1099511627776")) 56 | t.Run("Int64Graphite", 57 | compare(Int64Tag("foo", 1024*1024*1024*1024), TagFormatInfluxDB, "foo=1099511627776")) 58 | } 59 | 60 | func TestFormatTags(t *testing.T) { 61 | compare := func(tags []Tag, style *TagFormat, expected string) func(*testing.T) { 62 | return func(t *testing.T) { 63 | client := NewClient("127.0.0.1:4444", TagStyle(style), DefaultTags(StringTag("host", "foo"))) 64 | buf := client.formatTags([]byte{}, tags) 65 | 66 | if string(buf) != expected { 67 | t.Errorf("unexpected tag format: %#v != %#v", string(buf), expected) 68 | } 69 | } 70 | } 71 | 72 | t.Run("Datadog", 73 | compare([]Tag{StringTag("type", "web"), IntTag("status", 200)}, TagFormatDatadog, "|#host:foo,type:web,status:200")) 74 | t.Run("Influx", 75 | compare([]Tag{StringTag("type", "web"), IntTag("status", 200)}, TagFormatInfluxDB, ",host=foo,type=web,status=200")) 76 | t.Run("Graphite", 77 | compare([]Tag{StringTag("type", "web"), IntTag("status", 200)}, TagFormatGraphite, ";host=foo;type=web;status=200")) 78 | t.Run("Okmeter", 79 | compare([]Tag{StringTag("type", "web"), IntTag("status", 200)}, TagFormatOkmeter, ".host_is_foo.type_is_web.status_is_200")) 80 | } 81 | --------------------------------------------------------------------------------