diff --git a/AGENTS.md b/AGENTS.md index 328714c..dcb682e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ This repository is a Go utility library module: `github.com/pubgo/funk/v2`. - [stack](./stack/README.md) - [connmux](./connmux/README.md) - [cloudevent](./component/cloudevent/README.md) + - [pyroscope](./component/pyroscope/README.md) ## Working rules for AI coding agents diff --git a/component/pyroscope/README.md b/component/pyroscope/README.md new file mode 100644 index 0000000..e9e83a5 --- /dev/null +++ b/component/pyroscope/README.md @@ -0,0 +1,93 @@ +# Pyroscope Component + +Grafana [Pyroscope](https://grafana.com/docs/pyroscope/latest/) continuous profiling integration for Go services. + +Built on [`github.com/grafana/pyroscope-go`](https://github.com/grafana/pyroscope-go). + +## Installation + +```bash +go get github.com/pubgo/funk/v2/component/pyroscope +``` + +## Quick Start + +```go +import ( + "github.com/pubgo/funk/v2/component/lifecycle" + "github.com/pubgo/funk/v2/component/pyroscope" + "github.com/pubgo/funk/v2/log" +) + +func main() { + lc := /* lifecycle from your app */ + client := pyroscope.New(pyroscope.Param{ + Cfg: &pyroscope.Config{ + Enabled: true, + ServerAddress: "http://pyroscope:4040", + }, + Logger: log.GetLogger("app"), + Lc: lc, + }) + _ = client + + pyroscope.TagWrapper(ctx, pyroscope.Labels("handler", "CreateOrder"), func(ctx context.Context) { + // profiled code + }) +} +``` + +## Configuration + +| Field | Description | +|-------|-------------| +| `enabled` | Turn profiling on/off | +| `application_name` | Pyroscope app name (default: `{project}/{version}`) | +| `server_address` | Pyroscope server URL | +| `basic_auth_user` / `basic_auth_password` | HTTP basic auth (Grafana Cloud) | +| `tenant_id` | Multi-tenant Pyroscope ID | +| `upload_rate` | Profile upload interval (default: `1m`) | +| `profile_types` | CPU, heap, goroutine, etc. (defaults match pyroscope-go) | +| `tags` | Extra labels merged with hostname/env/version/instance_id | +| `disable_gc_runs` | Pass through to pyroscope-go | +| `disable_log` | Silence pyroscope client logs | + +Example YAML: + +```yaml +pyroscope: + enabled: true + server_address: http://pyroscope:4040 + application_name: my-service + profile_types: + - cpu + - alloc_objects + - inuse_objects + tags: + team: platform +``` + +## Lifecycle + +When `Param.Lc` is set, `BeforeStop` calls `profiler.Stop()` to flush remaining profiles on shutdown. + +## Profiling Tags + +Use `TagWrapper` or `Labels` (re-exported from pyroscope-go) to attach labels to hot paths: + +```go +pyroscope.TagWrapper(ctx, pyroscope.Labels("controller", "slow"), func(ctx context.Context) { + handleSlowPath(ctx) +}) +``` + +## Pull Mode + +For pull-based profiling, enable `net/http/pprof` in your HTTP server; no push client is required. See [Pyroscope Go SDK docs](https://grafana.com/docs/pyroscope/latest/configure-client/language-sdks/go_push/). + +## Example + +```bash +# requires a running Pyroscope server +PYROSCOPE_SERVER=http://localhost:4040 go run ./component/pyroscope/example +``` diff --git a/component/pyroscope/_doc.go b/component/pyroscope/_doc.go new file mode 100644 index 0000000..aebc7e3 --- /dev/null +++ b/component/pyroscope/_doc.go @@ -0,0 +1,7 @@ +package pyroscope + +// Continuous profiling client for Grafana Pyroscope. +// +// See README.md for configuration and usage. +// +// https://github.com/grafana/pyroscope-go diff --git a/component/pyroscope/client.go b/component/pyroscope/client.go new file mode 100644 index 0000000..7cf8cc1 --- /dev/null +++ b/component/pyroscope/client.go @@ -0,0 +1,153 @@ +package pyroscope + +import ( + "fmt" + + "github.com/grafana/pyroscope-go" + "github.com/samber/lo" + + "github.com/pubgo/funk/v2/buildinfo/version" + "github.com/pubgo/funk/v2/component/lifecycle" + "github.com/pubgo/funk/v2/log" + "github.com/pubgo/funk/v2/running" +) + +type Param struct { + Cfg *Config + Logger log.Logger + Lc lifecycle.Lifecycle +} + +type Client struct { + profiler *pyroscope.Profiler + logger log.Logger +} + +func (c *Client) Enabled() bool { + return c != nil && c.profiler != nil +} + +func (c *Client) Profiler() *pyroscope.Profiler { + if c == nil { + return nil + } + return c.profiler +} + +func (c *Client) Flush(wait bool) { + if c == nil || c.profiler == nil { + return + } + if c.logger != nil { + c.logger.Debug().Bool("wait", wait).Msg("flushing pyroscope profiler session") + } + c.profiler.Flush(wait) + if c.logger != nil { + c.logger.Debug().Bool("wait", wait).Msg("pyroscope profiler session flushed") + } +} + +func New(p Param) *Client { + cfg := mergeConfig(p.Cfg) + logger := resolveLogger(p) + + if !cfg.Enabled { + logger.Info().Msg("pyroscope profiler disabled by config") + return &Client{logger: logger} + } + if cfg.ServerAddress == "" { + logger.Warn().Msg("pyroscope profiler skipped: server_address is empty") + return &Client{logger: logger} + } + + pyroCfg := pyroscope.Config{ + ApplicationName: applicationName(cfg), + ServerAddress: cfg.ServerAddress, + BasicAuthUser: cfg.BasicAuthUser, + BasicAuthPassword: cfg.BasicAuthPassword, + TenantID: cfg.TenantID, + UploadRate: cfg.UploadRate, + ProfileTypes: cfg.profileTypes(), + Tags: defaultTags(cfg), + DisableGCRuns: cfg.DisableGCRuns, + } + if !cfg.DisableLog { + pyroCfg.Logger = newLoggerAdapter(logger) + } + + logger.Info().Func(func(e *log.Event) { + e.Str("application_name", pyroCfg.ApplicationName) + e.Str("server_address", pyroCfg.ServerAddress) + e.Dur("upload_rate", pyroCfg.UploadRate) + e.Strs("profile_types", profileTypeNames(pyroCfg.ProfileTypes)) + e.Any("tags", pyroCfg.Tags) + e.Bool("disable_gc_runs", pyroCfg.DisableGCRuns) + e.Bool("basic_auth", pyroCfg.BasicAuthUser != "") + e.Bool("tenant_id", pyroCfg.TenantID != "") + e.Bool("lifecycle_hook", p.Lc != nil) + }).Msg("starting pyroscope profiler") + + profiler, err := pyroscope.Start(pyroCfg) + if err != nil { + logger.Err(err). + Str("application_name", pyroCfg.ApplicationName). + Str("server_address", pyroCfg.ServerAddress). + Msg("failed to start pyroscope profiler") + return &Client{logger: logger} + } + logger.Info(). + Str("application_name", pyroCfg.ApplicationName). + Str("server_address", pyroCfg.ServerAddress). + Msg("pyroscope profiler started") + + if p.Lc != nil { + logger.Debug().Msg("register pyroscope profiler lifecycle stop hook") + p.Lc.BeforeStop(lifecycle.WrapNoCtxErr(func() { + stopProfiler(logger, profiler) + })) + } + + return &Client{profiler: profiler, logger: logger} +} + +func resolveLogger(p Param) log.Logger { + if p.Logger != nil { + return p.Logger.WithName(Name) + } + return log.GetLogger(Name) +} + +func stopProfiler(logger log.Logger, profiler *pyroscope.Profiler) { + logger.Info().Msg("stopping pyroscope profiler") + if err := profiler.Stop(); err != nil { + logger.Err(err).Msg("failed to stop pyroscope profiler") + return + } + logger.Info().Msg("pyroscope profiler stopped") +} + +func profileTypeNames(types []pyroscope.ProfileType) []string { + return lo.Map(types, func(item pyroscope.ProfileType, _ int) string { + return string(item) + }) +} + +func applicationName(cfg *Config) string { + if cfg.ApplicationName != "" { + return cfg.ApplicationName + } + return fmt.Sprintf("%s/%s", running.Project(), version.Version()) +} + +func defaultTags(cfg *Config) map[string]string { + tags := map[string]string{ + "hostname": running.Hostname, + "env": running.Env.String(), + "version": running.Version(), + "instance_id": running.InstanceID, + } + for k, v := range cfg.Tags { + tags[k] = v + } + return tags +} diff --git a/component/pyroscope/client_test.go b/component/pyroscope/client_test.go new file mode 100644 index 0000000..f2069c3 --- /dev/null +++ b/component/pyroscope/client_test.go @@ -0,0 +1,50 @@ +package pyroscope + +import ( + "testing" + "time" + + "github.com/grafana/pyroscope-go" + "github.com/stretchr/testify/assert" +) + +func TestMergeConfigDefaults(t *testing.T) { + cfg := mergeConfig(&Config{ + Enabled: true, + ServerAddress: "http://localhost:4040", + }) + assert.Equal(t, defaultProfileTypeNames(), cfg.ProfileTypes) + assert.Equal(t, time.Minute, cfg.UploadRate) +} + +func TestMergeConfigNil(t *testing.T) { + cfg := mergeConfig(nil) + assert.Equal(t, DefaultConfig(), cfg) +} + +func TestProfileTypesFallback(t *testing.T) { + cfg := mergeConfig(&Config{}) + assert.Equal(t, pyroscope.DefaultProfileTypes, cfg.profileTypes()) +} + +func TestApplicationNameDefault(t *testing.T) { + name := applicationName(&Config{}) + assert.NotEmpty(t, name) +} + +func TestDefaultTags(t *testing.T) { + tags := defaultTags(&Config{Tags: map[string]string{"service": "api"}}) + assert.Equal(t, "api", tags["service"]) + assert.NotEmpty(t, tags["hostname"]) + assert.NotEmpty(t, tags["env"]) +} + +func TestNewDisabled(t *testing.T) { + client := New(Param{Cfg: &Config{Enabled: false, ServerAddress: "http://localhost:4040"}}) + assert.False(t, client.Enabled()) +} + +func TestNewMissingServer(t *testing.T) { + client := New(Param{Cfg: &Config{Enabled: true}}) + assert.False(t, client.Enabled()) +} diff --git a/component/pyroscope/config.go b/component/pyroscope/config.go new file mode 100644 index 0000000..63c4f1b --- /dev/null +++ b/component/pyroscope/config.go @@ -0,0 +1,59 @@ +package pyroscope + +import ( + "time" + + "github.com/grafana/pyroscope-go" + "github.com/samber/lo" + + "github.com/pubgo/funk/v2/merge" +) + +const Name = "pyroscope" + +type Config struct { + Enabled bool `yaml:"enabled"` + ApplicationName string `yaml:"application_name"` + ServerAddress string `yaml:"server_address"` + BasicAuthUser string `yaml:"basic_auth_user"` + BasicAuthPassword string `yaml:"basic_auth_password"` + TenantID string `yaml:"tenant_id"` + UploadRate time.Duration `yaml:"upload_rate"` + ProfileTypes []string `yaml:"profile_types"` + Tags map[string]string `yaml:"tags"` + DisableGCRuns bool `yaml:"disable_gc_runs"` + DisableLog bool `yaml:"disable_log"` +} + +func DefaultConfig() *Config { + return &Config{ + Enabled: false, + ProfileTypes: defaultProfileTypeNames(), + UploadRate: time.Minute, + } +} + +func defaultProfileTypeNames() []string { + return lo.Map(pyroscope.DefaultProfileTypes, func(item pyroscope.ProfileType, _ int) string { + return string(item) + }) +} + +func (c *Config) profileTypes() []pyroscope.ProfileType { + if len(c.ProfileTypes) == 0 { + return pyroscope.DefaultProfileTypes + } + + types := make([]pyroscope.ProfileType, 0, len(c.ProfileTypes)) + for _, name := range c.ProfileTypes { + types = append(types, pyroscope.ProfileType(name)) + } + return types +} + +func mergeConfig(cfg *Config) *Config { + if cfg == nil { + return DefaultConfig() + } + return merge.Copy(DefaultConfig(), cfg).Unwrap() +} diff --git a/component/pyroscope/context.go b/component/pyroscope/context.go new file mode 100644 index 0000000..3063c51 --- /dev/null +++ b/component/pyroscope/context.go @@ -0,0 +1,15 @@ +package pyroscope + +import ( + "context" + + "github.com/grafana/pyroscope-go" +) + +type LabelSet = pyroscope.LabelSet + +var Labels = pyroscope.Labels + +func TagWrapper(ctx context.Context, labels LabelSet, cb func(context.Context)) { + pyroscope.TagWrapper(ctx, labels, cb) +} diff --git a/component/pyroscope/example/main.go b/component/pyroscope/example/main.go new file mode 100644 index 0000000..e91af3e --- /dev/null +++ b/component/pyroscope/example/main.go @@ -0,0 +1,57 @@ +// Minimal push-mode profiling example. +// +// Run: +// +// PYROSCOPE_SERVER=http://localhost:4040 go run ./component/pyroscope/example +package main + +import ( + "context" + "log" + "os" + "time" + + "github.com/pubgo/funk/v2/component/pyroscope" +) + +func main() { + server := os.Getenv("PYROSCOPE_SERVER") + if server == "" { + server = "http://127.0.0.1:4040" + } + + client := pyroscope.New(pyroscope.Param{ + Cfg: &pyroscope.Config{ + Enabled: true, + ServerAddress: server, + ApplicationName: "funk.example.pyroscope", + }, + }) + if !client.Enabled() { + log.Fatal("pyroscope client disabled") + } + defer func() { + if p := client.Profiler(); p != nil { + _ = p.Stop() + } + }() + + ctx := context.Background() + for i := 0; i < 3; i++ { + pyroscope.TagWrapper(ctx, pyroscope.Labels("iteration", "demo"), func(ctx context.Context) { + work() + }) + time.Sleep(time.Second) + } + + client.Flush(true) + log.Println("profiles uploaded") +} + +func work() { + sum := 0 + for i := 0; i < 1_000_000; i++ { + sum += i + } + _ = sum +} diff --git a/component/pyroscope/logger.go b/component/pyroscope/logger.go new file mode 100644 index 0000000..bd76440 --- /dev/null +++ b/component/pyroscope/logger.go @@ -0,0 +1,25 @@ +package pyroscope + +import ( + "github.com/pubgo/funk/v2/log" +) + +type loggerAdapter struct { + logger log.Logger +} + +func newLoggerAdapter(logger log.Logger) *loggerAdapter { + return &loggerAdapter{logger: logger.WithName("pyroscope")} +} + +func (l *loggerAdapter) Infof(format string, args ...any) { + l.logger.Info().Msgf(format, args...) +} + +func (l *loggerAdapter) Debugf(format string, args ...any) { + l.logger.Debug().Msgf(format, args...) +} + +func (l *loggerAdapter) Errorf(format string, args ...any) { + l.logger.Error().Msgf(format, args...) +} diff --git a/go.mod b/go.mod index f6d324e..42c87ce 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/go-github/v71 v71.0.0 github.com/gopherjs/gopherjs v1.17.2 + github.com/grafana/pyroscope-go v1.3.1 github.com/hashicorp/go-getter v1.8.4 github.com/hashicorp/go-version v1.8.0 github.com/huandu/go-clone v1.7.3 @@ -151,6 +152,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.11 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -161,7 +163,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect diff --git a/go.sum b/go.sum index 3edc8b5..fd0eadf 100644 --- a/go.sum +++ b/go.sum @@ -227,6 +227,10 @@ github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81 github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/grafana/pyroscope-go v1.3.1 h1:Eb9h55+vtLezn/DQ4iXz+SJrOz8CNghDk9xx8XQ4tc0= +github.com/grafana/pyroscope-go v1.3.1/go.mod h1:vjZr7UNVSvbpVH+G9SBy8K0fATjfYwl+W12xLNOx9Xg= +github.com/grafana/pyroscope-go/godeltaprof v0.1.11 h1:el5LYpXissAiCKZ5/6yjlr6mhYVV6Cp5lahTocxraXM= +github.com/grafana/pyroscope-go/godeltaprof v0.1.11/go.mod h1:jl1V8M4cWsXciROCPIDDG7CtjSjT/ECbp6eLVuMxYRI= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI= github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70 h1:0HADrxxqaQkGycO1JoUUA+B4FnIkuo8d2bz/hSaTFFQ= @@ -270,8 +274,8 @@ github.com/k0kubun/pp/v3 v3.5.0/go.mod h1:5lzno5ZZeEeTV/Ky6vs3g6d1U3WarDrH8k240v github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -399,8 +403,8 @@ github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ai github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=