Code

graph: Align time-series before grouping them.
[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         "sort"
33         "strings"
34         "time"
36         "github.com/gonum/plot"
37         "github.com/gonum/plot/plotter"
38         "github.com/gonum/plot/plotutil"
39         "github.com/sysdb/go/client"
40         "github.com/sysdb/go/sysdb"
41 )
43 // A Metric represents a single data-source of a graph.
44 type Metric struct {
45         // The unique identifier of the metric.
46         Hostname, Identifier string
48         // Attributes describing details of the metric.
49         Attributes map[string]string
51         ts *sysdb.Timeseries
52 }
54 // A Graph represents a single graph. It may reference multiple data-sources.
55 type Graph struct {
56         // Time range of the graph.
57         Start, End time.Time
59         // Content of the graph.
60         Metrics []Metric
62         // List of attributes to group by.
63         GroupBy []string
64 }
66 type pl struct {
67         *plot.Plot
69         ts int // Index of the current time-series.
70 }
72 func queryTimeseries(c *client.Client, metric Metric, start, end time.Time) (*sysdb.Timeseries, error) {
73         q, err := client.QueryString("TIMESERIES %s.%s START %s END %s",
74                 metric.Hostname, metric.Identifier, start, end)
75         if err != nil {
76                 return nil, fmt.Errorf("Failed to retrieve graph data: %v", err)
77         }
78         res, err := c.Query(q)
79         if err != nil {
80                 return nil, fmt.Errorf("Failed to retrieve graph data: %v", err)
81         }
83         ts, ok := res.(*sysdb.Timeseries)
84         if !ok {
85                 return nil, fmt.Errorf("TIMESERIES did not return a time-series but %T", res)
86         }
87         return ts, nil
88 }
90 func (p *pl) addTimeseries(c *client.Client, metric Metric, verbose bool) error {
91         for name, data := range metric.ts.Data {
92                 pts := make(plotter.XYs, len(data))
93                 for i, p := range data {
94                         pts[i].X = float64(time.Time(p.Timestamp).UnixNano())
95                         pts[i].Y = p.Value
96                 }
98                 l, err := plotter.NewLine(pts)
99                 if err != nil {
100                         return fmt.Errorf("Failed to create line plotter: %v", err)
101                 }
102                 l.LineStyle.Color = plotutil.DarkColors[p.ts%len(plotutil.DarkColors)]
104                 p.Add(l)
105                 if verbose {
106                         p.Legend.Add(fmt.Sprintf("%s %s %s", metric.Hostname, metric.Identifier, name), l)
107                 } else {
108                         p.Legend.Add(name, l)
109                 }
110                 p.ts++
111         }
112         return nil
115 // align aligns two timeseries such that start and end times and the step
116 // sizes match.
117 func align(ts1, ts2 *sysdb.Timeseries) error {
118         if len(ts1.Data) != len(ts2.Data) {
119                 return fmt.Errorf("mismatching data sources: %v != %v", ts1.Data, ts2.Data)
120         }
122         start := time.Time(ts1.Start)
123         if t := time.Time(ts2.Start); t.After(start) {
124                 start = t
125         }
126         end := time.Time(ts1.End)
127         if t := time.Time(ts2.End); t.Before(end) {
128                 end = t
129         }
130         if end.Before(start) {
131                 return fmt.Errorf("non-overlapping ranges: [%v, %v] <-> [%v, %v]",
132                         ts1.Start, ts1.End, ts2.Start, ts2.End)
133         }
135         range1 := time.Time(ts1.End).Sub(time.Time(ts1.Start))
136         range2 := time.Time(ts2.End).Sub(time.Time(ts2.Start))
137         for name := range ts1.Data {
138                 l1, l2 := len(ts1.Data[name]), len(ts2.Data[name])
139                 if l1 <= 1 || l2 <= 1 {
140                         if l1 == l2 && range1 == range2 {
141                                 continue
142                         }
143                         return fmt.Errorf("invalid value count for %q: %d != %d", name, l1, l2)
144                 }
146                 step1, step2 := range1/time.Duration(l1-1), range2/time.Duration(l2-1)
147                 if step1 != step2 || step1 <= 0 {
148                         return fmt.Errorf("mismatching steps sizes for %q: %v != %v", name, step1, step2)
149                 }
151                 for _, ts := range []*sysdb.Timeseries{ts1, ts2} {
152                         a := start.Sub(time.Time(ts.Start)) / step1
153                         b := end.Sub(time.Time(ts.Start)) / step1
154                         ts.Data[name] = ts.Data[name][a : b+1]
155                 }
156         }
158         ts1.Start, ts2.Start = sysdb.Time(start), sysdb.Time(start)
159         ts1.End, ts2.End = sysdb.Time(end), sysdb.Time(end)
160         return nil
163 // sum is an aggregation function that adds ts2 to ts1. The timeseries will be
164 // aligned.
165 func sum(ts1, ts2 *sysdb.Timeseries) error {
166         if err := align(ts1, ts2); err != nil {
167                 return fmt.Errorf("Incompatible time-series: %v", err)
168         }
170         for name := range ts1.Data {
171                 for i := range ts1.Data[name] {
172                         ts1.Data[name][i].Value += ts2.Data[name][i].Value
173                 }
174         }
175         return nil
178 func (g *Graph) group(c *client.Client, start, end time.Time) ([]Metric, error) {
179         if len(g.GroupBy) == 0 {
180                 for i, m := range g.Metrics {
181                         var err error
182                         if g.Metrics[i].ts, err = queryTimeseries(c, m, g.Start, g.End); err != nil {
183                                 return nil, err
184                         }
185                 }
186                 return g.Metrics, nil
187         }
189         names := make([]string, 0)
190         groups := make(map[string][]Metric)
191         for _, m := range g.Metrics {
192                 var key string
193                 for _, g := range g.GroupBy {
194                         key += "\x00" + m.Attributes[g]
195                 }
196                 if _, ok := groups[key]; !ok {
197                         names = append(names, key)
198                 }
199                 groups[key] = append(groups[key], m)
200         }
201         sort.Strings(names)
203         var metrics []Metric
204         for _, name := range names {
205                 group := groups[name]
206                 ts, err := queryTimeseries(c, group[0], g.Start, g.End)
207                 if err != nil {
208                         return nil, err
209                 }
210                 host := group[0].Hostname
211                 for _, m := range group[1:] {
212                         ts2, err := queryTimeseries(c, m, g.Start, g.End)
213                         if err != nil {
214                                 return nil, err
215                         }
216                         if err := sum(ts, ts2); err != nil {
217                                 return nil, err
218                         }
219                         if host != "" && host != m.Hostname {
220                                 host = ""
221                         }
222                 }
224                 metrics = append(metrics, Metric{
225                         Hostname:   host,
226                         Identifier: strings.Replace(name[1:], "\x00", "-", -1),
227                         ts:         ts,
228                 })
229         }
230         return metrics, nil
233 // Plot fetches a graph's time-series data using the specified client and
234 // plots it.
235 func (g *Graph) Plot(c *client.Client) (*plot.Plot, error) {
236         var err error
238         p := &pl{}
239         p.Plot, err = plot.New()
240         if err != nil {
241                 return nil, fmt.Errorf("Failed to create plot: %v", err)
242         }
243         p.Add(plotter.NewGrid())
244         p.X.Tick.Marker = dateTicks{}
246         metrics, err := g.group(c, g.Start, g.End)
247         if err != nil {
248                 return nil, err
249         }
250         for _, m := range metrics {
251                 if err := p.addTimeseries(c, m, len(g.Metrics) > 1); err != nil {
252                         return nil, err
253                 }
254         }
255         return p.Plot, nil
258 type dateTicks struct{}
260 func (dateTicks) Ticks(min, max float64) []plot.Tick {
261         // TODO: this is surely not the best we can do
262         // but it'll distribute ticks evenly.
263         ticks := plot.DefaultTicks{}.Ticks(min, max)
264         for i, t := range ticks {
265                 if t.Label == "" {
266                         // Skip minor ticks.
267                         continue
268                 }
269                 ticks[i].Label = time.Unix(0, int64(t.Value)).Format(time.RFC822)
270         }
271         return ticks
274 // vim: set tw=78 sw=4 sw=4 noexpandtab :