diff --git a/internal/driver/html/common.css b/internal/driver/html/common.css
index 14f836ff10..0a897ce291 100644
--- a/internal/driver/html/common.css
+++ b/internal/driver/html/common.css
@@ -148,6 +148,10 @@ a {
right: 2px;
}
+.help {
+ padding-left: 1em;
+}
+
{{/* Used to disable events when a modal dialog is displayed */}}
#dialog-overlay {
display: none;
diff --git a/internal/driver/html/header.html b/internal/driver/html/header.html
index e946e6b882..5405a0be95 100644
--- a/internal/driver/html/header.html
+++ b/internal/driver/html/header.html
@@ -83,6 +83,12 @@
{{range .Legend}}{{.}}
{{end}}
+
+ {{if .DocURL}}
+
+ {{end}}
diff --git a/internal/driver/webui.go b/internal/driver/webui.go
index 2a2d7fb1d2..dd628f7c2d 100644
--- a/internal/driver/webui.go
+++ b/internal/driver/webui.go
@@ -79,6 +79,7 @@ type webArgs struct {
Total int64
SampleTypes []string
Legend []string
+ DocURL string
Standalone bool // True for command-line generation of HTML
Help map[string]string
Nodes []string
@@ -290,6 +291,7 @@ func renderHTML(dst io.Writer, tmpl string, rpt *report.Report, errList, legend
data.Title = file + " " + profile
data.Errors = errList
data.Total = rpt.Total()
+ data.DocURL = rpt.DocURL()
data.Legend = legend
return getHTMLTemplates().ExecuteTemplate(dst, tmpl, data)
}
diff --git a/internal/report/report.go b/internal/report/report.go
index e21ce859d4..7035e23139 100644
--- a/internal/report/report.go
+++ b/internal/report/report.go
@@ -19,6 +19,7 @@ package report
import (
"fmt"
"io"
+ "net/url"
"path/filepath"
"regexp"
"sort"
@@ -1331,6 +1332,22 @@ func (rpt *Report) Total() int64 { return rpt.total }
// OutputFormat returns the output format for the report.
func (rpt *Report) OutputFormat() int { return rpt.options.OutputFormat }
+// DocURL returns the documentation URL for Report, or "" if not available.
+func (rpt *Report) DocURL() string {
+ u := rpt.prof.DocURL
+ if u == "" || !absoluteURL(u) {
+ return ""
+ }
+ return u
+}
+
+func absoluteURL(str string) bool {
+ // Avoid returning relative URLs to prevent unwanted local navigation
+ // within pprof server.
+ u, err := url.Parse(str)
+ return err == nil && (u.Scheme == "https" || u.Scheme == "http")
+}
+
func abs64(i int64) int64 {
if i < 0 {
return -i
diff --git a/internal/report/report_test.go b/internal/report/report_test.go
index e21189b40d..08ce9da1e5 100644
--- a/internal/report/report_test.go
+++ b/internal/report/report_test.go
@@ -547,3 +547,32 @@ func TestPrintAssemblyErrorMessage(t *testing.T) {
}
}
}
+
+func TestDocURL(t *testing.T) {
+ type testCase struct {
+ input string
+ want string
+ }
+ for name, c := range map[string]testCase{
+ "empty": {"", ""},
+ "http": {"http://example.com/pprof-help", "http://example.com/pprof-help"},
+ "https": {"https://example.com/pprof-help", "https://example.com/pprof-help"},
+ "relative": {"/foo", ""},
+ "nonhttp": {"mailto:nobody@example.com", ""},
+ } {
+ t.Run(name, func(t *testing.T) {
+ profile := testProfile.Copy()
+ profile.DocURL = c.input
+ rpt := New(profile, &Options{
+ OutputFormat: Dot,
+ Symbol: regexp.MustCompile(`.`),
+ TrimPath: "/some/path",
+ SampleValue: func(v []int64) int64 { return v[1] },
+ SampleUnit: testProfile.SampleType[1].Unit,
+ })
+ if got := rpt.DocURL(); got != c.want {
+ t.Errorf("bad doc URL %q, expecting %q", got, c.want)
+ }
+ })
+ }
+}
diff --git a/profile/encode.go b/profile/encode.go
index 860bb304c3..8ce9d3cf3b 100644
--- a/profile/encode.go
+++ b/profile/encode.go
@@ -122,6 +122,7 @@ func (p *Profile) preEncode() {
}
p.defaultSampleTypeX = addString(strings, p.DefaultSampleType)
+ p.docURLX = addString(strings, p.DocURL)
p.stringTable = make([]string, len(strings))
for s, i := range strings {
@@ -156,6 +157,7 @@ func (p *Profile) encode(b *buffer) {
encodeInt64Opt(b, 12, p.Period)
encodeInt64s(b, 13, p.commentX)
encodeInt64(b, 14, p.defaultSampleTypeX)
+ encodeInt64Opt(b, 15, p.docURLX)
}
var profileDecoder = []decoder{
@@ -237,6 +239,8 @@ var profileDecoder = []decoder{
func(b *buffer, m message) error { return decodeInt64s(b, &m.(*Profile).commentX) },
// int64 defaultSampleType = 14
func(b *buffer, m message) error { return decodeInt64(b, &m.(*Profile).defaultSampleTypeX) },
+ // string doc_link = 15;
+ func(b *buffer, m message) error { return decodeInt64(b, &m.(*Profile).docURLX) },
}
// postDecode takes the unexported fields populated by decode (with
@@ -384,6 +388,7 @@ func (p *Profile) postDecode() error {
p.commentX = nil
p.DefaultSampleType, err = getString(p.stringTable, &p.defaultSampleTypeX, err)
+ p.DocURL, err = getString(p.stringTable, &p.docURLX, err)
p.stringTable = nil
return err
}
diff --git a/profile/merge.go b/profile/merge.go
index eee0132e74..ba4d746407 100644
--- a/profile/merge.go
+++ b/profile/merge.go
@@ -476,6 +476,7 @@ func combineHeaders(srcs []*Profile) (*Profile, error) {
var timeNanos, durationNanos, period int64
var comments []string
seenComments := map[string]bool{}
+ var docURL string
var defaultSampleType string
for _, s := range srcs {
if timeNanos == 0 || s.TimeNanos < timeNanos {
@@ -494,6 +495,9 @@ func combineHeaders(srcs []*Profile) (*Profile, error) {
if defaultSampleType == "" {
defaultSampleType = s.DefaultSampleType
}
+ if docURL == "" {
+ docURL = s.DocURL
+ }
}
p := &Profile{
@@ -509,6 +513,7 @@ func combineHeaders(srcs []*Profile) (*Profile, error) {
Comments: comments,
DefaultSampleType: defaultSampleType,
+ DocURL: docURL,
}
copy(p.SampleType, srcs[0].SampleType)
return p, nil
diff --git a/profile/merge_test.go b/profile/merge_test.go
index 8c181f0c49..d2d05e63e7 100644
--- a/profile/merge_test.go
+++ b/profile/merge_test.go
@@ -17,6 +17,7 @@ package profile
import (
"bytes"
"fmt"
+ "reflect"
"testing"
"github.com/google/pprof/internal/proftest"
@@ -443,3 +444,63 @@ func TestCompatibilizeSampleTypes(t *testing.T) {
})
}
}
+
+func TestDocURLMerge(t *testing.T) {
+ const url1 = "http://example.com/url1"
+ const url2 = "http://example.com/url2"
+ type testCase struct {
+ name string
+ profiles []*Profile
+ want string
+ }
+ profile := func(url string) *Profile {
+ return &Profile{
+ PeriodType: &ValueType{Type: "cpu", Unit: "seconds"},
+ DocURL: url,
+ }
+ }
+ for _, test := range []testCase{
+ {
+ name: "nolinks",
+ profiles: []*Profile{
+ profile(""),
+ profile(""),
+ },
+ want: "",
+ },
+ {
+ name: "single",
+ profiles: []*Profile{
+ profile(url1),
+ },
+ want: url1,
+ },
+ {
+ name: "mix",
+ profiles: []*Profile{
+ profile(""),
+ profile(url1),
+ },
+ want: url1,
+ },
+ {
+ name: "different",
+ profiles: []*Profile{
+ profile(url1),
+ profile(url2),
+ },
+ want: url1,
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ merged, err := combineHeaders(test.profiles)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := merged.DocURL
+ if !reflect.DeepEqual(test.want, got) {
+ t.Errorf("unexpected links; want: %#v, got: %#v", test.want, got)
+ }
+ })
+ }
+}
diff --git a/profile/profile.go b/profile/profile.go
index 5551eb0bfa..0983656c20 100644
--- a/profile/profile.go
+++ b/profile/profile.go
@@ -39,6 +39,7 @@ type Profile struct {
Location []*Location
Function []*Function
Comments []string
+ DocURL string
DropFrames string
KeepFrames string
@@ -53,6 +54,7 @@ type Profile struct {
encodeMu sync.Mutex
commentX []int64
+ docURLX int64
dropFramesX int64
keepFramesX int64
stringTable []string
@@ -555,6 +557,9 @@ func (p *Profile) String() string {
for _, c := range p.Comments {
ss = append(ss, "Comment: "+c)
}
+ if url := p.DocURL; url != "" {
+ ss = append(ss, fmt.Sprintf("Doc: %s", url))
+ }
if pt := p.PeriodType; pt != nil {
ss = append(ss, fmt.Sprintf("PeriodType: %s %s", pt.Type, pt.Unit))
}
diff --git a/profile/profile_test.go b/profile/profile_test.go
index c9e39a6232..31adb66f7b 100644
--- a/profile/profile_test.go
+++ b/profile/profile_test.go
@@ -1869,6 +1869,28 @@ func TestParseKernelRelocation(t *testing.T) {
}
}
+func TestEncodeDecodeDocURL(t *testing.T) {
+ input := testProfile1.Copy()
+ input.DocURL = "http://example.comp/url"
+
+ // Encode/decode.
+ var buf bytes.Buffer
+ if err := input.Write(&buf); err != nil {
+ t.Fatal("encode: ", err)
+ }
+ output, err := Parse(&buf)
+ if err != nil {
+ t.Fatal("decode: ", err)
+ }
+ if want, got := input.String(), output.String(); want != got {
+ d, err := proftest.Diff([]byte(want), []byte(got))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Errorf("wrong result of encode/decode (-want,+got):\n%s\n", string(d))
+ }
+}
+
// parallel runs n copies of fn in parallel.
func parallel(n int, fn func()) {
var wg sync.WaitGroup
diff --git a/proto/profile.proto b/proto/profile.proto
index ff987a6171..9cb816e663 100644
--- a/proto/profile.proto
+++ b/proto/profile.proto
@@ -93,6 +93,12 @@ message Profile {
// Index into the string table of the type of the preferred sample
// value. If unset, clients should default to the last sample value.
int64 default_sample_type = 14;
+ // Documentation link for this profile. The URL must be absolute,
+ // e.g., http://pprof.example.com/cpu-profile.html
+ //
+ // The URL may be missing if the profile was generated by older code or code
+ // that did not bother to supply a link.
+ int64 doc_url = 15; // Index into string table.
}
// ValueType describes the semantics and measurement units of a value.