From 8361fd22d45e4fbf6b24309aaa1b6d49d9010759 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 24 Feb 2026 20:20:42 +0200 Subject: flamegraph: add native svg pipeline and tests --- internal/flamegraph/nativesvg.go | 74 +++++++++++++++++++++++ internal/flamegraph/svgwriter_test.go | 109 ++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 internal/flamegraph/nativesvg.go create mode 100644 internal/flamegraph/svgwriter_test.go (limited to 'internal/flamegraph') diff --git a/internal/flamegraph/nativesvg.go b/internal/flamegraph/nativesvg.go new file mode 100644 index 0000000..2c76a7d --- /dev/null +++ b/internal/flamegraph/nativesvg.go @@ -0,0 +1,74 @@ +package flamegraph + +import ( + "fmt" + "io" + "os" + "strings" + + "iter" +) + +type NativeSVG struct { + fields []string + countField string + config SVGConfig +} + +func NewNativeSVG(fields []string, countField string) NativeSVG { + return NativeSVG{ + fields: fields, + countField: countField, + config: defaultSVGConfig(), + } +} + +func (n NativeSVG) WriteSVGFromFile(iorDataFile string) error { + outFile := fmt.Sprintf("%s.%s-by-%s.svg", + strings.TrimSuffix(iorDataFile, ".ior.zst"), + strings.Join(n.fields, ":"), + n.countField, + ) + + iod, err := newIorDataFromFile(iorDataFile) + if err != nil { + return fmt.Errorf("read ior data: %w", err) + } + + fd, err := os.Create(outFile) + if err != nil { + return fmt.Errorf("create output %s: %w", outFile, err) + } + defer fd.Close() + + return n.WriteSVGFromIter(iod.iter(), fd) +} + +func (n NativeSVG) WriteSVGFromIter(records iter.Seq[IterRecord], w io.Writer) error { + tr := newTrie() + for record := range records { + frames, err := n.recordFrames(record) + if err != nil { + return err + } + tr.add(frames, record.Cnt.ValueByName(n.countField)) + } + tr.computeTotals() + return WriteSVG(w, tr, n.config) +} + +func (n NativeSVG) recordFrames(record IterRecord) ([]string, error) { + var frames []string + for _, fieldName := range n.fields { + value, err := record.StringByName(fieldName) + if err != nil { + return nil, fmt.Errorf("field %s: %w", fieldName, err) + } + for _, part := range strings.Split(value, ";") { + if part != "" { + frames = append(frames, part) + } + } + } + return frames, nil +} diff --git a/internal/flamegraph/svgwriter_test.go b/internal/flamegraph/svgwriter_test.go new file mode 100644 index 0000000..6fdbe3d --- /dev/null +++ b/internal/flamegraph/svgwriter_test.go @@ -0,0 +1,109 @@ +package flamegraph + +import ( + "bytes" + "strings" + "testing" +) + +func renderSVGForTest(t *testing.T, tr *trie, cfg SVGConfig) string { + t.Helper() + var buf bytes.Buffer + if err := WriteSVG(&buf, tr, cfg); err != nil { + t.Fatalf("WriteSVG failed: %v", err) + } + return buf.String() +} + +func TestWriteSVGBasic(t *testing.T) { + tr := newTrie() + tr.add([]string{"a", "b"}, 3) + tr.add([]string{"a", "c"}, 2) + tr.computeTotals() + + svg := renderSVGForTest(t, tr, defaultSVGConfig()) + if !strings.Contains(svg, "") { + t.Fatalf("expected valid svg wrapper, got: %s", svg) + } + if !strings.Contains(svg, "data-name=\"a\"") || !strings.Contains(svg, "data-name=\"b\"") { + t.Fatalf("expected rendered frame names, got: %s", svg) + } +} + +func TestWriteSVGEmptyTrie(t *testing.T) { + tr := newTrie() + tr.computeTotals() + + svg := renderSVGForTest(t, tr, defaultSVGConfig()) + if !strings.Contains(svg, "") { + t.Fatalf("expected valid svg wrapper, got: %s", svg) + } + if strings.Contains(svg, "class=\"frame\"") { + t.Fatalf("expected no rendered frames for empty trie, got: %s", svg) + } +} + +func TestWriteSVGMinWidth(t *testing.T) { + tr := newTrie() + tr.add([]string{"wide"}, 100) + tr.add([]string{"tiny"}, 1) + tr.computeTotals() + + cfg := defaultSVGConfig() + cfg.Width = 120 + cfg.MinWidthPx = 2.0 + svg := renderSVGForTest(t, tr, cfg) + + if !strings.Contains(svg, "data-name=\"wide\"") { + t.Fatalf("expected wide frame to be rendered, got: %s", svg) + } + if strings.Contains(svg, "data-name=\"tiny\"") { + t.Fatalf("expected tiny frame to be skipped by min width, got: %s", svg) + } +} + +func TestWriteSVGTitle(t *testing.T) { + tr := newTrie() + tr.add([]string{"a"}, 1) + tr.computeTotals() + + cfg := defaultSVGConfig() + cfg.Title = "Custom Flamegraph" + svg := renderSVGForTest(t, tr, cfg) + + if !strings.Contains(svg, "Custom Flamegraph") { + t.Fatalf("expected custom title in output, got: %s", svg) + } +} + +func TestFrameColor(t *testing.T) { + colorA1 := frameColor("read") + colorA2 := frameColor("read") + colorB := frameColor("write") + + if colorA1 != colorA2 { + t.Fatalf("expected deterministic color for identical names, got %q vs %q", colorA1, colorA2) + } + if !strings.HasPrefix(colorA1, "rgb(") || !strings.HasSuffix(colorA1, ")") { + t.Fatalf("expected rgb() format, got %q", colorA1) + } + if colorA1 == colorB { + t.Fatalf("expected different colors for different names, got %q", colorA1) + } +} + +func TestWriteSVGInvalidConfigFallsBack(t *testing.T) { + tr := newTrie() + tr.add([]string{"a"}, 1) + tr.computeTotals() + + cfg := SVGConfig{Title: "x", Width: 0, FrameHeight: 0, FontSize: 0, MinWidthPx: 0} + svg := renderSVGForTest(t, tr, cfg) + + if !strings.Contains(svg, `width="1200"`) { + t.Fatalf("expected fallback width, got: %s", svg) + } + if !strings.Contains(svg, "I/O Flame Graph") { + t.Fatalf("expected fallback title, got: %s", svg) + } +} -- cgit v1.2.3