diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-03 13:00:38 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-03 13:00:38 +0200 |
| commit | f92382c20193a5366d15c7347dcc8ed2743f3b85 (patch) | |
| tree | 11789e0336233501c778c521075f20c8d2c3558d /internal/flamegraph | |
| parent | 92e87642f2936f0da63d32113f75633b38be24f6 (diff) | |
Add WASM-ready flamegraph JSON export
Diffstat (limited to 'internal/flamegraph')
| -rw-r--r-- | internal/flamegraph/nativejson.go | 86 | ||||
| -rw-r--r-- | internal/flamegraph/nativejson_test.go | 75 | ||||
| -rw-r--r-- | internal/flamegraph/nativesvg.go | 12 |
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) { |
