Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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 ./...
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions examples/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module github.com/qm012/sim/examples

go 1.26

require (
github.com/google/uuid v1.6.0
github.com/qm012/sim v0.0.0
)

replace github.com/qm012/sim => ../
2 changes: 2 additions & 0 deletions examples/go.sum
Original file line number Diff line number Diff line change
@@ -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=
141 changes: 141 additions & 0 deletions examples/pprof-labeling/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# 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=<matched route>` 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.
85 changes: 85 additions & 0 deletions examples/pprof-labeling/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package main

import (
"context"
"fmt"
"log"
"log/slog"
"net/http"
"net/http/pprof"
"os"
"os/signal"
"syscall"

"github.com/google/uuid" //nolint:depguard // TODO: use the built-in `uuid` after upgrading to Go 1.27

"github.com/qm012/sim"
)

// init installs a JSON logger that decorates every record with the
// per-request trace_id (see traceIDLogHandler).
func init() {
logger := slog.New(newTraceIDLogHandler(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})))
slog.SetDefault(logger)
}

func main() {
app := sim.NewApp()
app.Use(
new(sim.ClientIPResolution).Handler,
traceIDHandler,
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")
}
54 changes: 54 additions & 0 deletions examples/pprof-labeling/traceid_log_handler.go
Original file line number Diff line number Diff line change
@@ -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)}
}
6 changes: 6 additions & 0 deletions go.work
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
go 1.26

use (
.
./examples
)
51 changes: 51 additions & 0 deletions pprof_labeling.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// 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); 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
// 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, 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
}

// 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))
})
})
}
Loading