Skip to content
Merged
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
93 changes: 93 additions & 0 deletions component/pyroscope/README.md
Original file line number Diff line number Diff line change
@@ -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
```
7 changes: 7 additions & 0 deletions component/pyroscope/_doc.go
Original file line number Diff line number Diff line change
@@ -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
153 changes: 153 additions & 0 deletions component/pyroscope/client.go
Original file line number Diff line number Diff line change
@@ -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
}
50 changes: 50 additions & 0 deletions component/pyroscope/client_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
59 changes: 59 additions & 0 deletions component/pyroscope/config.go
Original file line number Diff line number Diff line change
@@ -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()
}
Comment thread
kooksee marked this conversation as resolved.
15 changes: 15 additions & 0 deletions component/pyroscope/context.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading