summaryrefslogtreecommitdiff
path: root/internal/flamegraph
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-03 13:00:38 +0200
committerPaul Buetow <paul@buetow.org>2026-03-03 13:00:38 +0200
commitf92382c20193a5366d15c7347dcc8ed2743f3b85 (patch)
tree11789e0336233501c778c521075f20c8d2c3558d /internal/flamegraph
parent92e87642f2936f0da63d32113f75633b38be24f6 (diff)
Add WASM-ready flamegraph JSON export
Diffstat (limited to 'internal/flamegraph')
-rw-r--r--internal/flamegraph/nativejson.go86
-rw-r--r--internal/flamegraph/nativejson_test.go75
-rw-r--r--internal/flamegraph/nativesvg.go12
3 files changed, 171 insertions, 2 deletions
diff --git a/internal/flamegraph/nativejson.go b/internal/flamegraph/nativejson.go
new file mode 100644
index 0000000..088bcfc
--- /dev/null
+++ b/internal/flamegraph/nativejson.go
@@ -0,0 +1,86 @@
+package flamegraph
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "iter"
+ "os"
+ "strings"
+)
+
+type jsonNode struct {
+ Name string `json:"name"`
+ Value uint64 `json:"value"`
+ Total uint64 `json:"total"`
+ Children []jsonNode `json:"children,omitempty"`
+}
+
+type jsonFlamegraph struct {
+ Fields []string `json:"fields"`
+ CountField string `json:"countField"`
+ Root jsonNode `json:"root"`
+}
+
+func (n NativeSVG) WriteJSONFromFile(iorDataFile string) (outFile string, err error) {
+ outFile = fmt.Sprintf("%s.%s-by-%s.json",
+ strings.TrimSuffix(iorDataFile, ".ior.zst"),
+ strings.Join(n.fields, ":"),
+ n.countField,
+ )
+ defer func() {
+ if err != nil {
+ _ = os.Remove(outFile)
+ }
+ }()
+
+ iod, err := newIorDataFromFile(iorDataFile)
+ if err != nil {
+ return outFile, fmt.Errorf("read ior data: %w", err)
+ }
+
+ fd, err := os.Create(outFile)
+ if err != nil {
+ return outFile, fmt.Errorf("create output %s: %w", outFile, err)
+ }
+ defer fd.Close()
+
+ if err := n.WriteJSONFromIter(iod.iter(), fd); err != nil {
+ return outFile, err
+ }
+ return outFile, nil
+}
+
+func (n NativeSVG) WriteJSONFromIter(records iter.Seq[IterRecord], w io.Writer) error {
+ tr, err := n.buildTrieFromIter(records)
+ if err != nil {
+ return err
+ }
+
+ payload := jsonFlamegraph{
+ Fields: append([]string(nil), n.fields...),
+ CountField: n.countField,
+ Root: jsonNodeFromTrieNode(tr.root, "root"),
+ }
+
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ return enc.Encode(payload)
+}
+
+func jsonNodeFromTrieNode(node *trieNode, name string) jsonNode {
+ out := jsonNode{
+ Name: name,
+ Value: node.value,
+ Total: node.total,
+ }
+ if len(node.children) == 0 {
+ return out
+ }
+
+ out.Children = make([]jsonNode, 0, len(node.children))
+ for _, child := range node.children {
+ out.Children = append(out.Children, jsonNodeFromTrieNode(child, child.name))
+ }
+ return out
+}
diff --git a/internal/flamegraph/nativejson_test.go b/internal/flamegraph/nativejson_test.go
new file mode 100644
index 0000000..c76d327
--- /dev/null
+++ b/internal/flamegraph/nativejson_test.go
@@ -0,0 +1,75 @@
+package flamegraph
+
+import (
+ "encoding/json"
+ "os"
+ "testing"
+)
+
+type jsonNodeForTest struct {
+ Name string `json:"name"`
+ Value uint64 `json:"value"`
+ Total uint64 `json:"total"`
+ Children []jsonNodeForTest `json:"children"`
+}
+
+type jsonFlamegraphForTest struct {
+ Fields []string `json:"fields"`
+ CountField string `json:"countField"`
+ Root jsonNodeForTest `json:"root"`
+}
+
+func TestWriteJSONFromFileContainsFlamegraphTree(t *testing.T) {
+ dir := t.TempDir()
+ iorFile := writeTestIorZst(t, dir)
+
+ n := NewNativeSVG([]string{"comm", "path", "tracepoint"}, "count")
+ outFile, err := n.WriteJSONFromFile(iorFile)
+ if err != nil {
+ t.Fatalf("WriteJSONFromFile returned error: %v", err)
+ }
+
+ data, err := os.ReadFile(outFile)
+ if err != nil {
+ t.Fatalf("read output json: %v", err)
+ }
+
+ var payload jsonFlamegraphForTest
+ if err := json.Unmarshal(data, &payload); err != nil {
+ t.Fatalf("unmarshal output json: %v", err)
+ }
+
+ if payload.CountField != "count" {
+ t.Fatalf("count field = %q, want %q", payload.CountField, "count")
+ }
+ if len(payload.Fields) != 3 {
+ t.Fatalf("fields len = %d, want 3", len(payload.Fields))
+ }
+ if payload.Root.Name != "root" {
+ t.Fatalf("root name = %q, want %q", payload.Root.Name, "root")
+ }
+ if payload.Root.Total != 1 {
+ t.Fatalf("root total = %d, want 1", payload.Root.Total)
+ }
+ if len(payload.Root.Children) != 1 {
+ t.Fatalf("root children len = %d, want 1", len(payload.Root.Children))
+ }
+ if payload.Root.Children[0].Name != "tester" {
+ t.Fatalf("root child name = %q, want %q", payload.Root.Children[0].Name, "tester")
+ }
+}
+
+func TestWriteJSONFromFileCleansUpPartialOutputOnError(t *testing.T) {
+ dir := t.TempDir()
+ iorFile := writeTestIorZst(t, dir)
+
+ n := NewNativeSVG([]string{"invalidField"}, "count")
+ outFile, err := n.WriteJSONFromFile(iorFile)
+ if err == nil {
+ t.Fatal("expected error for invalid field, got nil")
+ }
+
+ if _, statErr := os.Stat(outFile); !os.IsNotExist(statErr) {
+ t.Fatalf("expected partial output to be removed, stat err=%v", statErr)
+ }
+}
diff --git a/internal/flamegraph/nativesvg.go b/internal/flamegraph/nativesvg.go
index 8a2bcd5..80061b4 100644
--- a/internal/flamegraph/nativesvg.go
+++ b/internal/flamegraph/nativesvg.go
@@ -58,18 +58,26 @@ func (n NativeSVG) WriteSVGFromFile(iorDataFile string) (outFile string, err err
}
func (n NativeSVG) WriteSVGFromIter(records iter.Seq[IterRecord], w io.Writer) error {
+ tr, err := n.buildTrieFromIter(records)
+ if err != nil {
+ return err
+ }
+ return WriteSVG(w, tr, n.config)
+}
+
+func (n NativeSVG) buildTrieFromIter(records iter.Seq[IterRecord]) (*trie, error) {
tr := newTrie()
var framesBuf []string
for record := range records {
frames, err := n.recordFrames(record, framesBuf)
if err != nil {
- return err
+ return nil, err
}
framesBuf = frames
tr.add(frames, record.Cnt.ValueByName(n.countField))
}
tr.computeTotals()
- return WriteSVG(w, tr, n.config)
+ return tr, nil
}
func (n NativeSVG) recordFrames(record IterRecord, framesBuf []string) ([]string, error) {