├── .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 | 
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 | 
34 |
35 | ### Counter
36 |
37 | * Count / RPS
38 | * Cumulative Count
39 |
40 | 
41 |
42 | ### Set
43 |
44 | * Unique Count / Unique RPS
45 | * Percent Unique
46 |
47 | 
48 |
49 | ### Timing / Histogram (DataDog)
50 |
51 | * Count / RPS
52 | * Median
53 | * 75th Percentile
54 | * 95th Percentile
55 |
56 | 
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 |
--------------------------------------------------------------------------------