From ded1d62e094736ead30fce9f295b7b3535ec30e8 Mon Sep 17 00:00:00 2001 From: Evan Jones Date: Thu, 17 Feb 2022 17:07:12 -0500 Subject: [PATCH 1/2] internal/graph: Escape tag labels correctly for dot Fixes syntax errors when trying to render pprof profiles that have double quotes in tags. These can be created with Go's pprof labels feature, for example with: pprof.Labels("key", "label \"double quote\"\nline two") Without this change, trying to render a diagram will fail with: Error: : syntax error in line 5 near 'quote' The problem is that the double quote ('"') was never escaped in the label strings. This required me changing how multiple tags are joined. Previously they were joined with the two character `\n` sequence, which is a centered newline in dot. This changes it to a single character newline "\n", which is then escaped as left-justified strings "\l" in dot. * Add a new graph test for labels. * Add a test for joinLabels. * Update the tests in driver which converts \n to \l. --- .../pprof.cpu.flat.functions.call_tree.dot | 4 ++-- .../testdata/pprof.cpu.flat.functions.dot | 4 ++-- internal/graph/dotgraph.go | 10 ++++---- internal/graph/dotgraph_test.go | 24 +++++++++++++++++++ internal/graph/graph.go | 4 +++- internal/graph/graph_test.go | 15 ++++++++++++ internal/graph/testdata/compose8.dot | 11 +++++++++ 7 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 internal/graph/testdata/compose8.dot diff --git a/internal/driver/testdata/pprof.cpu.flat.functions.call_tree.dot b/internal/driver/testdata/pprof.cpu.flat.functions.call_tree.dot index ae57f6647e..4d5d905279 100644 --- a/internal/driver/testdata/pprof.cpu.flat.functions.call_tree.dot +++ b/internal/driver/testdata/pprof.cpu.flat.functions.call_tree.dot @@ -2,12 +2,12 @@ digraph "testbinary" { node [style=filled fillcolor="#f8f8f8"] subgraph cluster_L { "File: testbinary" [shape=box fontsize=16 label="File: testbinary\lType: cpu\lDuration: 10s, Total samples = 1.12s (11.20%)\lShowing nodes accounting for 1.11s, 99.11% of 1.12s total\lDropped 3 nodes (cum <= 0.06s)\l\lSee https://git.io/JfYMW for how to read the graph\l" tooltip="testbinary"] } N1 [label="line1000\n1s (89.29%)" id="node1" fontsize=24 shape=box tooltip="line1000 (1s)" color="#b20500" fillcolor="#edd6d5"] -N1_0 [label = "key1:tag1\nkey2:tag1" id="N1_0" fontsize=8 shape=box3d tooltip="1s"] +N1_0 [label = "key1:tag1\lkey2:tag1" id="N1_0" fontsize=8 shape=box3d tooltip="1s"] N1 -> N1_0 [label=" 1s" weight=100 tooltip="1s" labeltooltip="1s"] N2 [label="line3000\n0 of 1.12s (100%)" id="node2" fontsize=8 shape=box tooltip="line3000 (1.12s)" color="#b20000" fillcolor="#edd5d5"] N3 [label="line3001\n0 of 1.11s (99.11%)" id="node3" fontsize=8 shape=box tooltip="line3001 (1.11s)" color="#b20000" fillcolor="#edd5d5"] N4 [label="line1000\n0.10s (8.93%)" id="node4" fontsize=14 shape=box tooltip="line1000 (0.10s)" color="#b28b62" fillcolor="#ede8e2"] -N4_0 [label = "key1:tag2\nkey3:tag2" id="N4_0" fontsize=8 shape=box3d tooltip="0.10s"] +N4_0 [label = "key1:tag2\lkey3:tag2" id="N4_0" fontsize=8 shape=box3d tooltip="0.10s"] N4 -> N4_0 [label=" 0.10s" weight=100 tooltip="0.10s" labeltooltip="0.10s"] N5 [label="line3002\n0.01s (0.89%)\nof 1.01s (90.18%)" id="node5" fontsize=10 shape=box tooltip="line3002 (1.01s)" color="#b20500" fillcolor="#edd6d5"] N6 [label="line2000\n0 of 1s (89.29%)" id="node6" fontsize=8 shape=box tooltip="line2000 (1s)" color="#b20500" fillcolor="#edd6d5"] diff --git a/internal/driver/testdata/pprof.cpu.flat.functions.dot b/internal/driver/testdata/pprof.cpu.flat.functions.dot index 4a812e4585..8799d01085 100644 --- a/internal/driver/testdata/pprof.cpu.flat.functions.dot +++ b/internal/driver/testdata/pprof.cpu.flat.functions.dot @@ -2,9 +2,9 @@ digraph "testbinary" { node [style=filled fillcolor="#f8f8f8"] subgraph cluster_L { "File: testbinary" [shape=box fontsize=16 label="File: testbinary\lType: cpu\lDuration: 10s, Total samples = 1.12s (11.20%)\lShowing nodes accounting for 1.12s, 100% of 1.12s total\l\lSee https://git.io/JfYMW for how to read the graph\l" tooltip="testbinary"] } N1 [label="line1000\n1.10s (98.21%)" id="node1" fontsize=24 shape=box tooltip="line1000 (1.10s)" color="#b20000" fillcolor="#edd5d5"] -N1_0 [label = "key1:tag1\nkey2:tag1" id="N1_0" fontsize=8 shape=box3d tooltip="1s"] +N1_0 [label = "key1:tag1\lkey2:tag1" id="N1_0" fontsize=8 shape=box3d tooltip="1s"] N1 -> N1_0 [label=" 1s" weight=100 tooltip="1s" labeltooltip="1s"] -N1_1 [label = "key1:tag2\nkey3:tag2" id="N1_1" fontsize=8 shape=box3d tooltip="0.10s"] +N1_1 [label = "key1:tag2\lkey3:tag2" id="N1_1" fontsize=8 shape=box3d tooltip="0.10s"] N1 -> N1_1 [label=" 0.10s" weight=100 tooltip="0.10s" labeltooltip="0.10s"] N2 [label="line3000\n0 of 1.12s (100%)" id="node2" fontsize=8 shape=box tooltip="line3000 (1.12s)" color="#b20000" fillcolor="#edd5d5"] N3 [label="line3001\n0 of 1.11s (99.11%)" id="node3" fontsize=8 shape=box tooltip="line3001 (1.11s)" color="#b20000" fillcolor="#edd5d5"] diff --git a/internal/graph/dotgraph.go b/internal/graph/dotgraph.go index 8008675248..a4a22da1a4 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, escapeForDot(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, escapeForDot(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) } } @@ -483,9 +483,9 @@ 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. -// See https://graphviz.org/doc/info/attrs.html#k:escString for more info. +// escapeForDot escapes double quotes and backslashes, and replaces newlines +// with left-justified newlines ("\l"), instead of centered ("\n"). +// 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`) } diff --git a/internal/graph/dotgraph_test.go b/internal/graph/dotgraph_test.go index 08d80eb024..402a560bfc 100644 --- a/internal/graph/dotgraph_test.go +++ b/internal/graph/dotgraph_test.go @@ -150,6 +150,30 @@ func TestComposeWithNamesThatNeedEscaping(t *testing.T) { compareGraphs(t, buf.Bytes(), "compose7.dot") } +func TestComposeWithTagsThatNeedEscaping(t *testing.T) { + g := baseGraph() + a, c := baseAttrsAndConfig() + // test both named and numeric tags + g.Nodes[0].LabelTags["a"] = &Tag{ + Name: `label"quote"` + "\nline2", + Cum: 10, + Flat: 10, + } + g.Nodes[0].NumericTags[""] = TagMap{ + "b": &Tag{ + Name: `numeric"quote"`, + Cum: 20, + Flat: 20, + Unit: "ms", + }, + } + + var buf bytes.Buffer + ComposeDot(&buf, g, a, c) + + compareGraphs(t, buf.Bytes(), "compose8.dot") +} + func baseGraph() *Graph { src := &Node{ Info: NodeInfo{Name: "src"}, diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 74b904c402..6ec6be1c49 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -519,6 +519,7 @@ func (g *Graph) TrimTree(kept NodePtrSet) { g.RemoveRedundantEdges() } +// joinLabels returns the labels as they should be displayed to the user (not escaped). func joinLabels(s *profile.Sample) string { if len(s.Label) == 0 { return "" @@ -531,7 +532,8 @@ func joinLabels(s *profile.Sample) string { } } sort.Strings(labels) - return strings.Join(labels, `\n`) + // join labels with a newline: this can be a bit confusing for labels with newlines + 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 bdcb984ee2..4d6b9e0c0f 100644 --- a/internal/graph/graph_test.go +++ b/internal/graph/graph_test.go @@ -531,3 +531,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 + "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 0000000000..9e011b7a18 --- /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\"\lline2" 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\"" 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] +} From b63906bd37b59069ef0128a16defaa1d0be50fc5 Mon Sep 17 00:00:00 2001 From: Evan Jones Date: Fri, 18 Feb 2022 09:24:21 -0500 Subject: [PATCH 2/2] Change escapeForDot to escape everything on to one line. This was suggested by code review. This requires a bit of a hack in joinLabels, so we have the right combination of escaped and not escaped strings. --- .../pprof.cpu.flat.functions.call_tree.dot | 4 +-- .../testdata/pprof.cpu.flat.functions.dot | 4 +-- internal/graph/dotgraph.go | 33 +++++++++++++++---- internal/graph/dotgraph_test.go | 21 +++++++----- internal/graph/graph.go | 10 +++--- internal/graph/graph_test.go | 4 +-- internal/graph/testdata/compose8.dot | 2 +- internal/report/report.go | 3 +- 8 files changed, 54 insertions(+), 27 deletions(-) diff --git a/internal/driver/testdata/pprof.cpu.flat.functions.call_tree.dot b/internal/driver/testdata/pprof.cpu.flat.functions.call_tree.dot index 4d5d905279..ae57f6647e 100644 --- a/internal/driver/testdata/pprof.cpu.flat.functions.call_tree.dot +++ b/internal/driver/testdata/pprof.cpu.flat.functions.call_tree.dot @@ -2,12 +2,12 @@ digraph "testbinary" { node [style=filled fillcolor="#f8f8f8"] subgraph cluster_L { "File: testbinary" [shape=box fontsize=16 label="File: testbinary\lType: cpu\lDuration: 10s, Total samples = 1.12s (11.20%)\lShowing nodes accounting for 1.11s, 99.11% of 1.12s total\lDropped 3 nodes (cum <= 0.06s)\l\lSee https://git.io/JfYMW for how to read the graph\l" tooltip="testbinary"] } N1 [label="line1000\n1s (89.29%)" id="node1" fontsize=24 shape=box tooltip="line1000 (1s)" color="#b20500" fillcolor="#edd6d5"] -N1_0 [label = "key1:tag1\lkey2:tag1" id="N1_0" fontsize=8 shape=box3d tooltip="1s"] +N1_0 [label = "key1:tag1\nkey2:tag1" id="N1_0" fontsize=8 shape=box3d tooltip="1s"] N1 -> N1_0 [label=" 1s" weight=100 tooltip="1s" labeltooltip="1s"] N2 [label="line3000\n0 of 1.12s (100%)" id="node2" fontsize=8 shape=box tooltip="line3000 (1.12s)" color="#b20000" fillcolor="#edd5d5"] N3 [label="line3001\n0 of 1.11s (99.11%)" id="node3" fontsize=8 shape=box tooltip="line3001 (1.11s)" color="#b20000" fillcolor="#edd5d5"] N4 [label="line1000\n0.10s (8.93%)" id="node4" fontsize=14 shape=box tooltip="line1000 (0.10s)" color="#b28b62" fillcolor="#ede8e2"] -N4_0 [label = "key1:tag2\lkey3:tag2" id="N4_0" fontsize=8 shape=box3d tooltip="0.10s"] +N4_0 [label = "key1:tag2\nkey3:tag2" id="N4_0" fontsize=8 shape=box3d tooltip="0.10s"] N4 -> N4_0 [label=" 0.10s" weight=100 tooltip="0.10s" labeltooltip="0.10s"] N5 [label="line3002\n0.01s (0.89%)\nof 1.01s (90.18%)" id="node5" fontsize=10 shape=box tooltip="line3002 (1.01s)" color="#b20500" fillcolor="#edd6d5"] N6 [label="line2000\n0 of 1s (89.29%)" id="node6" fontsize=8 shape=box tooltip="line2000 (1s)" color="#b20500" fillcolor="#edd6d5"] diff --git a/internal/driver/testdata/pprof.cpu.flat.functions.dot b/internal/driver/testdata/pprof.cpu.flat.functions.dot index 8799d01085..4a812e4585 100644 --- a/internal/driver/testdata/pprof.cpu.flat.functions.dot +++ b/internal/driver/testdata/pprof.cpu.flat.functions.dot @@ -2,9 +2,9 @@ digraph "testbinary" { node [style=filled fillcolor="#f8f8f8"] subgraph cluster_L { "File: testbinary" [shape=box fontsize=16 label="File: testbinary\lType: cpu\lDuration: 10s, Total samples = 1.12s (11.20%)\lShowing nodes accounting for 1.12s, 100% of 1.12s total\l\lSee https://git.io/JfYMW for how to read the graph\l" tooltip="testbinary"] } N1 [label="line1000\n1.10s (98.21%)" id="node1" fontsize=24 shape=box tooltip="line1000 (1.10s)" color="#b20000" fillcolor="#edd5d5"] -N1_0 [label = "key1:tag1\lkey2:tag1" id="N1_0" fontsize=8 shape=box3d tooltip="1s"] +N1_0 [label = "key1:tag1\nkey2:tag1" id="N1_0" fontsize=8 shape=box3d tooltip="1s"] N1 -> N1_0 [label=" 1s" weight=100 tooltip="1s" labeltooltip="1s"] -N1_1 [label = "key1:tag2\lkey3:tag2" id="N1_1" fontsize=8 shape=box3d tooltip="0.10s"] +N1_1 [label = "key1:tag2\nkey3:tag2" id="N1_1" fontsize=8 shape=box3d tooltip="0.10s"] N1 -> N1_1 [label=" 0.10s" weight=100 tooltip="0.10s" labeltooltip="0.10s"] N2 [label="line3000\n0 of 1.12s (100%)" id="node2" fontsize=8 shape=box tooltip="line3000 (1.12s)" color="#b20000" fillcolor="#edd5d5"] N3 [label="line3001\n0 of 1.11s (99.11%)" id="node3" fontsize=8 shape=box tooltip="line3001 (1.11s)" color="#b20000" fillcolor="#edd5d5"] diff --git a/internal/graph/dotgraph.go b/internal/graph/dotgraph.go index a4a22da1a4..972f1d2dc8 100644 --- a/internal/graph/dotgraph.go +++ b/internal/graph/dotgraph.go @@ -19,6 +19,7 @@ import ( "io" "math" "path/filepath" + "strconv" "strings" "github.com/google/pprof/internal/measurement" @@ -247,7 +248,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, escapeForDot(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, 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 +275,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, escapeForDot(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, 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) } } @@ -483,9 +484,29 @@ func escapeAllForDot(in []string) []string { return out } -// escapeForDot escapes double quotes and backslashes, and replaces newlines -// with left-justified newlines ("\l"), instead of centered ("\n"). -// See https://graphviz.org/docs/attr-types/escString/ for more info. +// escapeForDot escapes str so it displays as a single line. It escapes double +// quotes and backslashes, and replaces any non-printable characters with their +// Go escaped equivalent. See: +// https://graphviz.org/docs/attr-types/escString/ func escapeForDot(str string) string { - return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(str, `\`, `\\`), `"`, `\"`), "\n", `\l`) + var out strings.Builder + for _, rune := range str { + switch { + case rune == '\\': + out.WriteString(`\\`) + case rune == '"': + out.WriteString(`\"`) + case strconv.IsPrint(rune): + out.WriteRune(rune) + default: + // QuoteRune wraps the result in ''. + quoted := strconv.QuoteRune(rune) + quoted = quoted[1 : len(quoted)-1] + if quoted[0] == '\\' { + out.WriteByte('\\') + } + out.WriteString(quoted) + } + } + return out.String() } diff --git a/internal/graph/dotgraph_test.go b/internal/graph/dotgraph_test.go index 402a560bfc..5c891e04ee 100644 --- a/internal/graph/dotgraph_test.go +++ b/internal/graph/dotgraph_test.go @@ -153,15 +153,15 @@ func TestComposeWithNamesThatNeedEscaping(t *testing.T) { func TestComposeWithTagsThatNeedEscaping(t *testing.T) { g := baseGraph() a, c := baseAttrsAndConfig() - // test both named and numeric tags + // Tag names are normally escaped by joinLabels. g.Nodes[0].LabelTags["a"] = &Tag{ - Name: `label"quote"` + "\nline2", + Name: escapeForDot(`label"quote"` + "\nline2"), Cum: 10, Flat: 10, } g.Nodes[0].NumericTags[""] = TagMap{ "b": &Tag{ - Name: `numeric"quote"`, + Name: escapeForDot(`numeric"quote"`), Cum: 20, Flat: 20, Unit: "ms", @@ -368,16 +368,16 @@ func TestEscapeForDot(t *testing.T) { input []string want []string }{ + { + desc: "empty does nothing", + input: []string{``}, + want: []string{``}, + }, { desc: "with multiple doubles quotes", input: []string{`label: "foo" and "bar"`}, want: []string{`label: \"foo\" and \"bar\"`}, }, - { - desc: "with graphviz center line character", - input: []string{"label: foo \n bar"}, - want: []string{`label: foo \l bar`}, - }, { desc: "with two backslashes", input: []string{`label: \\`}, @@ -393,6 +393,11 @@ func TestEscapeForDot(t *testing.T) { input: []string{`label1: "foo"`, `label2: "bar"`}, want: []string{`label1: \"foo\"`, `label2: \"bar\"`}, }, + { + desc: "escape newlines and control chars", + input: []string{"line1\nline2", "null:\x00", "alert:\a", "backspace:\b"}, + want: []string{`line1\\nline2`, `null:\\x00`, `alert:\\a`, `backspace:\\b`}, + }, } { t.Run(tc.desc, func(t *testing.T) { if got := escapeAllForDot(tc.input); !reflect.DeepEqual(got, tc.want) { diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 6ec6be1c49..8a6211b675 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -270,7 +270,7 @@ func (e *Edge) WeightValue() int64 { // Tag represent sample annotations type Tag struct { - Name string + Name string // Must be escaped for dot Unit string // Describe the value, "" for non-numeric tags Value int64 Flat, FlatDiv int64 @@ -519,7 +519,7 @@ func (g *Graph) TrimTree(kept NodePtrSet) { g.RemoveRedundantEdges() } -// joinLabels returns the labels as they should be displayed to the user (not escaped). +// joinLabels returns the labels as a string escaped for dot. func joinLabels(s *profile.Sample) string { if len(s.Label) == 0 { return "" @@ -528,12 +528,12 @@ func joinLabels(s *profile.Sample) string { var labels []string for key, vals := range s.Label { for _, v := range vals { - labels = append(labels, key+":"+v) + labels = append(labels, escapeForDot(key+":"+v)) } } sort.Strings(labels) - // join labels with a newline: this can be a bit confusing for labels with newlines - return strings.Join(labels, "\n") + // To show labels on separate lines, include "\n" in the dot output. + 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 4d6b9e0c0f..d13e36e83b 100644 --- a/internal/graph/graph_test.go +++ b/internal/graph/graph_test.go @@ -536,11 +536,11 @@ func TestJoinLabels(t *testing.T) { input := &profile.Sample{ Label: map[string][]string{ "key1": {"v1", "v2"}, - // value with an embedded newline + // value with an embedded newline: is escaped "key2": {"value line1\nline2"}, }, } - const expected = "key1:v1\nkey1:v2\nkey2: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 index 9e011b7a18..3247d5d111 100644 --- a/internal/graph/testdata/compose8.dot +++ b/internal/graph/testdata/compose8.dot @@ -2,7 +2,7 @@ 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\"\lline2" id="N1_0" fontsize=8 shape=box3d tooltip="10"] +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\"" id="NN1_0" fontsize=8 shape=box3d tooltip="20"] N1 -> NN1_0 [label=" 20" weight=100 tooltip="20" labeltooltip="20"] diff --git a/internal/report/report.go b/internal/report/report.go index e2fb00314c..8295933b8f 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -1215,7 +1215,8 @@ func reportLabels(rpt *Report, g *graph.Graph, origCount, droppedNodes, droppedE // Help new users understand the graph. // A new line is intentionally added here to better show this message. if fullHeaders { - label = append(label, "\nSee https://git.io/JfYMW for how to read the graph") + // Include an empty string to separate with a blank line. + label = append(label, "", "See https://git.io/JfYMW for how to read the graph") } return label