Code

graph: Add support for grouping of metrics.
[sysdb/webui.git] / graph / graph.go
1 //
2 // Copyright (C) 2014-2015 Sebastian 'tokkee' Harl <sh@tokkee.org>
3 // All rights reserved.
4 //
5 // Redistribution and use in source and binary forms, with or without
6 // modification, are permitted provided that the following conditions
7 // are met:
8 // 1. Redistributions of source code must retain the above copyright
9 //    notice, this list of conditions and the following disclaimer.
10 // 2. Redistributions in binary form must reproduce the above copyright
11 //    notice, this list of conditions and the following disclaimer in the
12 //    documentation and/or other materials provided with the distribution.
13 //
14 // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
15 // ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
16 // TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
17 // PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR
18 // CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
19 // EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
20 // PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
21 // OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
22 // WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
23 // OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
24 // ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26 // Package graph handles time-series data provided by SysDB. It supports
27 // querying and post-processing of the data.
28 package graph
30 import (
31         "fmt"
32         "strings"
33         "time"
35         "github.com/gonum/plot"
36         "github.com/gonum/plot/plotter"
37         "github.com/gonum/plot/plotutil"
38         "github.com/sysdb/go/client"
39         "github.com/sysdb/go/sysdb"
40 )
42 // A Metric represents a single data-source of a graph.
43 type Metric struct {
44         // The unique identifier of the metric.
45         Hostname, Identifier string
47         // Attributes describing details of the metric.
48         Attributes map[string]string
50         ts *sysdb.Timeseries
51 }
53 // A Graph represents a single graph. It may reference multiple data-sources.
54 type Graph struct {
55         // Time range of the graph.
56         Start, End time.Time
58         // Content of the graph.
59         Metrics []Metric
61         // List of attributes to group by.
62         GroupBy []string
63 }
65 type pl struct {
66         *plot.Plot
68         ts int // Index of the current time-series.
69 }
71 func queryTimeseries(c *client.Client, metric Metric, start, end time.Time) (*sysdb.Timeseries, error) {
72         q, err := client.QueryString("TIMESERIES %s.%s START %s END %s",
73                 metric.Hostname, metric.Identifier, start, end)
74         if err != nil {
75                 return nil, fmt.Errorf("Failed to retrieve graph data: %v", err)
76         }
77         res, err := c.Query(q)
78         if err != nil {
79                 return nil, fmt.Errorf("Failed to retrieve graph data: %v", err)
80         }
82         ts, ok := res.(*sysdb.Timeseries)
83         if !ok {
84                 return nil, fmt.Errorf("TIMESERIES did not return a time-series but %T", res)
85         }
86         return ts, nil
87 }
89 func (p *pl) addTimeseries(c *client.Client, metric Metric, verbose bool) error {
90         for name, data := range metric.ts.Data {
91                 pts := make(plotter.XYs, len(data))
92                 for i, p := range data {
93                         pts[i].X = float64(time.Time(p.Timestamp).UnixNano())
94                         pts[i].Y = p.Value
95                 }
97                 l, err := plotter.NewLine(pts)
98                 if err != nil {
99                         return fmt.Errorf("Failed to create line plotter: %v", err)
100                 }
101                 l.LineStyle.Color = plotutil.DarkColors[p.ts%len(plotutil.DarkColors)]
103                 p.Add(l)
104                 if verbose {
105                         p.Legend.Add(fmt.Sprintf("%s %s %s", metric.Hostname, metric.Identifier, name), l)
106                 } else {
107                         p.Legend.Add(name, l)
108                 }
109                 p.ts++
110         }
111         return nil
114 // sum is an aggregation function that adds ts2 to ts1.
115 func sum(ts1, ts2 *sysdb.Timeseries) error {
116         if !ts1.Start.Equal(ts1.Start) || !ts1.End.Equal(ts2.End) {
117                 return fmt.Errorf("Timeseries cover different ranges: [%s, %s] != [%s, %s]",
118                         ts1.Start, ts1.End, ts2.Start, ts2.End)
119         }
120         if len(ts1.Data) != len(ts2.Data) {
121                 return fmt.Errorf("Incompatible time-series: %v != %v", ts1.Data, ts2.Data)
122         }
124         for name := range ts1.Data {
125                 if len(ts1.Data[name]) != len(ts2.Data[name]) {
126                         return fmt.Errorf("Time-series %q is not aligned", name)
127                 }
128                 for i := range ts1.Data[name] {
129                         if !ts1.Data[name][i].Timestamp.Equal(ts2.Data[name][i].Timestamp) {
130                                 return fmt.Errorf("Time-series %q is not aligned", name)
131                         }
132                         ts1.Data[name][i].Value += ts2.Data[name][i].Value
133                 }
134         }
135         return nil
138 func (g *Graph) group(c *client.Client, start, end time.Time) ([]Metric, error) {
139         if len(g.GroupBy) == 0 {
140                 for i, m := range g.Metrics {
141                         var err error
142                         if g.Metrics[i].ts, err = queryTimeseries(c, m, g.Start, g.End); err != nil {
143                                 return nil, err
144                         }
145                 }
146                 return g.Metrics, nil
147         }
149         groups := make(map[string][]Metric)
150         for _, m := range g.Metrics {
151                 var key string
152                 for _, g := range g.GroupBy {
153                         key += "\x00" + m.Attributes[g]
154                 }
155                 groups[key] = append(groups[key], m)
156         }
158         var metrics []Metric
159         for name, group := range groups {
160                 ts, err := queryTimeseries(c, group[0], g.Start, g.End)
161                 if err != nil {
162                         return nil, err
163                 }
164                 host := group[0].Hostname
165                 for _, m := range group[1:] {
166                         ts2, err := queryTimeseries(c, m, g.Start, g.End)
167                         if err != nil {
168                                 return nil, err
169                         }
170                         if err := sum(ts, ts2); err != nil {
171                                 return nil, err
172                         }
173                         if host != "" && host != m.Hostname {
174                                 host = ""
175                         }
176                 }
178                 metrics = append(metrics, Metric{
179                         Hostname:   host,
180                         Identifier: strings.Replace(name[1:], "\x00", "-", -1),
181                         ts:         ts,
182                 })
183         }
184         return metrics, nil
187 // Plot fetches a graph's time-series data using the specified client and
188 // plots it.
189 func (g *Graph) Plot(c *client.Client) (*plot.Plot, error) {
190         var err error
192         p := &pl{}
193         p.Plot, err = plot.New()
194         if err != nil {
195                 return nil, fmt.Errorf("Failed to create plot: %v", err)
196         }
197         p.Add(plotter.NewGrid())
198         p.X.Tick.Marker = dateTicks{}
200         metrics, err := g.group(c, g.Start, g.End)
201         if err != nil {
202                 return nil, err
203         }
204         for _, m := range metrics {
205                 if err := p.addTimeseries(c, m, len(g.Metrics) > 1); err != nil {
206                         return nil, err
207                 }
208         }
209         return p.Plot, nil
212 type dateTicks struct{}
214 func (dateTicks) Ticks(min, max float64) []plot.Tick {
215         // TODO: this is surely not the best we can do
216         // but it'll distribute ticks evenly.
217         ticks := plot.DefaultTicks{}.Ticks(min, max)
218         for i, t := range ticks {
219                 if t.Label == "" {
220                         // Skip minor ticks.
221                         continue
222                 }
223                 ticks[i].Label = time.Unix(0, int64(t.Value)).Format(time.RFC822)
224         }
225         return ticks
228 // vim: set tw=78 sw=4 sw=4 noexpandtab :