easypprof

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 22 Imported by: 0

README

easypprof

Production-safe pprof for Go services.

import _ "net/http/pprof" is the usual way to add profiling, and it is a common pentest finding. That import registers the handlers on http.DefaultServeMux. If anything in your process serves that mux on a public port, then /debug/pprof is public too. It leaks internals, ?seconds=600 is a free denial of service, and /debug/pprof/cmdline hands out whatever secrets you passed as flags.

easypprof gives you the same profiles without those problems:

net/http/pprof easypprof
Listener whatever serves DefaultServeMux its own listener and mux, 127.0.0.1:6060 by default
Touches DefaultServeMux yes, just by importing it never (the package doesn't import net/http/pprof)
Auth none named tokens (Bearer or Basic) or your own Authorizer; non-loopback binds require one
Network allowlist none optional CIDR allowlist on the socket address (never X-Forwarded-For)
Profile duration unbounded capped (MaxProfileDuration, 60s default)
Concurrent pulls unbounded limited (MaxConcurrent, 2 default), extra get 429
Audit trail none one slog line per request: who, what, status, bytes, duration
/cmdline on off unless EnableCmdline
Block / mutex profiles off (empty) on, sampled (10µs block rate, 1/100 mutex fraction)
Slowloris-style timeouts yours to set set
Flight recorder (Go 1.25+) n/a built in, with throttled Snapshot()

Install

go get github.com/arxdsilva/easypprof

Requires Go 1.23+. The flight recorder needs Go 1.25+.

Quick start

dbg, err := easypprof.New(easypprof.Config{
    Tokens: map[string]string{"oncall": os.Getenv("PPROF_TOKEN")},
})
if err != nil {
    log.Fatal(err) // e.g. PPROF_TOKEN unset or shorter than 16 chars
}
if err := dbg.Start(); err != nil {
    log.Fatal(err)
}
defer dbg.Shutdown(context.Background())

Then, from somewhere that can reach the port:

# CPU profile for 30s
curl -sH "Authorization: Bearer $PPROF_TOKEN" -o cpu.pb.gz \
  'localhost:6060/debug/pprof/profile?seconds=30'
go tool pprof -http=:7070 cpu.pb.gz

# lock contention
curl -sH "Authorization: Bearer $PPROF_TOKEN" -o mutex.pb.gz localhost:6060/debug/pprof/mutex
curl -sH "Authorization: Bearer $PPROF_TOKEN" -o block.pb.gz localhost:6060/debug/pprof/block

go tool pprof can't send a Bearer header, but it does send HTTP Basic credentials from the URL, so this works too. Note that the token ends up in your shell history:

go tool pprof -http=:7070 "http://oncall:$PPROF_TOKEN@localhost:6060/debug/pprof/heap"

Embed in your existing server

If you'd rather not open another port, mount the endpoints on the mux your API already serves. You keep the same guard: tokens, CIDR allowlist, duration cap, concurrency limit and audit log.

dbg, err := easypprof.New(easypprof.Config{
    Tokens: map[string]string{"oncall": os.Getenv("PPROF_TOKEN")},
})
if err != nil {
    log.Fatal(err)
}
if err := dbg.Mount(mux); err != nil { // registers /debug/pprof/
    log.Fatal(err)
}
defer dbg.Shutdown(context.Background())

For other routers, Handler() returns the http.Handler. Serve it at /debug/pprof/:

h, err := dbg.Handler()
if err != nil {
    log.Fatal(err)
}
r.Handle("/debug/pprof/*", h) // chi

Things that differ from the standalone server:

  • Auth is mandatory. Your API port is not loopback, so Mount and Handler refuse to run without Tokens or an Authorizer. Set AllowUnauthenticated only if something in front already authenticates callers.
  • Your server's WriteTimeout is extended for profile, trace and flightrecorder responses only, up to MaxProfileDuration + 30s, so long profiles aren't cut off.
  • Behind a load balancer or proxy, AllowedCIDRs sees the proxy's address, because X-Forwarded-For is never trusted. Rely on tokens.
  • Shutdown restores the runtime profiling rates and stops the flight recorder. A ServeMux can't unregister a route, so after Shutdown the endpoints answer 503.

Start and Mount are mutually exclusive on the same Server.

Turning it on and off at runtime

Disable() switches profiling off without unmounting the route or closing the port:

  • the endpoints answer 503 Service Unavailable (before auth, so a probe can't test tokens), and each request still gets an audit line with reason=profiling disabled
  • the block and mutex profiling rates are restored, and the flight recorder is stopped and its buffer dropped
  • profiles already in progress run to completion

Enable() turns it all back on. The flight recorder starts again with an empty buffer. Both calls are idempotent and safe from any goroutine. Call Disable() before Mount/Start to start switched off.

Toggle it from an admin route that your own auth already protects. Don't expose this route unauthenticated:

admin.HandleFunc("POST /admin/pprof/{state}", func(w http.ResponseWriter, r *http.Request) {
    switch r.PathValue("state") {
    case "on":
        if err := dbg.Enable(); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
    case "off":
        dbg.Disable()
    default:
        http.NotFound(w, r)
    }
})

Or flip it with a signal (kill -USR1 <pid>):

sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGUSR1)
go func() {
    for range sig {
        if dbg.Enabled() {
            dbg.Disable()
        } else if err := dbg.Enable(); err != nil {
            log.Printf("enable pprof: %v", err)
        }
    }
}()

While profiling is off, Snapshot and SnapshotTo return ErrFlightRecorderStopped, and those attempts don't count against MinInterval.

Kubernetes

Keep the default 127.0.0.1:6060. kubectl port-forward connects inside the pod's network namespace, so loopback is reachable through it and nothing else can reach it:

kubectl port-forward pod/ledger-7c9f 6060:6060

The access control is your Kubernetes RBAC (who may port-forward), and easypprof's tokens and audit log sit on top of it.

If you need the port reachable from inside the cluster (for a continuous profiler, say), bind the pod IP. easypprof will then insist on Tokens or an Authorizer. Also lock it down with a NetworkPolicy, and optionally AllowedCIDRs:

easypprof.Config{
    Addr:         os.Getenv("POD_IP") + ":6060",
    Tokens:       map[string]string{"pyroscope": os.Getenv("PPROF_SCRAPER_TOKEN")},
    AllowedCIDRs: []string{"10.20.0.0/16"},
}

Never expose the port through a Service of type LoadBalancer or an Ingress.

Configuration

Field Default Notes
Addr 127.0.0.1:6060 Non-loopback requires Tokens or Authorizer, or AllowUnauthenticated: true.
Tokens none map[name]token. Name is logged; each token must be ≥16 chars. Bearer or Basic (name:token). Never accepted in the query string.
Authorizer none func(*http.Request) (subject string, ok bool), replaces Tokens. Use for mTLS (r.TLS.PeerCertificates), mesh identity headers, OIDC.
AllowedCIDRs none Checked against the TCP peer address.
MaxProfileDuration 60s Caps ?seconds= on profile and trace.
MaxConcurrent 2 Profile requests in flight; extra get 429 with Retry-After.
BlockProfileRate 10000 ns; passed to runtime.SetBlockProfileRate. Negative disables.
MutexProfileFraction 100 1 in N events; passed to runtime.SetMutexProfileFraction. Negative disables.
EnableCmdline false /cmdline returns 404 otherwise.
DisableFullGoroutineDump false Rejects goroutine?debug=2, which stops the world for the whole dump.
FlightRecorder nil See below.
Logger slog.Default() Audit and lifecycle logs.

Runtime rates are applied in Start and restored in Shutdown.

Endpoints

All under /debug/pprof/, GET only:

  • / index page
  • heap (?gc=1 to collect first), allocs, goroutine (?debug=1|2 for text), block, mutex, threadcreate, plus any custom runtime/pprof profiles
  • profile?seconds=N CPU profile
  • trace?seconds=N execution trace
  • flightrecorder flight recorder snapshot (when enabled)
  • cmdline only with EnableCmdline

Delta profiles (heap?seconds=N) aren't supported. Take two profiles and compare them with go tool pprof -diff_base before.pb.gz after.pb.gz.

Labels: see which operation is burning CPU

Without labels, a CPU profile of a ledger shows database/sql and encoding/json. With labels you can ask what post_entry costs:

easypprof.Do(ctx, func(ctx context.Context) {
    postEntry(ctx, e)
}, "op", "post_entry")

mux.Handle("POST /transfers", easypprof.Label("transfers", transfersHandler))
go tool pprof -tagfocus op=post_entry cpu.pb.gz
go tool pprof -tags cpu.pb.gz

Labels propagate to goroutines started inside fn. Keep values low-cardinality, such as operation names and never account IDs, because profiles leave production.

Flight recorder (Go 1.25+)

Latency spikes don't wait for you to start a trace. The flight recorder keeps the last few seconds of execution trace in a ring buffer, and you write it out when something trips:

dbg, _ := easypprof.New(easypprof.Config{
    Tokens: tokens,
    FlightRecorder: &easypprof.FlightRecorderConfig{
        MinAge:      10 * time.Second, // history to keep
        MaxBytes:    16 << 20,         // memory cap
        Dir:         "/var/tmp/traces",
        MinInterval: time.Minute,      // at most one file per minute
    },
})

// in a handler or middleware:
if elapsed > 250*time.Millisecond {
    go dbg.Snapshot("slow-post-entry") // throttled, safe to call on every slow request
}
go tool trace /var/tmp/traces/trace-20261003T212703.066Z-slow-post-entry.out

Files are created 0600 in a 0700 directory. Other options: dbg.SnapshotTo(w) streams to any io.Writer (S3 upload, etc.), or GET /debug/pprof/flightrecorder pulls a snapshot on demand. On Go < 1.25, New returns ErrFlightRecorderUnsupported, and the example shows falling back without it.

Treat profiles as sensitive

Profiles contain function names, file paths and stack shapes, not variable values. Still:

  • goroutine?debug=2 and execution traces show more than you might expect, and core dumps (GOTRACEBACK=crash) contain memory, so leave those off in a regulated system.
  • Store pulled profiles with the same access controls and retention as other production diagnostics.
  • The audit log is your change-management trail, so ship it with your other logs.

Profiling around a pentest or load test

  1. Before: pull baseline heap, goroutine and 30s profile. Confirm port 6060 is not reachable from the tester's network position.
  2. During: keep the flight recorder on, pull CPU and mutex profiles while the test is hot. DoS-class issues show up here: goroutine leaks from half-open connections, unbounded memory on oversized payloads, expensive regex or JSON paths, and lock convoys on hot accounts.
  3. After: go tool pprof -diff_base baseline-heap.pb.gz after-heap.pb.gz. A goroutine count that doesn't return to baseline is a leak.

Example

example/ledger is a toy ledger with a deliberately hot lock:

export PPROF_TOKEN=$(openssl rand -hex 24)
go run ./example/ledger &
for i in $(seq 1 2000); do curl -s -XPOST 'localhost:8080/transfers?from=a&to=b&amount=1' >/dev/null & done
curl -sH "Authorization: Bearer $PPROF_TOKEN" -o mutex.pb.gz localhost:6060/debug/pprof/mutex
go tool pprof -top mutex.pb.gz   # -> main.(*ledger).transfer

Not included (on purpose)

  • Continuous profiling. Use Pyroscope, Parca, Datadog or Cloud Profiler. They can scrape easypprof's endpoints with a token.
  • Runtime metrics. Export runtime/metrics through your Prometheus or OpenTelemetry setup.
  • PGO. Pull a representative production CPU profile and commit it as default.pgo in your main package.

License

MIT

Documentation

Overview

Package easypprof exposes Go's pprof endpoints in a way that is safe to run against a production service.

The usual one-liner, import _ "net/http/pprof", registers the profiling handlers on http.DefaultServeMux. If anything in the process serves that mux on a public port, the endpoints are public too: they leak internals and /debug/pprof/profile?seconds=600 is a cheap denial of service. easypprof instead:

  • serves pprof on its own listener and mux (127.0.0.1:6060 by default), never on http.DefaultServeMux;
  • refuses to bind a non-loopback address without authentication;
  • authenticates callers with named tokens (Bearer or Basic) or a custom Authorizer, and can additionally restrict callers to CIDR ranges;
  • caps the duration of every ?seconds= profile and limits concurrent pulls;
  • writes an audit log entry (who, what, when, result) for every request;
  • hides /debug/pprof/cmdline by default, because command lines often carry DSNs and other secrets;
  • turns on sampled block and mutex profiling, which are off by default in the Go runtime and are the profiles that show lock and I/O contention;
  • on Go 1.25+, runs a runtime/trace flight recorder you can snapshot when something goes wrong.

Typical use, on a dedicated listener:

srv, err := easypprof.New(easypprof.Config{
	Tokens: map[string]string{"oncall": os.Getenv("PPROF_TOKEN")},
})
if err != nil {
	log.Fatal(err)
}
if err := srv.Start(); err != nil {
	log.Fatal(err)
}
defer srv.Shutdown(context.Background())

Or embedded in an existing server's mux, with no extra port. This mode always requires Tokens or Authorizer:

if err := srv.Mount(mux); err != nil {
	log.Fatal(err)
}
defer srv.Shutdown(context.Background())

Either way, profiling can be switched off and on at runtime, for example from an admin endpoint, without unmounting or closing anything:

srv.Disable() // endpoints answer 503, runtime profiling rates restored
srv.Enable()

Index

Constants

View Source
const (
	DefaultAddr                 = "127.0.0.1:6060"
	DefaultMaxProfileDuration   = 60 * time.Second
	DefaultMaxConcurrent        = 2
	DefaultBlockProfileRate     = 10_000 // sample blocking events of ~10µs and longer
	DefaultMutexProfileFraction = 100    // sample ~1% of mutex contention events
)

Defaults applied by New when the corresponding Config field is zero.

View Source
const PathPrefix = "/debug/pprof/"

PathPrefix is where the pprof endpoints are served.

Variables

View Source
var (
	// ErrFlightRecorderUnsupported is returned by New when FlightRecorder is
	// set but the binary was built with a Go version older than 1.25.
	ErrFlightRecorderUnsupported = errors.New("easypprof: flight recorder requires Go 1.25 or newer")

	// ErrFlightRecorderDisabled is returned by Snapshot methods when
	// Config.FlightRecorder was not set.
	ErrFlightRecorderDisabled = errors.New("easypprof: flight recorder is not enabled")

	// ErrFlightRecorderStopped is returned by Snapshot methods when the
	// flight recorder is configured but not running: before Start or Mount,
	// while the server is disabled, or after Shutdown.
	ErrFlightRecorderStopped = errors.New("easypprof: flight recorder is not running")

	// ErrSnapshotThrottled is returned by Snapshot when the previous snapshot
	// was taken less than FlightRecorderConfig.MinInterval ago.
	ErrSnapshotThrottled = errors.New("easypprof: snapshot throttled")

	// ErrNoSnapshotDir is returned by Snapshot when FlightRecorderConfig.Dir
	// is empty. Use SnapshotTo to write somewhere else.
	ErrNoSnapshotDir = errors.New("easypprof: FlightRecorderConfig.Dir is not set")
)

Functions

func Do

func Do(ctx context.Context, fn func(context.Context), labels ...string)

Do runs fn with the given pprof labels attached to the current goroutine and to every goroutine fn starts. CPU and goroutine profile samples taken while fn runs carry the labels, so you can filter a profile with `go tool pprof -tagfocus op=post_entry`.

labels are key/value pairs and must have even length:

easypprof.Do(ctx, func(ctx context.Context) {
	postEntry(ctx, entry)
}, "op", "post_entry", "ledger", "settlement")

Keep label values low-cardinality (operation names, not account IDs): they end up in profiles that may leave the production environment.

func Label

func Label(name string, h http.Handler) http.Handler

Label wraps an http.Handler so that work done while serving it is labeled handler=name in CPU and goroutine profiles.

mux.Handle("POST /transfers", easypprof.Label("transfer", transferHandler))

Types

type Config

type Config struct {
	// Addr is the TCP address to listen on. Defaults to DefaultAddr.
	//
	// Binding to anything other than a loopback address requires Tokens or
	// Authorizer, unless AllowUnauthenticated is set. Network position is not
	// authentication.
	Addr string

	// Tokens maps a caller name to a secret token. The name is recorded in the
	// audit log. Callers authenticate with either
	//
	//	Authorization: Bearer <token>
	//
	// or HTTP Basic auth with the name as the user and the token as the
	// password. Every token must be at least 16 characters long.
	Tokens map[string]string

	// Authorizer, if set, is used instead of Tokens. It returns the identity
	// of the caller for the audit log and whether the request is allowed. Use
	// it to plug in mTLS client certificates, OIDC, or a service mesh header.
	Authorizer func(r *http.Request) (subject string, ok bool)

	// AllowedCIDRs, if non-empty, restricts callers to these networks, based
	// on the connection's remote address. X-Forwarded-For is never trusted.
	AllowedCIDRs []string

	// AllowUnauthenticated permits listening on a non-loopback address with no
	// Tokens or Authorizer. Only set it when something in front of the server
	// already authenticates callers.
	AllowUnauthenticated bool

	// MaxProfileDuration caps the ?seconds= parameter accepted by profile,
	// trace and delta profile endpoints. Defaults to DefaultMaxProfileDuration.
	MaxProfileDuration time.Duration

	// MaxConcurrent limits how many profile requests run at once. Extra
	// requests get 429 Too Many Requests. Defaults to DefaultMaxConcurrent.
	MaxConcurrent int

	// BlockProfileRate is passed to runtime.SetBlockProfileRate on Start.
	// 0 means DefaultBlockProfileRate; a negative value leaves block
	// profiling off.
	BlockProfileRate int

	// MutexProfileFraction is passed to runtime.SetMutexProfileFraction on
	// Start. 0 means DefaultMutexProfileFraction; a negative value leaves
	// mutex profiling off.
	MutexProfileFraction int

	// EnableCmdline serves /debug/pprof/cmdline. It is off by default because
	// process arguments frequently contain secrets.
	EnableCmdline bool

	// DisableFullGoroutineDump rejects /debug/pprof/goroutine?debug=2. That
	// dump stops the world for its whole duration, which can be long in a
	// process with many goroutines.
	DisableFullGoroutineDump bool

	// FlightRecorder enables the runtime/trace flight recorder (Go 1.25+).
	// On older Go versions New returns ErrFlightRecorderUnsupported.
	FlightRecorder *FlightRecorderConfig

	// Logger receives audit and lifecycle logs. Defaults to slog.Default().
	Logger *slog.Logger
}

Config configures a Server. The zero value is valid and gives a server on 127.0.0.1:6060 with no authentication, which is reachable only from the same host or network namespace (for example through kubectl port-forward).

type FlightRecorderConfig

type FlightRecorderConfig struct {
	// MinAge is how much recent trace history to keep. Defaults to 10s.
	MinAge time.Duration

	// MaxBytes caps the memory used by the buffer; it takes precedence over
	// MinAge. Defaults to 16 MiB.
	MaxBytes uint64

	// Dir is where Snapshot writes trace files. Created with mode 0700 if it
	// does not exist. Leave empty to use only SnapshotTo and the HTTP
	// endpoint.
	Dir string

	// MinInterval is the minimum time between two Snapshot calls that write
	// files. It makes Snapshot safe to call from a hot path. Defaults to 1m.
	MinInterval time.Duration
}

FlightRecorderConfig configures the runtime/trace flight recorder. The recorder keeps the last few seconds of execution trace in memory so that, when something goes wrong (a latency spike, a timeout), you can write out what the scheduler, GC and goroutines were doing just before it.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is a hardened pprof debug server. Create it with New, then call Start and, on the way out, Shutdown.

func New

func New(cfg Config) (*Server, error)

New validates cfg and builds a Server. It does not touch the runtime or open a socket; Start does that.

func (*Server) Addr

func (s *Server) Addr() string

Addr returns the address the server is listening on, or "" before Start. Useful when Config.Addr uses port 0.

func (*Server) Disable

func (s *Server) Disable()

Disable turns profiling off without closing the listener or unmounting the handler: endpoints answer 503 Service Unavailable, the runtime profiling rates are restored and the flight recorder is stopped. Profiles already in progress run to completion. It is idempotent. Called before Start or Mount, the server starts switched off.

func (*Server) Enable

func (s *Server) Enable() error

Enable turns profiling back on after Disable: it re-applies the block and mutex rates, restarts the flight recorder (with an empty buffer) and lets requests through. It is idempotent. Before Start or Mount it only records the intent. After Shutdown it records the intent, but the handler keeps answering 503 until the server is started or mounted again.

func (*Server) Enabled

func (s *Server) Enabled() bool

Enabled reports whether profiling is switched on. It reflects the last Enable or Disable call, whether or not the server is running.

func (*Server) Handler

func (s *Server) Handler() (http.Handler, error)

Handler applies the runtime settings Start would (block and mutex rates, flight recorder) and returns the guarded pprof handler, for serving on an existing server instead of a dedicated listener. Serve it at PathPrefix; with a ServeMux, use Mount.

The host server's address is unknown here, so Handler requires Tokens or Authorizer unless AllowUnauthenticated is set. Call Shutdown on the way out to restore the runtime; the handler then answers 503 Service Unavailable.

func (*Server) Mount

func (s *Server) Mount(mux *http.ServeMux) error

Mount registers the pprof endpoints on mux at PathPrefix. See Handler.

func (*Server) Shutdown

func (s *Server) Shutdown(ctx context.Context) error

Shutdown stops the server, waiting for in-flight profiles until ctx is done, then stops the flight recorder and restores the runtime profiling rates. In mounted mode there is no listener to stop; the handler starts answering 503.

func (*Server) Snapshot

func (s *Server) Snapshot(reason string) (string, error)

Snapshot writes the flight recorder's buffer to a new file in FlightRecorderConfig.Dir and returns its path. reason ends up in the file name and the log, e.g. "p99-over-250ms".

It is throttled by MinInterval and returns ErrSnapshotThrottled when called too often, so it is fine to call it every time a latency threshold trips:

if elapsed > 250*time.Millisecond {
	go srv.Snapshot("slow-transfer")
}

func (*Server) SnapshotTo

func (s *Server) SnapshotTo(w io.Writer) (int64, error)

SnapshotTo writes the flight recorder's buffer to w, for example to upload it to object storage. It is not throttled.

func (*Server) Start

func (s *Server) Start() error

Start applies the block and mutex profiling rates, starts the flight recorder if configured, binds the listener and serves in the background. Bind errors are returned synchronously.

Directories

Path Synopsis
example
ledger command
Command ledger is a toy double-entry ledger that shows how to wire easypprof into a service.
Command ledger is a toy double-entry ledger that shows how to wire easypprof into a service.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL