diff --git a/internal/graph/dotgraph.go b/internal/graph/dotgraph.go index 09d40fd2c..00974cd64 100644 --- a/internal/graph/dotgraph.go +++ b/internal/graph/dotgraph.go @@ -247,7 +247,7 @@ func (b *builder) addNodelets(node *Node, nodeID int) bool { continue } weight := b.config.FormatValue(w) - nodelets += fmt.Sprintf(`N%d_%d [label = "%s" id="N%d_%d" fontsize=8 shape=box3d tooltip="%s"]`+"\n", nodeID, i, t.Name, nodeID, i, weight) + nodelets += fmt.Sprintf(`N%d_%d [label = "%s" id="N%d_%d" fontsize=8 shape=box3d tooltip="%s"]`+"\n", nodeID, i, escapeForDotCentered(t.Name), nodeID, i, weight) nodelets += fmt.Sprintf(`N%d -> N%d_%d [label=" %s" weight=100 tooltip="%s" labeltooltip="%s"]`+"\n", nodeID, nodeID, i, weight, weight, weight) if nts := lnts[t.Name]; nts != nil { nodelets += b.numericNodelets(nts, maxNodelets, flatTags, fmt.Sprintf(`N%d_%d`, nodeID, i)) @@ -274,7 +274,7 @@ func (b *builder) numericNodelets(nts []*Tag, maxNumNodelets int, flatTags bool, } if w != 0 { weight := b.config.FormatValue(w) - nodelets += fmt.Sprintf(`N%s_%d [label = "%s" id="N%s_%d" fontsize=8 shape=box3d tooltip="%s"]`+"\n", source, j, t.Name, source, j, weight) + nodelets += fmt.Sprintf(`N%s_%d [label = "%s" id="N%s_%d" fontsize=8 shape=box3d tooltip="%s"]`+"\n", source, j, escapeForDotCentered(t.Name), source, j, weight) nodelets += fmt.Sprintf(`%s -> N%s_%d [label=" %s" weight=100 tooltip="%s" labeltooltip="%s"%s]`+"\n", source, source, j, weight, weight, weight, attr) } } @@ -486,9 +486,16 @@ func escapeAllForDot(in []string) []string { return out } -// escapeForDot escapes double quotes and backslashes, and replaces Graphviz's -// "center" character (\n) with a left-justified character. +// escapeForDot escapes double quotes and backslashes, and replaces newlines +// with a left-justified escape (\l). // See https://graphviz.org/docs/attr-types/escString/ for more info. func escapeForDot(str string) string { return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(str, `\`, `\\`), `"`, `\"`), "\n", `\l`) } + +// escapeForDotCentered escapes double quotes and backslashes, and replaces +// newlines with Graphviz's center escape (\n). +// See https://graphviz.org/docs/attr-types/escString/ for more info. +func escapeForDotCentered(str string) string { + return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(str, `\`, `\\`), `"`, `\"`), "\n", `\n`) +} diff --git a/internal/graph/dotgraph_test.go b/internal/graph/dotgraph_test.go index b375bb622..84ec80e05 100644 --- a/internal/graph/dotgraph_test.go +++ b/internal/graph/dotgraph_test.go @@ -150,6 +150,29 @@ func TestComposeWithNamesThatNeedEscaping(t *testing.T) { compareGraphs(t, buf.Bytes(), "compose7.dot") } +func TestComposeWithTagsThatNeedEscaping(t *testing.T) { + g := baseGraph() + a, c := baseAttrsAndConfig() + g.Nodes[0].LabelTags["a"] = &Tag{ + Name: `label"quote"` + "\nline2", + Cum: 10, + Flat: 10, + } + g.Nodes[0].NumericTags[""] = TagMap{ + "b": &Tag{ + Name: `numeric"quote"` + "\nline2", + Cum: 20, + Flat: 20, + Unit: "ms", + }, + } + + var buf bytes.Buffer + ComposeDot(&buf, g, a, c) + + compareGraphs(t, buf.Bytes(), "compose8.dot") +} + func TestComposeWithCommentsWithNewlines(t *testing.T) { g := baseGraph() a, c := baseAttrsAndConfig() diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 8abbd83f7..e5c40cd8c 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -525,6 +525,8 @@ func (g *Graph) TrimTree(kept NodePtrSet) { g.RemoveRedundantEdges() } +// joinLabels returns the labels as a string. Newlines in the labels are +// replaced with "\n". Separate labels are joined with newlines. func joinLabels(s *profile.Sample) string { if len(s.Label) == 0 { return "" @@ -533,11 +535,13 @@ func joinLabels(s *profile.Sample) string { var labels []string for key, vals := range s.Label { for _, v := range vals { - labels = append(labels, key+":"+v) + joined := key + ":" + v + escaped := strings.ReplaceAll(joined, "\n", `\n`) + labels = append(labels, escaped) } } sort.Strings(labels) - return strings.Join(labels, `\n`) + return strings.Join(labels, "\n") } // isNegative returns true if the node is considered as "negative" for the diff --git a/internal/graph/graph_test.go b/internal/graph/graph_test.go index 3f47a73d4..ed6fc2a9d 100644 --- a/internal/graph/graph_test.go +++ b/internal/graph/graph_test.go @@ -545,3 +545,18 @@ func TestShortenFunctionName(t *testing.T) { } } } + +func TestJoinLabels(t *testing.T) { + input := &profile.Sample{ + Label: map[string][]string{ + "key1": {"v1", "v2"}, + // value with an embedded newline: is escaped to \n + "key2": {"value line1\nline2"}, + }, + } + const expected = "key1:v1\nkey1:v2\nkey2:value line1\\nline2" + output := joinLabels(input) + if output != expected { + t.Errorf("output=%#v != expected=%#v", output, expected) + } +} diff --git a/internal/graph/testdata/compose8.dot b/internal/graph/testdata/compose8.dot new file mode 100644 index 000000000..fefb64bbd --- /dev/null +++ b/internal/graph/testdata/compose8.dot @@ -0,0 +1,11 @@ +digraph "testtitle" { +node [style=filled fillcolor="#f8f8f8"] +subgraph cluster_L { "label1" [shape=box fontsize=16 label="label1\llabel2\llabel3: \"foo\"\l" tooltip="testtitle"] } +N1 [label="src\n10 (10.00%)\nof 25 (25.00%)" id="node1" fontsize=22 shape=box tooltip="src (25)" color="#b23c00" fillcolor="#edddd5"] +N1_0 [label = "label\"quote\"\nline2" id="N1_0" fontsize=8 shape=box3d tooltip="10"] +N1 -> N1_0 [label=" 10" weight=100 tooltip="10" labeltooltip="10"] +NN1_0 [label = "numeric\"quote\"\nline2" id="NN1_0" fontsize=8 shape=box3d tooltip="20"] +N1 -> NN1_0 [label=" 20" weight=100 tooltip="20" labeltooltip="20"] +N2 [label="dest\n15 (15.00%)\nof 25 (25.00%)" id="node2" fontsize=24 shape=box tooltip="dest (25)" color="#b23c00" fillcolor="#edddd5"] +N1 -> N2 [label=" 10" weight=11 color="#b28559" tooltip="src -> dest (10)" labeltooltip="src -> dest (10)" minlen=2] +}