diff --git a/Gopkg.lock b/Gopkg.lock
index cdc07e883648..9d5b485e1e52 100644
--- a/Gopkg.lock
+++ b/Gopkg.lock
@@ -1028,6 +1028,13 @@
pruneopts = "UT"
revision = "9f7362b77ad333b26c01c99de52a11bdb650ded2"
+[[projects]]
+ digest = "1:2eeeebccad4f052e6037527e86b8114c6bfd184a97f84d4449a5ea6ad202c216"
+ name = "github.com/maruel/panicparse"
+ packages = ["stack"]
+ pruneopts = "UT"
+ revision = "f20d4c4d746f810c9110e21928d4135e1f2a3efa"
+
[[projects]]
digest = "1:130d1249f8a867da8115c3de6cddf0ac29ca405117c88aa1541db690384a51c6"
name = "github.com/marusama/semaphore"
@@ -1786,6 +1793,7 @@
"github.com/lightstep/lightstep-tracer-go",
"github.com/linkedin/goavro",
"github.com/lufia/iostat",
+ "github.com/maruel/panicparse/stack",
"github.com/marusama/semaphore",
"github.com/mattn/go-isatty",
"github.com/mattn/goveralls",
diff --git a/Gopkg.toml b/Gopkg.toml
index f10472fba056..7585e776e716 100644
--- a/Gopkg.toml
+++ b/Gopkg.toml
@@ -36,6 +36,10 @@ ignored = [
name = "github.com/docker/docker"
branch = "master"
+[[constraint]]
+ name = "github.com/maruel/panicparse"
+ revision = "f20d4c4d746f810c9110e21928d4135e1f2a3efa"
+
# https://github.com/getsentry/raven-go/pull/139
[[constraint]]
name = "github.com/getsentry/raven-go"
diff --git a/pkg/server/debug/goroutineui/dump.go b/pkg/server/debug/goroutineui/dump.go
new file mode 100644
index 000000000000..a66227d11c08
--- /dev/null
+++ b/pkg/server/debug/goroutineui/dump.go
@@ -0,0 +1,100 @@
+// Copyright 2019 The Cockroach Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+// implied. See the License for the specific language governing
+// permissions and limitations under the License.
+
+package goroutineui
+
+import (
+ "bytes"
+ "io"
+ "io/ioutil"
+ "runtime"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/maruel/panicparse/stack"
+)
+
+// stacks is a wrapper for runtime.Stack that attempts to recover the data for all goroutines.
+func stacks() []byte {
+ // We don't know how big the traces are, so grow a few times if they don't fit. Start large, though.
+ var trace []byte
+ for n := 1 << 20; /* 1mb */ n <= (1 << 29); /* 512mb */ n *= 2 {
+ trace = make([]byte, n)
+ nbytes := runtime.Stack(trace, true /* all */)
+ if nbytes < len(trace) {
+ return trace[:nbytes]
+ }
+ }
+ return trace
+}
+
+// A Dump wraps a goroutine dump with functionality to output through panicparse.
+type Dump struct {
+ err error
+
+ now time.Time
+ buckets []*stack.Bucket
+}
+
+// NewDump grabs a goroutine dump and associates it with the supplied time.
+func NewDump(now time.Time) Dump {
+ return NewDumpFromBytes(now, stacks())
+}
+
+// NewDumpFromBytes is like NewDump, but treats the supplied bytes as a goroutine
+// dump.
+func NewDumpFromBytes(now time.Time, b []byte) Dump {
+ c, err := stack.ParseDump(bytes.NewReader(b), ioutil.Discard, true /* guesspaths */)
+ if err != nil {
+ return Dump{err: err}
+ }
+ return Dump{now: now, buckets: stack.Aggregate(c.Goroutines, stack.AnyValue)}
+}
+
+// SortCountDesc rearranges the goroutine buckets such that higher multiplicities
+// appear earlier.
+func (d Dump) SortCountDesc() {
+ sort.Slice(d.buckets, func(i, j int) bool {
+ a, b := d.buckets[i], d.buckets[j]
+ return len(a.IDs) > len(b.IDs)
+ })
+}
+
+// SortWaitDesc rearranges the goroutine buckets such that goroutines that have
+// longer wait times appear earlier.
+func (d Dump) SortWaitDesc() {
+ sort.Slice(d.buckets, func(i, j int) bool {
+ a, b := d.buckets[i], d.buckets[j]
+ return a.SleepMax > b.SleepMax
+ })
+}
+
+// HTML writes the rendered output of panicparse into the supplied Writer.
+func (d Dump) HTML(w io.Writer) error {
+ if d.err != nil {
+ return d.err
+ }
+ return writeToHTML(w, d.buckets, d.now)
+}
+
+// HTMLString is like HTML, but returns a string. If an error occurs, its string
+// representation is returned.
+func (d Dump) HTMLString() string {
+ var w strings.Builder
+ if err := d.HTML(&w); err != nil {
+ return err.Error()
+ }
+ return w.String()
+}
diff --git a/pkg/server/debug/goroutineui/dump_test.go b/pkg/server/debug/goroutineui/dump_test.go
new file mode 100644
index 000000000000..7e2074da5976
--- /dev/null
+++ b/pkg/server/debug/goroutineui/dump_test.go
@@ -0,0 +1,98 @@
+// Copyright 2019 The Cockroach Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+// implied. See the License for the specific language governing
+// permissions and limitations under the License.
+
+package goroutineui
+
+import (
+ "io/ioutil"
+ "testing"
+ "time"
+
+ "github.com/cockroachdb/cockroach/pkg/util/leaktest"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDumpHTML(t *testing.T) {
+ defer leaktest.AfterTest(t)()
+
+ now := time.Time{}
+ dump := NewDumpFromBytes(now, []byte(fixture))
+ dump.SortWaitDesc() // noop
+ dump.SortCountDesc() // noop
+ act := dump.HTMLString()
+
+ if false {
+ _ = ioutil.WriteFile("test.html", []byte(act), 0644)
+ }
+ assert.Equal(t, exp, act)
+}
+
+// This is the output of debug.PrintStack() on the go playground.
+const fixture = `goroutine 1 [running]:
+runtime/debug.Stack(0x434070, 0xddb11, 0x0, 0x40e0f8)
+ /usr/local/go/src/runtime/debug/stack.go:24 +0xc0
+runtime/debug.PrintStack()
+ /usr/local/go/src/runtime/debug/stack.go:16 +0x20
+main.main()
+ /tmp/sandbox157492124/main.go:6 +0x20`
+
+const exp = `
+
+
+`
diff --git a/pkg/server/debug/goroutineui/html.go b/pkg/server/debug/goroutineui/html.go
new file mode 100644
index 000000000000..0f68cdd623c4
--- /dev/null
+++ b/pkg/server/debug/goroutineui/html.go
@@ -0,0 +1,202 @@
+// Copyright 2019 The Cockroach Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+// implied. See the License for the specific language governing
+// permissions and limitations under the License.
+
+// Copyright 2017 Marc-Antoine Ruel. All rights reserved.
+// Use of this source code is governed under the Apache License, Version 2.0
+// that can be found in the LICENSE file.
+
+// NB: this file's original lives at:
+// https://github.com/maruel/panicparse/blob/master/internal/html.go
+//
+// Please modify this file only when absolutely necessary so that we can
+// pick up updates easily.
+
+package goroutineui
+
+import (
+ "html/template"
+ "io"
+ "time"
+
+ "github.com/maruel/panicparse/stack"
+)
+
+func writeToHTML(w io.Writer, buckets []*stack.Bucket, now time.Time) error {
+ m := template.FuncMap{
+ "funcClass": funcClass,
+ "notoColorEmoji1F4A3": notoColorEmoji1F4A3,
+ }
+ if len(buckets) > 1 {
+ m["routineClass"] = routineClass
+ } else {
+ m["routineClass"] = func(bucket *stack.Bucket) template.HTML { return "Routine" }
+ }
+ t, err := template.New("htmlTpl").Funcs(m).Parse(htmlTpl)
+ if err != nil {
+ return err
+ }
+ data := struct {
+ Buckets []*stack.Bucket
+ Now time.Time
+ }{buckets, now.Truncate(time.Second)}
+
+ return t.Execute(w, data)
+}
+
+func funcClass(line *stack.Call) template.HTML {
+ if line.IsStdlib {
+ if line.Func.IsExported() {
+ return "FuncStdLibExported"
+ }
+ return "FuncStdLib"
+ } else if line.IsPkgMain() {
+ return "FuncMain"
+ } else if line.Func.IsExported() {
+ return "FuncOtherExported"
+ }
+ return "FuncOther"
+}
+
+func routineClass(bucket *stack.Bucket) template.HTML {
+ if bucket.First {
+ return "RoutineFirst"
+ }
+ return "Routine"
+}
+
+const htmlTpl = `
+{{- define "RenderCall" -}}
+{{.SrcLine}} {{.Func.Name}}({{.Args}})
+{{- end -}}
+
+
+PanicParse
+
+
+
Generated on {{.Now.String}}.
+
+
+{{range .Buckets}}
+
{{if .First}}Running {{end}}Routine
+ {{len .IDs}}: {{.State}}
+ {{if .SleepMax -}}
+ {{- if ne .SleepMin .SleepMax}} [{{.SleepMin}}~{{.SleepMax}} minutes]
+ {{- else}} [{{.SleepMax}} minutes]
+ {{- end -}}
+ {{- end}}
+ {{if .Locked}} [locked]
+ {{- end -}}
+ {{- if .CreatedBy.SrcPath}} [Created by {{template "RenderCall" .CreatedBy}}]
+ {{- end -}}
+