diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 879f29e..1d80262 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,6 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-go@v2 with: - go-version: '^1.16.2' + go-version: '^1.20' - run: make test - run: make build - - run: make codegendiff diff --git a/.gitignore b/.gitignore index 1c2d52b..00d0a07 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .idea/* +.DS_store diff --git a/Makefile b/Makefile index 3664c77..327f2ea 100644 --- a/Makefile +++ b/Makefile @@ -11,13 +11,9 @@ test: $(GOBIN) test -v ./... .PHONY: build -build: $(BINDIR)/codegen +build: make -C tscli -$(BINDIR)/codegen: $(SCRIPTDIR)/codegen.go - @mkdir -p $(BINDIR) - GOOS=linux GOARCH=amd64 $(GOBIN) build -o $(BINDIR)/codegen $< - .PHONY: install install: build cp $(BINDIR)/tscli /usr/local/bin/ @@ -26,11 +22,3 @@ install: build clean: rm -rf $(BINDIR) -CODEGENFILE="set_aggregation_opts.go" -.PHONY: codegen -codegen: - $(BINDIR)/codegen -output ./tsplot/$(CODEGENFILE) - -.PHONY: codegendiff -codegendiff: - diff ./tsplot/$(CODEGENFILE) <($(BINDIR)/codegen -stdout) diff --git a/README.md b/README.md index 203290a..d2ec4eb 100644 --- a/README.md +++ b/README.md @@ -2,76 +2,40 @@ [![Go Report Card](https://goreportcard.com/badge/github.com/bitly/tsplot)](https://goreportcard.com/report/github.com/bitly/tsplot) [![Go Reference](https://pkg.go.dev/badge/github.com/bitly/tsplot.svg)](https://pkg.go.dev/github.com/bitly/tsplot) -This package provides a method of querying for raw time series data from the GCM APIs and additionally plotting that data for use in other applications. - -This came to be due to what we consider a small limitation in the Google APIs which require us to re-draw graphs to include them in other applications such as -Slack bots. There is no facility in the Google API that provides a PNG of already graphed data. - ## Authentication This package makes no effort to assist in authentication to the Google APIs. Instead, it will expect the caller to supply an authenticated client. More information on authentication can be found in the official [Google Cloud documentation](https://cloud.google.com/docs/authentication). - -## Query -tsplot helps to facilitate easy querying of the Google Cloud Monitoring API for time series matching the supplied criteria. -In addition it provides methods of overriding certain aspects of the query. - -For example, the following code snippet will return a single time series for the following metric descriptor: `custom.googleapis.com/opencensus/fishnet/queuereader_fishnet/messages_total`. ``` func main() { - ... snip ... +... snip ... start := time.Now().Add(-1 * time.Hour) end := time.Now() - mq := &tsplot.NewMetricQuery( - "bitly-gcp-prod", // GCP project - "custom.googleapis.com/opencensus/fishent/queuereader_fishnet/messages_total", // metric descriptor - &start, // start of time window - &end, // end of time window - ) - - // disable cross series reducer (MEAN reduction is default) - query.Set_REDUCE_NONE() - - // set different alignment window. (Default is 1 minute) - query.SetAlignmentPeriod(time.Minute * 2) - - tsi, err := mq.PerformWithClient(client) // client is provided by user - if err != nil { - fmt.Printf("error performing query: %v\n", err) - } -} -``` - -## Plotting -To plot the data, tsplot leverages the open source package [gonum/plot](github.com/gonum/plot) to create a graph and plot the data for a given time series. - -The example below creates a new graph containing a singular time series, plots it, and saves the resulting plot to disk. -``` -func main() { - - ... snip ... - - ts := tsplot.TimeSeries{} - - // optionally iterate over returned time series - timeSeries, _ := tsi.Next() - ts[metric] = ts.GetPoints() - - // create the plot with some formatting options - p, err := ts.Plot([]tsplot.PlotOption{ - tsplot.WithXAxisName("UTC"), - tsplot.WIthGrid(colornames.Darkgrey), - tsplot.WithTitle(metric)}...) - if err != nil { - return err + // create new request + request := monitoringpb.ListTimeSeriesRequest{ + Name: fmt.Sprintf("projects/%s", project), + Filter: query, + Interval: &monitoringpb.TimeInterval{ + EndTime: timestamppb.New(et), + StartTime: timestamppb.New(st), + }, + Aggregation: nil, + SecondaryAggregation: nil, + View: monitoringpb.ListTimeSeriesRequest_FULL, } - - // optionally save the plot to disk - p.Save(8*vg.Inch, 4*vg.Inch, "./my-graph.png") + + // execute the request and get the response from Google APIs + tsi := GoogleCloudMonitoringClient.ListTimeSeries(context.Background(), request) + + // Create the plot from the GAPI TimeSeries + plot, _ := tsplot.NewPlotFromTimeSeriesIterator(tsi, "", nil) + + // Save the new plot to disk. + plot.Save(8*vg.Inch, 4*vg.Inch, "my_plot.png") } ``` @@ -79,7 +43,3 @@ func main() { Query across multiple time series with mean reducer: ![graph1](sample/1.png) -### Graph Color Scheme -I'm not a UX designer, but I have selected colors that I find higher contrast -and easier to see. I am basing this completely off my colorblindness which is -unique to me. Improvements to the color palette used are welcome. diff --git a/go.mod b/go.mod index f80e9c9..c362dc7 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/bitly/tsplot -go 1.14 +go 1.20 require ( cloud.google.com/go v0.80.0 @@ -11,3 +11,25 @@ require ( google.golang.org/genproto v0.0.0-20210401141331-865547bb08e2 google.golang.org/protobuf v1.26.0 ) + +require ( + github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af // indirect + github.com/fogleman/gg v1.3.0 // indirect + github.com/go-fonts/liberation v0.1.1 // indirect + github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect + github.com/golang/protobuf v1.5.1 // indirect + github.com/google/go-cmp v0.5.5 // indirect + github.com/googleapis/gax-go/v2 v2.0.5 // indirect + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/phpdave11/gofpdf v1.4.2 // indirect + github.com/spf13/pflag v1.0.5 // indirect + go.opencensus.io v0.23.0 // indirect + golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4 // indirect + golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84 // indirect + golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4 // indirect + golang.org/x/text v0.3.5 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/grpc v1.36.1 // indirect +) diff --git a/go.sum b/go.sum index 1a8a942..89a3819 100644 --- a/go.sum +++ b/go.sum @@ -526,7 +526,6 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= gonum.org/v1/gonum v0.8.2 h1:CCXrcPKiGGotvnN6jfUsKk4rRqm7q09/YbKb5xCEvtM= gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= -gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc= gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= gonum.org/v1/plot v0.9.0 h1:3sEo36Uopv1/SA/dMFFaxXoL5XyikJ9Sf2Vll/k6+2E= diff --git a/scripts/codegen.go b/scripts/codegen.go deleted file mode 100644 index 508fd0b..0000000 --- a/scripts/codegen.go +++ /dev/null @@ -1,67 +0,0 @@ -package main -/* -codegen.go creates Set_* API methods for Google Cloud Monitoring aggregation alignment and reduction options. -Leverages the exported variables in the Google monitoring/v3 package (common.pb.go) to ensure that all -alignment and reduction options are covered. -*/ - -import ( - "flag" - "log" - "os" - "text/template" - - monitoringpb "google.golang.org/genproto/googleapis/monitoring/v3" -) - -var rawTpl = `package tsplot -/* -DO NOT EDIT -Generated by codegen.go -https://github.com/googleapis/go-genproto/blob/0135a39c27378c1b903c75204eff61a060be5eb7/googleapis/monitoring/v3/common.pb.go -*/ -{{range $name, $value := .Aligners}} -func (mq *MetricQuery) Set_{{$name}}() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner({{$value}})) -} -{{end -}} -{{range $name, $value := .Reducers}} -func (mq *MetricQuery) Set_{{$name}}() { - *mq.aggregation = append(*mq.aggregation, withCrossSeriesReducer({{$value}})) -} -{{end}} -` - -func main() { - outFileName := flag.String("output", "./tsplot/set_aggregation_opts.go", "Output path of generated file.") - toStdout := flag.Bool("stdout", false, "Toggle output to STDOUT.") - flag.Parse() - - var outFile *os.File - var fileErr error - - if *toStdout { - outFile = os.Stdout - } else { - outFile, fileErr = os.OpenFile(*outFileName, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644) - if fileErr != nil { - log.Print(fileErr) - } - defer outFile.Close() - } - - values := struct { - Aligners map[string]int32 - Reducers map[string]int32 - }{ - Aligners: monitoringpb.Aggregation_Aligner_value, - Reducers: monitoringpb.Aggregation_Reducer_value, - } - - t := template.New("codegen") - pt := template.Must(t.Parse(rawTpl)) - err := pt.Execute(outFile, values) - if err != nil { - log.Fatal(err) - } -} diff --git a/tscli/Makefile b/tscli/Makefile index 37fcc60..a70ff11 100644 --- a/tscli/Makefile +++ b/tscli/Makefile @@ -10,4 +10,4 @@ default: build .PHONY: build build: @mkdir -p $(BINDIR) - GOOS=linux GOARCH=amd64 $(GOBIN) build -o $(BINDIR)/tscli + $(GOBIN) build -o $(BINDIR)/tscli diff --git a/tscli/main.go b/tscli/main.go index f0c07ac..3a8a7cf 100644 --- a/tscli/main.go +++ b/tscli/main.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "os" "regexp" "strconv" @@ -13,10 +14,11 @@ import ( monitoring "cloud.google.com/go/monitoring/apiv3/v2" "github.com/bitly/tsplot/tsplot" "github.com/spf13/cobra" - "golang.org/x/image/colornames" "gonum.org/v1/plot/vg" - "google.golang.org/api/iterator" "google.golang.org/api/option" + monitoringpb "google.golang.org/genproto/googleapis/monitoring/v3" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" ) const GAP = "GOOGLE_APPLICATION_CREDENTIALS" @@ -48,34 +50,28 @@ from Google Cloud Monitoring (formerly StackDriver). RunE: executeQuery, } - project string - app string - service string - metric string - startTime string - endTime string - queryOverride string - outDir string - reduce bool - justPrint bool + project string + query string + startTime string + endTime string + outDir string + title string + groupBy string ) func init() { rootCmd.Flags().StringVarP(&project, "project", "p", "", "GCP Project.") - rootCmd.Flags().StringVarP(&app, "app", "a", "", "The (Bitly) application. Usually top level directory") - rootCmd.Flags().StringVarP(&service, "service", "s", "", "The (Bitly) service. Service directory found under application directory.") - rootCmd.Flags().StringVarP(&metric, "metric", "m", "", "The metric.") + rootCmd.Flags().StringVarP(&query, "query", "m", "", "The query filter.") rootCmd.Flags().StringVar(&startTime, "start", "", "Start time of window for which the query returns time series data for. Hours or minutes accepted, i.e: -5h or -5m.") rootCmd.Flags().StringVar(&endTime, "end", "now", "End of the time window for which the query returns time series data for. Hours or minutes accepted, i.e: -5h or -5m or now.") - rootCmd.Flags().BoolVar(&justPrint, "print-raw", false, "Only print time series data and exit.") - rootCmd.Flags().BoolVar(&reduce, "reduce", false, "Use a time series reducer to return a single averaged result.") - rootCmd.Flags().StringVar(&queryOverride, "query-override", "", "Override the default query. Must be a full valid query. Metric flag is not used.") rootCmd.Flags().StringVarP(&outDir, "output", "o", "", "Specify output directory for resulting plot. Defaults to current working directory.") + rootCmd.Flags().StringVarP(&title, "title", "t", "", "Specify title of graph.") + rootCmd.Flags().StringVar(&groupBy, "group-by", "", "Key to group metric by when dealing with multiple time series.") rootCmd.MarkFlagRequired("project") - rootCmd.MarkFlagRequired("app") - rootCmd.MarkFlagRequired("service") - rootCmd.MarkFlagRequired("metric") + rootCmd.MarkFlagRequired("query") rootCmd.MarkFlagRequired("start") + rootCmd.MarkFlagRequired("title") + rootCmd.MarkFlagRequired("output") } func auth(cmd *cobra.Command, args []string) error { @@ -95,10 +91,6 @@ func auth(cmd *cobra.Command, args []string) error { func executeQuery(cmd *cobra.Command, args []string) error { - if metric != "" && queryOverride != "" { - fmt.Println("warn: both --metric and --query-override flag used. Favoring --query-override.") - } - if !timeFormatOK(startTime) { return errors.New("err validating start time format") } @@ -114,69 +106,32 @@ func executeQuery(cmd *cobra.Command, args []string) error { st := parseTime(startTime) et := parseTime(endTime) - query := tsplot.NewMetricQuery( - project, - fmt.Sprintf("custom.googleapis.com/opencensus/%s/%s/%s", app, service, metric), - &st, - &et, - ) - - if queryOverride != "" { - query.SetQueryFilter(queryOverride) - } - - if !reduce { - query.Set_REDUCE_NONE() - } - - tsi, err := query.PerformWithClient(GoogleCloudMonitoringClient) + request := &monitoringpb.ListTimeSeriesRequest{ + Name: fmt.Sprintf("projects/%s", project), + //`resource.type = "global" AND metric.type = "custom.googleapis.com/opencensus/fishnet/queuereader_fishnet/messages_total"` + Filter: query, + Interval: &monitoringpb.TimeInterval{ + EndTime: timestamppb.New(et), + StartTime: timestamppb.New(st), + }, + Aggregation: &monitoringpb.Aggregation{ + AlignmentPeriod: durationpb.New(time.Minute * 1), + // todo: these need to be settable as they are not uniformly useful across all metric types. + PerSeriesAligner: monitoringpb.Aggregation_ALIGN_RATE, + CrossSeriesReducer: monitoringpb.Aggregation_REDUCE_MEAN, + GroupByFields: []string{fmt.Sprintf("metric.labels.%s", groupBy)}, + }, + View: monitoringpb.ListTimeSeriesRequest_FULL, + } + + tsi := GoogleCloudMonitoringClient.ListTimeSeries(context.Background(), request) + plot, err := tsplot.NewPlotFromTimeSeriesIterator(tsi, groupBy, tsplot.WithXTimeTicks(time.Kitchen), tsplot.WithTitle(title), tsplot.WithXAxisName("UTC")) if err != nil { - return err + log.Fatal(err) } - ts := tsplot.TimeSeries{} - for { - timeSeries, err := tsi.Next() - if err != nil { - if err == iterator.Done { - break - } - return err - } - - // todo: implement "just-print" mode for multiple time series - //if justPrint { - // fmt.Printf("%v", timeSeries) - // return nil - //} - - // key helps to fill out legend. - // Here we are grabbing the pod name. - key := timeSeries.GetMetric().GetLabels()["opencensus_task"] - if key == "" { - // Labels we want to use don't necessarily exist when a cross series reducer has been used. - // So we can just use "mean" in the legend. - key = "mean" - } - ts[key] = timeSeries.GetPoints() - } - - p, err := ts.Plot([]tsplot.PlotOption{tsplot.WithXAxisName("UTC"), - tsplot.WithXTimeTicks(time.Kitchen), - tsplot.WithFontSize(float64(12)), - tsplot.WithGrid(colornames.Darkgrey), - tsplot.WithTitle(metric)}...) - if err != nil { - return err - } - - if outDir == "" { - outDir, _ = os.Getwd() - } - saveFile := fmt.Sprintf("%s/%s-%s.png", outDir, service, metric) - p.Save(8*vg.Inch, 4*vg.Inch, saveFile) - - return nil + saveFile := fmt.Sprintf("%s/%s.png", outDir, title) + return plot.Save(8*vg.Inch, 4*vg.Inch, saveFile) } func timeFormatOK(s string) bool { diff --git a/tsplot/color_palettes.go b/tsplot/color_palettes.go index 0d249df..ce09289 100644 --- a/tsplot/color_palettes.go +++ b/tsplot/color_palettes.go @@ -6,6 +6,31 @@ import ( "golang.org/x/image/colornames" ) +var usedColors = make(map[string]color.RGBA) + +// simple colors (subset of golang.org/x/image/colornames) +var availableColors = map[string]color.RGBA{ + "blue": color.RGBA{0x00, 0x00, 0xff, 0xff}, // rgb(0, 0, 255) + "brown": color.RGBA{0xa5, 0x2a, 0x2a, 0xff}, // rgb(165, 42, 42) + "orange": color.RGBA{0xff, 0xa5, 0x00, 0xff}, // rgb(255, 165, 0) + "hotpink": color.RGBA{0xff, 0x69, 0xb4, 0xff}, // rgb(255, 105, 180) + "red": color.RGBA{0xff, 0x00, 0x00, 0xff}, // rgb(255, 0, 0) + "purple": color.RGBA{0x80, 0x00, 0x80, 0xff}, // rgb(128, 0, 128) + "yellow": color.RGBA{0xff, 0xff, 0x00, 0xff}, // rgb(255, 255, 0) + "green": color.RGBA{0x00, 0x80, 0x00, 0xff}, // rgb(0, 128, 0) + +} + +func getUnusedColor() color.RGBA { + for k, v := range availableColors { + if _, ok := usedColors[k]; !ok { + usedColors[k] = v + return v + } + } + return colornames.Black +} + type ColorPalette struct { Foreground color.Color Background color.Color diff --git a/tsplot/options.go b/tsplot/options.go index 7410bcf..9bb291b 100644 --- a/tsplot/options.go +++ b/tsplot/options.go @@ -2,13 +2,12 @@ package tsplot import ( "image/color" - "time" "gonum.org/v1/plot" "gonum.org/v1/plot/font" "gonum.org/v1/plot/plotter" + "gonum.org/v1/plot/vg" monitoringpb "google.golang.org/genproto/googleapis/monitoring/v3" - "google.golang.org/protobuf/types/known/durationpb" ) // PlotOption defines the type used to configure the underlying *plot.Plot. @@ -107,27 +106,21 @@ func ApplyDefaultHighContrast(p *plot.Plot) { } } -// aggregationOption defines the type used to configure the underlying *monitoringpb.Aggregation. -// A function that returns aggregationOption can be used to set options on the *monitoringpb.Aggregation. -type aggregationOption func(agg *monitoringpb.Aggregation) - -// withAlignmentPeriod sets the duration of the aggregation's alignment period. -func withAlignmentPeriod(d time.Duration) aggregationOption { - return func(agg *monitoringpb.Aggregation) { - agg.AlignmentPeriod = durationpb.New(d) - } -} - -// withPerSeriesAligner sets the alignment method used for the time series. -func withPerSeriesAligner(aligner monitoringpb.Aggregation_Aligner) aggregationOption { - return func(agg *monitoringpb.Aggregation) { - agg.PerSeriesAligner = aligner +// WithLineFromPoints creates a *plotter.Line from the passed in data points and adds +// it to the plot. +func WithLineFromPoints(pts []*monitoringpb.Point) PlotOption { + return func(p *plot.Plot) { + line, _ := createLine(pts) + line.Width = vg.Points(1) + p.Add(line) } } -// withCrossSeriesReducer sets the reduction method used for the time series. -func withCrossSeriesReducer(reducer monitoringpb.Aggregation_Reducer) aggregationOption { - return func(agg *monitoringpb.Aggregation) { - agg.CrossSeriesReducer = reducer +func WithColoredLineFromPoints(pts []*monitoringpb.Point, color color.Color) PlotOption { + return func(p *plot.Plot) { + line, _ := createLine(pts) + line.Width = vg.Points(1) + line.Color = color + p.Add(line) } } diff --git a/tsplot/plot.go b/tsplot/plot.go index 099549f..12b19f2 100644 --- a/tsplot/plot.go +++ b/tsplot/plot.go @@ -1,64 +1,121 @@ package tsplot import ( - "errors" + "fmt" + monitoring "cloud.google.com/go/monitoring/apiv3/v2" "gonum.org/v1/plot" "gonum.org/v1/plot/plotter" - "gonum.org/v1/plot/vg" + "google.golang.org/api/iterator" + "google.golang.org/genproto/googleapis/api/metric" monitoringpb "google.golang.org/genproto/googleapis/monitoring/v3" ) -// TimeSeries is a map representation of unique names to time series. -type TimeSeries map[string][]*monitoringpb.Point +// findMaxFromPoints finds the maximum value of a list of metric points of the GAPI MetricDescriptor Value type. +func findMaxFromPoints(gapiValueType metric.MetricDescriptor_ValueType, points []*monitoringpb.Point) (float64, error) { + var maximum float64 + + switch gapiValueType { + case metric.MetricDescriptor_INT64: + maximum = findMaxFromInt64Data(points) + case metric.MetricDescriptor_DOUBLE: + maximum = findMaxFromFloat64Data(points) + default: + return 0, fmt.Errorf("cannot operate on value type %q", gapiValueType.String()) + } + + return maximum, nil -// Plot builds and returns a *plot.Plot. If the TimeSeriesGroup field -// in TimeSeriesPlot is empty, a nil plot is returned as well as an error. -// This is also the case if an error is encountered building the line from the XY coordinates. -func (ts TimeSeries) Plot(opts ...PlotOption) (*plot.Plot, error) { +} - if len(ts) == 0 { - return nil, errors.New("no data to plot") +// findMaxFromFloat64Data is a helper function to findMaxFromPoints but is specific to the data being of type float64. +// The other helper function findMaxFromInt64Data is also required due to the methods that which Google allows the consumer +// to fetch the data. +func findMaxFromFloat64Data(points []*monitoringpb.Point) float64 { + var maximum float64 + for _, v := range points { + cur := v.GetValue().GetDoubleValue() + if cur > maximum { + maximum = cur + } } + return maximum +} - p := plot.New() +// findMaxFromInt64Data is a helper function to findMaxFromPoints but is specific to the data being of type float64. +// The other helper function findMaxFromFloat64Data is also required due to the methods that which Google allows the consumer +// to fetch the data. +func findMaxFromInt64Data(points []*monitoringpb.Point) float64 { + var maximum int64 + for _, v := range points { + cur := v.GetValue().GetInt64Value() + if cur > maximum { + maximum = cur + } + } + return float64(maximum) +} - // default high contrast - ApplyDefaultHighContrast(p) +// NewPlotFromTimeSeries creates a plot from a single time series. +func NewPlotFromTimeSeries(ts *monitoringpb.TimeSeries, opts ...PlotOption) (*plot.Plot, error) { + points := ts.GetPoints() + YMax, err := findMaxFromPoints(ts.GetValueType(), points) + if err != nil { + return nil, err + } - // user overrides + opts = append(opts, WithLineFromPoints(points)) + p := plot.New() for _, opt := range opts { opt(p) } - lineColors := DefaultColors_HighContrast.LineColors + p.Y.Max = YMax + 200 + return p, nil +} - // create a unique line for each time series - // each line should have a unique color and entry - // in the legend. - // current limit = 4 - limit := len(lineColors) - 1 - for name, series := range ts { - if limit < 0 { - break - } - line, err := createLine(series) +// NewPlotFromTimeSeriesIterator creates a plot from multiple time series. +func NewPlotFromTimeSeriesIterator(tsi *monitoring.TimeSeriesIterator, legendKey string, opts ...PlotOption) (*plot.Plot, error) { + + var yMax float64 + p := plot.New() + for { + timeSeries, err := tsi.Next() if err != nil { + if err == iterator.Done { + break + } return nil, err } - // width of the line - line.Width = vg.Points(2) + points := timeSeries.GetPoints() + + // Find the maximum datapoint between the different time series data. + // Use this to scale the Y Axis. + curMax, _ := findMaxFromPoints(timeSeries.GetValueType(), points) + if curMax > yMax { + yMax = curMax + } - // color the line - line.Color = lineColors[limit] + // add colored line to plot + lineColor := getUnusedColor() + applyLine := WithColoredLineFromPoints(timeSeries.GetPoints(), lineColor) + applyLine(p) // add to legend - p.Legend.Add(name, line) + if legendKey != "" { + legendEntry, _ := plotter.NewPolygon() + legendEntry.Color = lineColor + p.Legend.Left = true + p.Legend.Add(timeSeries.GetMetric().GetLabels()[legendKey], legendEntry) + } + } + + // set Y Axis scale + p.Y.Max = yMax + 200 - // add to chart - p.Add(line) - limit-- + for _, opt := range opts { + opt(p) } return p, nil @@ -69,7 +126,7 @@ func createLine(dataPoints []*monitoringpb.Point) (*plotter.Line, error) { var XYs plotter.XYs for _, point := range dataPoints { x := point.GetInterval().GetEndTime().GetSeconds() - y := point.GetValue().GetDoubleValue() + y := point.GetValue().GetDoubleValue() // todo: This breaks if the value type is an int64 XYs = append(XYs, plotter.XY{ X: float64(x), Y: y, diff --git a/tsplot/query.go b/tsplot/query.go deleted file mode 100644 index cc5ae41..0000000 --- a/tsplot/query.go +++ /dev/null @@ -1,118 +0,0 @@ -package tsplot - -import ( - "context" - "errors" - "fmt" - "time" - - monitoring "cloud.google.com/go/monitoring/apiv3/v2" - monitoringpb "google.golang.org/genproto/googleapis/monitoring/v3" - "google.golang.org/protobuf/types/known/durationpb" - "google.golang.org/protobuf/types/known/timestamppb" -) - -// DefaultQueryFilter the query filter used if no overrides are given. -const DefaultQueryFilter = "resource.type = \"global\" AND metric.type = \"%s\"" - -// MetricQuery is a type that encapsulates the various parts that are used to form a ListTimeSeriesRequest. -// If EndTime is not provided, it will be defaulted to the current time. -// -// Required Fields: -// Project -// MetricDescriptor -// StartTime -type MetricQuery struct { - Project string - MetricDescriptor string - StartTime *time.Time - EndTime *time.Time - - queryFilter string - aggregation *[]aggregationOption -} - -// NewMetricQuery creates a new MetricQuery type with the aggregation opts initialized. -func NewMetricQuery(project, metric string, startTime, endTime *time.Time) *MetricQuery { - return &MetricQuery{ - Project: project, - MetricDescriptor: metric, - StartTime: startTime, - EndTime: endTime, - aggregation: &[]aggregationOption{}, - } -} - -// SetQueryFilter provides a hook to modify the metric query filter. -func (mq *MetricQuery) SetQueryFilter(queryFilter string) { - mq.queryFilter = queryFilter -} - -// SetAlignmentPeriod sets the alignment duration. -func (mq *MetricQuery) SetAlignmentPeriod(d time.Duration) { - *mq.aggregation = append(*mq.aggregation, withAlignmentPeriod(d)) -} - -// request builds and returns a *monitoringpb.ListTimeSeriesRequest. -// If there is not enough information to build the request an error is returned. -func (mq *MetricQuery) request() (*monitoringpb.ListTimeSeriesRequest, error) { - - var tsreq monitoringpb.ListTimeSeriesRequest - - if mq.Project == "" { - return nil, errors.New("MetricQuery missing GCE Project") - } - - if mq.MetricDescriptor == "" && mq.queryFilter == "" { - return nil, errors.New("MetricQuery missing MetricDescriptor") - } - - if mq.StartTime == nil { - return nil, errors.New("start time has not been provided") - } - - now := time.Now() - if mq.EndTime == nil { - mq.EndTime = &now - } - - // Complete override of timeSeriesRequestFilter. Use verbatim. - // Resolves: https://github.com/bitly/tsplot/issues/9 - timeSeriesRequestFilter := fmt.Sprintf(DefaultQueryFilter, mq.MetricDescriptor) - if mq.queryFilter != "" { - timeSeriesRequestFilter = mq.queryFilter - } - - tsreq = monitoringpb.ListTimeSeriesRequest{ - Name: fmt.Sprintf("projects/%s", mq.Project), - Filter: timeSeriesRequestFilter, - Interval: &monitoringpb.TimeInterval{ - EndTime: timestamppb.New(*mq.EndTime), - StartTime: timestamppb.New(*mq.StartTime), - }, - Aggregation: &monitoringpb.Aggregation{ - AlignmentPeriod: durationpb.New(time.Minute * 1), - PerSeriesAligner: monitoringpb.Aggregation_ALIGN_RATE, - CrossSeriesReducer: monitoringpb.Aggregation_REDUCE_MEAN, - }, - View: monitoringpb.ListTimeSeriesRequest_FULL, - } - - for _, opt := range *mq.aggregation { - opt(tsreq.Aggregation) - } - - return &tsreq, nil -} - -// PerformWithClient sends the MetricQuery.ListTimeSeriesRequest to the Google Cloud Monitoring API. -// If the request has not been built yet, i.e: BuildRequest() has not been called on the MetricQuery, -// an error will be returned. A Google Cloud Monitoring client is required to be passed in as a parameter -// if authentication has not been set up on the client, an error will result from the call. -func (mq *MetricQuery) PerformWithClient(client *monitoring.MetricClient) (*monitoring.TimeSeriesIterator, error) { - request, err := mq.request() - if err != nil { - return nil, err - } - return client.ListTimeSeries(context.Background(), request), nil -} diff --git a/tsplot/query_test.go b/tsplot/query_test.go deleted file mode 100644 index 2ef438b..0000000 --- a/tsplot/query_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package tsplot - -import ( - "testing" - "time" - - monitoringpb "google.golang.org/genproto/googleapis/monitoring/v3" -) - -func TestMetricQuery_BuildRequest(t *testing.T) { - tests := []struct { - desc string - in *MetricQuery - out *MetricQuery - expectErr bool - }{ - { - desc: "fail to build request, missing Project", - in: &MetricQuery{ - MetricDescriptor: "compute.googleapis.com/instance/cpu/usage_time", - }, - out: &MetricQuery{}, - expectErr: true, - }, - { - desc: "fail to build request, missing MetricDescriptor", - in: &MetricQuery{ - Project: "my-project", - }, - out: &MetricQuery{}, - expectErr: true, - }, - } - - for _, test := range tests { - _, err := test.in.request() - if err != nil && !test.expectErr { - t.Errorf("got unexpected err: %v\n", err) - } - } -} - -func TestMetricQuery_SetQueryFilter(t *testing.T) { - expectedFilter := "some advanced query" - st := time.Now().Add(-1 * time.Hour) - et := time.Now() - query := NewMetricQuery("bitly-gcp-prod", "", &st, &et) - query.SetQueryFilter(expectedFilter) - - tsr, err := query.request() - if err != nil { - t.Error(err) - } - - filter := tsr.GetFilter() - if filter != expectedFilter { - t.Fatalf("query filter not overriden. got: %s, expected: %s", filter, expectedFilter) - } -} - -func TestMetricQuery_SetAlignmentPeriod(t *testing.T) { - expectedAlignmentPeriod := time.Minute * 10 - st := time.Now().Add(-1 * time.Hour) - et := time.Now() - query := NewMetricQuery("bitly-gcp-prod", "some metric", &st, &et) - query.SetAlignmentPeriod(expectedAlignmentPeriod) - - req, err := query.request() - if err != nil { - t.Error(err) - } - - alignmentPeriod := req.GetAggregation().GetAlignmentPeriod().GetSeconds() - if req.GetAggregation().GetAlignmentPeriod().GetSeconds() != int64(expectedAlignmentPeriod.Seconds()) { - t.Fatalf("alignment period not overriden. got %d, expected: %s", alignmentPeriod, expectedAlignmentPeriod) - } -} - -func TestMetricQuery_AggregationOptions(t *testing.T) { - st := time.Now().Add(-1 * time.Hour) - et := time.Now() - query := NewMetricQuery("bitly-gcp-prod", "some metric", &st, &et) - query.Set_ALIGN_NONE() - query.Set_REDUCE_NONE() - - req, err := query.request() - if err != nil { - t.Error(err) - } - - aggregation := req.GetAggregation() - if aggregation.GetPerSeriesAligner() != monitoringpb.Aggregation_ALIGN_NONE { - t.Fatal("aligner not overridden") - } - if aggregation.GetCrossSeriesReducer() != monitoringpb.Aggregation_REDUCE_NONE { - t.Fatal("reducer not overridden") - } -} diff --git a/tsplot/set_aggregation_opts.go b/tsplot/set_aggregation_opts.go deleted file mode 100644 index fe298a3..0000000 --- a/tsplot/set_aggregation_opts.go +++ /dev/null @@ -1,139 +0,0 @@ -package tsplot -/* -DO NOT EDIT -Generated by codegen.go -https://github.com/googleapis/go-genproto/blob/0135a39c27378c1b903c75204eff61a060be5eb7/googleapis/monitoring/v3/common.pb.go -*/ - -func (mq *MetricQuery) Set_ALIGN_COUNT() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(13)) -} - -func (mq *MetricQuery) Set_ALIGN_COUNT_FALSE() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(24)) -} - -func (mq *MetricQuery) Set_ALIGN_COUNT_TRUE() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(16)) -} - -func (mq *MetricQuery) Set_ALIGN_DELTA() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(1)) -} - -func (mq *MetricQuery) Set_ALIGN_FRACTION_TRUE() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(17)) -} - -func (mq *MetricQuery) Set_ALIGN_INTERPOLATE() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(3)) -} - -func (mq *MetricQuery) Set_ALIGN_MAX() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(11)) -} - -func (mq *MetricQuery) Set_ALIGN_MEAN() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(12)) -} - -func (mq *MetricQuery) Set_ALIGN_MIN() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(10)) -} - -func (mq *MetricQuery) Set_ALIGN_NEXT_OLDER() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(4)) -} - -func (mq *MetricQuery) Set_ALIGN_NONE() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(0)) -} - -func (mq *MetricQuery) Set_ALIGN_PERCENTILE_05() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(21)) -} - -func (mq *MetricQuery) Set_ALIGN_PERCENTILE_50() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(20)) -} - -func (mq *MetricQuery) Set_ALIGN_PERCENTILE_95() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(19)) -} - -func (mq *MetricQuery) Set_ALIGN_PERCENTILE_99() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(18)) -} - -func (mq *MetricQuery) Set_ALIGN_PERCENT_CHANGE() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(23)) -} - -func (mq *MetricQuery) Set_ALIGN_RATE() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(2)) -} - -func (mq *MetricQuery) Set_ALIGN_STDDEV() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(15)) -} - -func (mq *MetricQuery) Set_ALIGN_SUM() { - *mq.aggregation = append(*mq.aggregation, withPerSeriesAligner(14)) -} - -func (mq *MetricQuery) Set_REDUCE_COUNT() { - *mq.aggregation = append(*mq.aggregation, withCrossSeriesReducer(6)) -} - -func (mq *MetricQuery) Set_REDUCE_COUNT_FALSE() { - *mq.aggregation = append(*mq.aggregation, withCrossSeriesReducer(15)) -} - -func (mq *MetricQuery) Set_REDUCE_COUNT_TRUE() { - *mq.aggregation = append(*mq.aggregation, withCrossSeriesReducer(7)) -} - -func (mq *MetricQuery) Set_REDUCE_FRACTION_TRUE() { - *mq.aggregation = append(*mq.aggregation, withCrossSeriesReducer(8)) -} - -func (mq *MetricQuery) Set_REDUCE_MAX() { - *mq.aggregation = append(*mq.aggregation, withCrossSeriesReducer(3)) -} - -func (mq *MetricQuery) Set_REDUCE_MEAN() { - *mq.aggregation = append(*mq.aggregation, withCrossSeriesReducer(1)) -} - -func (mq *MetricQuery) Set_REDUCE_MIN() { - *mq.aggregation = append(*mq.aggregation, withCrossSeriesReducer(2)) -} - -func (mq *MetricQuery) Set_REDUCE_NONE() { - *mq.aggregation = append(*mq.aggregation, withCrossSeriesReducer(0)) -} - -func (mq *MetricQuery) Set_REDUCE_PERCENTILE_05() { - *mq.aggregation = append(*mq.aggregation, withCrossSeriesReducer(12)) -} - -func (mq *MetricQuery) Set_REDUCE_PERCENTILE_50() { - *mq.aggregation = append(*mq.aggregation, withCrossSeriesReducer(11)) -} - -func (mq *MetricQuery) Set_REDUCE_PERCENTILE_95() { - *mq.aggregation = append(*mq.aggregation, withCrossSeriesReducer(10)) -} - -func (mq *MetricQuery) Set_REDUCE_PERCENTILE_99() { - *mq.aggregation = append(*mq.aggregation, withCrossSeriesReducer(9)) -} - -func (mq *MetricQuery) Set_REDUCE_STDDEV() { - *mq.aggregation = append(*mq.aggregation, withCrossSeriesReducer(5)) -} - -func (mq *MetricQuery) Set_REDUCE_SUM() { - *mq.aggregation = append(*mq.aggregation, withCrossSeriesReducer(4)) -} -