From e413a45d6400404e371e16b0e7190e98c744e962 Mon Sep 17 00:00:00 2001 From: qm012 <67568757+qm012@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:20:51 +0800 Subject: [PATCH 1/2] add pprof labeling --- Makefile | 4 + examples/README.md | 5 + examples/go.mod | 3 + examples/pprof-labeling/README.md | 1 + examples/pprof-labeling/main.go | 63 +++++++++++++ go.work | 6 ++ pprof_labeling.go | 50 ++++++++++ pprof_labeling_test.go | 151 ++++++++++++++++++++++++++++++ 8 files changed, 283 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/go.mod create mode 100644 examples/pprof-labeling/README.md create mode 100644 examples/pprof-labeling/main.go create mode 100644 go.work create mode 100644 pprof_labeling.go create mode 100644 pprof_labeling_test.go diff --git a/Makefile b/Makefile index 30c97eb..1c48a5f 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,7 @@ +# Make targets operate on the root module only, even when a go.work +# exists for local development (go tool -modfile rejects workspaces). +export GOWORK := off + .PHONY: test-unit test-unit: ## Run unit tests go test -v ./... diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..fd99ea5 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,5 @@ +# Sim examples + +Runnable mini-apps that show how to build with [sim](https://github.com/qm012/sim). Each example lives +in its own directory with a README explaining what it demonstrates; +run one with `go run .` from its directory. diff --git a/examples/go.mod b/examples/go.mod new file mode 100644 index 0000000..0fc8994 --- /dev/null +++ b/examples/go.mod @@ -0,0 +1,3 @@ +module github.com/qm012/sim/examples + +go 1.27 diff --git a/examples/pprof-labeling/README.md b/examples/pprof-labeling/README.md new file mode 100644 index 0000000..226d6b9 --- /dev/null +++ b/examples/pprof-labeling/README.md @@ -0,0 +1 @@ +# pprof labeling \ No newline at end of file diff --git a/examples/pprof-labeling/main.go b/examples/pprof-labeling/main.go new file mode 100644 index 0000000..76eadfe --- /dev/null +++ b/examples/pprof-labeling/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "uuid" + + "github.com/qm012/sim" +) + +type ctxTraceIDKey struct{} + +func traceIDHandler(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), ctxTraceIDKey{}, uuid.NewV7().String()) + h.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func traceIDFromContext(ctx context.Context) string { + if ip, ok := ctx.Value(ctxTraceIDKey{}).(string); ok { + return ip + } + return "" +} + +func panicHandler(_ http.ResponseWriter, _ *http.Request) { + panic("oops") +} + +var pprofLabeling = &sim.PprofLabeling{ + Labels: func(r *http.Request) []string { + return []string{"trace_id", traceIDFromContext(r.Context()), "pattern", r.Pattern} + }, +} + +func init() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + slog.SetDefault(logger) +} + +func main() { + app := sim.NewApp() + app.Use( + new(sim.ClientIPResolution).Handler, + new(sim.RequestLogging).Handler, + traceIDHandler, + pprofLabeling.Handler, + new(sim.Recovery).Handler, + ) + app.Get("/panic", panicHandler) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + if err := app.Run(ctx, ":8080"); err != nil { + log.Fatal(err) + } +} diff --git a/go.work b/go.work new file mode 100644 index 0000000..f7194c7 --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.27 + +use ( + . + ./examples +) diff --git a/pprof_labeling.go b/pprof_labeling.go new file mode 100644 index 0000000..e78d45c --- /dev/null +++ b/pprof_labeling.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 The Sim Authors +// Use of this source code is governed by a MIT license +// that can be found in the LICENSE file. + +package sim + +import ( + "context" + "net/http" + "runtime/pprof" +) + +// PprofLabeling unconditionally labels every request it serves via +// [runtime/pprof.Do]. The labels ride along with the request's +// goroutine, so an active CPU profile and goroutine tracebacks since +// Go 1.27 carry them (GODEBUG=tracebacklabels=0 disables the +// latter), and panics recovered by [Recovery] are attributed to +// their route. +// +// Register it after any wrapper whose values the Labels function reads +// from the request context, such as a trace ID wrapper, since the +// context is only populated by wrappers registered earlier in the chain. +// Prefer registering it before [Recovery] so samples taken while +// recovering from panics carry the labels too; [Default] does not +// register PprofLabeling, so compose the chain with [App.Use] as shown +// on [Default]. +type PprofLabeling struct { + // Labels returns the key/value pairs applied to each request. + // Nil applies a single "pattern" label holding the matched pattern. + // Keep the cardinality bounded: a unique value per request, such as + // a trace ID, grows the profile linearly with the request count + // during the collection window. + Labels func(*http.Request) []string +} + +// Handler wraps h and serves each request inside [runtime/pprof.Do]. +// It captures the current field values at call time; +// later changes do not affect the returned handler. +func (p *PprofLabeling) Handler(h http.Handler) http.Handler { + labels := p.Labels + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + kv := []string{"pattern", r.Pattern} + if labels != nil { + kv = labels(r) + } + pprof.Do(r.Context(), pprof.Labels(kv...), func(ctx context.Context) { + h.ServeHTTP(w, r.WithContext(ctx)) + }) + }) +} diff --git a/pprof_labeling_test.go b/pprof_labeling_test.go new file mode 100644 index 0000000..e504e14 --- /dev/null +++ b/pprof_labeling_test.go @@ -0,0 +1,151 @@ +// Copyright (c) 2026 The Sim Authors +// Use of this source code is governed by a MIT license +// that can be found in the LICENSE file. + +package sim_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "runtime/pprof" + "testing" + + "github.com/qm012/sim" +) + +// serveLabeled serves req through pl.Handler and returns the pprof +// labels visible to the wrapped handler's context. +func serveLabeled(t *testing.T, pl *sim.PprofLabeling, req *http.Request) map[string]string { + t.Helper() + got := map[string]string{} + h := pl.Handler(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + pprof.ForLabels(r.Context(), func(key, value string) bool { + got[key] = value + return true + }) + })) + h.ServeHTTP(httptest.NewRecorder(), req) + return got +} + +func TestPprofLabeling(t *testing.T) { + type ctxKey struct{} + tests := []struct { + name string + pl sim.PprofLabeling + req func() *http.Request + want map[string]string + }{ + { + name: "default labels the matched pattern", + req: func() *http.Request { + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/users/42", nil) + req.Pattern = "GET /users/{id}" + return req + }, + want: map[string]string{"pattern": "GET /users/{id}"}, + }, + { + name: "custom labels replace the default", + pl: sim.PprofLabeling{Labels: func(*http.Request) []string { + return []string{"method", http.MethodGet, "tenant", "acme"} + }}, + req: func() *http.Request { + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) + req.Pattern = "GET /" + return req + }, + want: map[string]string{"method": http.MethodGet, "tenant": "acme"}, + }, + { + name: "labels read from the request context", + pl: sim.PprofLabeling{Labels: func(r *http.Request) []string { + id, _ := r.Context().Value(ctxKey{}).(string) + return []string{"trace_id", id} + }}, + req: func() *http.Request { + ctx := context.WithValue(t.Context(), ctxKey{}, "t-1") + return httptest.NewRequestWithContext(ctx, http.MethodGet, "/", nil) + }, + want: map[string]string{"trace_id": "t-1"}, + }, + { + name: "custom labels returning none drop the default", + pl: sim.PprofLabeling{Labels: func(*http.Request) []string { return nil }}, + req: func() *http.Request { + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) + req.Pattern = "GET /" + return req + }, + want: map[string]string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := serveLabeled(t, &tt.pl, tt.req()); !reflect.DeepEqual(got, tt.want) { + t.Errorf("labels = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPprofLabelingCapturesFieldsAtCallTime(t *testing.T) { + pl := &sim.PprofLabeling{Labels: func(*http.Request) []string { + return []string{"trace_id", "captured"} + }} + handler := pl.Handler(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got := map[string]string{} + pprof.ForLabels(r.Context(), func(key, value string) bool { + got[key] = value + return true + }) + if got["trace_id"] != "captured" { + t.Errorf("trace_id = %q, want %q (captured at call time)", got["trace_id"], "captured") + } + })) + + // Mutate after Handler; the returned handler keeps the captured values. + pl.Labels = func(*http.Request) []string { + return []string{"trace_id", "mutated"} + } + handler.ServeHTTP(httptest.NewRecorder(), + httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)) +} + +func TestPprofLabelingPropagatesPanic(t *testing.T) { + handler := (&sim.PprofLabeling{}).Handler(http.HandlerFunc( + func(http.ResponseWriter, *http.Request) { panic("boom") })) + + defer func() { + if recover() == nil { + t.Error("panic did not propagate") + } + }() + handler.ServeHTTP(httptest.NewRecorder(), + httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)) +} + +func ExamplePprofLabeling() { + // Nil Labels defaults to a single "pattern" label; assign a Labels + // function to add per-request pairs such as a trace ID, and + // register the wrapper with Use, before Recovery. + pl := &sim.PprofLabeling{Labels: func(r *http.Request) []string { + return []string{"pattern", r.Pattern, "trace_id", "acme"} + }} + handler := pl.Handler(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + pprof.ForLabels(r.Context(), func(key, value string) bool { + fmt.Println(key, value) + return true + }) + })) + + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil) + req.Pattern = "GET /" + handler.ServeHTTP(httptest.NewRecorder(), req) + // Output: + // pattern GET / + // trace_id acme +} From a55c5da5663e7de566d7d990b750c8ac67e751fa Mon Sep 17 00:00:00 2001 From: qm012 <67568757+qm012@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:42:37 +0800 Subject: [PATCH 2/2] add example Signed-off-by: qm012 <67568757+qm012@users.noreply.github.com> --- README.md | 7 +- examples/go.mod | 9 +- examples/go.sum | 2 + examples/pprof-labeling/README.md | 142 +++++++++++++++++- examples/pprof-labeling/main.go | 82 ++++++---- .../pprof-labeling/traceid_log_handler.go | 54 +++++++ go.work | 2 +- pprof_labeling.go | 15 +- pprof_labeling_test.go | 23 --- 9 files changed, 272 insertions(+), 64 deletions(-) create mode 100644 examples/go.sum create mode 100644 examples/pprof-labeling/traceid_log_handler.go diff --git a/README.md b/README.md index d9a9d31..e95b4b0 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,13 @@ native performance untouched. Simple, not simplistic. | `ClientIPResolution` | Resolves the real client IP behind trusted proxies and adds `client_ip` to request logs | | `RequestLogging` | Writes structured `slog` records per request | | `Recovery` | Turns panics into a logged stack trace and HTTP 500 instead of a crash | +| `PprofLabeling` | Labels each request's goroutine with its route pattern for pprof attribution (opt-in) | -`Default` bundles all three wrappers, ready to use with no configuration. +`Default` bundles `ClientIPResolution`, `RequestLogging` and +`Recovery`, ready to use with no configuration. `PprofLabeling` is +opt-in: add it with `app.Use` when you need route attribution in +profiles and tracebacks (see +[examples/pprof-labeling](examples/pprof-labeling/README.md)). ## Installation diff --git a/examples/go.mod b/examples/go.mod index 0fc8994..d6e17e3 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -1,3 +1,10 @@ module github.com/qm012/sim/examples -go 1.27 +go 1.26 + +require ( + github.com/google/uuid v1.6.0 + github.com/qm012/sim v0.0.0 +) + +replace github.com/qm012/sim => ../ diff --git a/examples/go.sum b/examples/go.sum new file mode 100644 index 0000000..7790d7c --- /dev/null +++ b/examples/go.sum @@ -0,0 +1,2 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/examples/pprof-labeling/README.md b/examples/pprof-labeling/README.md index 226d6b9..f3a529d 100644 --- a/examples/pprof-labeling/README.md +++ b/examples/pprof-labeling/README.md @@ -1 +1,141 @@ -# pprof labeling \ No newline at end of file +# pprof labeling + +Labels every request's goroutine with its route pattern via +`sim.PprofLabeling`, so CPU profiles and panic tracebacks can be +attributed to routes. The app serves traffic on `:8080` and the +standard `net/http/pprof` handlers on a separate metrics server at +`:8081`. Every log record also carries a per-request `trace_id` +injected into the context by a tiny middleware and read back by a +custom `slog.Handler`. + +## Run + +```sh +go run . +``` + +Then try the two routes: + +```sh +curl http://localhost:8080/hello +curl http://localhost:8080/panic +``` + +`/panic` is recovered by `sim.Recovery` and logged as an ERROR record +with the stack trace and the request details. + +## Route labels in panic tracebacks + +`PprofLabeling` wraps each request in `runtime/pprof.Do` with a +`pattern=` label. The runtime can print those labels in +goroutine tracebacks, controlled by the `tracebacklabels` GODEBUG +setting: + +- This module targets Go 1.26, where the setting defaults to `0`. + Start the example with it enabled to see labels in the panic log: + + ```sh + GODEBUG=tracebacklabels=1 go run . + ``` + + (PowerShell: `$env:GODEBUG='tracebacklabels=1'; go run .`) +- From Go 1.27 on, the default is `1`. Set `GODEBUG=tracebacklabels=0` + to switch it back off permanently for the binary. + +With labels enabled, `curl http://localhost:8080/panic` produces a +`[Recovery] panic recovered` record whose stack starts with: + +```text +goroutine N [running] {pattern: "GET /panic"}: +``` + +The same labels also appear in `?debug=2` goroutine dumps (see +below) and in CPU profile samples. Even without the setting, +Recovery's structured `request.pattern` attribute identifies the +route. + +## CPU profiling with `go tool pprof` + +Point `go tool pprof` at the *running* service's metrics port. While +profiling, keep some traffic flowing (`/hello` in a loop): + +```sh +go tool pprof -http=:8082 "http://localhost:8081/debug/pprof/profile?seconds=10" +``` + +The browser UI opens automatically; choose **VIEW → Flame Graph** for +the flame graph (wider = more CPU, the x-axis is not time). Samples +taken on the request goroutine carry the `pattern` label, so you can +profile one route at a time with `-tagfocus`: + +```sh +go tool pprof -http=:8082 -tagfocus="pattern:GET /hello" "http://localhost:8081/debug/pprof/profile?seconds=10" +``` + +`-tagfocus` is a filter: it *drops* every sample without a matching +label. Keep traffic flowing while sampling, and confirm the filter +applied via the `Active filters:` line in the output (or `-raw`, which +shows `pattern:[GET /hello]` on matching samples). Samples from +connection-level goroutines (accept, background reads) carry no +label, so even under load a filtered profile is much smaller than the +full one. + +Other profiles work the same way: + +```sh +go tool pprof http://localhost:8081/debug/pprof/goroutine +``` + +The `goroutine` and `goroutineleak` profiles also carry the labels, +but only for goroutines sampled *inside* the labeled section — the +handler must be doing real work when the snapshot is taken, which is +rare for a handler as fast as `/hello`. + +`heap`, `allocs`, `block`, `mutex` and `threadcreate` do not carry +labels: their records are per-event, not per-goroutine. + +## Execution tracing with `go tool trace` + +```sh +curl -o trace.out "http://localhost:8081/debug/pprof/trace?seconds=5" +go tool trace trace.out +``` + +This complements pprof: instead of statistical samples you get a +timeline of scheduling, GC and blocking events. Note that execution +traces do not carry the pprof goroutine labels — labels only appear +in profiles, tracebacks and `?debug=2` goroutine dumps, so use pprof +for route attribution. + +## Goroutine dumps and leaks + +`?debug=2` dumps every goroutine as a traceback. With +`GODEBUG=tracebacklabels=1`, goroutines inside the labeled section +start with `{pattern: "GET /hello"}` in their header: + +```sh +curl "http://localhost:8081/debug/pprof/goroutine?debug=2" +``` + +`goroutineleak` reports goroutines that blocked for a long time — +as text or as a pprof profile whose samples carry the labels, so a +leaked request goroutine names its route: + +```sh +curl "http://localhost:8081/debug/pprof/goroutineleak?debug=2" +go tool pprof -http=:8082 "http://localhost:8081/debug/pprof/goroutineleak" +``` + +## Available endpoints + +`main.go` registers `pprof.Index` plus `cmdline`, `profile`, `symbol` +and `trace` on `:8081`. Everything else — `goroutine`, +`threadcreate`, `heap`, `allocs`, `block`, `mutex` and Go 1.27's +`goroutineleak` — is served through `pprof.Index` without extra +registration: + +```sh +curl "http://localhost:8081/debug/pprof/goroutineleak?debug=1" +``` + +Browse `http://localhost:8081/debug/pprof/` for the full list. diff --git a/examples/pprof-labeling/main.go b/examples/pprof-labeling/main.go index 76eadfe..548da7b 100644 --- a/examples/pprof-labeling/main.go +++ b/examples/pprof-labeling/main.go @@ -2,45 +2,24 @@ package main import ( "context" + "fmt" "log" "log/slog" "net/http" + "net/http/pprof" "os" "os/signal" "syscall" - "uuid" + + "github.com/google/uuid" //nolint:depguard // TODO: use the built-in `uuid` after upgrading to Go 1.27 "github.com/qm012/sim" ) -type ctxTraceIDKey struct{} - -func traceIDHandler(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := context.WithValue(r.Context(), ctxTraceIDKey{}, uuid.NewV7().String()) - h.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -func traceIDFromContext(ctx context.Context) string { - if ip, ok := ctx.Value(ctxTraceIDKey{}).(string); ok { - return ip - } - return "" -} - -func panicHandler(_ http.ResponseWriter, _ *http.Request) { - panic("oops") -} - -var pprofLabeling = &sim.PprofLabeling{ - Labels: func(r *http.Request) []string { - return []string{"trace_id", traceIDFromContext(r.Context()), "pattern", r.Pattern} - }, -} - +// init installs a JSON logger that decorates every record with the +// per-request trace_id (see traceIDLogHandler). func init() { - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + logger := slog.New(newTraceIDLogHandler(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))) slog.SetDefault(logger) } @@ -48,16 +27,59 @@ func main() { app := sim.NewApp() app.Use( new(sim.ClientIPResolution).Handler, - new(sim.RequestLogging).Handler, traceIDHandler, - pprofLabeling.Handler, + new(sim.PprofLabeling).Handler, + new(sim.RequestLogging).Handler, new(sim.Recovery).Handler, ) app.Get("/panic", panicHandler) + app.Get("/hello", helloHandler) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() + go metricsServer(ctx, ":8081") if err := app.Run(ctx, ":8080"); err != nil { log.Fatal(err) } } + +// metricsServer serves the net/http/pprof handlers on a dedicated +// port, kept off the app's mux so profiling endpoints are not +// exposed on the public listener. pprof.Index additionally serves +// the goroutine, threadcreate, heap, allocs, block, mutex and +// goroutineleak profiles without any extra registration. +func metricsServer(ctx context.Context, addr string) { + app := sim.NewApp() + app.Get("/debug/pprof/", pprof.Index) + app.Get("/debug/pprof/cmdline", pprof.Cmdline) + app.Get("/debug/pprof/profile", pprof.Profile) + app.Get("/debug/pprof/symbol", pprof.Symbol) + app.Get("/debug/pprof/trace", pprof.Trace) + if err := app.Run(ctx, addr); err != nil { + log.Fatal(err) + } +} + +type ctxTraceIDKey struct{} + +func traceIDHandler(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), ctxTraceIDKey{}, uuid.New().String()) + h.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func traceIDFromContext(ctx context.Context) string { + if id, ok := ctx.Value(ctxTraceIDKey{}).(string); ok { + return id + } + return "" +} + +func helloHandler(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprintf(w, "hello") +} + +func panicHandler(_ http.ResponseWriter, _ *http.Request) { + panic("oops") +} diff --git a/examples/pprof-labeling/traceid_log_handler.go b/examples/pprof-labeling/traceid_log_handler.go new file mode 100644 index 0000000..0da736b --- /dev/null +++ b/examples/pprof-labeling/traceid_log_handler.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "errors" + "log/slog" +) + +// traceIDLogHandler is a [slog.Handler] that attaches the trace ID +// stored in the request context to each log record as trace_id. +type traceIDLogHandler struct { + next slog.Handler +} + +var _ slog.Handler = (*traceIDLogHandler)(nil) + +// newTraceIDLogHandler returns a handler that decorates next with a +// trace_id attribute sourced from the request context. +func newTraceIDLogHandler(next slog.Handler) slog.Handler { + return &traceIDLogHandler{ + next: next, + } +} + +// Enabled implements [slog.Handler]. +func (h *traceIDLogHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.next.Enabled(ctx, level) +} + +// Handle implements [slog.Handler]. +func (h *traceIDLogHandler) Handle(ctx context.Context, record slog.Record) error { + if h.next == nil { + return errors.New("traceIDLog: handler is missing") + } + + record.AddAttrs(slog.String("trace_id", traceIDFromContext(ctx))) + return h.next.Handle(ctx, record) +} + +// WithAttrs implements [slog.Handler]. +func (h *traceIDLogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + if h.next == nil { + return h + } + return &traceIDLogHandler{next: h.next.WithAttrs(attrs)} +} + +// WithGroup implements [slog.Handler]. +func (h *traceIDLogHandler) WithGroup(name string) slog.Handler { + if h.next == nil { + return h + } + return &traceIDLogHandler{next: h.next.WithGroup(name)} +} diff --git a/go.work b/go.work index f7194c7..d16b2ad 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.27 +go 1.26 use ( . diff --git a/pprof_labeling.go b/pprof_labeling.go index e78d45c..5086bb5 100644 --- a/pprof_labeling.go +++ b/pprof_labeling.go @@ -14,8 +14,8 @@ import ( // [runtime/pprof.Do]. The labels ride along with the request's // goroutine, so an active CPU profile and goroutine tracebacks since // Go 1.27 carry them (GODEBUG=tracebacklabels=0 disables the -// latter), and panics recovered by [Recovery] are attributed to -// their route. +// latter); registered before [Recovery], they also attribute the +// panics it recovers to their route. // // Register it after any wrapper whose values the Labels function reads // from the request context, such as a trace ID wrapper, since the @@ -25,11 +25,12 @@ import ( // register PprofLabeling, so compose the chain with [App.Use] as shown // on [Default]. type PprofLabeling struct { - // Labels returns the key/value pairs applied to each request. - // Nil applies a single "pattern" label holding the matched pattern. - // Keep the cardinality bounded: a unique value per request, such as - // a trace ID, grows the profile linearly with the request count - // during the collection window. + // Labels returns the key/value pairs applied to each request, as + // alternating key, value strings of even length per + // [runtime/pprof.Labels]. Nil applies a single "pattern" label + // holding the matched pattern. Keep the cardinality bounded: a + // unique value per request, such as a trace ID, grows the profile + // linearly with the request count during the collection window. Labels func(*http.Request) []string } diff --git a/pprof_labeling_test.go b/pprof_labeling_test.go index e504e14..4d5d37d 100644 --- a/pprof_labeling_test.go +++ b/pprof_labeling_test.go @@ -6,7 +6,6 @@ package sim_test import ( "context" - "fmt" "net/http" "net/http/httptest" "reflect" @@ -127,25 +126,3 @@ func TestPprofLabelingPropagatesPanic(t *testing.T) { handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)) } - -func ExamplePprofLabeling() { - // Nil Labels defaults to a single "pattern" label; assign a Labels - // function to add per-request pairs such as a trace ID, and - // register the wrapper with Use, before Recovery. - pl := &sim.PprofLabeling{Labels: func(r *http.Request) []string { - return []string{"pattern", r.Pattern, "trace_id", "acme"} - }} - handler := pl.Handler(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - pprof.ForLabels(r.Context(), func(key, value string) bool { - fmt.Println(key, value) - return true - }) - })) - - req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil) - req.Pattern = "GET /" - handler.ServeHTTP(httptest.NewRecorder(), req) - // Output: - // pattern GET / - // trace_id acme -}