grafana/pkg/services/screenshot/screenshot.go
George Robinson bad4f7fec5
Alerting: Change screenshots to use components (#55156)
* Alerting: Change screenshots to use components

This commit changes screenshots to use a number of components instead of a set of functional wrappers.

It moves the uploading of screenshots from the screenshot package to the image package so we can re-use the same code for both uploading screenshots and server-side images; SingleFlight from the screenshot package to the image package so we can use it for both taking and uploading the screenshot, where as before it was used just for taking the screenshot; and it also removes the use of a cache because we know that screenshots can be taken at most once per tick of the scheduler.
2022-09-21 10:25:07 +01:00

162 lines
4.9 KiB
Go

package screenshot
import (
"context"
"errors"
"fmt"
"net/url"
"path"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/rendering"
"github.com/grafana/grafana/pkg/setting"
)
const (
namespace = "grafana"
subsystem = "screenshot"
)
var (
ErrScreenshotsUnavailable = errors.New("screenshots unavailable")
)
// Screenshot represents a path to a screenshot on disk.
type Screenshot struct {
Path string
}
type screenshotFunc func(ctx context.Context, opts ScreenshotOptions) (*Screenshot, error)
// ScreenshotService is an interface for taking screenshots.
//
//go:generate mockgen -destination=screenshot_mock.go -package=screenshot github.com/grafana/grafana/pkg/services/screenshot ScreenshotService
type ScreenshotService interface {
Take(ctx context.Context, opts ScreenshotOptions) (*Screenshot, error)
}
// HeadlessScreenshotService takes screenshots using a headless browser.
type HeadlessScreenshotService struct {
ds dashboards.DashboardService
rs rendering.Service
duration prometheus.Histogram
failures *prometheus.CounterVec
successes prometheus.Counter
}
func NewHeadlessScreenshotService(ds dashboards.DashboardService, rs rendering.Service, r prometheus.Registerer) ScreenshotService {
return &HeadlessScreenshotService{
ds: ds,
rs: rs,
duration: promauto.With(r).NewHistogram(prometheus.HistogramOpts{
Name: "duration_seconds",
Buckets: []float64{0.1, 0.25, 0.5, 1, 2, 5, 10, 15},
Namespace: namespace,
Subsystem: subsystem,
}),
failures: promauto.With(r).NewCounterVec(prometheus.CounterOpts{
Name: "failures_total",
Namespace: namespace,
Subsystem: subsystem,
}, []string{"reason"}),
successes: promauto.With(r).NewCounter(prometheus.CounterOpts{
Name: "successes_total",
Namespace: namespace,
Subsystem: subsystem,
}),
}
}
// Take returns a screenshot of the panel. It returns an error if either the panel,
// or the dashboard that contains the panel, does not exist, or the screenshot could not be
// taken due to an error. It uses both the context and the timeout in ScreenshotOptions,
// however the same context is used for both database queries and the request to the
// rendering service, while the timeout in ScreenshotOptions is passed to the rendering service
// where it is used as a client timeout. It is not recommended to pass a context without a deadline
// and the context deadline should be at least as long as the timeout in ScreenshotOptions.
func (s *HeadlessScreenshotService) Take(ctx context.Context, opts ScreenshotOptions) (*Screenshot, error) {
start := time.Now()
defer func() { s.duration.Observe(time.Since(start).Seconds()) }()
q := models.GetDashboardQuery{Uid: opts.DashboardUID}
if err := s.ds.GetDashboard(ctx, &q); err != nil {
s.instrumentError(err)
return nil, err
}
u := url.URL{}
u.Path = path.Join("d-solo", q.Result.Uid, q.Result.Slug)
p := u.Query()
p.Add("orgId", strconv.FormatInt(q.Result.OrgId, 10))
p.Add("panelId", strconv.FormatInt(opts.PanelID, 10))
u.RawQuery = p.Encode()
opts = opts.SetDefaults()
renderOpts := rendering.Opts{
AuthOpts: rendering.AuthOpts{
OrgID: q.Result.OrgId,
OrgRole: org.RoleAdmin,
},
ErrorOpts: rendering.ErrorOpts{
ErrorConcurrentLimitReached: true,
ErrorRenderUnavailable: true,
},
TimeoutOpts: rendering.TimeoutOpts{
Timeout: opts.Timeout,
},
Width: opts.Width,
Height: opts.Height,
Theme: opts.Theme,
ConcurrentLimit: setting.AlertingRenderLimit,
Path: u.String(),
}
result, err := s.rs.Render(ctx, renderOpts, nil)
if err != nil {
s.instrumentError(err)
return nil, fmt.Errorf("failed to take screenshot: %w", err)
}
defer s.successes.Inc()
screenshot := Screenshot{Path: result.FilePath}
return &screenshot, nil
}
func (s *HeadlessScreenshotService) instrumentError(err error) {
if errors.Is(err, dashboards.ErrDashboardNotFound) {
defer s.failures.With(prometheus.Labels{
"reason": "dashboard_not_found",
}).Inc()
} else if errors.Is(err, context.Canceled) {
defer s.failures.With(prometheus.Labels{
"reason": "context_canceled",
}).Inc()
} else {
defer s.failures.With(prometheus.Labels{
"reason": "error",
}).Inc()
}
}
// NoOpScreenshotService is a service that takes no-op screenshots.
type NoOpScreenshotService struct{}
func (s *NoOpScreenshotService) Take(_ context.Context, _ ScreenshotOptions) (*Screenshot, error) {
return &Screenshot{}, nil
}
// ScreenshotUnavailableService is a service that returns ErrScreenshotsUnavailable.
type ScreenshotUnavailableService struct{}
func (s *ScreenshotUnavailableService) Take(_ context.Context, _ ScreenshotOptions) (*Screenshot, error) {
return nil, ErrScreenshotsUnavailable
}