├── diydashboard.png ├── go.mod ├── go.sum ├── .godocdown.tmpl ├── LICENSE.txt ├── dashboard_test.go ├── metrics.go ├── dashboard.go ├── grafana.go ├── README.md └── metrics_test.go /diydashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christophberger/grada/HEAD/diydashboard.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/christophberger/grada 2 | 3 | go 1.22.2 4 | 5 | require github.com/google/go-cmp v0.6.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 2 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 3 | -------------------------------------------------------------------------------- /.godocdown.tmpl: -------------------------------------------------------------------------------- 1 | 12 | 13 | # Grada - A DIY dashboard based on Grafana 14 | 15 | Create a DIY dashboard for all metrics generated by your code, using a Grafana dashboard as a frontend. 16 | 17 | Whenever you have code that generates time series data (for example, the current CPU load, number of network connections, number of goroutines,...), you can feed this data to a Grafana panel with only a few lines of code. 18 | 19 | "Time series data" in this context is nothing more but a floating-point value and a timestamp. 20 | 21 | ![DIY Dashboard](diydashboard.png) 22 | 23 | {{ .EmitSynopsis }} 24 | 25 | {{ .EmitUsage }} -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Christoph Berger 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the author nor the names of his 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | -------------------------------------------------------------------------------- /dashboard_test.go: -------------------------------------------------------------------------------- 1 | package grada 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | "time" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func TestDashboard_CreateMetricWithBufSize(t *testing.T) { 12 | type args struct { 13 | target string 14 | size int 15 | } 16 | 17 | mt := &metrics{sync.Mutex{}, map[string]*Metric{}} 18 | 19 | tests := []struct { 20 | name string 21 | metrics *metrics 22 | args args 23 | wantErr bool 24 | }{ 25 | { 26 | "create1", 27 | mt, 28 | args{"target1", 10}, 29 | false, 30 | }, 31 | { 32 | "create1again", 33 | mt, 34 | args{"target1", 10}, 35 | true, 36 | }, 37 | } 38 | for _, tt := range tests { 39 | t.Run(tt.name, func(t *testing.T) { 40 | d := &Dashboard{ 41 | srv: &server{ 42 | metrics: tt.metrics, 43 | }, 44 | } 45 | got, err := d.CreateMetricWithBufSize(tt.args.target, tt.args.size) 46 | if (err != nil) != tt.wantErr { 47 | t.Errorf("Server.CreateMetric() error = %v, wantErr %v", err, tt.wantErr) 48 | return 49 | } 50 | want := d.srv.metrics.metric[tt.args.target] 51 | if !cmp.Equal(got, want, cmp.AllowUnexported((*got), (*got).m)) { 52 | t.Errorf("Server.CreateMetric():\ngot %v\nwant %v\ndiff:\n%s", got, want, cmp.Diff(got, want, cmp.AllowUnexported(*got, (*got).m))) 53 | } 54 | }) 55 | } 56 | } 57 | 58 | func TestGetDashboard(t *testing.T) { 59 | tests := []struct { 60 | name string 61 | }{} 62 | for _, tt := range tests { 63 | t.Run(tt.name, func(t *testing.T) { 64 | got := GetDashboard() 65 | if got.srv == nil { 66 | t.Errorf("GetDashboard().srv == nil") 67 | } 68 | if got.srv.metrics == nil { 69 | t.Errorf("GetDashboard().srv.metrics == nil") 70 | } 71 | }) 72 | } 73 | } 74 | 75 | func TestDashboard_bufSizeFor(t *testing.T) { 76 | tests := []struct { 77 | name string 78 | timeRange, interval time.Duration 79 | want int 80 | }{ 81 | {"1min, 1s", time.Minute, time.Second, 60}, 82 | {"1h, 10s", time.Hour, 10 * time.Second, 360}, 83 | {"12s, 11s", 12 * time.Second, 11 * time.Second, 1}, 84 | {"1min, 2min", time.Minute, 2 * time.Minute, 1}, 85 | } 86 | for _, tt := range tests { 87 | t.Run(tt.name, func(t *testing.T) { 88 | d := &Dashboard{} 89 | if got := d.bufSizeFor(tt.timeRange, tt.interval); got != tt.want { 90 | t.Errorf("Dashboard.For() = %v, want %v", got, tt.want) 91 | } 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /metrics.go: -------------------------------------------------------------------------------- 1 | package grada 2 | 3 | import ( 4 | "errors" 5 | "sort" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | // ## The data aggregator 11 | 12 | // Count is a single time series data tuple, consisting of 13 | // a floating-point value N and a timestamp T. 14 | type Count struct { 15 | N float64 16 | T time.Time 17 | } 18 | 19 | // Metric is a ring buffer of Counts. It collects time series data that a Grafana 20 | // dashboard panel can request at regular intervals. 21 | // Each Metric has a name that Grafana uses for selecting the desired data stream. 22 | // See Dashboard.CreateMetric(). 23 | type Metric struct { 24 | m sync.Mutex 25 | list []Count 26 | head int 27 | unsorted bool // AddWithTime() and AddCount() do not add in a sorted manner. 28 | } 29 | 30 | // Add a single value to the Metric buffer, along with the current time stamp. 31 | // When the buffer is full, every new value overwrites the oldest one. 32 | func (g *Metric) Add(n float64) { 33 | g.m.Lock() 34 | defer g.m.Unlock() 35 | g.list[g.head] = Count{n, time.Now()} 36 | g.head = (g.head + 1) % len(g.list) 37 | } 38 | 39 | // AddWithTime adds a single (value, timestamp) tuple to the ring buffer. 40 | func (g *Metric) AddWithTime(n float64, t time.Time) { 41 | g.AddCount(Count{n, t}) 42 | } 43 | 44 | // AddCount adds a complete Count object to the metric data. 45 | func (g *Metric) AddCount(c Count) { 46 | g.m.Lock() 47 | defer g.m.Unlock() 48 | g.unsorted = true 49 | g.list[g.head] = c 50 | g.head = (g.head + 1) % len(g.list) 51 | } 52 | 53 | // sort sorts the list of metrics by timestamp. 54 | // if the list is already sorted, sort() is a no-op. 55 | func (g *Metric) sort() { 56 | if !g.unsorted { 57 | return 58 | } 59 | 60 | // the ring buffer is unsorted. 61 | 62 | // sooner implements the less func for sort.Slice. 63 | sooner := func(i, j int) bool { 64 | return g.list[i].T.UnixNano() < g.list[j].T.UnixNano() 65 | } 66 | 67 | sort.Slice(g.list, sooner) 68 | g.head = 0 69 | g.unsorted = false 70 | } 71 | 72 | // fetchDatapoints is called by the Web API server. 73 | // It extracts all datapoints from g.list that fall within the time range [from, to], 74 | // with at most maxDataPoints items. 75 | func (g *Metric) fetchDatapoints(from, to time.Time, maxDataPoints int) *[]row { 76 | 77 | g.m.Lock() 78 | defer g.m.Unlock() 79 | length := len(g.list) 80 | 81 | g.sort() 82 | 83 | // Stage 1: extract all data points within the given time range. 84 | pointsInRange := make([]row, 0, length) 85 | for i := 0; i < length; i++ { 86 | count := g.list[(i+g.head)%length] // wrap around 87 | if count.T.After(from) && count.T.Before(to) { 88 | pointsInRange = append(pointsInRange, row{count.N, count.T.UnixNano() / 1000000}) // need ms 89 | } 90 | } 91 | 92 | points := len(pointsInRange) 93 | 94 | if points <= maxDataPoints { 95 | return &pointsInRange 96 | } 97 | 98 | // Stage 2: if more data points than requested exist in the time range, 99 | // thin out the slice evenly 100 | rows := make([]row, maxDataPoints) 101 | ratio := float64(len(pointsInRange)) / float64(len(rows)) 102 | for i := range rows { 103 | rows[i] = pointsInRange[int(float64(i)*ratio)] 104 | } 105 | 106 | return &rows 107 | } 108 | 109 | // metrics is a map of all metric buffers, with the key being the target name. 110 | // Used internally by the HTTP server and the dashboard. 111 | type metrics struct { 112 | m sync.Mutex 113 | metric map[string]*Metric 114 | } 115 | 116 | // Get gets the metric with name "target" from the Metrics map. If a metric of that name 117 | // does not exists in the map, Get returns an error. 118 | func (m *metrics) Get(target string) (*Metric, error) { 119 | m.m.Lock() 120 | mt, ok := m.metric[target] 121 | m.m.Unlock() 122 | if !ok { 123 | return nil, errors.New("no such metric: " + target) 124 | } 125 | return mt, nil 126 | } 127 | 128 | // Put adds a Metric to the Metrics map. Adding an already existing metric 129 | // is an error. 130 | func (m *metrics) Put(target string, metric *Metric) error { 131 | m.m.Lock() 132 | defer m.m.Unlock() 133 | 134 | _, exists := m.metric[target] 135 | if exists { 136 | return errors.New("metric " + target + " already exists") 137 | } 138 | m.metric[target] = metric 139 | return nil 140 | } 141 | 142 | // Delete removes a metric from the Metrics map. Deleting a non-existing 143 | // metric is an error. 144 | func (m *metrics) Delete(target string) error { 145 | m.m.Lock() 146 | defer m.m.Unlock() 147 | _, exists := m.metric[target] 148 | if !exists { 149 | return errors.New("cannot delete metric: " + target + " does not exist") 150 | } 151 | delete(m.metric, target) 152 | return nil 153 | } 154 | 155 | // Create creates a new Metric with the given target name and buffer size 156 | // and adds it to the Metrics map. 157 | // If a metric for target "target" exists already, Create returns an error. 158 | func (m *metrics) Create(target string, size int) (*Metric, error) { 159 | metric := &Metric{ 160 | list: make([]Count, size, size), 161 | } 162 | err := m.Put(target, metric) 163 | return metric, err 164 | } 165 | -------------------------------------------------------------------------------- /dashboard.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package grada is a simple DIY dashboard based on [Grafana](https://github.com/grafana) and the [Simple JSON Datasource plugin](https://github.com/grafana/simple-json-datasource). 3 | 4 | The motivation behind Grada 5 | 6 | Grada provides an easy way of monitoring any sort of time series data generated by your code. Whether you want to observe the number of active goroutines, the CPU load, the air temperature outside your house, or whatever data source you can think of - Grada makes it easy to turn that data into graphs, gauges, histograms, or tables. 7 | 8 | Using Grafana as a dashboard server 9 | 10 | I happened to stumble upon Grafana recently. Grafana is a highly configurable dashboard server for time series databases and other data sources. Quickly, an idea came to mind: Why not using this for any sort of data generated by my own code? And this is how Grada was born. 11 | 12 | Now whenever you have some data that can be associated with a point in time, Grada can put this data into a dashboard. 13 | 14 | How to use Grada 15 | 16 | In a very dense overview: 17 | 18 | * Create a new dashboard. 19 | * Set timeRange to the range that Grafana requests. 20 | * Set interval to the interval (in nanoseconds) in which your app delivers data points. 21 | * Create one or more "Metric", e.g. metric1. 22 | * Call metric1.Add(...) to add new data points as they appear. 23 | * Start your app. 24 | * Install and start Grafana. 25 | * Create a Grafana dashboard. 26 | * Set the dashboard update interval to the one chosen 27 | * Add a panel (for example, a gauge). 28 | * Set its datasource to the metrics created by your app. e.g. metric1, metric2, etc. 29 | * The data of the respective metric(s) should now begin to appear in the panel. 30 | 31 | 32 | Details and sample code 33 | 34 | For more details, see the blog article at https://appliedgo.net/diydashboard as well as the package API documentation below. 35 | 36 | The article consists of a step-by-step setup of Grafana and a sample app that produces "fake" time series data. 37 | 38 | 39 | Installation and update 40 | 41 | Simply run 42 | 43 | go get -u github.com/christophberger/grada 44 | 45 | */ 46 | package grada 47 | 48 | import ( 49 | "time" 50 | ) 51 | 52 | // Dashboard is the central data type of Grada. 53 | // 54 | // Start by creating a new dashboard through GetDashboard(). 55 | // 56 | // Then create one or more metrics as needed using CreateMetric() 57 | // or CreateMetricWithBufSize(). 58 | // 59 | // Finally, have your code add data points to the metric by calling 60 | // Metric.Add() or Metric.AddWithTime(). 61 | type Dashboard struct { 62 | srv *server 63 | } 64 | 65 | // GetDashboard initializes and/or returns the only existing dashboard. 66 | // This also starts the HTTP server that responds to queries from Grafana. 67 | // Default port is 3001. Overwrite this port by setting the environment 68 | // variable GRADA_PORT to the desired port number. 69 | func GetDashboard() *Dashboard { 70 | d := &Dashboard{} 71 | d.srv = startServer() 72 | return d 73 | } 74 | 75 | // CreateMetric creates a new metric for the given target name, time range, and 76 | // data update interval, and stores this metric in the server. 77 | // 78 | // A metric is a named data stream for time series data. A Grafana dashboard 79 | // panel connects to a data stream based on the metric name selected in the 80 | // panel settings. 81 | // 82 | // timeRange is the maximum time range the Grafana dashboard will ask for. 83 | // This depends on the user setting for the dashboard. 84 | // 85 | // interval is the (average) interval in which the data points get delivered. 86 | // 87 | // The quotient of timeRange and interval determines the size of the ring buffer 88 | // that holds the most recent data points. 89 | // Typically, the timeRange of a dashboard request should be much larger than 90 | // the interval for the incoming data. 91 | // 92 | // Creating a metric for an existing target is an error. To replace a metric 93 | // (which is rarely needed), call DeleteMetric first. 94 | func (d *Dashboard) CreateMetric(target string, timeRange, interval time.Duration) (*Metric, error) { 95 | return d.CreateMetricWithBufSize(target, d.bufSizeFor(timeRange, interval)) 96 | } 97 | 98 | // CreateMetricWithBufSize creates a new metric for the given target and with the 99 | // given buffer size, and stores this metric in the server. 100 | // 101 | // Use this method if you know how large the buffer must be. Otherwise prefer 102 | // CreateMetric() that calculates the buffer size for you. 103 | // 104 | // Buffer size should be chosen so that the buffer can hold enough items for a given 105 | // time range that Grafana asks for and the given rate of data point updates. 106 | // 107 | // Example: If the dashboards's time range is 5 minutes and the incoming data arrives every 108 | // second, the buffer should hold 300 item (5*60*1) at least. 109 | // 110 | // Creating a metric for an existing target is an error. To replace a metric 111 | // (which is rarely needed), call DeleteMetric first. 112 | func (d *Dashboard) CreateMetricWithBufSize(target string, size int) (*Metric, error) { 113 | return d.srv.metrics.Create(target, size) 114 | } 115 | 116 | // bufSizeFor takes a duration and a rate (number of data points per second) 117 | // and returns the required ring buffer size. 118 | // Used by CreateMetric(). 119 | func (d *Dashboard) bufSizeFor(timeRange, interval time.Duration) int { 120 | if interval.Nanoseconds() >= timeRange.Nanoseconds() { 121 | return 1 122 | } 123 | return int(timeRange.Nanoseconds() / interval.Nanoseconds()) 124 | } 125 | 126 | // DeleteMetric deletes the metric for the given target from the server. 127 | func (d *Dashboard) DeleteMetric(target string) error { 128 | return d.srv.metrics.Delete(target) 129 | } 130 | -------------------------------------------------------------------------------- /grafana.go: -------------------------------------------------------------------------------- 1 | package grada 2 | 3 | // Code required for communicating with Grafana: 4 | // * Server 5 | // * Handlers 6 | // * Structs 7 | // 8 | // Grafana sends three queries: 9 | // * /search for retrieving the available targets 10 | // * /query for requesting new sets of data 11 | // * /annotation for requesting chart annotations 12 | 13 | import ( 14 | "bytes" 15 | "encoding/json" 16 | "math/rand" 17 | "net/http" 18 | "os" 19 | "time" 20 | ) 21 | 22 | // query is a `/query` request from Grafana. 23 | // 24 | // All JSON-related structs were generated from the JSON examples 25 | // of the "SimpleJson" data source documentation 26 | // using [JSON-to-Go](https://mholt.github.io/json-to-go/), 27 | // with a little tweaking afterwards. 28 | type query struct { 29 | PanelID int `json:"panelId"` 30 | Range struct { 31 | From time.Time `json:"from"` 32 | To time.Time `json:"to"` 33 | Raw struct { 34 | From string `json:"from"` 35 | To string `json:"to"` 36 | } `json:"raw"` 37 | } `json:"range"` 38 | RangeRaw struct { 39 | From string `json:"from"` 40 | To string `json:"to"` 41 | } `json:"rangeRaw"` 42 | Interval string `json:"interval"` 43 | IntervalMs int `json:"intervalMs"` 44 | Targets []struct { 45 | Target string `json:"target"` 46 | RefID string `json:"refId"` 47 | Type string `json:"type"` 48 | } `json:"targets"` 49 | Format string `json:"format"` 50 | MaxDataPoints int `json:"maxDataPoints"` 51 | } 52 | 53 | // row is used in timeseriesResponse and tableResponse. 54 | // Grafana's JSON contains weird arrays with mixed types! 55 | type row []interface{} 56 | 57 | // column is used in tableResponse. 58 | type column struct { 59 | Text string `json:"text"` 60 | Type string `json:"type"` 61 | } 62 | 63 | // timeseriesResponse is the response to a `/query` request 64 | // if "Type" is set to "timeserie". 65 | // It sends time series data back to Grafana. 66 | type timeseriesResponse struct { 67 | Target string `json:"target"` 68 | Datapoints []row `json:"datapoints"` 69 | } 70 | 71 | // tableResponse is the response to send when "Type" is "table". 72 | type tableResponse struct { 73 | Columns []column `json:"columns"` 74 | Rows []row `json:"rows"` 75 | Type string `json:"type"` 76 | } 77 | 78 | var debug bool 79 | 80 | // ## The server 81 | 82 | // server is a Web API server for Grafana. It manages a list of metrics 83 | // by target name. When Grafana requests new data for a target, 84 | // the server returns the current list of metrics for that target. 85 | type server struct { 86 | metrics *metrics 87 | } 88 | 89 | func writeError(w http.ResponseWriter, e error, m string) { 90 | w.WriteHeader(http.StatusBadRequest) 91 | w.Write([]byte("{\"error\": \"" + m + ": " + e.Error() + "\"}")) 92 | 93 | } 94 | 95 | func (srv *server) queryHandler(w http.ResponseWriter, r *http.Request) { 96 | var q bytes.Buffer 97 | 98 | _, err := q.ReadFrom(r.Body) 99 | if err != nil { 100 | writeError(w, err, "Cannot read request body") 101 | return 102 | } 103 | 104 | query := &query{} 105 | err = json.Unmarshal(q.Bytes(), query) 106 | if err != nil { 107 | writeError(w, err, "cannot unmarshal request body") 108 | return 109 | } 110 | 111 | // Our example should contain exactly one target. 112 | 113 | // Depending on the type, we need to send either a timeseries response 114 | // or a table response. 115 | switch query.Targets[0].Type { 116 | case "timeserie": 117 | srv.sendTimeseries(w, query) 118 | case "table": 119 | srv.sendTable(w, query) 120 | } 121 | } 122 | 123 | // sendTimeseries creates and writes a JSON response to a request for time series data. 124 | func (srv *server) sendTimeseries(w http.ResponseWriter, q *query) { 125 | 126 | response := []timeseriesResponse{} 127 | 128 | for _, t := range q.Targets { 129 | target := t.Target 130 | metric, err := srv.metrics.Get(target) 131 | if err != nil { 132 | writeError(w, err, "Cannot get metric for target "+target) 133 | return 134 | } 135 | response = append(response, timeseriesResponse{ 136 | Target: target, 137 | Datapoints: *(metric.fetchDatapoints(q.Range.From, q.Range.To, q.MaxDataPoints)), 138 | }) 139 | } 140 | 141 | jsonResp, err := json.Marshal(response) 142 | if err != nil { 143 | writeError(w, err, "cannot marshal timeseries response") 144 | } 145 | 146 | w.Write(jsonResp) 147 | 148 | } 149 | 150 | // TODO: Just a dummy for now 151 | // sendTable creates and writes a JSON response to a request for table data 152 | func (srv *server) sendTable(w http.ResponseWriter, q *query) { 153 | 154 | response := []tableResponse{ 155 | { 156 | Columns: []column{ 157 | {Text: "Name", Type: "string"}, 158 | {Text: "Value", Type: "number"}, 159 | {Text: "Time", Type: "time"}, 160 | }, 161 | Rows: []row{ 162 | {"Alpha", rand.Intn(100), float64(int64(time.Now().UnixNano() / 1000000))}, 163 | {"Bravo", rand.Intn(100), float64(int64(time.Now().UnixNano() / 1000000))}, 164 | {"Charlie", rand.Intn(100), float64(int64(time.Now().UnixNano() / 1000000))}, 165 | {"Delta", rand.Intn(100), float64(int64(time.Now().UnixNano() / 1000000))}, 166 | }, 167 | Type: "table", 168 | }, 169 | } 170 | 171 | jsonResp, err := json.Marshal(response) 172 | if err != nil { 173 | writeError(w, err, "cannot marshal table response") 174 | } 175 | 176 | w.Write(jsonResp) 177 | 178 | } 179 | 180 | // A search request from Grafana expects a list of target names as a response. 181 | // These names are shown in the metrics dropdown when selecting a metric in 182 | // the Metrics tab of a panel. 183 | func (srv *server) searchHandler(w http.ResponseWriter, r *http.Request) { 184 | var targets []string 185 | for t, _ := range srv.metrics.metric { 186 | targets = append(targets, t) 187 | } 188 | resp, err := json.Marshal(targets) 189 | if err != nil { 190 | writeError(w, err, "cannot marshal targets response") 191 | } 192 | w.Write(resp) 193 | } 194 | 195 | // startServer creates and starts the API server. 196 | func startServer() *server { 197 | 198 | server := &server{ 199 | metrics: &metrics{ 200 | metric: map[string]*Metric{}, 201 | }, 202 | } 203 | 204 | // Grafana expects a "200 OK" status for "/" when testing the connection. 205 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 206 | w.WriteHeader(http.StatusOK) 207 | }) 208 | 209 | http.HandleFunc("/query", server.queryHandler) 210 | http.HandleFunc("/search", server.searchHandler) 211 | 212 | // Determine the port. Default is 3001 but can be changed via 213 | // environment variable GRADA_PORT. 214 | port := "3001" 215 | portenv := os.Getenv("GRADA_PORT") 216 | if portenv != "" { 217 | port = portenv 218 | } 219 | 220 | // Start the server. 221 | go http.ListenAndServe(":"+port, nil) 222 | return server 223 | } 224 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 12 | 13 | # Grada - A DIY dashboard based on Grafana 14 | 15 | Create a DIY dashboard for all metrics generated by your code, using a Grafana dashboard as a frontend. 16 | 17 | Whenever you have code that generates time series data (for example, the current CPU load, number of network connections, number of goroutines,...), you can feed this data to a Grafana panel with only a few lines of code. 18 | 19 | "Time series data" in this context is nothing more but a floating-point value and a timestamp. 20 | 21 | ![DIY Dashboard](diydashboard.png) 22 | 23 | Package grada is a simple DIY dashboard based on 24 | [Grafana](https://github.com/grafana) and the [Simple JSON Datasource 25 | plugin](https://github.com/grafana/simple-json-datasource). 26 | 27 | 28 | ### The motivation behind Grada 29 | 30 | Grada provides an easy way of monitoring any sort of time series data generated 31 | by your code. Whether you want to observe the number of active goroutines, the 32 | CPU load, the air temperature outside your house, or whatever data source you 33 | can think of - Grada makes it easy to turn that data into graphs, gauges, 34 | histograms, or tables. 35 | 36 | 37 | ### Using Grafana as a dashboard server 38 | 39 | I happened to stumble upon Grafana recently. Grafana is a highly configurable 40 | dashboard server for time series databases and other data sources. Quickly, an 41 | idea came to mind: Why not using this for any sort of data generated by my own 42 | code? And this is how Grada was born. 43 | 44 | Now whenever you have some data that can be associated with a point in time, 45 | Grada can put this data into a dashboard. 46 | 47 | 48 | ### How to use Grada 49 | 50 | In a very dense overview: 51 | 52 | * Create a new dashboard. 53 | * Set timeRange to the range that Grafana requests. 54 | * Set interval to the interval (in nanoseconds) in which your app delivers data points. 55 | * Create one or more "Metric", e.g. metric1. 56 | * Call metric1.Add(...) to add new data points as they appear. 57 | * Start your app. 58 | * Install and start Grafana. 59 | * Create a Grafana dashboard. 60 | * Set the dashboard update interval to the one chosen 61 | * Add a panel (for example, a gauge). 62 | * Set its datasource to the metrics created by your app. e.g. metric1, metric2, etc. 63 | * The data of the respective metric(s) should now begin to appear in the panel. 64 | 65 | 66 | ### Details and sample code 67 | 68 | For more details, see the blog article at https://appliedgo.net/diydashboard as 69 | well as the package API documentation below. 70 | 71 | The article consists of a step-by-step setup of Grafana and a sample app that 72 | produces "fake" time series data. 73 | 74 | 75 | ### Installation 76 | 77 | - Create a new project with `go mod init` 78 | - Write code that imports `github.com/christophberger/grada` 79 | - Run either `go mod tidy` or `go mod download` to fetch missing dependencies 80 | 81 | ## Usage 82 | 83 | #### type Count 84 | 85 | ```go 86 | type Count struct { 87 | N float64 88 | T time.Time 89 | } 90 | ``` 91 | 92 | Count is a single time series data tuple, consisting of a floating-point value N 93 | and a timestamp T. 94 | 95 | #### type Dashboard 96 | 97 | ```go 98 | type Dashboard struct { 99 | } 100 | ``` 101 | 102 | Dashboard is the central data type of Grada. 103 | 104 | Start by creating a new dashboard through GetDashboard(). 105 | 106 | Then create one or more metrics as needed using CreateMetric() or 107 | CreateMetricWithBufSize(). 108 | 109 | Finally, have your code add data points to the metric by calling Metric.Add() or 110 | Metric.AddWithTime(). 111 | 112 | #### func GetDashboard 113 | 114 | ```go 115 | func GetDashboard() *Dashboard 116 | ``` 117 | GetDashboard initializes and/or returns the only existing dashboard. This also 118 | starts the HTTP server that responds to queries from Grafana. Default port is 119 | 3001. Overwrite this port by setting the environment variable GRADA_PORT to the 120 | desired port number. 121 | 122 | #### func (*Dashboard) CreateMetric 123 | 124 | ```go 125 | func (d *Dashboard) CreateMetric(target string, timeRange, interval time.Duration) (*Metric, error) 126 | ``` 127 | CreateMetric creates a new metric for the given target name, time range, and 128 | data update interval, and stores this metric in the server. 129 | 130 | A metric is a named data stream for time series data. A Grafana dashboard panel 131 | connects to a data stream based on the metric name selected in the panel 132 | settings. 133 | 134 | timeRange is the maximum time range the Grafana dashboard will ask for. This 135 | depends on the user setting for the dashboard. 136 | 137 | interval is the (average) interval in which the data points get delivered. 138 | 139 | The quotient of timeRange and interval determines the size of the ring buffer 140 | that holds the most recent data points. Typically, the timeRange of a dashboard 141 | request should be much larger than the interval for the incoming data. 142 | 143 | Creating a metric for an existing target is an error. To replace a metric (which 144 | is rarely needed), call DeleteMetric first. 145 | 146 | #### func (*Dashboard) CreateMetricWithBufSize 147 | 148 | ```go 149 | func (d *Dashboard) CreateMetricWithBufSize(target string, size int) (*Metric, error) 150 | ``` 151 | CreateMetricWithBufSize creates a new metric for the given target and with the 152 | given buffer size, and stores this metric in the server. 153 | 154 | Use this method if you know how large the buffer must be. Otherwise prefer 155 | CreateMetric() that calculates the buffer size for you. 156 | 157 | Buffer size should be chosen so that the buffer can hold enough items for a 158 | given time range that Grafana asks for and the given rate of data point updates. 159 | 160 | Example: If the dashboards's time range is 5 minutes and the incoming data 161 | arrives every second, the buffer should hold 300 item (5*60*1) at least. 162 | 163 | Creating a metric for an existing target is an error. To replace a metric (which 164 | is rarely needed), call DeleteMetric first. 165 | 166 | #### func (*Dashboard) DeleteMetric 167 | 168 | ```go 169 | func (d *Dashboard) DeleteMetric(target string) error 170 | ``` 171 | DeleteMetric deletes the metric for the given target from the server. 172 | 173 | #### type Metric 174 | 175 | ```go 176 | type Metric struct { 177 | } 178 | ``` 179 | 180 | Metric is a ring buffer of Counts. It collects time series data that a Grafana 181 | dashboard panel can request at regular intervals. Each Metric has a name that 182 | Grafana uses for selecting the desired data stream. See 183 | Dashboard.CreateMetric(). 184 | 185 | #### func (*Metric) Add 186 | 187 | ```go 188 | func (g *Metric) Add(n float64) 189 | ``` 190 | Add a single value to the Metric buffer, along with the current time stamp. When 191 | the buffer is full, every new value overwrites the oldest one. 192 | 193 | #### func (*Metric) AddCount 194 | 195 | ```go 196 | func (g *Metric) AddCount(c Count) 197 | ``` 198 | AddCount adds a complete Count object to the metric data. 199 | 200 | #### func (*Metric) AddWithTime 201 | 202 | ```go 203 | func (g *Metric) AddWithTime(n float64, t time.Time) 204 | ``` 205 | AddWithTime adds a single (value, timestamp) tuple to the ring buffer. 206 | -------------------------------------------------------------------------------- /metrics_test.go: -------------------------------------------------------------------------------- 1 | package grada 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | "time" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func TestMetric_Add(t *testing.T) { 12 | type fields struct { 13 | list []Count 14 | head int 15 | } 16 | type args struct { 17 | n float64 18 | } 19 | tests := []struct { 20 | name string 21 | fields fields 22 | args args 23 | newHead int 24 | }{ 25 | { 26 | name: "target1", 27 | fields: fields{ 28 | list: []Count{{1, time.Now()}, {2, time.Now()}, {3, time.Now()}}, 29 | head: 1}, 30 | args: args{n: 4}, 31 | newHead: 2, 32 | }, 33 | 34 | { 35 | name: "target2", 36 | fields: fields{ 37 | list: []Count{{4, time.Now()}, {5, time.Now()}, {6, time.Now()}}, 38 | head: 2}, 39 | args: args{n: 7}, 40 | newHead: 0, 41 | }, 42 | } 43 | 44 | for _, tt := range tests { 45 | t.Run(tt.name, func(t *testing.T) { 46 | g := &Metric{ 47 | m: sync.Mutex{}, 48 | list: tt.fields.list, 49 | head: tt.fields.head, 50 | } 51 | g.Add(tt.args.n) 52 | if tt.fields.list[tt.fields.head].N != tt.args.n { 53 | t.Errorf("failed adding %f to metric for target %s", tt.args.n, tt.name) 54 | } 55 | }) 56 | } 57 | } 58 | 59 | func TestMetric_AddWithTime(t *testing.T) { 60 | type fields struct { 61 | list []Count 62 | head int 63 | } 64 | type args struct { 65 | n float64 66 | t time.Time 67 | } 68 | tests := []struct { 69 | name string 70 | fields fields 71 | args args 72 | newHead int 73 | }{ 74 | { 75 | name: "target1", 76 | fields: fields{ 77 | list: []Count{{1, time.Now()}, {2, time.Now()}, {3, time.Now()}}, 78 | head: 1}, 79 | args: args{n: 4, t: time.Date(2017, time.October, 25, 11, 16, 54, 0, time.UTC)}, 80 | newHead: 2, 81 | }, 82 | 83 | { 84 | name: "target2", 85 | fields: fields{ 86 | list: []Count{{4, time.Now()}, {5, time.Now()}, {6, time.Now()}}, 87 | head: 2}, 88 | args: args{n: 7, t: time.Date(2017, time.October, 25, 11, 16, 54, 0, time.UTC)}, 89 | newHead: 0, 90 | }, 91 | } 92 | for _, tt := range tests { 93 | t.Run(tt.name, func(t *testing.T) { 94 | g := &Metric{ 95 | m: sync.Mutex{}, 96 | list: tt.fields.list, 97 | head: tt.fields.head, 98 | } 99 | g.AddWithTime(tt.args.n, tt.args.t) 100 | if tt.fields.list[tt.fields.head].N != tt.args.n { 101 | t.Errorf("failed adding %f to metric for target %s", tt.args.n, tt.name) 102 | } 103 | if tt.fields.list[tt.fields.head].T != tt.args.t { 104 | t.Errorf("failed adding time %s to metric for target %s", tt.args.t.String(), tt.name) 105 | } 106 | }) 107 | } 108 | } 109 | 110 | func TestMetric_AddCount(t *testing.T) { 111 | type fields struct { 112 | list []Count 113 | head int 114 | } 115 | type args struct { 116 | c Count 117 | } 118 | tests := []struct { 119 | name string 120 | fields fields 121 | args args 122 | newHead int 123 | }{ 124 | { 125 | name: "target1", 126 | fields: fields{ 127 | list: []Count{{1, time.Now()}, {2, time.Now()}, {3, time.Now()}}, 128 | head: 1}, 129 | args: args{c: Count{N: 4, T: time.Date(2017, time.October, 25, 11, 16, 54, 0, time.UTC)}}, 130 | newHead: 2, 131 | }, 132 | 133 | { 134 | name: "target2", 135 | fields: fields{ 136 | list: []Count{{4, time.Now()}, {5, time.Now()}, {6, time.Now()}}, 137 | head: 2}, 138 | args: args{c: Count{N: 7, T: time.Date(2017, time.October, 25, 11, 16, 54, 0, time.UTC)}}, 139 | newHead: 0, 140 | }, 141 | } 142 | for _, tt := range tests { 143 | t.Run(tt.name, func(t *testing.T) { 144 | g := &Metric{ 145 | m: sync.Mutex{}, 146 | list: tt.fields.list, 147 | head: tt.fields.head, 148 | } 149 | g.AddCount(tt.args.c) 150 | if got := tt.fields.list[tt.fields.head].N; got != tt.args.c.N { 151 | t.Errorf("AddCount(%f, %s) failed for %s - got %f", tt.args.c.N, tt.args.c.T.String(), tt.name, got) 152 | } 153 | if got := tt.fields.list[tt.fields.head].T; got != tt.args.c.T { 154 | t.Errorf("AddCount(%f, %s) failed for %s - got %v", tt.args.c.N, tt.args.c.T.String(), tt.name, got) 155 | } 156 | }) 157 | } 158 | } 159 | 160 | func TestMetric_fetchDatapoints(t *testing.T) { 161 | type fields struct { 162 | list []Count 163 | head int 164 | } 165 | 166 | t1 := time.Date(2017, time.October, 25, 11, 16, 54, 0, time.UTC) 167 | t2 := time.Date(2017, time.October, 25, 11, 17, 54, 0, time.UTC) 168 | t3 := time.Date(2017, time.October, 25, 11, 18, 54, 0, time.UTC) 169 | t1ms := t1.UnixNano() / 1000000 170 | t2ms := t2.UnixNano() / 1000000 171 | t3ms := t3.UnixNano() / 1000000 172 | 173 | tests := []struct { 174 | name string 175 | fields fields 176 | from, to time.Time 177 | max int 178 | want *[]row 179 | }{ 180 | { 181 | "fetchAll", 182 | fields{[]Count{{3, t3}, {1, t1}, {2, t2}}, 1}, 183 | time.Date(2017, time.October, 25, 11, 15, 54, 0, time.UTC), 184 | time.Date(2017, time.October, 25, 11, 20, 54, 0, time.UTC), 185 | 3, 186 | &[]row{{1.0, t1ms}, {2.0, t2ms}, {3.0, t3ms}}, 187 | }, 188 | { 189 | "fetchTimeRange", 190 | fields{[]Count{{3, t3}, {1, t1}, {2, t2}}, 1}, 191 | time.Date(2017, time.October, 25, 11, 17, 00, 0, time.UTC), 192 | time.Date(2017, time.October, 25, 11, 20, 54, 0, time.UTC), 193 | 3, 194 | &[]row{{2.0, t2ms}, {3.0, t3ms}}, 195 | }, 196 | { 197 | "fetchMaxPoints", 198 | fields{[]Count{{3, t3}, {1, t1}, {2, t2}}, 1}, 199 | time.Date(2017, time.October, 25, 11, 15, 00, 0, time.UTC), 200 | time.Date(2017, time.October, 25, 11, 20, 00, 0, time.UTC), 201 | 2, 202 | &[]row{{1.0, t1ms}, {2.0, t2ms}}, 203 | }, 204 | } 205 | 206 | for _, tt := range tests { 207 | t.Run(tt.name, func(t *testing.T) { 208 | g := &Metric{ 209 | m: sync.Mutex{}, 210 | list: tt.fields.list, 211 | head: tt.fields.head, 212 | } 213 | if got := g.fetchDatapoints(tt.from, tt.to, tt.max); !cmp.Equal(got, tt.want) { 214 | t.Errorf("Metric.fetchDatapoints():\ngot %#v,\nwant %#v\nDiff: %s", got, tt.want, cmp.Diff(got, tt.want)) 215 | } 216 | }) 217 | } 218 | } 219 | 220 | func TestMetrics_Get(t *testing.T) { 221 | type fields struct { 222 | metric map[string]*Metric 223 | } 224 | type args struct { 225 | target string 226 | } 227 | 228 | t1 := time.Date(2017, time.October, 25, 11, 16, 54, 0, time.UTC) 229 | t2 := time.Date(2017, time.October, 25, 11, 17, 54, 0, time.UTC) 230 | t3 := time.Date(2017, time.October, 25, 11, 18, 54, 0, time.UTC) 231 | 232 | metric := &Metric{sync.Mutex{}, []Count{{3, t3}, {1, t1}, {2, t2}}, 1, false} 233 | 234 | tests := []struct { 235 | name string 236 | fields *fields 237 | args args 238 | want *Metric 239 | wantErr bool 240 | }{ 241 | { 242 | "get1", 243 | &fields{map[string]*Metric{"target1": metric}}, 244 | args{"target1"}, 245 | metric, 246 | false, 247 | }, 248 | } 249 | for _, tt := range tests { 250 | t.Run(tt.name, func(t *testing.T) { 251 | mt := &metrics{ 252 | m: sync.Mutex{}, 253 | metric: tt.fields.metric, 254 | } 255 | got, err := mt.Get(tt.args.target) 256 | if (err != nil) != tt.wantErr { 257 | t.Errorf("Metrics.Get() error = %v, wantErr %v", err, tt.wantErr) 258 | return 259 | } 260 | if got != tt.want { // strict identity required 261 | t.Errorf("Metrics.Get():\ngot %v\n want %v", &got, &tt.want) 262 | } 263 | }) 264 | } 265 | } 266 | 267 | func TestMetrics_Put(t *testing.T) { 268 | type fields struct { 269 | metric map[string]*Metric 270 | } 271 | type args struct { 272 | target string 273 | metric *Metric 274 | } 275 | t1 := time.Date(2017, time.October, 25, 11, 16, 54, 0, time.UTC) 276 | t2 := time.Date(2017, time.October, 25, 11, 17, 54, 0, time.UTC) 277 | t3 := time.Date(2017, time.October, 25, 11, 18, 54, 0, time.UTC) 278 | metric := &Metric{sync.Mutex{}, []Count{{3, t3}, {1, t1}, {2, t2}}, 1, false} 279 | 280 | tests := []struct { 281 | name string 282 | fields *fields 283 | args args 284 | wantErr bool 285 | }{ 286 | { 287 | "put1", 288 | &fields{map[string]*Metric{"target1": metric}}, 289 | args{"target1", metric}, 290 | false, 291 | }, 292 | } 293 | for _, tt := range tests { 294 | t.Run(tt.name, func(t *testing.T) { 295 | m := &metrics{ 296 | m: sync.Mutex{}, 297 | metric: map[string]*Metric{}, 298 | } 299 | err := m.Put(tt.args.target, tt.args.metric) 300 | if (err != nil) != tt.wantErr { 301 | t.Errorf("Metrics.Put() error = %v, wantErr %v", err, tt.wantErr) 302 | } 303 | if mt, err := m.Get(tt.args.target); err != nil || mt != metric { 304 | t.Errorf("Metrics.Put():\ngot %v\nwant %v", &mt, &metric) 305 | } 306 | }) 307 | } 308 | } 309 | 310 | func TestMetrics_Delete(t *testing.T) { 311 | type fields struct { 312 | metric map[string]*Metric 313 | } 314 | type args struct { 315 | target string 316 | } 317 | t1 := time.Date(2017, time.October, 25, 11, 16, 54, 0, time.UTC) 318 | t2 := time.Date(2017, time.October, 25, 11, 17, 54, 0, time.UTC) 319 | t3 := time.Date(2017, time.October, 25, 11, 18, 54, 0, time.UTC) 320 | metric := &Metric{sync.Mutex{}, []Count{{3, t3}, {1, t1}, {2, t2}}, 1, false} 321 | 322 | tests := []struct { 323 | name string 324 | fields *fields 325 | args args 326 | wantErr bool 327 | }{ 328 | { 329 | "delete1", 330 | &fields{map[string]*Metric{"target1": metric}}, 331 | args{"target1"}, 332 | false, 333 | }} 334 | for _, tt := range tests { 335 | t.Run(tt.name, func(t *testing.T) { 336 | m := &metrics{ 337 | m: sync.Mutex{}, 338 | metric: tt.fields.metric, 339 | } 340 | if err := m.Delete(tt.args.target); (err != nil) != tt.wantErr { 341 | t.Errorf("Metrics.Delete() error = %v, wantErr %v", err, tt.wantErr) 342 | } 343 | if len(tt.fields.metric) > 0 { 344 | t.Errorf("Metrics.Delete():\ngot %v\nwant ", m.metric[tt.args.target]) 345 | } 346 | }) 347 | } 348 | } 349 | 350 | func TestMetrics_Create(t *testing.T) { 351 | type args struct { 352 | target string 353 | size int 354 | } 355 | 356 | metric := map[string]*Metric{} 357 | 358 | tests := []struct { 359 | name string 360 | args args 361 | wantErr bool 362 | }{ 363 | { 364 | "metric1", 365 | args{"target1", 10}, 366 | false, 367 | }, 368 | { 369 | "metric1again", 370 | args{"target1", 10}, 371 | true, 372 | }, 373 | } 374 | for _, tt := range tests { 375 | t.Run(tt.name, func(t *testing.T) { 376 | mt := &metrics{ 377 | m: sync.Mutex{}, 378 | metric: metric, 379 | } 380 | got, err := mt.Create(tt.args.target, tt.args.size) 381 | if (err != nil) != tt.wantErr { 382 | t.Errorf("Metrics.Create() error = %v, wantErr %v", err, tt.wantErr) 383 | return 384 | } 385 | want := mt.metric[tt.args.target] 386 | if !cmp.Equal(got, want, cmp.AllowUnexported((*got), (*got).m)) { 387 | t.Errorf("Metrics.Create():\ngot %v\nwant %v\ndiff:\n%s", got, want, cmp.Diff(got, want, cmp.AllowUnexported(*got, (*got).m))) 388 | } 389 | if cap(got.list) != tt.args.size { 390 | t.Errorf("Metrics.Create(): got size %d, want %d", cap(got.list), tt.args.size) 391 | } 392 | }) 393 | } 394 | } 395 | 396 | func TestMetric_sort(t *testing.T) { 397 | type fields struct { 398 | list []Count 399 | head int 400 | unsorted bool 401 | } 402 | tests := []struct { 403 | name string 404 | fields fields 405 | want fields 406 | }{ 407 | { 408 | name: "sorted", 409 | fields: fields{ 410 | list: []Count{ 411 | Count{N: 1, T: time.Unix(1509369032, 630000001)}, 412 | Count{N: 2, T: time.Unix(1509369032, 630000002)}, 413 | Count{N: 3, T: time.Unix(1509369032, 630000003)}, 414 | Count{N: 4, T: time.Unix(1509369032, 630000004)}, 415 | Count{N: 5, T: time.Unix(1509369032, 630000005)}, 416 | }, 417 | head: 0, 418 | unsorted: false, 419 | }, 420 | want: fields{ 421 | []Count{ 422 | Count{N: 1, T: time.Unix(1509369032, 630000001)}, 423 | Count{N: 2, T: time.Unix(1509369032, 630000002)}, 424 | Count{N: 3, T: time.Unix(1509369032, 630000003)}, 425 | Count{N: 4, T: time.Unix(1509369032, 630000004)}, 426 | Count{N: 5, T: time.Unix(1509369032, 630000005)}, 427 | }, 428 | 0, 429 | false, 430 | }, 431 | }, 432 | { 433 | name: "sortedButShifted", 434 | fields: fields{ 435 | list: []Count{ 436 | Count{N: 4, T: time.Unix(1509369032, 630000004)}, 437 | Count{N: 5, T: time.Unix(1509369032, 630000005)}, 438 | Count{N: 1, T: time.Unix(1509369032, 630000001)}, 439 | Count{N: 2, T: time.Unix(1509369032, 630000002)}, 440 | Count{N: 3, T: time.Unix(1509369032, 630000003)}, 441 | }, 442 | head: 2, 443 | unsorted: false, 444 | }, 445 | want: fields{ 446 | []Count{ 447 | Count{N: 4, T: time.Unix(1509369032, 630000004)}, 448 | Count{N: 5, T: time.Unix(1509369032, 630000005)}, 449 | Count{N: 1, T: time.Unix(1509369032, 630000001)}, 450 | Count{N: 2, T: time.Unix(1509369032, 630000002)}, 451 | Count{N: 3, T: time.Unix(1509369032, 630000003)}, 452 | }, 453 | 2, 454 | false, 455 | }, 456 | }, 457 | { 458 | name: "unsorted", 459 | fields: fields{ 460 | list: []Count{ 461 | Count{N: 4, T: time.Unix(1509369032, 630000004)}, 462 | Count{N: 3, T: time.Unix(1509369032, 630000003)}, 463 | Count{N: 5, T: time.Unix(1509369032, 630000005)}, 464 | Count{N: 1, T: time.Unix(1509369032, 630000001)}, 465 | Count{N: 2, T: time.Unix(1509369032, 630000002)}, 466 | }, 467 | head: 5, 468 | unsorted: true, 469 | }, 470 | want: fields{ 471 | []Count{ 472 | Count{N: 1, T: time.Unix(1509369032, 630000001)}, 473 | Count{N: 2, T: time.Unix(1509369032, 630000002)}, 474 | Count{N: 3, T: time.Unix(1509369032, 630000003)}, 475 | Count{N: 4, T: time.Unix(1509369032, 630000004)}, 476 | Count{N: 5, T: time.Unix(1509369032, 630000005)}, 477 | }, 478 | 0, 479 | false, 480 | }, 481 | }, 482 | } 483 | for _, tt := range tests { 484 | t.Run(tt.name, func(t *testing.T) { 485 | g := &Metric{ 486 | list: tt.fields.list, 487 | head: tt.fields.head, 488 | unsorted: tt.fields.unsorted, 489 | } 490 | g.sort() 491 | got := g.list 492 | want := tt.want.list 493 | if !cmp.Equal(got, want) { 494 | t.Errorf("Metric.sort(): got %v,\nwant %v\ndiff:\n%s", got, want, cmp.Diff(got, want)) 495 | } 496 | }) 497 | } 498 | } 499 | --------------------------------------------------------------------------------