diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-24 20:20:42 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-24 20:20:42 +0200 |
| commit | 8361fd22d45e4fbf6b24309aaa1b6d49d9010759 (patch) | |
| tree | 1aef478a8bc096acee794802b8dc7e29b32ecdb9 | |
| parent | 81735bb46a75dce67a06e383f0703871e23b29d4 (diff) | |
flamegraph: add native svg pipeline and tests
| -rw-r--r-- | internal/flags/flags.go | 2 | ||||
| -rw-r--r-- | internal/flamegraph/nativesvg.go | 74 | ||||
| -rw-r--r-- | internal/flamegraph/svgwriter_test.go | 109 | ||||
| -rw-r--r-- | internal/ior.go | 31 |
4 files changed, 203 insertions, 13 deletions
diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 87ece5d..5bde9c6 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -21,7 +21,7 @@ var ( pidFilter atomic.Int64 ) -const flamegraphToolDefault = "$HOME/git/FlameGraph/flamegraph.pl" +const flamegraphToolDefault = "" var ( validCollapsedFields = []string{ 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, "<svg") || !strings.Contains(svg, "</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, "<svg") || !strings.Contains(svg, "</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) + } +} diff --git a/internal/ior.go b/internal/ior.go index e46796b..ee1f988 100644 --- a/internal/ior.go +++ b/internal/ior.go @@ -99,18 +99,25 @@ func Run() error { if iorFile != "" { noTraceRun = true - collapsed := flamegraph.NewCollapsed(iorFile, cfg.CollapsedFields, cfg.CountField) - collapsedFile, err := collapsed.Write(iorFile) - if err != nil { - return err - } - - tool, err := flamegraph.NewTool(collapsedFile) - if err != nil { - return err - } - if err := tool.WriteSVG(); err != nil { - return err + if cfg.FlamegraphTool != "" { + collapsed := flamegraph.NewCollapsed(iorFile, cfg.CollapsedFields, cfg.CountField) + collapsedFile, err := collapsed.Write(iorFile) + if err != nil { + return err + } + + tool, err := flamegraph.NewTool(collapsedFile) + if err != nil { + return err + } + if err := tool.WriteSVG(); err != nil { + return err + } + } else { + native := flamegraph.NewNativeSVG(cfg.CollapsedFields, cfg.CountField) + if err := native.WriteSVGFromFile(iorFile); err != nil { + return err + } } } |
