github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/benchmark/internal/cireport/imagereport.go (about)

     1  package cireport
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"path"
     7  	"regexp"
     8  	"strconv"
     9  	"strings"
    10  	"text/template"
    11  	"time"
    12  	"unicode"
    13  
    14  	"github.com/sirupsen/logrus"
    15  	"golang.org/x/sync/errgroup"
    16  	"golang.org/x/text/transform"
    17  	"golang.org/x/text/unicode/norm"
    18  
    19  	"github.com/pyroscope-io/pyroscope/benchmark/internal/config"
    20  )
    21  
    22  type Uploader interface {
    23  	WriteFile(dest string, data []byte) (string, error)
    24  }
    25  
    26  type DashboardScreenshotter interface {
    27  	AllPanels(ctx context.Context, dashboardUID string, from int64, to int64) ([]Panel, error)
    28  }
    29  
    30  type ImageReporter struct {
    31  	uploader      Uploader
    32  	screenshotter DashboardScreenshotter
    33  }
    34  
    35  func ImageReportCLI(cfg config.ImageReport) (string, error) {
    36  	uploader, err := decideUploader(cfg.UploadType, cfg.UploadBucket)
    37  	if err != nil {
    38  		return "", err
    39  	}
    40  
    41  	gs := GrafanaScreenshotter{
    42  		GrafanaURL:     cfg.GrafanaAddress,
    43  		TimeoutSeconds: cfg.TimeoutSeconds,
    44  	}
    45  
    46  	r := NewImageReporter(gs, uploader)
    47  
    48  	from, to := decideTimestamp(cfg.From, cfg.To)
    49  
    50  	return r.Report(
    51  		context.Background(),
    52  		cfg.DashboardUID,
    53  		cfg.UploadDest,
    54  		from,
    55  		to,
    56  	)
    57  }
    58  
    59  type screenshotPanel struct {
    60  	Title string
    61  	URL   string
    62  }
    63  
    64  func NewImageReporter(screenshotter GrafanaScreenshotter, uploader Uploader) *ImageReporter {
    65  	return &ImageReporter{
    66  		uploader,
    67  		&screenshotter,
    68  	}
    69  }
    70  
    71  func (r *ImageReporter) Report(ctx context.Context, dashboardUID string, dir string, from int64, to int64) (string, error) {
    72  	// screenshot all panes
    73  	logrus.Debug("taking screenshot of all panels")
    74  	panels, err := r.screenshotter.AllPanels(ctx, dashboardUID, from, to)
    75  	if err != nil {
    76  		return "", err
    77  	}
    78  
    79  	sp := make([]screenshotPanel, len(panels))
    80  
    81  	// upload
    82  	logrus.Debug("uploading screenshots")
    83  	g, ctx := errgroup.WithContext(ctx)
    84  	for i, p := range panels {
    85  		p := p
    86  		i := i
    87  
    88  		g.Go(func() error {
    89  			publicURL, err := r.uploader.WriteFile(filename(dir, p.Title), p.Data)
    90  			if err != nil {
    91  				return err
    92  			}
    93  
    94  			// TODO lock this?
    95  			sp[i].Title = p.Title
    96  			sp[i].URL = publicURL
    97  			return nil
    98  		})
    99  	}
   100  
   101  	if err := g.Wait(); err != nil {
   102  		return "", err
   103  	}
   104  
   105  	logrus.Debug("generating markdown report")
   106  	return r.tpl(sp)
   107  }
   108  
   109  func filename(dir string, s string) string {
   110  	return path.Join(dir, normalizeWord(s)+".png")
   111  }
   112  
   113  func normalizeWord(s string) string {
   114  	isMn := func(r rune) bool {
   115  		return unicode.Is(unicode.Mn, r)
   116  	}
   117  
   118  	// unicode -> ascii
   119  	t := transform.Chain(norm.NFD, transform.RemoveFunc(isMn), norm.NFC)
   120  	// TODO: handle error
   121  	result, _, _ := transform.String(t, s)
   122  
   123  	result = strings.ReplaceAll(result, " ", "_")
   124  	// TODO: handle error
   125  	reg, _ := regexp.Compile("[^a-zA-Z0-9_]+")
   126  	result = reg.ReplaceAllString(result, "")
   127  
   128  	result = strings.ToLower(result)
   129  
   130  	return result
   131  }
   132  
   133  func (*ImageReporter) tpl(panels []screenshotPanel) (string, error) {
   134  	var tpl strings.Builder
   135  
   136  	data := struct {
   137  		Panels []screenshotPanel
   138  		Ts     string
   139  	}{
   140  		Panels: panels,
   141  		// cache bust
   142  		Ts: strconv.FormatInt(time.Now().Unix(), 10),
   143  	}
   144  	t, err := template.New("image-report.gotpl").
   145  		Funcs(template.FuncMap{}).
   146  		ParseFS(resources, "resources/image-report.gotpl")
   147  	if err != nil {
   148  		return "", err
   149  	}
   150  
   151  	if err := t.Execute(&tpl, data); err != nil {
   152  		return "", err
   153  	}
   154  
   155  	return tpl.String(), nil
   156  }
   157  
   158  func decideTimestamp(fromInt, toInt int) (from, to int64) {
   159  	now := time.Now()
   160  	from = int64(fromInt)
   161  	to = int64(toInt)
   162  
   163  	// set defaults if appropriate
   164  	if to == 0 {
   165  		// TODO use UnixMilli()
   166  		to = now.UnixNano() / int64(time.Millisecond)
   167  	}
   168  
   169  	if from == 0 {
   170  		// TODO use UnixMilli()
   171  		from = now.Add(time.Duration(5)*-time.Minute).UnixNano() / int64(time.Millisecond)
   172  	}
   173  
   174  	return from, to
   175  }
   176  
   177  func decideUploader(uploadType string, uploadBucket string) (Uploader, error) {
   178  	var uploader Uploader
   179  	switch uploadType {
   180  	case "s3":
   181  		u, err := NewS3Writer(uploadBucket)
   182  		uploader = u
   183  
   184  		if err != nil {
   185  			return nil, err
   186  		}
   187  	case "fs":
   188  		uploader = &FsWriter{}
   189  	default:
   190  		return nil, fmt.Errorf("invalid upload type: '%s'", uploadType)
   191  	}
   192  
   193  	return uploader, nil
   194  }