├── .github └── workflows │ ├── codeql-analysis.yml │ └── go.yml ├── .gitignore ├── LICENSE ├── README.md ├── bucket ├── fixed.go ├── fixed_test.go ├── interface.go ├── metricwindow.go ├── metricwindow_test.go ├── raw.go ├── raw_test.go ├── window.go └── window_test.go ├── datagram ├── metric.go ├── metric_test.go ├── parser.go ├── parser_test.go ├── sender.go └── sender_test.go ├── example ├── images │ ├── count.png │ ├── demo.gif │ ├── gauge.png │ ├── set.png │ └── timing.png ├── jerks │ └── jerks.go └── statter │ └── statter.go ├── go.mod ├── go.sum ├── router ├── router.go └── router_test.go ├── script ├── build └── test ├── statstee.go ├── streams ├── device.go ├── device_test.go ├── interface.go ├── interface_test.go ├── listener.go ├── listener_test.go ├── sniffer.go └── sniffer_test.go └── views ├── colors.go ├── colors_test.go ├── display.go ├── double-buffer.go ├── loop.go ├── plot.go ├── router.go └── set.go /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '18 17 * * 6' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | # Override automatic language detection by changing the below list 21 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 22 | language: ['go'] 23 | # Learn more... 24 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v2 29 | with: 30 | # We must fetch at least the immediate parents so that if this is 31 | # a pull request then we can checkout the head. 32 | fetch-depth: 2 33 | 34 | # If this run was triggered by a pull request event, then checkout 35 | # the head of the pull request instead of the merge commit. 36 | - run: git checkout HEAD^2 37 | if: ${{ github.event_name == 'pull_request' }} 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v1 42 | with: 43 | languages: ${{ matrix.language }} 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v1 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 https://git.io/JvXDl 52 | 53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 54 | # and modify them (or add more) to build your code if your project 55 | # uses a compiled language 56 | 57 | #- run: | 58 | # make bootstrap 59 | # make release 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v1 63 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - name: Install libpcap 17 | run: sudo apt-get install libpcap-dev 18 | 19 | - name: Set up Go 1.x 20 | uses: actions/setup-go@v2 21 | with: 22 | go-version: ^1.14 23 | id: go 24 | 25 | - name: Check out code into the Go module directory 26 | uses: actions/checkout@v2 27 | 28 | - name: Get dependencies 29 | run: go get -v -t -d ./... 30 | 31 | - name: Build 32 | run: go build -v ./... 33 | 34 | - name: Test 35 | run: go test -race -cover -v ./... 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cover.* 2 | dist/ 3 | vendor/ 4 | *.log 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Chris Roche 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # statstee 2 | 3 | _The Cross-Platform Proxyless Command Line UI for StatsD Metrics_ 4 | 5 | ![Demo Animation of UI](/example/images/demo.gif?raw=true) 6 | 7 | The `statstee` utility collects and plots metrics emitted from other processes on a host without interrupting or proxying their flow to the [StatsD daemon][statsd] or subsequent backend services. 8 | 9 | `statstee` relies on [libpcap][libpcap] ([WinPcap][winpcap] for Windows) to capture the UDP metric datagrams off the network. Values are bucketed in one-second intervals and plots are shown depending on the metric datatype. Current and moving averages are also provided for each graph. 10 | 11 | ## Features 12 | 13 | - [x] Cross-Platform (OSX / Linux / Windows) 14 | - [x] Proxyless capture mode (daemon and processes can still use default host/port) 15 | - [x] Listener mode if no daemon is present (prevents packet loss) 16 | - [x] [Configurable](#usage) with sensible defaults (loopback interface, port 8125) 17 | - [x] Supports all StatsD metric data-types ([gauge](#gauge), [counter](#counter), [set](#set), [timing](#timing--histogram-datadog)) 18 | - [x] Supports DataDog histogram metrics (treated like standard StatsD timing) 19 | - [x] Graphed time-series for each metric type 20 | - [x] Current value with 1-, 5- & 10-minute moving averages (EWMA) 21 | 22 | ### Planned Features 23 | 24 | - [ ] Filter/search support 25 | - [ ] Sample rate support 26 | - [ ] DataDog [events][dd-events] and [service checks][dd-checks] 27 | - [ ] Save and/or replay metric data 28 | 29 | ### Gauge 30 | 31 | * Current Value 32 | 33 | ![Gauge Metrics](/example/images/gauge.png?raw=true) 34 | 35 | ### Counter 36 | 37 | * Count / RPS 38 | * Cumulative Count 39 | 40 | ![Counter Metrics](/example/images/count.png?raw=true) 41 | 42 | ### Set 43 | 44 | * Unique Count / Unique RPS 45 | * Percent Unique 46 | 47 | ![Set Metrics](/example/images/set.png?raw=true) 48 | 49 | ### Timing / Histogram (DataDog) 50 | 51 | * Count / RPS 52 | * Median 53 | * 75th Percentile 54 | * 95th Percentile 55 | 56 | ![Timing Metrics](/example/images/timing.png?raw=true) 57 | 58 | ## Install 59 | 60 | Static binaries for many major platforms are forthcoming. For now, please refer to the [development section](#development) for installation instructions. 61 | 62 | ## Usage 63 | 64 | ``` 65 | → statstee -h 66 | Usage of dist/statstee: 67 | -c bool 68 | force capture mode, even if StatsD is not present 69 | -d string 70 | network device to capture on (default "_first_loopback_") 71 | -l bool 72 | force listen mode, error if the port cannot be bound 73 | -p int 74 | port to capture on (default 8125) 75 | -v bool 76 | display debug output to statstee.log 77 | ``` 78 | 79 | **NB:** You will likely need to run `statstee` as root or with `sudo` in order to snoop on the network traffic. 80 | 81 | ## Development 82 | 83 | _Requires Go 1.14 or Higher_ 84 | 85 | #### Install libpcap for your OS & architecture 86 | 87 | * **OSX:** `brew install libpcap` 88 | * **Ubuntu/Debian:** `apt-get install libpcap-dev` 89 | * **Windows:** [run installer][winpcap-install] 90 | 91 | #### Download statstee 92 | 93 | `go get github.com/rodaine/statstee` 94 | 95 | #### Run tests 96 | 97 | `script/test` 98 | 99 | #### Build executables 100 | 101 | `script/build` 102 | 103 | #### Run executables 104 | 105 | * `dist/statstee -v` - This app, with logging to `./statstee.log`! 106 | * `dist/statter` - Demo app for experimenting (outputs all metric types) 107 | * `dist/jerks -n 1 -r 1000` - Load testing app (`n` metrics at `r` rps) 108 | 109 | ## License 110 | 111 | The MIT License (MIT) 112 | 113 | Copyright (c) 2016 Chris Roche 114 | 115 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 116 | 117 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 118 | 119 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 120 | 121 | [statsd]: https://github.com/etsy/statsd 122 | [libpcap]: http://www.tcpdump.org/ 123 | [winpcap]: http://www.winpcap.org/ 124 | [winpcap-install]: http://www.winpcap.org/install/default.htm 125 | [dd]: https://www.datadoghq.com/ 126 | [dd-events]: http://docs.datadoghq.com/guides/dogstatsd/#events-1 127 | [dd-checks]: http://docs.datadoghq.com/guides/dogstatsd/#service-checks 128 | -------------------------------------------------------------------------------- /bucket/fixed.go: -------------------------------------------------------------------------------- 1 | package bucket 2 | 3 | type fixed struct { 4 | sum float64 5 | freq, uniq float64 6 | last float64 7 | mean float64 8 | min, max float64 9 | median, p75, p95, p99 float64 10 | } 11 | 12 | func NewFixed(b Interface) Interface { 13 | return &fixed{ 14 | sum: b.Sum(), 15 | freq: b.Freq(), 16 | uniq: b.Unique(), 17 | last: b.Last(), 18 | mean: b.Mean(), 19 | min: b.Min(), 20 | max: b.Max(), 21 | median: b.Median(), 22 | p75: b.P75(), 23 | p95: b.P95(), 24 | p99: b.P99(), 25 | } 26 | } 27 | 28 | func (b *fixed) Add(m float64) { 29 | // noop 30 | } 31 | 32 | func (b *fixed) Sum() float64 { 33 | return b.sum 34 | } 35 | 36 | func (b *fixed) Freq() float64 { 37 | return b.freq 38 | } 39 | 40 | func (b *fixed) Unique() float64 { 41 | return b.uniq 42 | } 43 | 44 | func (b *fixed) Last() float64 { 45 | return b.last 46 | } 47 | 48 | func (b *fixed) Mean() float64 { 49 | return b.mean 50 | } 51 | 52 | func (b *fixed) Min() float64 { 53 | return b.min 54 | } 55 | 56 | func (b *fixed) Max() float64 { 57 | return b.max 58 | } 59 | 60 | func (b *fixed) Median() float64 { 61 | return b.median 62 | } 63 | 64 | func (b *fixed) P75() float64 { 65 | return b.p75 66 | } 67 | 68 | func (b *fixed) P95() float64 { 69 | return b.p95 70 | } 71 | 72 | func (b *fixed) P99() float64 { 73 | return b.p99 74 | } 75 | 76 | func (b *fixed) Reset() { 77 | b.sum = 0 78 | b.freq = 0 79 | b.last = 0 80 | b.mean = 0 81 | b.min = 0 82 | b.max = 0 83 | b.min = 0 84 | b.max = 0 85 | b.median = 0 86 | b.p75 = 0 87 | b.p95 = 0 88 | b.p99 = 0 89 | } 90 | 91 | var _ Interface = &fixed{} 92 | -------------------------------------------------------------------------------- /bucket/fixed_test.go: -------------------------------------------------------------------------------- 1 | package bucket 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestFixed(t *testing.T) { 10 | t.Parallel() 11 | 12 | raw := NewRaw() 13 | for i := 1; i <= 1000; i++ { 14 | raw.Add(float64(i)) 15 | } 16 | 17 | fixed := NewFixed(raw) 18 | 19 | is := assert.New(t) 20 | is.Equal(raw.Sum(), fixed.Sum()) 21 | is.Equal(raw.Freq(), fixed.Freq()) 22 | is.Equal(raw.Unique(), fixed.Unique()) 23 | is.Equal(raw.Last(), fixed.Last()) 24 | is.Equal(raw.Mean(), fixed.Mean()) 25 | is.Equal(raw.Min(), fixed.Min()) 26 | is.Equal(raw.Max(), fixed.Max()) 27 | is.Equal(raw.Median(), fixed.Median()) 28 | is.Equal(raw.P75(), fixed.P75()) 29 | is.Equal(raw.P95(), fixed.P95()) 30 | is.Equal(raw.P99(), fixed.P99()) 31 | 32 | fixed.Add(123) 33 | is.Equal(raw.Freq(), fixed.Freq()) 34 | 35 | fixed.Reset() 36 | 37 | is.Zero(fixed.Sum()) 38 | is.Zero(fixed.Freq()) 39 | is.Zero(fixed.Last()) 40 | } 41 | -------------------------------------------------------------------------------- /bucket/interface.go: -------------------------------------------------------------------------------- 1 | package bucket 2 | 3 | type Interface interface { 4 | Add(v float64) 5 | 6 | Sum() float64 7 | Freq() float64 8 | Unique() float64 9 | 10 | Last() float64 11 | 12 | Mean() float64 13 | Min() float64 14 | Max() float64 15 | 16 | Median() float64 17 | P75() float64 18 | P95() float64 19 | P99() float64 20 | 21 | Reset() 22 | } 23 | -------------------------------------------------------------------------------- /bucket/metricwindow.go: -------------------------------------------------------------------------------- 1 | package bucket 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/rodaine/statstee/datagram" 8 | ) 9 | 10 | var DummyWindow = NewMetricWindow(datagram.DummyMetric, 1, time.Hour) 11 | 12 | type MetricWindow struct { 13 | sync.Mutex 14 | *Window 15 | 16 | Metric datagram.Metric 17 | curr Interface 18 | } 19 | 20 | func NewMetricWindow(m datagram.Metric, size int, d time.Duration) *MetricWindow { 21 | w := &MetricWindow{ 22 | Metric: m, 23 | Window: NewWindow(size), 24 | curr: NewRaw(), 25 | } 26 | 27 | go w.tick(d) 28 | 29 | return w 30 | } 31 | 32 | func (w *MetricWindow) tick(d time.Duration) { 33 | for _ = range time.NewTicker(d).C { 34 | w.Lock() 35 | 36 | w.Push(w.curr) 37 | w.curr.Reset() 38 | 39 | w.Unlock() 40 | } 41 | } 42 | 43 | func (w *MetricWindow) Add(v float64) { 44 | w.Lock() 45 | defer w.Unlock() 46 | w.curr.Add(v) 47 | } 48 | -------------------------------------------------------------------------------- /bucket/metricwindow_test.go: -------------------------------------------------------------------------------- 1 | package bucket 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/rodaine/statstee/datagram" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestMetricWindow(t *testing.T) { 12 | t.Parallel() 13 | 14 | w := NewMetricWindow(datagram.DummyMetric, 2, time.Second) 15 | assert.Zero(t, w.Last()[1]) 16 | w.Add(1) 17 | <-time.NewTimer(1100 * time.Millisecond).C 18 | assert.Equal(t, 1.0, w.Last()[1]) 19 | } 20 | -------------------------------------------------------------------------------- /bucket/raw.go: -------------------------------------------------------------------------------- 1 | package bucket 2 | 3 | import ( 4 | "math" 5 | "sort" 6 | ) 7 | 8 | type raw struct { 9 | values []float64 10 | last float64 11 | } 12 | 13 | func NewRaw() Interface { 14 | return &raw{values: []float64{}} 15 | } 16 | 17 | func (b *raw) Add(m float64) { 18 | b.values = append(b.values, m) 19 | b.last = m 20 | } 21 | 22 | func (b *raw) Sum() float64 { 23 | sum := 0.0 24 | for _, val := range b.values { 25 | sum += val 26 | } 27 | 28 | return sum 29 | } 30 | 31 | func (b *raw) Freq() float64 { 32 | return float64(len(b.values)) 33 | } 34 | 35 | func (b *raw) Unique() float64 { 36 | freq := len(b.values) 37 | if freq == 0 { 38 | return 0 39 | } 40 | 41 | b.sort() 42 | 43 | ct := 1 44 | for i := 1; i < freq; i++ { 45 | if b.values[i] != b.values[i-1] { 46 | ct++ 47 | } 48 | } 49 | 50 | return float64(ct) 51 | } 52 | 53 | func (b *raw) Last() float64 { 54 | return b.last 55 | } 56 | 57 | func (b *raw) Mean() float64 { 58 | ct := b.Freq() 59 | if ct == 0 { 60 | return 0.0 61 | } 62 | 63 | return b.Sum() / float64(ct) 64 | } 65 | 66 | func (b *raw) Min() float64 { 67 | if b.Freq() == 0 { 68 | return 0.0 69 | } 70 | 71 | b.sort() 72 | return b.values[0] 73 | } 74 | 75 | func (b *raw) Max() float64 { 76 | 77 | ct := len(b.values) 78 | if ct == 0 { 79 | return 0.0 80 | } 81 | 82 | b.sort() 83 | return b.values[ct-1] 84 | } 85 | 86 | func (b *raw) Median() float64 { 87 | 88 | return b.percentile(0.5) 89 | } 90 | 91 | func (b *raw) P75() float64 { 92 | 93 | return b.percentile(0.75) 94 | } 95 | 96 | func (b *raw) P95() float64 { 97 | 98 | return b.percentile(0.95) 99 | } 100 | 101 | func (b *raw) P99() float64 { 102 | return b.percentile(0.99) 103 | } 104 | 105 | func (b *raw) Reset() { 106 | b.values = make([]float64, 0, cap(b.values)) 107 | b.last = 0.0 108 | } 109 | 110 | func (b *raw) sort() { 111 | if !sort.Float64sAreSorted(b.values) { 112 | sort.Float64s(b.values) 113 | } 114 | } 115 | 116 | func (b *raw) percentile(p float64) float64 { 117 | if p <= 0.0 || p > 1.0 { 118 | panic("percentile out of range") 119 | } 120 | 121 | ct := len(b.values) 122 | if ct == 0 { 123 | return 0.0 124 | } 125 | 126 | b.sort() 127 | 128 | n := float64(ct) 129 | if p >= n/(n+1) { 130 | return b.values[ct-1] 131 | } 132 | 133 | idx := p * (n + 1) 134 | i := int(idx) 135 | r := math.Mod(idx, 1) 136 | return b.values[i-1] + r*(b.values[i]-b.values[i-1]) 137 | } 138 | 139 | var _ Interface = &raw{} 140 | -------------------------------------------------------------------------------- /bucket/raw_test.go: -------------------------------------------------------------------------------- 1 | package bucket 2 | 3 | import ( 4 | "testing" 5 | 6 | "math" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestRaw_Sum(t *testing.T) { 12 | t.Parallel() 13 | 14 | sum := 0.0 15 | b := NewRaw() 16 | assert.Zero(t, b.Sum()) 17 | 18 | for _, v := range []float64{1, -2, 3, -4, 5} { 19 | sum += v 20 | b.Add(v) 21 | } 22 | 23 | assert.Equal(t, sum, b.Sum()) 24 | } 25 | 26 | func TestRaw_Freq(t *testing.T) { 27 | t.Parallel() 28 | 29 | ct := 0 30 | b := NewRaw() 31 | assert.Zero(t, b.Freq()) 32 | 33 | for _, v := range []float64{1, 2, 3, 4, 5} { 34 | ct++ 35 | b.Add(v) 36 | } 37 | 38 | assert.Equal(t, float64(ct), b.Freq()) 39 | } 40 | 41 | func TestRaw_Unique(t *testing.T) { 42 | t.Parallel() 43 | 44 | b := NewRaw() 45 | assert.Zero(t, b.Unique()) 46 | 47 | for _, v := range []float64{1, 1, 2, 2, 3} { 48 | b.Add(v) 49 | } 50 | 51 | assert.Equal(t, float64(3), b.Unique()) 52 | } 53 | 54 | func TestRaw_Last(t *testing.T) { 55 | t.Parallel() 56 | 57 | var last float64 58 | b := NewRaw() 59 | for _, last = range []float64{1, 3, 5, 7, 9} { 60 | b.Add(last) 61 | } 62 | 63 | assert.Equal(t, last, b.Last()) 64 | } 65 | 66 | func TestRaw_Mean(t *testing.T) { 67 | t.Parallel() 68 | 69 | b := NewRaw() 70 | assert.Zero(t, b.Mean()) 71 | 72 | ct := 0 73 | sum := 0.0 74 | 75 | for _, v := range []float64{1, 2, 3, 4, 5} { 76 | ct++ 77 | sum += v 78 | b.Add(v) 79 | } 80 | 81 | assert.Equal(t, sum/float64(ct), b.Mean()) 82 | } 83 | 84 | func TestRaw_Min(t *testing.T) { 85 | t.Parallel() 86 | 87 | b := NewRaw() 88 | assert.Zero(t, b.Min()) 89 | 90 | min := math.MaxFloat64 91 | for _, v := range []float64{5, 1, 4, 3, 2} { 92 | min = math.Min(min, v) 93 | b.Add(v) 94 | } 95 | 96 | assert.Equal(t, min, b.Min()) 97 | } 98 | 99 | func TestRaw_Max(t *testing.T) { 100 | t.Parallel() 101 | 102 | b := NewRaw() 103 | assert.Zero(t, b.Max()) 104 | 105 | max := 0.0 106 | for _, v := range []float64{5, 1, 4, 3, 2} { 107 | max = math.Max(max, v) 108 | b.Add(v) 109 | } 110 | 111 | assert.Equal(t, max, b.Max()) 112 | } 113 | 114 | func TestRaw_Median(t *testing.T) { 115 | t.Parallel() 116 | 117 | tests := []struct { 118 | vals []float64 119 | expected float64 120 | }{ 121 | {[]float64{}, 0}, 122 | {[]float64{1, 2}, 1.5}, 123 | {[]float64{1, 1, 1}, 1}, 124 | {[]float64{3, 2, 1}, 2}, 125 | {[]float64{4, 2, 3, 1}, 2.5}, 126 | } 127 | 128 | for _, test := range tests { 129 | b := NewRaw() 130 | for _, v := range test.vals { 131 | b.Add(v) 132 | } 133 | assert.Equal(t, test.expected, b.Median(), "%+v", test) 134 | } 135 | } 136 | 137 | func TestRaw_P75(t *testing.T) { 138 | t.Parallel() 139 | 140 | tests := []struct { 141 | vals []float64 142 | expected float64 143 | }{ 144 | {[]float64{}, 0}, 145 | {[]float64{1, 1, 1, 1, 1}, 1}, 146 | {[]float64{3, 2, 1}, 3}, 147 | {[]float64{4, 2, 3, 1}, 3.75}, 148 | {[]float64{1, 2, 3, 4, 5}, 4.5}, 149 | } 150 | 151 | for _, test := range tests { 152 | b := NewRaw() 153 | for _, v := range test.vals { 154 | b.Add(v) 155 | } 156 | assert.Equal(t, test.expected, b.P75(), "%+v", test) 157 | } 158 | } 159 | 160 | func TestRaw_P95(t *testing.T) { 161 | t.Parallel() 162 | 163 | vals := make([]float64, 100) 164 | for i := 1; i <= 100; i++ { 165 | vals[i-1] = float64(i) 166 | } 167 | 168 | tests := []struct { 169 | vals []float64 170 | expected float64 171 | }{ 172 | {[]float64{}, 0}, 173 | {[]float64{3, 2, 1}, 3}, 174 | {vals, 96}, 175 | } 176 | 177 | for _, test := range tests { 178 | b := NewRaw() 179 | for _, v := range test.vals { 180 | b.Add(v) 181 | } 182 | assert.InDelta(t, test.expected, b.P95(), 0.1, "%+v", test) 183 | } 184 | } 185 | 186 | func TestRaw_P99(t *testing.T) { 187 | t.Parallel() 188 | 189 | vals := make([]float64, 1000) 190 | for i := 1; i <= 1000; i++ { 191 | vals[i-1] = float64(i) 192 | } 193 | 194 | tests := []struct { 195 | vals []float64 196 | expected float64 197 | }{ 198 | {[]float64{}, 0}, 199 | {[]float64{3, 2, 1}, 3}, 200 | {vals, 991}, 201 | } 202 | 203 | for _, test := range tests { 204 | b := NewRaw() 205 | for _, v := range test.vals { 206 | b.Add(v) 207 | } 208 | assert.InDelta(t, test.expected, b.P99(), 0.1, "%+v", test) 209 | } 210 | } 211 | 212 | func TestRaw_Reset(t *testing.T) { 213 | t.Parallel() 214 | 215 | b := NewRaw() 216 | b.Add(123) 217 | 218 | is := assert.New(t) 219 | is.NotZero(b.Last()) 220 | is.NotZero(b.Sum()) 221 | is.NotZero(b.Freq()) 222 | 223 | b.Reset() 224 | is.Zero(b.Last()) 225 | is.Zero(b.Sum()) 226 | is.Zero(b.Freq()) 227 | } 228 | 229 | func TestRaw_PercentilePanic(t *testing.T) { 230 | t.Parallel() 231 | 232 | for _, p := range []float64{-1, 0, 1.1} { 233 | assert.Panics(t, func() { 234 | b, ok := NewRaw().(*raw) 235 | if !ok { 236 | assert.FailNow(t, "not a *raw") 237 | return 238 | } 239 | b.Add(0) 240 | b.percentile(p) 241 | }) 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /bucket/window.go: -------------------------------------------------------------------------------- 1 | package bucket 2 | 3 | import ( 4 | "math" 5 | "sync" 6 | ) 7 | 8 | const WindowSize = 1024 9 | const EmptyValue = math.MaxFloat64 10 | 11 | type mapFunc func(idx int, b Interface, prev float64) float64 12 | 13 | type Window struct { 14 | sync.RWMutex 15 | 16 | head int 17 | size int 18 | buckets []Interface 19 | 20 | cumSum float64 21 | 22 | last float64 23 | } 24 | 25 | type Averages struct { 26 | EWMA1, EWMA5, EWMA10 float64 27 | } 28 | 29 | func NewWindow(size int) *Window { 30 | w := &Window{ 31 | size: size, 32 | buckets: make([]Interface, size), 33 | } 34 | 35 | for i := 0; i < size; i++ { 36 | w.buckets[i] = &fixed{} 37 | } 38 | 39 | return w 40 | } 41 | 42 | func (w *Window) Index(offset int) int { 43 | return (w.head + offset) % w.size 44 | } 45 | 46 | func (w *Window) Push(b Interface) { 47 | w.Lock() 48 | defer w.Unlock() 49 | 50 | if w.buckets[w.head].Freq() > 0 { 51 | w.last = w.buckets[w.head].Last() 52 | w.cumSum += w.buckets[w.head].Sum() 53 | } 54 | 55 | w.buckets[w.head] = NewFixed(b) 56 | w.head = w.Index(1) 57 | } 58 | 59 | func (w *Window) Count() []float64 { return w.mapFloat(w._count) } 60 | func (w *Window) CountAverages() Averages { return w.averages(w._count, false) } 61 | 62 | func (w *Window) Unique() []float64 { return w.mapFloat(w._unique) } 63 | func (w *Window) UniqueAverages() Averages { return w.averages(w._unique, false) } 64 | 65 | func (w *Window) UniquePercent() []float64 { return w.mapFloat(w._uniquePercent) } 66 | func (w *Window) UniquePercentAverages() Averages { return w.averages(w._uniquePercent, true) } 67 | 68 | func (w *Window) Sum() []float64 { return w.mapFloat(w._sum) } 69 | func (w *Window) SumAverages() Averages { return w.averages(w._sum, false) } 70 | 71 | func (w *Window) Median() []float64 { return w.mapFloat(w._median) } 72 | func (w *Window) MedianAverages() Averages { return w.averages(w._median, true) } 73 | 74 | func (w *Window) P75() []float64 { return w.mapFloat(w._p75) } 75 | func (w *Window) P75Averages() Averages { return w.averages(w._p75, true) } 76 | 77 | func (w *Window) P95() []float64 { return w.mapFloat(w._p95) } 78 | func (w *Window) P95Averages() Averages { return w.averages(w._p95, true) } 79 | 80 | func (w *Window) Last() []float64 { return w.mapFloat(w._last) } 81 | func (w *Window) LastAverages() Averages { return w.averages(w._last, true) } 82 | 83 | func (w *Window) CumSum() []float64 { 84 | sums := w.Sum() 85 | cumSum := w.cumSum 86 | for i, sum := range sums { 87 | sums[i] += cumSum 88 | cumSum += sum 89 | } 90 | return sums 91 | } 92 | 93 | func (w *Window) mapFloat(f mapFunc) []float64 { 94 | w.RLock() 95 | defer w.RUnlock() 96 | 97 | vals := make([]float64, w.size) 98 | 99 | prev := EmptyValue 100 | for i := 0; i < w.size; i++ { 101 | vals[i] = f(i, w.buckets[w.Index(i)], prev) 102 | prev = vals[i] 103 | } 104 | 105 | return vals 106 | } 107 | 108 | func (w *Window) mapEWMA(f mapFunc, fill bool) []float64 { 109 | w.RLock() 110 | defer w.RUnlock() 111 | 112 | if w.size == 0 { 113 | return []float64{} 114 | } 115 | 116 | i := 0 117 | for ; i < w.size; i++ { 118 | if w.buckets[w.Index(i)].Freq() > 0 { 119 | break 120 | } 121 | } 122 | 123 | vals := make([]float64, w.size-i) 124 | prev := EmptyValue 125 | 126 | for j := 0; i < w.size; i, j = i+1, j+1 { 127 | b := w.buckets[w.Index(i)] 128 | 129 | if b.Freq() == 0 && fill && prev != EmptyValue { 130 | vals[j] = prev 131 | continue 132 | } 133 | 134 | vals[j] = f(i, b, prev) 135 | prev = vals[j] 136 | } 137 | 138 | return vals 139 | } 140 | 141 | // based off: https://en.wikipedia.org/wiki/Moving_average#Application_to_measuring_computer_performance 142 | func (w *Window) ewma(data []float64, minutes float64) float64 { 143 | ct := len(data) 144 | if ct == 0 { 145 | return 0.0 146 | } 147 | 148 | W := float64(ct) / 60.0 // How long is the data relevant (in minutes) 149 | a := math.Exp(-1.0 / (W * minutes)) 150 | 151 | y := data[0] 152 | for i := 1; i < ct; i++ { 153 | y = data[i] + a*(y-data[i]) 154 | } 155 | 156 | return y 157 | } 158 | 159 | func (w *Window) averages(f mapFunc, fill bool) Averages { 160 | data := w.mapEWMA(f, fill) 161 | return Averages{ 162 | EWMA1: w.ewma(data, 1), 163 | EWMA5: w.ewma(data, 5), 164 | EWMA10: w.ewma(data, 10), 165 | } 166 | } 167 | 168 | func (w *Window) _count(_ int, b Interface, _ float64) float64 { return b.Freq() } 169 | func (w *Window) _unique(_ int, b Interface, _ float64) float64 { return b.Unique() } 170 | func (w *Window) _sum(_ int, b Interface, _ float64) float64 { return b.Sum() } 171 | func (w *Window) _median(_ int, b Interface, _ float64) float64 { return b.Median() } 172 | func (w *Window) _p75(_ int, b Interface, _ float64) float64 { return b.P75() } 173 | func (w *Window) _p95(_ int, b Interface, _ float64) float64 { return b.P95() } 174 | 175 | func (w *Window) _uniquePercent(_ int, b Interface, _ float64) float64 { 176 | ct := b.Freq() 177 | if ct == 0 { 178 | return ct 179 | } 180 | return 100.0 * b.Unique() / ct 181 | } 182 | 183 | func (w *Window) _last(i int, b Interface, prev float64) float64 { 184 | if b.Freq() == 0 { 185 | if i == 0 { 186 | return w.last 187 | } 188 | return prev 189 | } 190 | return b.Last() 191 | } 192 | -------------------------------------------------------------------------------- /bucket/window_test.go: -------------------------------------------------------------------------------- 1 | package bucket 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestWindow_Count(t *testing.T) { 10 | t.Parallel() 11 | 12 | w := NewWindow(3) 13 | 14 | tests := []struct { 15 | vals []float64 16 | expected []float64 17 | }{ 18 | {[]float64{}, []float64{0, 0, 0}}, 19 | {[]float64{1}, []float64{0, 0, 1}}, 20 | {[]float64{1, 2}, []float64{0, 1, 2}}, 21 | {[]float64{1, 2, 3}, []float64{1, 2, 3}}, 22 | {[]float64{}, []float64{2, 3, 0}}, 23 | } 24 | 25 | for _, test := range tests { 26 | b := NewRaw() 27 | for _, v := range test.vals { 28 | b.Add(v) 29 | } 30 | w.Push(b) 31 | assert.EqualValues(t, test.expected, w.Count(), "%+v", test) 32 | } 33 | } 34 | 35 | func TestWindow_Sum(t *testing.T) { 36 | t.Parallel() 37 | 38 | w := NewWindow(3) 39 | 40 | tests := []struct { 41 | vals []float64 42 | expected []float64 43 | }{ 44 | {[]float64{}, []float64{0, 0, 0}}, 45 | {[]float64{1}, []float64{0, 0, 1}}, 46 | {[]float64{1, 2}, []float64{0, 1, 3}}, 47 | {[]float64{1, 2, 3}, []float64{1, 3, 6}}, 48 | {[]float64{}, []float64{3, 6, 0}}, 49 | } 50 | 51 | for _, test := range tests { 52 | b := NewRaw() 53 | for _, v := range test.vals { 54 | b.Add(v) 55 | } 56 | w.Push(b) 57 | assert.EqualValues(t, test.expected, w.Sum(), "%+v", test) 58 | } 59 | } 60 | 61 | func TestWindow_CumSum(t *testing.T) { 62 | t.Parallel() 63 | 64 | w := NewWindow(3) 65 | 66 | tests := []struct { 67 | vals []float64 68 | expected []float64 69 | }{ 70 | {[]float64{}, []float64{0, 0, 0}}, 71 | {[]float64{1}, []float64{0, 0, 1}}, 72 | {[]float64{1, 2}, []float64{0, 1, 4}}, 73 | {[]float64{1, 2, 3}, []float64{1, 4, 10}}, 74 | {[]float64{1, 2}, []float64{4, 10, 13}}, 75 | {[]float64{1}, []float64{10, 13, 14}}, 76 | {[]float64{}, []float64{13, 14, 14}}, 77 | } 78 | 79 | for _, test := range tests { 80 | b := NewRaw() 81 | for _, v := range test.vals { 82 | b.Add(v) 83 | } 84 | w.Push(b) 85 | assert.EqualValues(t, test.expected, w.CumSum(), "%+v", test) 86 | } 87 | } 88 | 89 | func TestWindow_Unique(t *testing.T) { 90 | t.Parallel() 91 | 92 | w := NewWindow(3) 93 | 94 | tests := []struct { 95 | vals []float64 96 | expected []float64 97 | }{ 98 | {[]float64{}, []float64{0, 0, 0}}, 99 | {[]float64{1}, []float64{0, 0, 1}}, 100 | {[]float64{1, 2}, []float64{0, 1, 2}}, 101 | {[]float64{1, 2, 1}, []float64{1, 2, 2}}, 102 | {[]float64{}, []float64{2, 2, 0}}, 103 | } 104 | 105 | for _, test := range tests { 106 | b := NewRaw() 107 | for _, v := range test.vals { 108 | b.Add(v) 109 | } 110 | w.Push(b) 111 | assert.EqualValues(t, test.expected, w.Unique(), "%+v", test) 112 | } 113 | } 114 | 115 | func TestWindow_Median(t *testing.T) { 116 | t.Parallel() 117 | 118 | w := NewWindow(3) 119 | 120 | tests := []struct { 121 | vals []float64 122 | expected []float64 123 | }{ 124 | {[]float64{}, []float64{0, 0, 0}}, 125 | {[]float64{1}, []float64{0, 0, 1}}, 126 | {[]float64{1, 2}, []float64{0, 1, 1.5}}, 127 | {[]float64{1, 2, 2, 10000}, []float64{1, 1.5, 2}}, 128 | {[]float64{}, []float64{1.5, 2, 0}}, 129 | } 130 | 131 | for _, test := range tests { 132 | b := NewRaw() 133 | for _, v := range test.vals { 134 | b.Add(v) 135 | } 136 | w.Push(b) 137 | assert.EqualValues(t, test.expected, w.Median(), "%+v", test) 138 | } 139 | } 140 | 141 | func TestWindow_P75(t *testing.T) { 142 | t.Parallel() 143 | 144 | w := NewWindow(3) 145 | 146 | tests := []struct { 147 | vals []float64 148 | expected []float64 149 | }{ 150 | {[]float64{}, []float64{0, 0, 0}}, 151 | {[]float64{1}, []float64{0, 0, 1}}, 152 | {[]float64{1, 2}, []float64{0, 1, 2}}, 153 | {[]float64{1, 2, 3, 4}, []float64{1, 2, 3.75}}, 154 | {[]float64{}, []float64{2, 3.75, 0}}, 155 | } 156 | 157 | for _, test := range tests { 158 | b := NewRaw() 159 | for _, v := range test.vals { 160 | b.Add(v) 161 | } 162 | w.Push(b) 163 | assert.EqualValues(t, test.expected, w.P75(), "%+v", test) 164 | } 165 | } 166 | 167 | func TestWindow_P95(t *testing.T) { 168 | t.Parallel() 169 | 170 | w := NewWindow(3) 171 | 172 | tests := []struct { 173 | vals []float64 174 | expected []float64 175 | }{ 176 | {[]float64{}, []float64{0, 0, 0}}, 177 | {[]float64{1}, []float64{0, 0, 1}}, 178 | {[]float64{1, 2}, []float64{0, 1, 2}}, 179 | {[]float64{1, 2, 3, 4}, []float64{1, 2, 4}}, 180 | {[]float64{}, []float64{2, 4, 0}}, 181 | } 182 | 183 | for _, test := range tests { 184 | b := NewRaw() 185 | for _, v := range test.vals { 186 | b.Add(v) 187 | } 188 | w.Push(b) 189 | assert.EqualValues(t, test.expected, w.P95(), "%+v", test) 190 | } 191 | } 192 | 193 | func TestWindow_UniquePercent(t *testing.T) { 194 | t.Parallel() 195 | 196 | w := NewWindow(3) 197 | 198 | tests := []struct { 199 | vals []float64 200 | expected []float64 201 | }{ 202 | {[]float64{}, []float64{0, 0, 0}}, 203 | {[]float64{1}, []float64{0, 0, 100}}, 204 | {[]float64{1, 2}, []float64{0, 100, 100}}, 205 | {[]float64{1, 2, 1, 2}, []float64{100, 100, 50}}, 206 | {[]float64{}, []float64{100, 50, 0}}, 207 | } 208 | 209 | for _, test := range tests { 210 | b := NewRaw() 211 | for _, v := range test.vals { 212 | b.Add(v) 213 | } 214 | w.Push(b) 215 | assert.EqualValues(t, test.expected, w.UniquePercent(), "%+v", test) 216 | } 217 | } 218 | 219 | func TestWindow_Last(t *testing.T) { 220 | t.Parallel() 221 | 222 | w := NewWindow(3) 223 | 224 | tests := []struct { 225 | vals []float64 226 | expected []float64 227 | }{ 228 | {[]float64{}, []float64{0, 0, 0}}, 229 | {[]float64{1}, []float64{0, 0, 1}}, 230 | {[]float64{}, []float64{0, 1, 1}}, 231 | {[]float64{2}, []float64{1, 1, 2}}, 232 | {[]float64{}, []float64{1, 2, 2}}, 233 | {[]float64{}, []float64{2, 2, 2}}, 234 | {[]float64{}, []float64{2, 2, 2}}, 235 | {[]float64{0}, []float64{2, 2, 0}}, 236 | } 237 | 238 | for _, test := range tests { 239 | b := NewRaw() 240 | for _, v := range test.vals { 241 | b.Add(v) 242 | } 243 | w.Push(b) 244 | assert.EqualValues(t, test.expected, w.Last(), "%+v", test) 245 | } 246 | } 247 | 248 | func TestWindow_EWMA(t *testing.T) { 249 | t.Parallel() 250 | 251 | data := make([]float64, 600) 252 | for i := 0; i < len(data); i++ { 253 | data[i] = float64(i) 254 | } 255 | 256 | w := NewWindow(600) 257 | assert.Zero(t, w.ewma([]float64{}, 1)) 258 | 259 | assert.InDelta(t, 589.5, w.ewma(data, 1), 0.1) 260 | assert.InDelta(t, 549.5, w.ewma(data, 5), 0.1) 261 | assert.InDelta(t, 499.7, w.ewma(data, 10), 0.1) 262 | } 263 | 264 | func TestWindow_MapEWMA(t *testing.T) { 265 | t.Parallel() 266 | 267 | w := NewWindow(0) 268 | assert.Empty(t, w.mapEWMA(w._sum, false)) 269 | 270 | w = NewWindow(3) 271 | 272 | tests := []struct { 273 | vals []float64 274 | fill bool 275 | expected []float64 276 | }{ 277 | {[]float64{}, false, []float64{}}, 278 | {[]float64{1}, false, []float64{1}}, 279 | {[]float64{2}, false, []float64{1, 2}}, 280 | {[]float64{}, false, []float64{1, 2, 0}}, 281 | {[]float64{3}, true, []float64{2, 2, 3}}, 282 | } 283 | 284 | for _, test := range tests { 285 | b := NewRaw() 286 | for _, v := range test.vals { 287 | b.Add(v) 288 | } 289 | w.Push(b) 290 | assert.EqualValues(t, test.expected, w.mapEWMA(w._sum, test.fill)) 291 | } 292 | } 293 | 294 | func TestWindow_Averages(t *testing.T) { 295 | t.Parallel() 296 | 297 | w := NewWindow(600) 298 | for i := 0; i < 600; i++ { 299 | b := NewRaw() 300 | b.Add(float64(i)) 301 | w.Push(b) 302 | } 303 | 304 | avgs := w.averages(w._sum, false) 305 | assert.InDelta(t, 589.5, avgs.EWMA1, 0.1) 306 | assert.InDelta(t, 549.5, avgs.EWMA5, 0.1) 307 | assert.InDelta(t, 499.7, avgs.EWMA10, 0.1) 308 | } 309 | 310 | func TestWindow_AllAverages(t *testing.T) { 311 | t.Parallel() 312 | 313 | w := NewWindow(600) 314 | for i := 0; i < 600; i++ { 315 | b := NewRaw() 316 | b.Add(1) 317 | w.Push(b) 318 | } 319 | 320 | tests := []struct { 321 | f func() Averages 322 | ewma1 float64 323 | ewma5 float64 324 | ewma10 float64 325 | }{ 326 | {w.CountAverages, 1, 1, 1}, 327 | {w.UniqueAverages, 1, 1, 1}, 328 | {w.UniquePercentAverages, 100, 100, 100}, 329 | {w.SumAverages, 1, 1, 1}, 330 | {w.MedianAverages, 1, 1, 1}, 331 | {w.P75Averages, 1, 1, 1}, 332 | {w.P95Averages, 1, 1, 1}, 333 | {w.LastAverages, 1, 1, 1}, 334 | } 335 | 336 | for _, test := range tests { 337 | avgs := test.f() 338 | assert.Equal(t, test.ewma1, avgs.EWMA1, "%+v", test) 339 | assert.Equal(t, test.ewma5, avgs.EWMA5, "%+v", test) 340 | assert.Equal(t, test.ewma10, avgs.EWMA10, "%+v", test) 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /datagram/metric.go: -------------------------------------------------------------------------------- 1 | package datagram 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | type MetricType string 11 | 12 | const ( 13 | Counter MetricType = "c" 14 | Gauge MetricType = "g" 15 | Histogram MetricType = "h" 16 | Timer MetricType = "ms" 17 | Set MetricType = "s" 18 | Unknown MetricType = "?" 19 | 20 | sampleRatePrefix = byte('@') 21 | tagsPrefix = byte('#') 22 | ) 23 | 24 | var ( 25 | MalformedMetricError = errors.New("the metric is malformed") 26 | 27 | DummyMetric = Metric{ 28 | Name: "statstee", 29 | Type: Unknown, 30 | } 31 | 32 | prefixes = map[MetricType]string{ 33 | Histogram: "H", 34 | Timer: "T", 35 | Counter: "C", 36 | Gauge: "G", 37 | Set: "S", 38 | Unknown: "?", 39 | } 40 | ) 41 | 42 | type Metric struct { 43 | Name string 44 | Value float64 45 | Type MetricType 46 | SampleRate float64 47 | Tags []string 48 | } 49 | 50 | func ParseMetric(raw string) (m Metric, err error) { 51 | parts := strings.Split(raw, "|") 52 | if len(parts) < 2 { 53 | return m, MalformedMetricError 54 | } 55 | 56 | kv := strings.Split(parts[0], ":") 57 | if len(kv) != 2 { 58 | return m, MalformedMetricError 59 | } 60 | 61 | m.Name = kv[0] 62 | if m.Value, err = strconv.ParseFloat(kv[1], 64); err != nil { 63 | return 64 | } 65 | 66 | if parts[1] == "" { 67 | return m, MalformedMetricError 68 | } 69 | m.Type = MetricType(parts[1]) 70 | 71 | m.SampleRate = 1 72 | 73 | if len(parts) > 2 { 74 | for _, part := range parts[2:] { 75 | switch part[0] { 76 | case sampleRatePrefix: 77 | if m.SampleRate, err = strconv.ParseFloat(part[1:], 64); err != nil { 78 | return 79 | } 80 | case tagsPrefix: 81 | m.Tags = strings.Split(part[1:], ",") 82 | default: 83 | return m, MalformedMetricError 84 | } 85 | } 86 | } 87 | 88 | return 89 | } 90 | 91 | func (m Metric) String() string { 92 | out := fmt.Sprintf("%s:%g|%s", m.Name, m.Value, m.Type) 93 | 94 | if m.SampleRate != 1 { 95 | out += fmt.Sprintf("|@%g", m.SampleRate) 96 | } 97 | 98 | if len(m.Tags) > 0 { 99 | out += fmt.Sprintf("|#%s", strings.Join(m.Tags, ",")) 100 | } 101 | 102 | return out 103 | } 104 | 105 | func (m Metric) TypePrefix() string { 106 | if c, ok := prefixes[m.Type]; ok { 107 | return c 108 | } 109 | 110 | return prefixes[Unknown] 111 | } 112 | -------------------------------------------------------------------------------- /datagram/metric_test.go: -------------------------------------------------------------------------------- 1 | package datagram 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestMetric_Parse(t *testing.T) { 10 | t.Parallel() 11 | is := assert.New(t) 12 | 13 | tests := []struct { 14 | Raw string 15 | Error bool 16 | Expected Metric 17 | }{ 18 | {"", true, Metric{}}, 19 | {"foo.bar", true, Metric{}}, 20 | {"foo.bar:123", true, Metric{}}, 21 | {"foo.bar:123|", true, Metric{}}, 22 | {"foo.bar|h", true, Metric{}}, 23 | {"foo.bar:fizz|h", true, Metric{}}, 24 | {"foo.bar:123|h|@fizz", true, Metric{}}, 25 | {"foo.bar:456|h|$invalid", true, Metric{}}, 26 | 27 | {"foo.bar:123|h", false, Metric{Name: "foo.bar", Value: 123, Type: Histogram, SampleRate: 1}}, 28 | {"foo.bar:123|h|@0.5", false, Metric{Name: "foo.bar", Value: 123, Type: Histogram, SampleRate: 0.5}}, 29 | {"foo.bar:123|h|#tag1:val,tag2", false, Metric{Name: "foo.bar", Value: 123, Type: Histogram, SampleRate: 1, Tags: []string{"tag1:val", "tag2"}}}, 30 | {"foo.bar:123|h|@0.5|#tag1:val,tag2", false, Metric{Name: "foo.bar", Value: 123, Type: Histogram, SampleRate: 0.5, Tags: []string{"tag1:val", "tag2"}}}, 31 | } 32 | 33 | for _, test := range tests { 34 | actual, err := ParseMetric(test.Raw) 35 | 36 | if test.Error { 37 | is.Error(err, "%+v", test) 38 | continue 39 | } 40 | 41 | is.NoError(err, "%+v", test) 42 | is.EqualValues(test.Expected, actual, "%+v", test) 43 | } 44 | } 45 | 46 | func TestMetric_String(t *testing.T) { 47 | t.Parallel() 48 | is := assert.New(t) 49 | 50 | tests := []struct { 51 | Expected string 52 | Metric Metric 53 | }{ 54 | {"foo.bar:123|c", Metric{Name: "foo.bar", Value: 123, Type: Counter, SampleRate: 1}}, 55 | {"foo.bar:123|c|@0.5", Metric{Name: "foo.bar", Value: 123, Type: Counter, SampleRate: 0.5}}, 56 | {"foo.bar:123|c|#tag1:val,tag2", Metric{Name: "foo.bar", Value: 123, Type: Counter, SampleRate: 1, Tags: []string{"tag1:val", "tag2"}}}, 57 | {"foo.bar:123|c|@0.5|#tag1:val,tag2", Metric{Name: "foo.bar", Value: 123, Type: Counter, SampleRate: 0.5, Tags: []string{"tag1:val", "tag2"}}}, 58 | } 59 | 60 | for _, test := range tests { 61 | is.Equal(test.Expected, test.Metric.String(), "%#v", test.Metric) 62 | } 63 | } 64 | 65 | func TestMetric_Prefix(t *testing.T) { 66 | t.Parallel() 67 | is := assert.New(t) 68 | 69 | tests := []struct { 70 | Type MetricType 71 | Prefix string 72 | }{ 73 | {Histogram, "H"}, 74 | {Timer, "T"}, 75 | {Counter, "C"}, 76 | {Gauge, "G"}, 77 | {Set, "S"}, 78 | {Unknown, "?"}, 79 | {"foobar", "?"}, 80 | } 81 | 82 | for _, test := range tests { 83 | m := Metric{Type: test.Type} 84 | is.Equal(test.Prefix, m.TypePrefix(), "%#v", test) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /datagram/parser.go: -------------------------------------------------------------------------------- 1 | package datagram 2 | 3 | import "log" 4 | 5 | type Parser interface { 6 | Parse(data <-chan []byte) 7 | Chan() <-chan Metric 8 | } 9 | 10 | type parser struct { 11 | c chan Metric 12 | } 13 | 14 | func NewParser() Parser { 15 | return &parser{make(chan Metric, 1000)} 16 | } 17 | 18 | func (p *parser) Parse(data <-chan []byte) { 19 | defer close(p.c) 20 | for raw := range data { 21 | m, err := ParseMetric(string(raw)) 22 | if err != nil { 23 | log.Printf("unable to parse datagram: %v", err) 24 | continue 25 | } 26 | p.c <- m 27 | } 28 | } 29 | 30 | func (p *parser) Chan() <-chan Metric { 31 | return p.c 32 | } 33 | 34 | var _ Parser = &parser{} 35 | -------------------------------------------------------------------------------- /datagram/parser_test.go: -------------------------------------------------------------------------------- 1 | package datagram 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestParser(t *testing.T) { 10 | t.Parallel() 11 | 12 | data := make(chan []byte, 3) 13 | data <- []byte("test.parser:1|c") 14 | data <- []byte("totally malformed metric") 15 | data <- []byte("test.parser:2|c") 16 | close(data) 17 | 18 | first := Metric{ 19 | Name: "test.parser", 20 | Type: Counter, 21 | Value: 1, 22 | SampleRate: 1, 23 | } 24 | 25 | second := Metric{ 26 | Name: "test.parser", 27 | Type: Counter, 28 | Value: 2, 29 | SampleRate: 1, 30 | } 31 | 32 | p := NewParser() 33 | p.Parse(data) 34 | 35 | c := p.Chan() 36 | assert.EqualValues(t, first, <-c) 37 | assert.EqualValues(t, second, <-c) 38 | 39 | _, more := <-c 40 | assert.False(t, more) 41 | } 42 | -------------------------------------------------------------------------------- /datagram/sender.go: -------------------------------------------------------------------------------- 1 | package datagram 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | ) 7 | 8 | type Sender struct { 9 | conn *net.UDPConn 10 | } 11 | 12 | func NewSender(host string, port int) (*Sender, error) { 13 | addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", host, port)) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | conn, err := net.DialUDP("udp", nil, addr) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | return &Sender{conn}, nil 24 | } 25 | 26 | func (s *Sender) Send(m Metric) error { 27 | _, err := s.conn.Write([]byte(m.String())) 28 | return err 29 | } 30 | -------------------------------------------------------------------------------- /datagram/sender_test.go: -------------------------------------------------------------------------------- 1 | package datagram 2 | 3 | import ( 4 | "net" 5 | "testing" 6 | 7 | "sync" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestSender_BadHost(t *testing.T) { 13 | t.Parallel() 14 | 15 | s, err := NewSender("this is not a real host", -1) 16 | assert.Nil(t, s) 17 | assert.Error(t, err) 18 | } 19 | 20 | func TestSender_Send(t *testing.T) { 21 | t.Parallel() 22 | 23 | s, err := NewSender("localhost", 8182) 24 | assert.NoError(t, err) 25 | defer s.conn.Close() 26 | 27 | m := Metric{ 28 | Name: "test.sender", 29 | Type: Counter, 30 | Value: 1, 31 | SampleRate: 1, 32 | } 33 | b := make([]byte, len(m.String())) 34 | 35 | addr, _ := net.ResolveUDPAddr("udp", "localhost:8182") 36 | conn, _ := net.ListenUDP("udp", addr) 37 | 38 | wg := sync.WaitGroup{} 39 | wg.Add(1) 40 | go func() { 41 | conn.ReadFromUDP(b) 42 | wg.Done() 43 | conn.Close() 44 | }() 45 | 46 | err = s.Send(m) 47 | assert.NoError(t, err) 48 | 49 | wg.Wait() 50 | 51 | assert.Equal(t, m.String(), string(b)) 52 | } 53 | -------------------------------------------------------------------------------- /example/images/count.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodaine/statstee/af2c885f1ffe4bca3dc3d90e93987e41e6e674f9/example/images/count.png -------------------------------------------------------------------------------- /example/images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodaine/statstee/af2c885f1ffe4bca3dc3d90e93987e41e6e674f9/example/images/demo.gif -------------------------------------------------------------------------------- /example/images/gauge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodaine/statstee/af2c885f1ffe4bca3dc3d90e93987e41e6e674f9/example/images/gauge.png -------------------------------------------------------------------------------- /example/images/set.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodaine/statstee/af2c885f1ffe4bca3dc3d90e93987e41e6e674f9/example/images/set.png -------------------------------------------------------------------------------- /example/images/timing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rodaine/statstee/af2c885f1ffe4bca3dc3d90e93987e41e6e674f9/example/images/timing.png -------------------------------------------------------------------------------- /example/jerks/jerks.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "math/rand" 7 | "os" 8 | "os/signal" 9 | "runtime" 10 | "strconv" 11 | "syscall" 12 | 13 | "github.com/juju/ratelimit" 14 | "github.com/rodaine/statstee/datagram" 15 | ) 16 | 17 | var ( 18 | num = 1 19 | rps int64 = 1000 20 | ) 21 | 22 | func init() { 23 | flag.IntVar(&num, "n", num, "number of different metrics to generate") 24 | flag.Int64Var(&rps, "r", rps, "the metrics per second to send") 25 | flag.Parse() 26 | } 27 | 28 | func main() { 29 | limiter := ratelimit.NewBucketWithRate(float64(rps), rps) 30 | 31 | for n := runtime.NumCPU(); n >= 0; n-- { 32 | go beAJerk(limiter) 33 | } 34 | 35 | waitForSignal() 36 | } 37 | 38 | func beAJerk(limiter *ratelimit.Bucket) { 39 | sender, _ := datagram.NewSender("localhost", 8125) 40 | for { 41 | limiter.Wait(1) 42 | sender.Send(datagram.Metric{ 43 | Type: datagram.Histogram, 44 | Name: "jerks." + strconv.Itoa(rand.Intn(num)), 45 | Value: rand.Float64(), 46 | SampleRate: 1, 47 | }) 48 | } 49 | } 50 | 51 | func waitForSignal() { 52 | c := make(chan os.Signal) 53 | signal.Notify(c, os.Interrupt, os.Kill, syscall.SIGTERM) 54 | <-c 55 | log.Println("kill signal received") 56 | } 57 | -------------------------------------------------------------------------------- /example/statter/statter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "math" 6 | "math/rand" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/rodaine/statstee/datagram" 13 | ) 14 | 15 | func main() { 16 | go histogram() 17 | go timer() 18 | go count() 19 | go gauge() 20 | go set() 21 | 22 | waitForSignal() 23 | } 24 | 25 | func histogram() { 26 | sender, _ := datagram.NewSender("localhost", 8125) 27 | for _ = range time.NewTicker(time.Millisecond * 3).C { 28 | sender.Send(datagram.Metric{ 29 | Type: datagram.Histogram, 30 | Name: "statter.histogram", 31 | Value: math.Sin(float64(time.Now().Unix())/math.Pi) + rand.Float64(), 32 | SampleRate: 1, 33 | }) 34 | } 35 | } 36 | 37 | func count() { 38 | sender, _ := datagram.NewSender("localhost", 8125) 39 | for _ = range time.NewTicker(time.Millisecond * 5).C { 40 | sender.Send(datagram.Metric{ 41 | Type: datagram.Counter, 42 | Name: "statter.count", 43 | Value: math.Abs(math.Sin(float64(time.Now().Unix())/math.Pi)) / 10, 44 | SampleRate: 1, 45 | }) 46 | } 47 | } 48 | 49 | func gauge() { 50 | sender, _ := datagram.NewSender("localhost", 8125) 51 | for _ = range time.NewTicker(time.Millisecond * 7).C { 52 | sender.Send(datagram.Metric{ 53 | Type: datagram.Gauge, 54 | Name: "statter.gauge", 55 | Value: math.Sin(float64(time.Now().Unix())/10*math.Pi) + math.Cos(float64(time.Now().Second())/100*math.Pi), 56 | SampleRate: 1, 57 | }) 58 | } 59 | } 60 | 61 | func set() { 62 | sender, _ := datagram.NewSender("localhost", 8125) 63 | for _ = range time.NewTicker(time.Millisecond).C { 64 | sender.Send(datagram.Metric{ 65 | Type: datagram.Set, 66 | Name: "statter.set", 67 | Value: float64(rand.Intn(time.Now().Second() + 1)), 68 | SampleRate: 1, 69 | }) 70 | } 71 | } 72 | 73 | func timer() { 74 | sender, _ := datagram.NewSender("localhost", 8125) 75 | for _ = range time.NewTicker(time.Millisecond * 11).C { 76 | x := float64(time.Now().Unix()) / math.Pi 77 | sender.Send(datagram.Metric{ 78 | Type: datagram.Timer, 79 | Name: "statter.timer", 80 | Value: 1 + math.Min(math.Sin(x), math.Cos(x)), 81 | SampleRate: 1, 82 | }) 83 | } 84 | } 85 | 86 | func waitForSignal() { 87 | c := make(chan os.Signal) 88 | signal.Notify(c, os.Interrupt, os.Kill, syscall.SIGTERM) 89 | <-c 90 | log.Println("kill signal received") 91 | } 92 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rodaine/statstee 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2 // indirect 7 | github.com/gizak/termui v0.0.0-20160127191243-08a5d3f67b7d 8 | github.com/google/gopacket v1.1.17 9 | github.com/juju/ratelimit v1.0.1 10 | github.com/mattn/go-runewidth v0.0.9 // indirect 11 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 12 | github.com/nsf/termbox-go v0.0.0-20160122232915-362329b0aa64 // indirect 13 | github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0 // indirect 14 | github.com/stretchr/testify v1.1.4-0.20160305165446-6fe211e49392 15 | golang.org/x/net v0.33.0 16 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2 h1:5zdDAMuB3gvbHB1m2BZT9+t9w+xaBmK3ehb7skDXcwM= 2 | github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/gizak/termui v0.0.0-20160127191243-08a5d3f67b7d h1:RDcXA5E2qm1zurPk4rMCiq0KN4GQfu5/IaGx+vJGUQ0= 4 | github.com/gizak/termui v0.0.0-20160127191243-08a5d3f67b7d/go.mod h1:PkJoWUt/zacQKysNfQtcw1RW+eK2SxkieVBtl+4ovLA= 5 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 6 | github.com/google/gopacket v1.1.17 h1:rMrlX2ZY2UbvT+sdz3+6J+pp2z+msCq9MxTU6ymxbBY= 7 | github.com/google/gopacket v1.1.17/go.mod h1:UdDNZ1OO62aGYVnPhxT1U6aI7ukYtA/kB8vaU0diBUM= 8 | github.com/juju/ratelimit v1.0.1 h1:+7AIFJVQ0EQgq/K9+0Krm7m530Du7tIz0METWzN0RgY= 9 | github.com/juju/ratelimit v1.0.1/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= 10 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 11 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 12 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 13 | github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= 14 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 15 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 16 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 17 | github.com/nsf/termbox-go v0.0.0-20160122232915-362329b0aa64 h1:1VGj+mx5/3DRbmK7E8orVY7RbY4jYPRuwMgKUBfqOag= 18 | github.com/nsf/termbox-go v0.0.0-20160122232915-362329b0aa64/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= 19 | github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0 h1:GD+A8+e+wFkqje55/2fOVnZPkoDIu1VooBWfNrnY8Uo= 20 | github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 21 | github.com/stretchr/testify v1.1.4-0.20160305165446-6fe211e49392 h1:7ubzBW6wJ46nWdWvZQlDjtGTnupA4Z1dyHY9Xbhq3us= 22 | github.com/stretchr/testify v1.1.4-0.20160305165446-6fe211e49392/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 23 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 24 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 25 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 26 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 27 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 28 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 29 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 30 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 31 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 32 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 33 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 34 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 35 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 36 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 37 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 38 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 39 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 40 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 41 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 42 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 43 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 44 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 45 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 46 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 47 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 48 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 49 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 50 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 51 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 52 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 53 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 54 | golang.org/x/sys v0.0.0-20190405154228-4b34438f7a67/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 55 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 56 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 61 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 63 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 64 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 65 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 66 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 67 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 68 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 69 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 70 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 71 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 72 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 73 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 74 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 75 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 76 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 77 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 78 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 79 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 80 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 81 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 82 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 83 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 84 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 85 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 86 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 87 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 88 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 89 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 90 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 91 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 92 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 93 | -------------------------------------------------------------------------------- /router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "sync" 5 | 6 | "time" 7 | 8 | "sort" 9 | 10 | "github.com/rodaine/statstee/bucket" 11 | "github.com/rodaine/statstee/datagram" 12 | ) 13 | 14 | type Router struct { 15 | sync.RWMutex 16 | 17 | c <-chan datagram.Metric 18 | 19 | metrics []*bucket.MetricWindow 20 | metricsLookup map[string]int 21 | 22 | selected string 23 | } 24 | 25 | func (r *Router) Len() int { return len(r.metrics) } 26 | func (r *Router) Less(i, j int) bool { return r.metrics[i].Metric.Name < r.metrics[j].Metric.Name } 27 | func (r *Router) Swap(i, j int) { 28 | a, b := r.metrics[i].Metric.Name, r.metrics[j].Metric.Name 29 | r.metrics[i], r.metrics[j] = r.metrics[j], r.metrics[i] 30 | r.metricsLookup[a], r.metricsLookup[b] = j, i 31 | } 32 | 33 | func New(c <-chan datagram.Metric) *Router { 34 | r := &Router{ 35 | c: c, 36 | metrics: []*bucket.MetricWindow{}, 37 | metricsLookup: map[string]int{}, 38 | } 39 | return r 40 | } 41 | 42 | func (r *Router) Listen() { 43 | for m := range r.c { 44 | w := r.addOrGet(m) 45 | w.Add(m.Value) 46 | } 47 | } 48 | 49 | func (r *Router) addOrGet(m datagram.Metric) *bucket.MetricWindow { 50 | r.RLock() 51 | if idx, found := r.metricsLookup[m.Name]; found { 52 | r.RUnlock() 53 | return r.metrics[idx] 54 | } 55 | r.RUnlock() 56 | 57 | return r.add(m) 58 | } 59 | 60 | func (r *Router) add(m datagram.Metric) *bucket.MetricWindow { 61 | r.Lock() 62 | defer r.Unlock() 63 | 64 | w := bucket.NewMetricWindow(m, bucket.WindowSize, time.Second) 65 | 66 | idx := len(r.metrics) 67 | r.metrics = append(r.metrics, w) 68 | r.metricsLookup[m.Name] = idx 69 | sort.Sort(r) 70 | 71 | if r.selected == "" { 72 | r.selected = m.Name 73 | } 74 | 75 | return w 76 | } 77 | 78 | func (r *Router) Selected() string { 79 | r.RLock() 80 | defer r.RUnlock() 81 | return r.selected 82 | } 83 | 84 | func (r *Router) Metrics() []datagram.Metric { 85 | r.RLock() 86 | defer r.RUnlock() 87 | 88 | out := make([]datagram.Metric, len(r.metrics)) 89 | for i, m := range r.metrics { 90 | out[i] = m.Metric 91 | } 92 | 93 | return out 94 | } 95 | 96 | func (r *Router) SelectedMetric() *bucket.MetricWindow { 97 | r.RLock() 98 | defer r.RUnlock() 99 | 100 | if r.selected == "" { 101 | return bucket.DummyWindow 102 | } 103 | 104 | idx, found := r.metricsLookup[r.selected] 105 | if !found { 106 | return bucket.DummyWindow 107 | } 108 | 109 | return r.metrics[idx] 110 | } 111 | 112 | func (r *Router) Previous() { 113 | r.Lock() 114 | defer r.Unlock() 115 | 116 | if len(r.metrics) == 0 { 117 | r.selected = "" 118 | return 119 | } 120 | 121 | idx, found := r.metricsLookup[r.selected] 122 | if r.selected == "" || !found { 123 | r.selected = r.metrics[0].Metric.Name 124 | return 125 | } 126 | 127 | if idx == 0 { 128 | return 129 | } 130 | 131 | r.selected = r.metrics[idx-1].Metric.Name 132 | } 133 | 134 | func (r *Router) Next() { 135 | r.Lock() 136 | defer r.Unlock() 137 | 138 | if len(r.metrics) == 0 { 139 | r.selected = "" 140 | return 141 | } 142 | 143 | idx, found := r.metricsLookup[r.selected] 144 | if r.selected == "" || !found { 145 | r.selected = r.metrics[0].Metric.Name 146 | return 147 | } 148 | 149 | if idx == len(r.metrics)-1 { 150 | return 151 | } 152 | 153 | r.selected = r.metrics[idx+1].Metric.Name 154 | } 155 | -------------------------------------------------------------------------------- /router/router_test.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "testing" 5 | 6 | "time" 7 | 8 | "github.com/rodaine/statstee/bucket" 9 | "github.com/rodaine/statstee/datagram" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestRouter_Listen(t *testing.T) { 14 | t.Parallel() 15 | 16 | c := make(chan datagram.Metric, 3) 17 | c <- datagram.Metric{ 18 | Name: "foo.bar", 19 | Type: datagram.Counter, 20 | Value: 1, 21 | SampleRate: 1, 22 | } 23 | c <- datagram.Metric{ 24 | Name: "fizz.buzz", 25 | Type: datagram.Histogram, 26 | Value: 1, 27 | SampleRate: 1, 28 | } 29 | c <- datagram.Metric{ 30 | Name: "fizz.buzz", 31 | Type: datagram.Histogram, 32 | Value: 1, 33 | SampleRate: 1, 34 | } 35 | close(c) 36 | 37 | r := New(c) 38 | r.Listen() 39 | <-time.NewTimer(100 * time.Millisecond).C 40 | 41 | m := r.Metrics() 42 | assert.Len(t, m, 2) 43 | 44 | assert.Equal(t, "foo.bar", r.Selected(), "first metric added should be selected") 45 | assert.Equal(t, "fizz.buzz", m[0].Name, "alphabetized metrics") 46 | } 47 | 48 | func TestRouter_SelectedMetric(t *testing.T) { 49 | t.Parallel() 50 | 51 | c := make(chan datagram.Metric) 52 | defer close(c) 53 | 54 | r := New(c) 55 | go r.Listen() 56 | 57 | assert.True(t, bucket.DummyWindow == r.SelectedMetric()) 58 | r.selected = "foo" 59 | assert.True(t, bucket.DummyWindow == r.SelectedMetric()) 60 | r.selected = "" 61 | 62 | c <- datagram.Metric{ 63 | Name: "foo.bar", 64 | Type: datagram.Counter, 65 | Value: 1, 66 | SampleRate: 1, 67 | } 68 | 69 | assert.False(t, bucket.DummyWindow == r.SelectedMetric()) 70 | assert.NotNil(t, r.SelectedMetric()) 71 | } 72 | 73 | func TestRouter_PreviousNext(t *testing.T) { 74 | t.Parallel() 75 | 76 | c := make(chan datagram.Metric, 2) 77 | r := New(c) 78 | 79 | r.Previous() 80 | assert.Empty(t, r.Selected()) 81 | r.Next() 82 | assert.Empty(t, r.Selected()) 83 | 84 | c <- datagram.Metric{ 85 | Name: "foo.bar", 86 | Type: datagram.Counter, 87 | Value: 1, 88 | SampleRate: 1, 89 | } 90 | c <- datagram.Metric{ 91 | Name: "fizz.buzz", 92 | Type: datagram.Set, 93 | Value: 1, 94 | SampleRate: 1, 95 | } 96 | close(c) 97 | r.Listen() 98 | 99 | 100 | assert.Equal(t, "foo.bar", r.Selected(), "first metric added should be selected") 101 | r.selected = "not.found" 102 | r.Previous() 103 | assert.Equal(t, "fizz.buzz", r.Selected(), "not found metric should default to first") 104 | r.selected = "" 105 | r.Next() 106 | assert.Equal(t, "fizz.buzz", r.Selected(), "not found metric should default to first") 107 | 108 | r.Previous() 109 | assert.Equal(t, "fizz.buzz", r.Selected(), "if on first item, don't change on previous") 110 | r.Next() 111 | assert.Equal(t, "foo.bar", r.Selected()) 112 | r.Next() 113 | assert.Equal(t, "foo.bar", r.Selected(), "if on last item, don't change on next") 114 | r.Previous() 115 | assert.Equal(t, "fizz.buzz", r.Selected()) 116 | } 117 | -------------------------------------------------------------------------------- /script/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | mkdir -p dist 5 | 6 | executables=`go list -f "{{ .Name }}: {{ .ImportPath }}" ./... | grep "main:" | cut -d ' ' -f2` 7 | 8 | for executable in ${executables[@]}; do 9 | name=`basename $executable` 10 | go build -o "dist/${name}" "$executable" 11 | done 12 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | go test -race -covermode atomic ./... 5 | -------------------------------------------------------------------------------- /statstee.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | 11 | "github.com/rodaine/statstee/datagram" 12 | "github.com/rodaine/statstee/router" 13 | "github.com/rodaine/statstee/streams" 14 | "github.com/rodaine/statstee/views" 15 | "golang.org/x/net/context" 16 | ) 17 | 18 | const ( 19 | logFile = "statstee.log" 20 | ) 21 | 22 | var ( 23 | logWriter io.WriteCloser 24 | streamError error 25 | 26 | deviceInterface string = streams.LoopbackAbbr 27 | sniffedPort int = streams.DefaultStatsDPort 28 | outputDebug bool = false 29 | listenMode bool = false 30 | captureMode bool = false 31 | ) 32 | 33 | func init() { 34 | flag.StringVar(&deviceInterface, "d", deviceInterface, "network device to capture on") 35 | flag.IntVar(&sniffedPort, "p", sniffedPort, "port to capture on") 36 | flag.BoolVar(&listenMode, "l", listenMode, "force listen mode, error if the port cannot be bound") 37 | flag.BoolVar(&captureMode, "c", captureMode, "force capture mode, even if StatsD is not present") 38 | flag.BoolVar(&outputDebug, "v", outputDebug, "display debug output to "+logFile) 39 | flag.Parse() 40 | 41 | log.SetOutput(ioutil.Discard) 42 | } 43 | 44 | func main() { 45 | if outputDebug { 46 | logWriter, _ = os.OpenFile(logFile, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0644) 47 | log.SetFlags(log.Lmicroseconds | log.LstdFlags | log.Lshortfile) 48 | log.SetOutput(logWriter) 49 | defer logWriter.Close() 50 | } 51 | 52 | mode := streams.DefaultMode 53 | switch { 54 | case listenMode: 55 | mode = streams.ListenMode 56 | case captureMode: 57 | mode = streams.CaptureMode 58 | } 59 | stream, err := streams.ResolveStream(mode, deviceInterface, sniffedPort) 60 | fatalIfError(err) 61 | 62 | parser := datagram.NewParser() 63 | router := router.New(parser.Chan()) 64 | 65 | go captureMetrics(router) 66 | go parser.Parse(stream.Chan()) 67 | go stream.Listen(context.TODO()) 68 | 69 | fatalIfError(views.Loop(router)) 70 | fatalIfError(streamError) 71 | } 72 | 73 | func captureMetrics(r *router.Router) { 74 | r.Listen() 75 | views.Quit() 76 | } 77 | 78 | func fatalIfError(err error) { 79 | if err != nil { 80 | log.Println(err) 81 | fmt.Fprintln(os.Stderr, err) 82 | os.Exit(1) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /streams/device.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | ) 7 | 8 | const ( 9 | // Placeholder to identify the first loopback device (usually lo or lo0, depending on platform) 10 | LoopbackAbbr = "_first_loopback_" 11 | 12 | // Device flags used to identify a loopback device 13 | LoopbackFlags = net.FlagLoopback | net.FlagUp 14 | ) 15 | 16 | func resolveDevice(iface string) (i net.Interface, err error) { 17 | ifaces, _ := net.Interfaces() 18 | 19 | for _, i := range ifaces { 20 | if i.Name == iface { 21 | return i, nil 22 | } 23 | 24 | if iface == LoopbackAbbr && isLoopback(i) { 25 | return i, nil 26 | } 27 | } 28 | 29 | return i, fmt.Errorf("unknown interface device: %s", iface) 30 | } 31 | 32 | func isLoopback(iface net.Interface) bool { 33 | return iface.Flags&LoopbackFlags == LoopbackFlags 34 | } 35 | -------------------------------------------------------------------------------- /streams/device_test.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "net" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestDevice_IsLoopback(t *testing.T) { 11 | t.Parallel() 12 | 13 | tests := []struct { 14 | Interface net.Interface 15 | Expected bool 16 | }{ 17 | {net.Interface{Flags: LoopbackFlags}, true}, 18 | {net.Interface{Flags: net.FlagLoopback}, false}, 19 | {net.Interface{Flags: net.FlagUp}, false}, 20 | {net.Interface{}, false}, 21 | } 22 | 23 | for _, test := range tests { 24 | assert.Equal(t, test.Expected, isLoopback(test.Interface), "%+v", test) 25 | } 26 | } 27 | 28 | func TestDevice_ResolveDevice(t *testing.T) { 29 | t.Parallel() 30 | 31 | ifaces, _ := net.Interfaces() 32 | var loopback *net.Interface 33 | for _, iface := range ifaces { 34 | if isLoopback(iface) { 35 | loopback = &iface 36 | break 37 | } 38 | } 39 | if loopback == nil { 40 | t.Skip("no loopback network interface found") 41 | } 42 | 43 | i, err := resolveDevice("this is not a real device") 44 | assert.Error(t, err) 45 | 46 | i, err = resolveDevice(loopback.Name) 47 | assert.NoError(t, err) 48 | assert.Equal(t, i.Name, loopback.Name) 49 | 50 | i, err = resolveDevice(LoopbackAbbr) 51 | assert.NoError(t, err) 52 | assert.Equal(t, i.Name, loopback.Name) 53 | } 54 | -------------------------------------------------------------------------------- /streams/interface.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import "golang.org/x/net/context" 4 | 5 | const ( 6 | // DefaultStatsDPort is the default port the StatsD daemon listens on 7 | DefaultStatsDPort = 8125 8 | 9 | maxDatagramSize = 8192 10 | channelBuffer = 1000 11 | ) 12 | 13 | type StreamMode uint8 14 | 15 | const ( 16 | DefaultMode StreamMode = iota 17 | ListenMode 18 | CaptureMode 19 | ) 20 | 21 | // Interface describes a stream source for stats metrics 22 | type Interface interface { 23 | // Listen begins reading metric datagrams off the network, sending the raw bytes to the data channel. This method 24 | // blocks until ctx is Done or an internal error arises. 25 | Listen(ctx context.Context) error 26 | 27 | // Chan returns the channel through which the raw datagrams will be returned. If the channel is closed, this stream 28 | // is no longer valid and a new one will need to be created. 29 | Chan() <-chan []byte 30 | } 31 | 32 | func ResolveStream(mode StreamMode, device string, port int) (stream Interface, err error) { 33 | switch mode { 34 | case ListenMode: 35 | return newListener(port) 36 | case CaptureMode: 37 | return newSniffer(device, port) 38 | case DefaultMode: 39 | fallthrough 40 | default: 41 | // get the network device 42 | iface, err := resolveDevice(device) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | // if it's loopback, attempt to listen 48 | if isLoopback(iface) { 49 | if stream, err = newListener(port); err == nil { 50 | return stream, err 51 | } 52 | } 53 | 54 | // can't listen, must sniff 55 | return newSniffer(device, port) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /streams/interface_test.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestInterface_ResolveStream(t *testing.T) { 10 | t.Parallel() 11 | port := DefaultStatsDPort + 55 // arbitrary so as to not collide with other tests 12 | 13 | s, err := ResolveStream(ListenMode, "", port) 14 | assert.NoError(t, err) 15 | assert.IsType(t, &listener{}, s) 16 | s.(*listener).conn.Close() 17 | 18 | s, err = ResolveStream(CaptureMode, LoopbackAbbr, port) 19 | assert.NoError(t, err) 20 | assert.IsType(t, &sniffer{}, s) 21 | 22 | s, err = ResolveStream(DefaultMode, "this is an erroneous device", port) 23 | assert.Error(t, err) 24 | assert.Nil(t, s) 25 | 26 | s, err = ResolveStream(DefaultMode, LoopbackAbbr, port) 27 | assert.NoError(t, err) 28 | assert.IsType(t, &listener{}, s) 29 | defer s.(*listener).conn.Close() 30 | 31 | s, err = ResolveStream(DefaultMode, LoopbackAbbr, port) 32 | assert.NoError(t, err) 33 | assert.IsType(t, &sniffer{}, s) 34 | } 35 | -------------------------------------------------------------------------------- /streams/listener.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | 7 | "log" 8 | 9 | "time" 10 | 11 | "golang.org/x/net/context" 12 | ) 13 | 14 | type listener struct { 15 | conn *net.UDPConn 16 | c chan []byte 17 | } 18 | 19 | func newListener(port int) (Interface, error) { 20 | addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf(":%d", port)) 21 | if err != nil { 22 | log.Printf("unable to resolve UDP address: %v", err) 23 | return nil, err 24 | } 25 | 26 | conn, err := net.ListenUDP("udp", addr) 27 | if err != nil { 28 | log.Printf("unable to open UDP connection: %v", err) 29 | return nil, err 30 | } 31 | 32 | return &listener{ 33 | conn: conn, 34 | c: make(chan []byte, channelBuffer), 35 | }, nil 36 | } 37 | 38 | func (l *listener) Listen(ctx context.Context) error { 39 | defer l.conn.Close() 40 | defer close(l.c) 41 | 42 | buffer := make([]byte, maxDatagramSize) 43 | for { 44 | select { 45 | case <-ctx.Done(): 46 | return nil 47 | default: 48 | //noop 49 | } 50 | 51 | l.conn.SetReadDeadline(time.Now().Add(time.Second)) 52 | n, _, err := l.conn.ReadFromUDP(buffer) 53 | if err != nil { 54 | if netErr, ok := err.(net.Error); ok { 55 | if netErr.Timeout() || netErr.Temporary() { 56 | continue 57 | } 58 | } 59 | log.Printf("unable to read bytes from connection: %v", err) 60 | return err 61 | } 62 | 63 | raw := make([]byte, n) 64 | copy(raw, buffer[:n]) 65 | 66 | l.c <- raw 67 | } 68 | } 69 | 70 | func (l *listener) Chan() <-chan []byte { 71 | return l.c 72 | } 73 | 74 | var _ Interface = &listener{} 75 | -------------------------------------------------------------------------------- /streams/listener_test.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "testing" 5 | 6 | "sync" 7 | 8 | "time" 9 | 10 | "github.com/rodaine/statstee/datagram" 11 | "github.com/stretchr/testify/assert" 12 | "golang.org/x/net/context" 13 | ) 14 | 15 | func TestListener_New(t *testing.T) { 16 | t.Parallel() 17 | port := DefaultStatsDPort - 42 18 | 19 | l, err := newListener(-1) 20 | assert.Error(t, err) 21 | assert.Nil(t, l) 22 | 23 | l, err = newListener(port) 24 | assert.NoError(t, err) 25 | assert.IsType(t, &listener{}, l) 26 | defer l.(*listener).conn.Close() 27 | 28 | l, err = newListener(port) 29 | assert.Error(t, err) 30 | assert.Nil(t, l) 31 | } 32 | 33 | func TestListener_Listen(t *testing.T) { 34 | t.Parallel() 35 | port := DefaultStatsDPort + 12 36 | 37 | ctx, cancel := context.WithCancel(context.Background()) 38 | 39 | l, err := newListener(port) 40 | assert.NoError(t, err) 41 | assert.NotNil(t, l) 42 | 43 | var lerr error 44 | wg := sync.WaitGroup{} 45 | wg.Add(1) 46 | go func() { 47 | lerr = l.Listen(ctx) 48 | wg.Done() 49 | }() 50 | 51 | m := datagram.Metric{ 52 | Name: "foo.bar", 53 | Type: datagram.Counter, 54 | Value: 1, 55 | SampleRate: 1, 56 | } 57 | 58 | s, _ := datagram.NewSender("localhost", port) 59 | s.Send(m) 60 | 61 | b := <-l.Chan() 62 | cancel() 63 | 64 | assert.Equal(t, m.String(), string(b)) 65 | wg.Wait() 66 | assert.NoError(t, lerr) 67 | } 68 | 69 | func TestListener_Listen_Error(t *testing.T) { 70 | t.Parallel() 71 | port := DefaultStatsDPort + 432 72 | 73 | l, err := newListener(port) 74 | assert.NoError(t, err) 75 | assert.NotNil(t, l) 76 | l.(*listener).conn.Close() 77 | 78 | ctx, _ := context.WithTimeout(context.Background(), time.Second) 79 | 80 | err = l.Listen(ctx) 81 | assert.Error(t, err) 82 | } 83 | -------------------------------------------------------------------------------- /streams/sniffer.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/google/gopacket" 8 | "github.com/google/gopacket/pcap" 9 | "golang.org/x/net/context" 10 | ) 11 | 12 | const ( 13 | udpPortFilter = "udp port %d" 14 | ) 15 | 16 | type sniffer struct { 17 | handle *pcap.InactiveHandle 18 | port int 19 | c chan []byte 20 | } 21 | 22 | func newSniffer(device string, port int) (Interface, error) { 23 | iface, err := resolveDevice(device) 24 | if err != nil { 25 | log.Printf("unable to resolve device: %v", err) 26 | return nil, err 27 | } 28 | 29 | handle, err := pcap.NewInactiveHandle(iface.Name) 30 | if err != nil { 31 | log.Printf("unable to create handle: %v", err) 32 | return nil, err 33 | } 34 | 35 | handle.SetSnapLen(maxDatagramSize) 36 | handle.SetImmediateMode(true) 37 | handle.SetPromisc(true) 38 | handle.SetTimeout(pcap.BlockForever) 39 | handle.SetRFMon(true) 40 | 41 | s := &sniffer{ 42 | handle: handle, 43 | port: port, 44 | c: make(chan []byte, channelBuffer), 45 | } 46 | 47 | return s, nil 48 | } 49 | 50 | func (s *sniffer) Listen(ctx context.Context) error { 51 | defer s.handle.CleanUp() 52 | defer close(s.c) 53 | 54 | h, err := s.handle.Activate() 55 | if err != nil { 56 | err = fmt.Errorf("unable to activate pcap handle: %v", err) 57 | log.Println(err) 58 | return err 59 | } 60 | defer h.Close() 61 | 62 | if err = h.SetBPFFilter(fmt.Sprintf(udpPortFilter, s.port)); err != nil { 63 | err = fmt.Errorf("unable to apply BPF filter: %v", err) 64 | log.Println(err) 65 | return err 66 | } 67 | 68 | packetSource := gopacket.NewPacketSource(h, h.LinkType()) 69 | for { 70 | select { 71 | case <-ctx.Done(): 72 | return nil 73 | case packet := <-packetSource.Packets(): 74 | raw := packet.ApplicationLayer().Payload() 75 | s.c <- raw 76 | } 77 | } 78 | } 79 | 80 | func (s *sniffer) Chan() <-chan []byte { 81 | return s.c 82 | } 83 | 84 | var _ Interface = &sniffer{} 85 | -------------------------------------------------------------------------------- /streams/sniffer_test.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "sync" 8 | 9 | "time" 10 | 11 | "github.com/google/gopacket/pcap" 12 | "github.com/rodaine/statstee/datagram" 13 | "github.com/stretchr/testify/assert" 14 | "golang.org/x/net/context" 15 | ) 16 | 17 | var canPCAP = false 18 | 19 | func TestMain(m *testing.M) { 20 | i, _ := resolveDevice(LoopbackAbbr) 21 | h, err := pcap.OpenLive(i.Name, 1, false, time.Second) 22 | 23 | canPCAP = err == nil 24 | if canPCAP { 25 | h.Close() 26 | } 27 | 28 | os.Exit(m.Run()) 29 | } 30 | 31 | func TestSniffer_New(t *testing.T) { 32 | t.Parallel() 33 | port := DefaultStatsDPort + 111 34 | 35 | s, err := newSniffer("this is not a real device", port) 36 | assert.Error(t, err) 37 | assert.Nil(t, s) 38 | 39 | s, err = newSniffer(LoopbackAbbr, port) 40 | assert.NoError(t, err) 41 | assert.NotNil(t, s) 42 | s.(*sniffer).handle.CleanUp() 43 | } 44 | 45 | func TestSniffer_Listen(t *testing.T) { 46 | t.Parallel() 47 | if !canPCAP { 48 | t.Skip("cannot use PCAP due to permissions. Run tests as sudo") 49 | } 50 | 51 | port := DefaultStatsDPort - 123 52 | 53 | s, err := newSniffer(LoopbackAbbr, port) 54 | assert.NoError(t, err) 55 | assert.NotNil(t, s) 56 | 57 | var serr error 58 | ctx, cancel := context.WithCancel(context.Background()) 59 | wg := sync.WaitGroup{} 60 | wg.Add(1) 61 | go func() { 62 | serr = s.Listen(ctx) 63 | wg.Done() 64 | }() 65 | 66 | m := datagram.Metric{ 67 | Name: "foo.bar", 68 | Type: datagram.Counter, 69 | Value: 1, 70 | SampleRate: 1, 71 | } 72 | 73 | sender, _ := datagram.NewSender("localhost", port) 74 | sender.Send(m) 75 | 76 | b := <-s.Chan() 77 | assert.Equal(t, m.String(), string(b)) 78 | 79 | cancel() 80 | wg.Wait() 81 | 82 | assert.NoError(t, serr) 83 | } 84 | -------------------------------------------------------------------------------- /views/colors.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gizak/termui" 7 | "github.com/rodaine/statstee/datagram" 8 | ) 9 | 10 | const ( 11 | markdownColorFormat = "fg-%s,bg-%s" 12 | ) 13 | 14 | var ( 15 | datagramColorNames = map[datagram.MetricType]string{ 16 | datagram.Histogram: "green", 17 | datagram.Timer: "green", 18 | datagram.Counter: "blue", 19 | datagram.Gauge: "yellow", 20 | datagram.Set: "magenta", 21 | datagram.Unknown: "red", 22 | } 23 | 24 | datagramColors map[datagram.MetricType]termui.Attribute 25 | ) 26 | 27 | func init() { 28 | datagramColors = make(map[datagram.MetricType]termui.Attribute, len(datagramColorNames)) 29 | for t, name := range datagramColorNames { 30 | datagramColors[t] = termui.StringToAttribute(name) 31 | } 32 | } 33 | 34 | func markdown(fg, bg string) string { 35 | return fmt.Sprintf(markdownColorFormat, fg, bg) 36 | } 37 | -------------------------------------------------------------------------------- /views/colors_test.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestColors_Init(t *testing.T) { 10 | t.Parallel() 11 | assert.Len(t, datagramColors, len(datagramColorNames)) 12 | } 13 | 14 | func TestColors_Markdown(t *testing.T) { 15 | t.Parallel() 16 | assert.Equal(t, "fg-blue,bg-orange", markdown("blue", "orange")) 17 | } 18 | -------------------------------------------------------------------------------- /views/display.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/gizak/termui" 7 | "github.com/rodaine/statstee/bucket" 8 | "github.com/rodaine/statstee/router" 9 | ) 10 | 11 | const ( 12 | maxGridSpan = 12 13 | routerGridSpan = 3 14 | dataGridSpan = maxGridSpan - routerGridSpan 15 | ) 16 | 17 | type display struct { 18 | sync.RWMutex 19 | router *router.Router 20 | 21 | grid *termui.Grid 22 | routerView *routerView 23 | dataView *plotSet 24 | 25 | width, height int 26 | } 27 | 28 | func newDisplay(r *router.Router) *display { 29 | d := &display{ 30 | router: r, 31 | routerView: newRouterView(r), 32 | dataView: newSet(bucket.DummyWindow, nil), 33 | } 34 | 35 | d.grid = termui.NewGrid(termui.NewRow( 36 | termui.NewCol(routerGridSpan, 0, d.routerView.list()), 37 | d.dataView.row, 38 | )) 39 | 40 | return d 41 | } 42 | 43 | func (d *display) update() { 44 | d.routerView.update(d.router) 45 | 46 | if sel := d.router.SelectedMetric(); sel.Metric.Name != d.dataView.metric.Name { 47 | d.dataView = newSet(sel, d.grid.Rows[0].Cols[1]) 48 | d.dataView.height = d.height 49 | d.grid.Rows[0].Cols[1] = d.dataView.row 50 | d.grid.Align() 51 | } 52 | 53 | d.dataView.update() 54 | } 55 | 56 | func (d *display) dimSync(width, height int) { 57 | d.width, d.height = width, height 58 | 59 | d.routerView.height = height 60 | d.dataView.height = height 61 | 62 | d.grid.Width = width 63 | d.grid.Align() 64 | } 65 | -------------------------------------------------------------------------------- /views/double-buffer.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "sync" 7 | 8 | "github.com/gizak/termui" 9 | "github.com/rodaine/statstee/router" 10 | ) 11 | 12 | const ( 13 | minWidth = 78 14 | minHeight = 20 15 | ) 16 | 17 | type doubleBuffer struct { 18 | sync.RWMutex 19 | width, height int 20 | current, next *display 21 | } 22 | 23 | func newBuffer(r *router.Router) *doubleBuffer { 24 | db := &doubleBuffer{ 25 | current: newDisplay(r), 26 | next: newDisplay(r), 27 | } 28 | 29 | db.dimSync() 30 | db.current.update() 31 | db.next.update() 32 | 33 | return db 34 | } 35 | 36 | func (db *doubleBuffer) draw() { 37 | var b termui.Bufferer 38 | db.RLock() 39 | if db.tooSmall() { 40 | b = db.smallView() 41 | } else { 42 | b = db.current.grid 43 | } 44 | 45 | termui.Render(b) 46 | db.RUnlock() 47 | } 48 | 49 | func (db *doubleBuffer) lazy() { 50 | go db.update(false) 51 | } 52 | 53 | func (db *doubleBuffer) update(force bool) { 54 | db.Lock() 55 | defer db.Unlock() 56 | 57 | if force { 58 | db.dimSync() 59 | } 60 | 61 | if db.tooSmall() { 62 | return 63 | } 64 | 65 | db.next.update() 66 | db.next.grid.Align() 67 | db.current, db.next = db.next, db.current 68 | } 69 | 70 | func (db *doubleBuffer) dimSync() { 71 | w, h := termui.TermWidth(), termui.TermHeight() 72 | 73 | db.width, db.height = w, h 74 | db.current.dimSync(w, h) 75 | db.next.dimSync(w, h) 76 | } 77 | 78 | func (db *doubleBuffer) smallView() *termui.Par { 79 | w := db.width 80 | h := db.height 81 | 82 | if w < 1 { 83 | w = 1 84 | } 85 | 86 | if h < 1 { 87 | h = 1 88 | } 89 | 90 | txt := fmt.Sprintf("SCREEN TOO SMALL\nCURRENT: %dx%d\nMIN: %dx%d", db.width, db.height, minWidth, minHeight) 91 | log.Println(txt) 92 | 93 | v := termui.NewPar(txt) 94 | v.Border = false 95 | v.Width = w 96 | v.Height = h 97 | 98 | v.Bg = termui.ColorRed 99 | v.TextBgColor = termui.ColorRed 100 | v.TextFgColor = termui.ColorBlack 101 | 102 | return v 103 | } 104 | 105 | func (db *doubleBuffer) tooSmall() bool { 106 | return db.width < minWidth || db.height < minHeight 107 | } 108 | -------------------------------------------------------------------------------- /views/loop.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/gizak/termui" 7 | "github.com/rodaine/statstee/router" 8 | ) 9 | 10 | var quitOnce = &sync.Once{} 11 | 12 | func Loop(r *router.Router) (err error) { 13 | if err = termui.Init(); err != nil { 14 | return 15 | } 16 | defer termui.Close() 17 | defer recover() 18 | 19 | db := newBuffer(r) 20 | db.draw() 21 | 22 | registerHandlers(r, db) 23 | termui.Loop() 24 | return 25 | } 26 | 27 | func Quit() { 28 | quitOnce.Do(termui.StopLoop) 29 | } 30 | 31 | func registerHandlers(r *router.Router, db *doubleBuffer) { 32 | termui.Handle("/sys/kbd/q", func(termui.Event) { 33 | Quit() 34 | }) 35 | 36 | termui.Handle("/sys/wnd/resize", func(e termui.Event) { 37 | db.update(true) 38 | db.draw() 39 | }) 40 | 41 | termui.Handle("/sys/kbd/j", func(e termui.Event) { 42 | r.Next() 43 | db.update(false) 44 | db.draw() 45 | }) 46 | 47 | termui.Handle("/sys/kbd/k", func(e termui.Event) { 48 | r.Previous() 49 | db.update(false) 50 | db.draw() 51 | }) 52 | 53 | termui.Handle("/timer/1s", func(e termui.Event) { 54 | db.draw() 55 | db.lazy() 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /views/plot.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/gizak/termui" 8 | "github.com/rodaine/statstee/bucket" 9 | ) 10 | 11 | const ( 12 | plotLabelColor = termui.ColorBlack | termui.AttrBold 13 | plotLineModifier = termui.AttrBold 14 | 15 | axesFormat = "15:04:05" 16 | valFormat = "%s: %.4g" 17 | avgFormat = "%s - avg(1m,5m,10m) %.4g, %.4g, %.4g" 18 | ) 19 | 20 | type plotFunc func() []float64 21 | type avgFunc func() bucket.Averages 22 | 23 | type plot struct { 24 | avgs avgFunc 25 | f plotFunc 26 | label string 27 | lc *termui.LineChart 28 | } 29 | 30 | func newPlot(label string, color termui.Attribute, f plotFunc, avgs avgFunc) *plot { 31 | lc := termui.NewLineChart() 32 | lc.BorderLabel = label 33 | lc.BorderBg = color 34 | 35 | p := &plot{ 36 | f: f, 37 | label: label, 38 | avgs: avgs, 39 | lc: lc, 40 | } 41 | p.reset() 42 | 43 | return p 44 | } 45 | 46 | func (p *plot) update() { 47 | p.reset() 48 | 49 | data := p.f() 50 | p.lc.Data = p.resize(data) 51 | p.lc.DataLabels = p.labels(len(p.lc.Data)) 52 | 53 | p.lc.BorderLabel = fmt.Sprintf( 54 | valFormat, 55 | p.label, 56 | data[len(data)-1], 57 | ) 58 | 59 | if p.avgs == nil { 60 | return 61 | } 62 | 63 | avgs := p.avgs() 64 | p.lc.BorderLabel = fmt.Sprintf( 65 | avgFormat, 66 | p.lc.BorderLabel, 67 | avgs.EWMA1, 68 | avgs.EWMA5, 69 | avgs.EWMA10, 70 | ) 71 | } 72 | 73 | func (p *plot) chart() *termui.LineChart { 74 | return p.lc 75 | } 76 | 77 | func (p *plot) reset() { 78 | label := p.lc.BorderLabel 79 | color := p.lc.BorderBg 80 | 81 | lc := termui.NewLineChart() 82 | lc.Width = p.lc.Width 83 | lc.Height = p.lc.Height 84 | 85 | lc.BorderLeft = false 86 | lc.BorderRight = false 87 | lc.BorderBottom = false 88 | 89 | lc.BorderBg = color 90 | lc.BorderFg = color 91 | 92 | lc.BorderLabel = label 93 | lc.BorderLabelBg = color 94 | lc.BorderLabelFg = plotLabelColor 95 | 96 | lc.LineColor = color | plotLineModifier 97 | 98 | *p.lc = *lc 99 | } 100 | 101 | func (p *plot) resize(data []float64) []float64 { 102 | points := (p.lc.Width - 9) * 2 103 | offset := len(data) - points 104 | 105 | if offset <= 0 { 106 | return data 107 | } 108 | 109 | if offset < len(data) { 110 | return data[offset:] 111 | } 112 | 113 | return []float64{} 114 | } 115 | 116 | func (p *plot) labels(size int) []string { 117 | now := time.Now() 118 | 119 | lbls := make([]string, size) 120 | for i := 0; i < size; i++ { 121 | lbls[i] = now.Add(-1 * time.Duration(size-i) * time.Second).Format(axesFormat) 122 | } 123 | 124 | return lbls 125 | } 126 | -------------------------------------------------------------------------------- /views/router.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/gizak/termui" 8 | "github.com/rodaine/statstee/datagram" 9 | "github.com/rodaine/statstee/router" 10 | ) 11 | 12 | const ( 13 | instructions = "q:Quit j:Next k:Prev" 14 | headerTextFormat = "%s | %d/%d" 15 | headerTextColor = termui.ColorBlack | termui.AttrBold 16 | 17 | borderColor = termui.ColorWhite 18 | 19 | unselectedFormat = " [%s] %s" 20 | selectedFormat = "[%s](%s,BOLD)" 21 | 22 | selectedPadding = " " 23 | selectedColor = "black" 24 | ) 25 | 26 | type routerView struct { 27 | offset int 28 | l *termui.List 29 | height int 30 | } 31 | 32 | func newRouterView(r *router.Router) *routerView { 33 | v := &routerView{l: termui.NewList()} 34 | 35 | v.l.BorderLabel = instructions 36 | v.l.BorderLabelFg = headerTextColor 37 | v.l.BorderLabelBg = borderColor 38 | 39 | v.l.BorderFg = borderColor 40 | v.l.BorderBg = borderColor 41 | 42 | return v 43 | } 44 | 45 | func (v *routerView) update(r *router.Router) { 46 | s := r.Selected() 47 | ms := r.Metrics() 48 | 49 | v.l.Height = v.height 50 | 51 | items := make([]string, len(ms)) 52 | selIdx := 0 53 | 54 | for i, m := range ms { 55 | selected := m.Name == s 56 | items[i] = v.metricItemLabel(m, selected) 57 | if selected { 58 | selIdx = i 59 | } 60 | } 61 | 62 | v.l.BorderLabel = fmt.Sprintf(headerTextFormat, instructions, selIdx+1, len(ms)) 63 | 64 | max := v.offset + v.l.Height - 3 65 | if selIdx >= v.offset && selIdx <= max { 66 | // noop 67 | } else if selIdx < v.offset { 68 | v.offset = selIdx 69 | } else { 70 | v.offset += selIdx - max 71 | } 72 | 73 | v.l.Items = items[v.offset:] 74 | } 75 | 76 | func (v *routerView) metricItemLabel(m datagram.Metric, selected bool) string { 77 | lbl := fmt.Sprintf(unselectedFormat, m.TypePrefix(), m.Name) 78 | if !selected { 79 | return lbl 80 | } 81 | 82 | offset := v.l.Width - len(lbl) 83 | if offset > 0 { 84 | lbl += strings.Repeat(selectedPadding, offset) 85 | } 86 | 87 | return fmt.Sprintf( 88 | selectedFormat, 89 | lbl, 90 | markdown(selectedColor, datagramColorNames[m.Type]), 91 | ) 92 | } 93 | 94 | func (v *routerView) list() *termui.List { 95 | return v.l 96 | } 97 | -------------------------------------------------------------------------------- /views/set.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "github.com/gizak/termui" 5 | "github.com/rodaine/statstee/bucket" 6 | "github.com/rodaine/statstee/datagram" 7 | ) 8 | 9 | const ( 10 | headerBorder = termui.ColorWhite 11 | headerLabelColor = termui.ColorBlack | termui.AttrBold 12 | ) 13 | 14 | type plotSet struct { 15 | metric datagram.Metric 16 | plots []*plot 17 | header *termui.Par 18 | row *termui.Row 19 | height int 20 | } 21 | 22 | func newSet(w *bucket.MetricWindow, prev *termui.Row) *plotSet { 23 | s := &plotSet{metric: w.Metric} 24 | s.header = s.setHeader() 25 | 26 | color := datagramColors[w.Metric.Type] 27 | 28 | switch w.Metric.Type { 29 | case datagram.Gauge: 30 | s.plots = []*plot{ 31 | newPlot("Gauge Value", color, w.Last, w.LastAverages), 32 | } 33 | case datagram.Counter: 34 | s.plots = []*plot{ 35 | newPlot("Count", color, w.Sum, w.SumAverages), 36 | newPlot("Cumulative Count", color, w.CumSum, nil), 37 | } 38 | case datagram.Histogram, datagram.Timer: 39 | s.plots = []*plot{ 40 | newPlot("Count", color, w.Count, w.CountAverages), 41 | newPlot("Median", color, w.Median, w.MedianAverages), 42 | newPlot("75th Percentile", color, w.P75, w.P75Averages), 43 | newPlot("95th Percentile", color, w.P95, w.P95Averages), 44 | } 45 | case datagram.Set: 46 | s.plots = []*plot{ 47 | newPlot("Unique Count", color, w.Unique, w.UniqueAverages), 48 | newPlot("Percent Unique", color, w.UniquePercent, w.UniquePercentAverages), 49 | } 50 | } 51 | 52 | s.row = termui.NewCol(dataGridSpan, 0, s.items()...) 53 | return s 54 | } 55 | 56 | func (s *plotSet) update() { 57 | ht := s.height - 1 58 | ct := len(s.plots) 59 | 60 | for _, p := range s.plots { 61 | p.lc.Height = ht / ct 62 | ht -= p.lc.Height 63 | ct-- 64 | 65 | p.update() 66 | } 67 | 68 | } 69 | 70 | func (s *plotSet) items() []termui.GridBufferer { 71 | items := make([]termui.GridBufferer, 1+len(s.plots)) 72 | items[0] = s.setHeader() 73 | 74 | for i, p := range s.plots { 75 | items[1+i] = p.chart() 76 | } 77 | 78 | return items 79 | } 80 | 81 | func (s *plotSet) setHeader() *termui.Par { 82 | p := termui.NewPar(s.metric.Name) 83 | 84 | p.Height = 1 85 | p.Border = false 86 | p.Bg = headerBorder 87 | p.TextBgColor = headerBorder 88 | p.TextFgColor = headerLabelColor 89 | 90 | return p 91 | } 92 | --------------------------------------------------------------------------------