Skip to content
Merged
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ internal/web/ui/build/
packaging/windows/LICENSE
packaging/windows/agent-windows-amd64.exe
cmd/grafana-agent/Dockerfile
alloy
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only checked Dockerfile and Dockerfile.windows, which are not using it.

2 changes: 1 addition & 1 deletion .github/workflows/test_pyroscope_pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ jobs:
go-version-file: go.mod
cache: false

- run: make GO_TAGS="nodocker" test-pyroscope
- run: sudo make test-pyroscope
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is sudo needed for? A couple of concerns here:

  • Not sure if GitHub runners are truly ephemeral
  • It becomes easy to merge a change that only allows to run these tests as sudo later

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pyroscope.java component requires root to instrument container's FS and send signals / interact with jvm

I've added a test that requires root.

What do you suggest?

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ node_modules
# file of the pair will detect a dirty work tree and detect the wrong tag name.
.tag-only
.image-tag
alloy
137 changes: 137 additions & 0 deletions internal/component/pyroscope/java/integration/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
//go:build linux && (amd64 || arm64)

package integration

import (
"context"
"fmt"
"net/http"
"os"
"strings"
"sync"
"testing"
"time"

"github.com/go-kit/log"
"github.com/grafana/alloy/internal/component/discovery"
"github.com/grafana/alloy/internal/component/pyroscope"
"github.com/grafana/alloy/internal/component/pyroscope/java"
"github.com/grafana/alloy/internal/component/pyroscope/testutil"
"github.com/grafana/alloy/internal/component/pyroscope/util/test"
pyroutil "github.com/grafana/alloy/internal/component/pyroscope/util/test/container"
querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
)

func TestPyroscopeJavaIntegration(t *testing.T) {
if os.Getenv("GITHUB_ACTIONS") == "true" && os.Getenv("GITHUB_JOB") != "test_pyroscope" {
t.Skip("Skipping Pyroscope Java integration test in GitHub Actions (job name is not test_pyroscope)")
}
wg := sync.WaitGroup{}
defer func() {
wg.Wait()
}()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
l := log.NewSyncLogger(log.NewLogfmtLogger(os.Stderr))
l = log.WithPrefix(l,
"test", t.Name(),
"ts", log.DefaultTimestampUTC,
)

_, pyroscopeEndpoint := pyroutil.StartPyroscopeContainer(t, ctx, l)

_, javaEndpoint, pid := pyroutil.StartJavaApplicationContainer(t, ctx, l)

t.Logf("Pyroscope endpoint: %s", pyroscopeEndpoint)
t.Logf("Java application endpoint: %s", javaEndpoint)
t.Logf("Java process PID in container: %d", pid)

reg := prometheus.NewRegistry()

writeComponent, err := testutil.CreateWriteComponent(l, reg, pyroscopeEndpoint)
require.NoError(t, err, "Failed to create write component")

args := java.DefaultArguments()
args.ForwardTo = []pyroscope.Appendable{writeComponent}
args.ProfilingConfig.Interval = time.Second
args.Targets = []discovery.Target{
discovery.NewTargetFromMap(map[string]string{
java.LabelProcessID: fmt.Sprintf("%d", pid),
"service_name": "spring-petclinic",
}),
}
javaComponent, err := java.New(
log.With(l, "component", "pyroscope.java"),
reg,
"test-java",
args,
)
require.NoError(t, err, "Failed to create java component")

wg.Add(2)
go func() {
defer wg.Done()
_ = javaComponent.Run(ctx)
}()
go func() {
defer wg.Done()
for ctx.Err() == nil {
burn(javaEndpoint)
time.Sleep(100 * time.Millisecond)
}
}()

require.Eventually(t, func() bool {
req := &querierv1.SelectMergeProfileRequest{
ProfileTypeID: `process_cpu:cpu:nanoseconds:cpu:nanoseconds`,
LabelSelector: `{service_name="spring-petclinic"}`,
Start: time.Now().Add(-time.Hour).UnixMilli(),
End: time.Now().UnixMilli(),
}
res, err := test.Query(pyroscopeEndpoint, req)
if err != nil {
t.Logf("Error querying endpoint: %v", err)
return false
}
ss := res.String()
if !strings.Contains(ss, `org/springframework/samples/petclinic/web/VetController.showVetList`) {
return false
}
if !strings.Contains(ss, `libjvm.so.JavaThread::thread_main_inner`) {
return false
}
return true
}, 90*time.Second, 100*time.Millisecond)

require.Eventually(t, func() bool {
req := &querierv1.SelectMergeProfileRequest{
ProfileTypeID: `memory:alloc_in_new_tlab_bytes:bytes:space:bytes`,
LabelSelector: `{service_name="spring-petclinic"}`,
Start: time.Now().Add(-time.Hour).UnixMilli(),
End: time.Now().UnixMilli(),
}
res, err := test.Query(pyroscopeEndpoint, req)
if err != nil {
t.Logf("Error querying endpoint: %v", err)
return false
}
ss := res.String()
if !strings.Contains(ss, `org/springframework/samples/petclinic/web/VetController.showVetList`) {
return false
}
if strings.Contains(ss, `libjvm.so.JavaThread::thread_main_inner`) {
return false
}
return true
}, 90*time.Second, 100*time.Millisecond)
cancel()
}

func burn(url string) {
_, _ = http.DefaultClient.Get(url + "/")
_, _ = http.DefaultClient.Get(url + "/owners/find")
_, _ = http.DefaultClient.Get(url + "/vets")
_, _ = http.DefaultClient.Get(url + "/oups")
}
36 changes: 36 additions & 0 deletions internal/component/pyroscope/testutil/components.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//go:build linux && (arm64 || amd64)

package testutil

import (
"fmt"

"github.com/go-kit/log"
"github.com/grafana/alloy/internal/component/pyroscope"
"github.com/grafana/alloy/internal/component/pyroscope/write"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/trace/noop"
)

// CreateWriteComponent creates a pyroscope.write component that forwards to the given endpoint
func CreateWriteComponent(l log.Logger, reg prometheus.Registerer, endpoint string) (pyroscope.Appendable, error) {
var receiver pyroscope.Appendable
e := write.GetDefaultEndpointOptions()
e.URL = endpoint

_, err := write.New(
log.With(l, "component", "pyroscope.write"),
noop.Tracer{},
reg,
func(exports write.Exports) {
receiver = exports.Receiver
},
"test",
"",
write.Arguments{Endpoints: []*write.EndpointOptions{&e}},
)
if err != nil {
return nil, fmt.Errorf("error creating write component: %w", err)
}
return receiver, nil
}
18 changes: 2 additions & 16 deletions internal/component/pyroscope/util/internal/cmd/playground/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@ import (
"github.com/grafana/alloy/internal/component/pyroscope"
"github.com/grafana/alloy/internal/component/pyroscope/ebpf"
"github.com/grafana/alloy/internal/component/pyroscope/java"
"github.com/grafana/alloy/internal/component/pyroscope/write"
"github.com/grafana/alloy/internal/component/pyroscope/testutil"
"github.com/oklog/run"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/trace/noop"
)

var (
Expand All @@ -39,20 +38,7 @@ func parseConfig() *config {
}

func newWrite() pyroscope.Appendable {
var receiver pyroscope.Appendable
e := write.GetDefaultEndpointOptions()
e.URL = "http://localhost:4040"
_, err := write.New(
log.With(l, "component", "write"),
noop.Tracer{},
reg,
func(exports write.Exports) {
receiver = exports.Receiver
},
"playground",
"",
write.Arguments{Endpoints: []*write.EndpointOptions{&e}},
)
receiver, err := testutil.CreateWriteComponent(l, reg, "http://localhost:4040")
if err != nil {
_ = l.Log("msg", "error creating write component", "err", err)
os.Exit(1)
Expand Down
54 changes: 54 additions & 0 deletions internal/component/pyroscope/util/test/container/java.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package container

import (
"context"
"fmt"
stdlog "log"
"testing"
"time"

"github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat"
"github.com/go-kit/log"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)

func StartJavaApplicationContainer(t *testing.T, ctx context.Context, l log.Logger) (testcontainers.Container, string, int) {
req := testcontainers.ContainerRequest{
Image: "springcommunity/spring-framework-petclinic:latest",
ExposedPorts: []string{"8080/tcp"},
WaitingFor: wait.ForHTTP("/").WithPort("8080/tcp").WithStartupTimeout(3 * time.Minute),
Env: map[string]string{
"JAVA_OPTS": "-Xmx512m -Xms256m",
},
HostConfigModifier: func(hc *container.HostConfig) {
hc.PidMode = "host"
},
}

c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
Logger: stdlog.New(log.NewStdlibAdapter(l), "", 0),
})
require.NoError(t, err)

t.Cleanup(func() {
err := testcontainers.TerminateContainer(c)
require.NoError(t, err)
})

mappedPort, err := c.MappedPort(ctx, nat.Port("8080/tcp"))
require.NoError(t, err)

host, err := c.Host(ctx)
require.NoError(t, err)

endpoint := fmt.Sprintf("http://%s:%s", host, mappedPort.Port())
inspected, err := c.Inspect(t.Context())
require.NoError(t, err)

return c, endpoint, inspected.State.Pid
}
46 changes: 46 additions & 0 deletions internal/component/pyroscope/util/test/container/pyroscope.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package container

import (
"context"
"fmt"
stdlog "log"
"testing"

"github.com/go-kit/log"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)

func StartPyroscopeContainer(t *testing.T, ctx context.Context, l log.Logger) (testcontainers.Container, string) {
req := testcontainers.ContainerRequest{
Image: "grafana/pyroscope:latest",
Cmd: []string{"--ingester.min-ready-duration=0s"},
ExposedPorts: []string{"4040/tcp"},
WaitingFor: wait.ForHTTP("/ready").WithPort("4040/tcp"),
Env: map[string]string{
"PYROSCOPE_LOG_LEVEL": "debug",
},
}

c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
Logger: stdlog.New(log.NewStdlibAdapter(l), "", 0),
})
require.NoError(t, err)

t.Cleanup(func() {
err := testcontainers.TerminateContainer(c)
require.NoError(t, err)
})

mappedPort, err := c.MappedPort(ctx, "4040/tcp")
require.NoError(t, err)

host, err := c.Host(ctx)
require.NoError(t, err)

endpoint := fmt.Sprintf("http://%s:%s", host, mappedPort.Port())
return c, endpoint
}
24 changes: 24 additions & 0 deletions internal/component/pyroscope/util/test/query.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package test

import (
"context"
"net/http"

"connectrpc.com/connect"
"github.com/google/pprof/profile"
querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1"
"github.com/grafana/pyroscope/api/gen/proto/go/querier/v1/querierv1connect"
)

func Query(url string, q *querierv1.SelectMergeProfileRequest) (*profile.Profile, error) {
client := querierv1connect.NewQuerierServiceClient(http.DefaultClient, url)
res, err := client.SelectMergeProfile(context.Background(), connect.NewRequest(q))
if err != nil {
return nil, err
}
bs, err := res.Msg.MarshalVT()
if err != nil {
return nil, err
}
return profile.ParseData(bs)
}