Skip to content

Commit 847785b

Browse files
authored
feat(go): add CSV rendering (#767)
1 parent 92b9f2e commit 847785b

File tree

11 files changed

+391
-21
lines changed

11 files changed

+391
-21
lines changed

.github/workflows/docker-test.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ jobs:
2727
runs-on: ${{ matrix.runs-on }}
2828
permissions:
2929
contents: read # clone the repository
30+
id-token: write # required to read secrets
3031
steps:
3132
- uses: actions/checkout@v4
3233
with:
@@ -43,12 +44,33 @@ jobs:
4344
BUILDKIT_STEP_LOG_MAX_SPEED: -1
4445
FILE: ${{ matrix.file }}
4546
run: docker build . -t image-renderer -f "$FILE"
47+
48+
- name: Read license
49+
if: ${{ github.event.repository.fork == false }}
50+
id: license_secret
51+
uses: grafana/shared-workflows/actions/get-vault-secrets@a37de51f3d713a30a9e4b21bcdfbd38170020593 # get-vault-secrets/v1
52+
with:
53+
export_env: false
54+
repo_secrets: |
55+
LICENSE_JWT=grafana-enterprise-license:license-jwt
56+
- name: Write license to file system
57+
if: ${{ github.event.repository.fork == false }}
58+
env:
59+
SECRET: ${{ fromJson(steps.license_secret.outputs.secrets).LICENSE_JWT }}
60+
run: |
61+
set -euo pipefail
62+
LPATH="$(mktemp --tmpdir XXXXXXXXXXXX.jwt)"
63+
echo "$SECRET" > "$LPATH"
64+
echo "LICENSE_JWT=$LPATH" >> "$GITHUB_ENV"
65+
4666
- name: go test ./tests/acceptance/...
4767
run: go test ./tests/acceptance/... -count=1
4868
env:
4969
TERM: linux
5070
IMAGE: image-renderer
5171
UPDATE_FIXTURES: 'true' # this will make changes, so we can see them
72+
REQUIRE_ACCEPTANCE: 'true' # fail if no image can be found
73+
REQUIRE_ENTERPRISE: ${{ github.event.repository.fork == false }}
5274

5375
- name: Tar changed files
5476
if: failure()

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,6 @@ tests/testdata/diff_*
4444
cache
4545

4646
/devenv/docker/tracing/tempo-data/
47+
48+
# Grafana Enterprise licence keys
49+
*.jwt

docs/testing.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,14 @@ Or you can also pull a specific image to test, for example:
3030

3131
And then run the tests
3232

33-
`IMAGE=grafana/grafana-image-renderer:v4.0.13 go test ./tests/acceptance/...`
33+
`IMAGE=grafana/grafana-image-renderer:v4.0.13 go test ./tests/acceptance/...`
34+
35+
### Enterprise tests
36+
37+
Some tests require an active Enterprise licence.
38+
39+
If you're a Grafana Labs employee, you can find one of these in the `grafana-enterprise` repository:
40+
41+
```shell
42+
$ ln -s ../grafana-enterprise/tools/license.jwt .
43+
```

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ require (
1717
github.com/urfave/cli-altsrc/v3 v3.0.1
1818
github.com/urfave/cli/v3 v3.4.1
1919
golang.org/x/sync v0.16.0
20+
golang.org/x/text v0.28.0
2021
gopkg.in/yaml.v3 v3.0.1
2122
)
2223

@@ -82,7 +83,6 @@ require (
8283
golang.org/x/image v0.30.0 // indirect
8384
golang.org/x/mod v0.27.0 // indirect
8485
golang.org/x/sys v0.35.0 // indirect
85-
golang.org/x/text v0.28.0 // indirect
8686
golang.org/x/tools v0.36.0 // indirect
8787
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
8888
google.golang.org/protobuf v1.36.7 // indirect

pkg/api/middleware/logger.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package middleware
2+
3+
import (
4+
"log/slog"
5+
"net/http"
6+
"sync"
7+
)
8+
9+
func RequestLogger(h http.Handler) http.Handler {
10+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
11+
lw := &loggingResponseWriter{w: w}
12+
defer func() {
13+
slog.DebugContext(r.Context(), "request complete",
14+
"method", r.Method,
15+
"mux_pattern", r.Pattern,
16+
"uri", r.URL,
17+
"status", lw.statusCode,
18+
"status_text", http.StatusText(lw.statusCode))
19+
}()
20+
h.ServeHTTP(lw, r)
21+
})
22+
}
23+
24+
type loggingResponseWriter struct {
25+
w http.ResponseWriter
26+
once sync.Once
27+
statusCode int
28+
}
29+
30+
var (
31+
_ http.ResponseWriter = (*loggingResponseWriter)(nil)
32+
_ http.Flusher = (*loggingResponseWriter)(nil)
33+
)
34+
35+
func (l *loggingResponseWriter) Header() http.Header {
36+
return l.w.Header()
37+
}
38+
39+
func (l *loggingResponseWriter) WriteHeader(code int) {
40+
l.once.Do(func() {
41+
l.statusCode = code
42+
})
43+
l.w.WriteHeader(code)
44+
}
45+
46+
func (l *loggingResponseWriter) Write(b []byte) (int, error) {
47+
l.once.Do(func() {
48+
l.statusCode = http.StatusOK
49+
})
50+
return l.w.Write(b)
51+
}
52+
53+
func (l *loggingResponseWriter) Flush() {
54+
if flusher, ok := l.w.(http.Flusher); ok {
55+
flusher.Flush()
56+
}
57+
}

pkg/api/mux.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ func NewHandler(
2727
mux.Handle("GET /healthz", HandleGetHealthz())
2828
mux.Handle("GET /version", HandleGetVersion(versions, browser))
2929
mux.Handle("GET /render", middleware.RequireAuthToken(middleware.TrustedURL(HandlePostRender(browser)), string(token)))
30+
mux.Handle("GET /render/csv", middleware.RequireAuthToken(middleware.TrustedURL(HandlePostRenderCSV(browser)), string(token)))
3031
mux.Handle("GET /render/version", HandleGetRenderVersion(versions))
3132

3233
handler := middleware.RequestMetrics(mux)
34+
handler = middleware.RequestLogger(handler)
3335
handler = middleware.Recovery(handler) // must come last!
3436
return handler, nil
3537
}

pkg/api/rendercsv.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"net/http"
8+
"strconv"
9+
"time"
10+
11+
"github.com/grafana/grafana-image-renderer/pkg/service"
12+
"github.com/prometheus/client_golang/prometheus"
13+
)
14+
15+
var (
16+
// This also implicitly gives us a count for each result type, so we can calculate success rate.
17+
MetricRenderCSVDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
18+
Name: "http_render_csv_request_duration",
19+
Help: "How long does a single CSV render take?",
20+
ConstLabels: prometheus.Labels{"unit": "seconds"},
21+
Buckets: []float64{0.5, 1, 3, 4, 5, 7, 9, 10, 11, 15, 19, 20, 21, 24, 27, 29, 30, 31, 35, 55, 95, 125, 305, 605},
22+
}, []string{"result"})
23+
)
24+
25+
func HandlePostRenderCSV(browser *service.BrowserService) http.Handler {
26+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+
url := r.URL.Query().Get("url")
28+
if url == "" {
29+
http.Error(w, "missing 'url' query parameter", http.StatusBadRequest)
30+
return
31+
}
32+
if encoding := r.URL.Query().Get("encoding"); encoding != "" && encoding != "csv" {
33+
http.Error(w, "invalid 'encoding' query parameter: must be 'csv' or empty/missing", http.StatusBadRequest)
34+
return
35+
}
36+
ctx := r.Context()
37+
if timeout := r.URL.Query().Get("timeout"); timeout != "" {
38+
if regexpOnlyNumbers.MatchString(timeout) {
39+
seconds, err := strconv.Atoi(timeout)
40+
if err != nil {
41+
http.Error(w, fmt.Sprintf("invalid 'timeout' query parameter: %v", err), http.StatusBadRequest)
42+
return
43+
}
44+
timeoutCtx, cancelTimeout := context.WithTimeout(r.Context(), time.Duration(seconds)*time.Second)
45+
defer cancelTimeout()
46+
ctx = timeoutCtx
47+
} else {
48+
timeout, err := time.ParseDuration(timeout)
49+
if err != nil {
50+
http.Error(w, fmt.Sprintf("invalid 'timeout' query parameter: %v", err), http.StatusBadRequest)
51+
return
52+
}
53+
timeoutCtx, cancelTimeout := context.WithTimeout(r.Context(), timeout)
54+
defer cancelTimeout()
55+
ctx = timeoutCtx
56+
}
57+
}
58+
renderKey := r.URL.Query().Get("renderKey")
59+
domain := r.URL.Query().Get("domain")
60+
61+
start := time.Now()
62+
contents, err := browser.RenderCSV(ctx, url, renderKey, domain)
63+
if err != nil {
64+
MetricRenderCSVDuration.WithLabelValues("error").Observe(time.Since(start).Seconds())
65+
http.Error(w, "CSV rendering failed", http.StatusInternalServerError)
66+
slog.ErrorContext(ctx, "failed to render CSV", "err", err)
67+
return
68+
}
69+
MetricRenderCSVDuration.WithLabelValues("success").Observe(time.Since(start).Seconds())
70+
71+
w.Header().Set("Content-Type", "text/csv")
72+
w.Header().Set("Content-Disposition", `attachment; filename="data.csv"`)
73+
_, _ = w.Write(contents)
74+
})
75+
}

pkg/metrics/registry.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func NewRegistry() *prometheus.Registry {
2121
middleware.MetricTrustedURLRequests,
2222

2323
api.MetricRenderDuration,
24+
api.MetricRenderCSVDuration,
2425
)
2526
return registry
2627
}

pkg/service/browser.go

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ import (
88
"errors"
99
"fmt"
1010
"log/slog"
11+
"os"
1112
"os/exec"
13+
"path/filepath"
1214
"strings"
1315
"time"
1416

17+
"github.com/chromedp/cdproto/browser"
1518
"github.com/chromedp/cdproto/emulation"
1619
"github.com/chromedp/cdproto/network"
1720
"github.com/chromedp/cdproto/page"
@@ -208,12 +211,8 @@ type renderingOptions struct {
208211
landscape bool
209212
}
210213

211-
func (s *BrowserService) Render(ctx context.Context, url string, optionFuncs ...RenderingOption) ([]byte, string, error) {
212-
if url == "" {
213-
return nil, "text/plain", fmt.Errorf("url must not be empty")
214-
}
215-
216-
opts := &renderingOptions{ // set sensible defaults here; we want all values filled in to show explicit intent
214+
func defaultRenderingOptions() *renderingOptions {
215+
return &renderingOptions{ // set sensible defaults here; we want all values filled in to show explicit intent
217216
gpu: false, // assume no GPU: this can be heavy, and if it exists, it likely exists for AI/ML/transcoding/... purposes, not for us
218217
sandbox: false, // FIXME: enable this; <https://github.com/grafana/grafana-operator-experience-squad/issues/1460>
219218
timezone: time.UTC, // UTC ensures consistency when it is not configured but the users' servers are in multiple locations
@@ -227,6 +226,14 @@ func (s *BrowserService) Render(ctx context.Context, url string, optionFuncs ...
227226
printer: defaultPDFPrinter(), // print as PDF if no other format is requested
228227
landscape: true,
229228
}
229+
}
230+
231+
func (s *BrowserService) Render(ctx context.Context, url string, optionFuncs ...RenderingOption) ([]byte, string, error) {
232+
if url == "" {
233+
return nil, "text/plain", fmt.Errorf("url must not be empty")
234+
}
235+
236+
opts := defaultRenderingOptions()
230237
for _, f := range s.defaultRenderingOptions {
231238
if err := f(opts); err != nil {
232239
return nil, "text/plain", fmt.Errorf("failed to apply default rendering option: %w", err)
@@ -284,6 +291,79 @@ func (s *BrowserService) Render(ctx context.Context, url string, optionFuncs ...
284291
}
285292
}
286293

294+
// RenderCSV visits a web page and downloads the CSV inside.
295+
//
296+
// You may be thinking: what the hell are we doing? Why are we using a browser for this?
297+
// The CSV endpoint just returns HTML. The actual query is done by the browser, and then a script _in the webpage_ downloads it as a CSV file.
298+
// This SHOULD be replaced at some point, such that the Grafana server does all the work; this is just not acceptable behaviour...
299+
func (s *BrowserService) RenderCSV(ctx context.Context, url, renderKey, domain string) ([]byte, error) {
300+
if url == "" {
301+
return nil, fmt.Errorf("url must not be empty")
302+
}
303+
304+
traceID, err := getTraceID(ctx)
305+
if err != nil {
306+
return nil, fmt.Errorf("failed to get trace ID: %w", err)
307+
}
308+
log := s.log.With("trace_id", traceID)
309+
310+
allocatorOptions, err := s.createAllocatorOptions(defaultRenderingOptions())
311+
if err != nil {
312+
return nil, fmt.Errorf("failed to create allocator options: %w", err)
313+
}
314+
allocatorCtx, cancelAllocator := chromedp.NewExecAllocator(ctx, allocatorOptions...)
315+
defer cancelAllocator()
316+
browserCtx, cancelBrowser := chromedp.NewContext(allocatorCtx, browserLoggers(ctx, log))
317+
defer cancelBrowser()
318+
319+
tmpDir, err := os.MkdirTemp("", "gir-csv-"+traceID+"-*")
320+
if err != nil {
321+
return nil, fmt.Errorf("failed to create temporary directory: %w", err)
322+
}
323+
defer func() {
324+
if err := os.RemoveAll(tmpDir); err != nil {
325+
log.WarnContext(ctx, "failed to remove temporary directory", "path", tmpDir, "error", err)
326+
}
327+
}()
328+
329+
actions := []chromedp.Action{
330+
setCookies([]*network.SetCookieParams{
331+
{
332+
Name: "renderKey",
333+
Value: renderKey,
334+
Domain: domain,
335+
},
336+
}),
337+
browser.SetDownloadBehavior(browser.SetDownloadBehaviorBehaviorAllow).WithDownloadPath(tmpDir),
338+
chromedp.Navigate(url),
339+
waitForViz(),
340+
waitForDuration(time.Second),
341+
}
342+
if err := chromedp.Run(browserCtx, actions...); err != nil {
343+
return nil, fmt.Errorf("failed to run browser: %w", err)
344+
}
345+
346+
// Wait for the file to be downloaded.
347+
var entries []os.DirEntry
348+
for {
349+
if err := ctx.Err(); err != nil {
350+
return nil, err
351+
}
352+
353+
entries, err = os.ReadDir(tmpDir)
354+
if err == nil && len(entries) > 0 {
355+
break // file exists now
356+
}
357+
}
358+
359+
fileContents, err := os.ReadFile(filepath.Join(tmpDir, entries[0].Name()))
360+
if err != nil {
361+
return nil, fmt.Errorf("failed to read temporary file: %w", err)
362+
}
363+
364+
return fileContents, nil
365+
}
366+
287367
func getTraceID(context.Context) (string, error) {
288368
// TODO: Use OTEL trace ID from context
289369
id, err := uuid.NewRandom()

0 commit comments

Comments
 (0)