Files
tailscale/tsweb/debug.go
T
Brad Fitzpatrick f92ca3f545 tsweb: add /debug/runtime-metrics page exposing Go runtime/metrics
The tsweb DebugHandler already links to expvar, Prometheus varz, and
pprof, but the only runtime visibility beyond pprof was the handful of
runtime.MemStats fields that varz special-cases. The runtime/metrics
package has far more (GC CPU classes, scheduler latencies, stop-the-world
pause histograms, heap breakdowns, and so on) and is the runtime's
preferred, cheaper interface.

Add a /debug/runtime-metrics handler. By default it serves only an index
of metric names, kinds, and descriptions, which are static, so viewing
the page does not read any values. The index is an HTML table for
browsers (Accept: text/html) and plain text otherwise; format=html or
format=text overrides the sniffing.

Values are read only on request, always as JSON:

  - /debug/runtime-metrics/gc/heap/allocs:bytes returns that metric's
    bare value, handy for scripts and curl.
  - /debug/runtime-metrics?name=NAME (repeatable) returns a JSON object
    keyed by metric name. A trailing * matches by prefix, so name=/gc/*
    returns the GC metrics and name=* returns everything.

Histograms are objects with counts and buckets arrays; infinite bucket
boundaries, which JSON cannot represent, are the strings "-Inf" and
"+Inf".

In the HTML index, exact metric names link to the bare value form, and
each run of metrics sharing a directory is headed by a row linking every
ancestor directory to its wildcard query (/gc/*, /gc/heap/*, ...).

Responses inherit the debug handler's nosniff, framing, and CSP headers,
and application/json is not a script MIME type, so the values cannot be
pulled in cross-origin as a script by a malicious page.

Like the pprof handlers, this is excluded from js/wasm builds to keep
them small.

Updates #21300

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I7c2e9f4a1b8d3e6f0a5c9b2d4e7f1a3c6b8d0e2f
2026-09-15 15:26:06 -07:00

202 lines
6.6 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package tsweb
import (
"expvar"
"fmt"
"html"
"io"
"net/http"
"net/url"
"os"
"runtime"
"tailscale.com/feature"
"tailscale.com/tsweb/varz"
"tailscale.com/version"
)
// DebugHandler is an http.Handler that serves a debugging "homepage",
// and provides helpers to register more debug endpoints and reports.
//
// The rendered page consists of three sections: informational
// key/value pairs, links to other pages, and additional
// program-specific HTML. Callers can add to these sections using the
// KV, URL and Section helpers respectively.
//
// Additionally, the Handle method offers a shorthand for correctly
// registering debug handlers and cross-linking them from /debug/.
type DebugHandler struct {
mux *http.ServeMux // where this handler is registered
kvs []func(io.Writer) // output one <li>...</li> each, see KV()
urls []string // one <li>...</li> block with link each
sections []func(io.Writer, *http.Request) // invoked in registration order prior to outputting </body>
title string // title displayed on index page
}
// PrometheusHandler is an optional hook to enable native Prometheus
// support in the debug handler. It is disabled by default. Import the
// tailscale.com/tsweb/promvarz package to enable this feature.
var PrometheusHandler feature.Hook[func(*DebugHandler)]
// hookRuntimeMetrics registers the Go runtime/metrics debug page on a
// DebugHandler. It is set by runtimemetrics.go, which is excluded from
// js/wasm builds to keep them small.
var hookRuntimeMetrics feature.Hook[func(*DebugHandler)]
// Debugger returns the DebugHandler registered on mux at /debug/,
// creating it if necessary.
func Debugger(mux *http.ServeMux) *DebugHandler {
h, pat := mux.Handler(&http.Request{URL: &url.URL{Path: "/debug/"}})
if d, ok := h.(*DebugHandler); ok && pat == "/debug/" {
return d
}
ret := &DebugHandler{
mux: mux,
title: fmt.Sprintf("%s debug", version.CmdName()),
}
mux.Handle("/debug/", ret)
ret.KVFunc("Uptime", func() any { return varz.Uptime() })
ret.KV("Version", version.Long())
ret.Handle("vars", "Metrics (Go)", expvar.Handler())
if f, ok := hookRuntimeMetrics.GetOk(); ok {
f(ret)
}
if PrometheusHandler.IsSet() {
PrometheusHandler.Get()(ret)
} else {
ret.Handle("varz", "Metrics (Prometheus)", http.HandlerFunc(varz.Handler))
}
addProfilingHandlers(ret)
ret.Handle("gc", "force GC", http.HandlerFunc(gcHandler))
hostname, err := os.Hostname()
if err == nil {
ret.KV("Machine", hostname)
}
return ret
}
// ServeHTTP implements http.Handler.
func (d *DebugHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !AllowDebugAccess(r) {
http.Error(w, "debug access denied", http.StatusForbidden)
return
}
if r.URL.Path != "/debug/" {
// Sub-handlers are handled by the parent mux directly.
http.NotFound(w, r)
return
}
AddBrowserHeaders(w)
f := func(format string, args ...any) { fmt.Fprintf(w, format, args...) }
f("<html><body><h1>%s</h1><ul>", html.EscapeString(d.title))
for _, kv := range d.kvs {
kv(w)
}
for _, url := range d.urls {
io.WriteString(w, url)
}
for _, section := range d.sections {
section(w, r)
}
}
func (d *DebugHandler) handle(slug string, handler http.Handler) string {
href := "/debug/" + slug
d.mux.Handle(href, Protected(debugBrowserHeaderHandler(handler)))
return href
}
// Handle registers handler at /debug/<slug> and adds a link to it
// on /debug/ with the provided description.
func (d *DebugHandler) Handle(slug, desc string, handler http.Handler) {
href := d.handle(slug, handler)
d.URL(href, desc)
}
// Handle registers handler at /debug/<slug> and adds a link to it
// on /debug/ with the provided description.
func (d *DebugHandler) HandleFunc(slug, desc string, handler http.HandlerFunc) {
d.Handle(slug, desc, handler)
}
// HandleSilent registers handler at /debug/<slug>. It does not add
// a descriptive entry in /debug/ for it. This should be used
// sparingly, for things that need to be registered but would pollute
// the list of debug links.
func (d *DebugHandler) HandleSilent(slug string, handler http.Handler) {
d.handle(slug, handler)
}
// HandleSilent registers handler at /debug/<slug>. It does not add
// a descriptive entry in /debug/ for it. This should be used
// sparingly, for things that need to be registered but would pollute
// the list of debug links.
func (d *DebugHandler) HandleSilentFunc(slug string, handler http.HandlerFunc) {
d.HandleSilent(slug, handler)
}
// KV adds a key/value list item to /debug/.
func (d *DebugHandler) KV(k string, v any) {
val := html.EscapeString(fmt.Sprintf("%v", v))
d.kvs = append(d.kvs, func(w io.Writer) {
fmt.Fprintf(w, "<li><b>%s:</b> %s</li>", k, val)
})
}
// KVFunc adds a key/value list item to /debug/. v is called on every
// render of /debug/.
func (d *DebugHandler) KVFunc(k string, v func() any) {
d.kvs = append(d.kvs, func(w io.Writer) {
val := html.EscapeString(fmt.Sprintf("%v", v()))
fmt.Fprintf(w, "<li><b>%s:</b> %s</li>", k, val)
})
}
// URL adds a URL and description list item to /debug/.
func (d *DebugHandler) URL(url, desc string) {
if desc != "" {
desc = " (" + desc + ")"
}
d.urls = append(d.urls, fmt.Sprintf(`<li><a href="%s">%s</a>%s</li>`, url, url, html.EscapeString(desc)))
}
// Section invokes f on every render of /debug/ to add supplemental
// HTML to the page body.
func (d *DebugHandler) Section(f func(w io.Writer, r *http.Request)) {
d.sections = append(d.sections, f)
}
// Title sets the title at the top of the debug page.
func (d *DebugHandler) Title(title string) {
d.title = title
}
func gcHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("running GC...\n"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
runtime.GC()
w.Write([]byte("Done.\n"))
}
// debugBrowserHeaderHandler is a wrapper around BrowserHeaderHandler with a
// more relaxed Content-Security-Policy that's acceptable for internal debug
// pages. It should not be used on any public-facing handlers!
func debugBrowserHeaderHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
AddBrowserHeaders(w)
// The only difference from AddBrowserHeaders is that this policy
// allows inline CSS styles. They make debug pages much easier to
// prototype, while the risk of user-injected CSS is relatively low.
w.Header().Set("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; block-all-mixed-content; object-src 'none'; style-src 'self' 'unsafe-inline'")
h.ServeHTTP(w, r)
})
}