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
66 changes: 47 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,66 @@
# parca-load

This is a tool that continuously queries Parca instances for their data.
A load testing tool that continuously queries Parca instances.

It is based on the Parca gRPC APIs defined on https://buf.build/parca-dev/parca and uses the generated connect-go code via `go.buf.build/bufbuild/connect-go/parca-dev/parca`.

### Installation
## Installation

```
go install github.com/parca-dev/parca-load@latest
```

or run the container images
or via container:

```
docker run -d ghcr.io/parca-dev/parca-load
docker run ghcr.io/parca-dev/parca-load
```

### How it works
## How it works

The tool runs five query types in parallel at a configurable interval (default: 5s):

- **ProfileTypes** - discovers available profile types
- **Labels** - queries label names for each profile type
- **Values** - queries label values (if `-values-for-labels` is set)
- **QueryRange** - fetches profile series data
- **Query (merge)** - fetches merged flamegraph data

Each query type runs against all configured profile types, time ranges, and label selectors.

It runs a goroutine per API type.
Metrics are exposed at `http://<addr>/metrics` (default: `127.0.0.1:7171`).

It starts a `Labels` goroutine that starts querying all labels on a Parca instance and then writes these into a shared map.
The map is then read by the `Values` goroutine that selects a random label and queries all values for it.
## Flags

This process it repeated every 5 seconds (configurable).
The entries of the shared map eventually expire to not query too old data.
| Flag | Default | Description |
|------|---------|-------------|
| `-url` | `http://localhost:7070` | Parca instance URL |
| `-addr` | `127.0.0.1:7171` | HTTP server address for metrics |
| `-query-interval` | `5s` | Interval between query rounds |
| `-query-range` | `15m;12h;168h` | Time ranges for queries (semicolon-separated) |
| `-types` | (auto-discover) | Profile types to query (semicolon-separated) |
| `-labels` | `all` | Label selectors for filtering (semicolon-separated) |
| `-values-for-labels` | (none) | Label names to query values for (semicolon-separated) |
| `-token` | | Bearer token for authentication |
| `-headers` | | Custom headers (`key=value,key2=value2`) |
| `-client-timeout` | `10s` | HTTP client timeout |

Similarly, it starts querying `ProfileTypes` every 10 seconds (configurable) to know what profile types are available.
The result is written to a shared map.
Every 15 seconds (configurable) there are `QueryRange` requests (querying 15min and 7 day ranges) for all series.
## Examples

Once the profile series are discovered above there are `Query` requests querying single profiles every 10 seconds.
For these queries it picks a random timestamp of the available time range and queries a random report type (flame graph, top table, pprof download).
```bash
# Basic usage with auto-discovered profile types
./parca-load -url=http://localhost:7070

Every 15 seconds (configurable) there are `Query` requests that actually request merged profiles for either 15min, or 7 days, if enough data is available for each in a series.
# Specific profile types
./parca-load -url=http://localhost:7070 \
-types='parca_agent:samples:count:cpu:nanoseconds:delta'

# Custom time ranges and label filtering
./parca-load -url=http://localhost:7070 \
-query-range='1h;24h' \
-labels='{job="api",env="dev"}'

# Query values for specific labels
./parca-load -url=http://localhost:7070 \
-values-for-labels='job;namespace'
```

Metrics are collected and available on http://localhost:7171/metrics
Profile type format: `name:sample_type:sample_unit:period_type:period_unit[:delta]`
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,15 @@ require (
github.com/hashicorp/vault/api/auth/kubernetes v0.10.0
github.com/oklog/run v1.2.0
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/prometheus v0.301.0
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
golang.org/x/sync v0.19.0
google.golang.org/protobuf v1.36.11
)

require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
Expand All @@ -36,6 +35,7 @@ require (
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
Expand Down
10 changes: 2 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
Expand Down Expand Up @@ -79,8 +77,6 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/prometheus/prometheus v0.301.0 h1:0z8dgegmILivNomCd79RKvVkIols8vBGPKmcIBc7OyY=
github.com/prometheus/prometheus v0.301.0/go.mod h1:BJLjWCKNfRfjp7Q48DrAjARnCi7GhfUVvUFEAWTssZM=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
Expand All @@ -93,10 +89,10 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
Expand All @@ -110,7 +106,5 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
68 changes: 64 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
)

const grpcCodeOK = "ok"
const (
grpcCodeOK = "ok"
flagSeparator = ";"
)

func main() {
url := flag.String("url", "http://localhost:7070", "The URL for the Parca instance to query")
Expand All @@ -35,7 +38,10 @@ func main() {
customHeadersStr := flag.String("headers", "", "Comma-separated custom headers in the format 'key=value,key2=value2' to attach to requests")

queryInterval := flag.Duration("query-interval", 5*time.Second, "The time interval between queries to the Parca instance")
queryRangeStr := flag.String("query-range", "15m,12h,168h", "Comma-separated time durations for query")
queryRangeStr := flag.String("query-range", "15m;12h;168h", "Semicolon-separated time durations for query ranges")
labelsStr := flag.String("labels", "all", "Semicolon-separated label selectors for queries (e.g., '{job=\"api\"};{level=\"info\"}'), or 'all' for no filtering")
typesStr := flag.String("types", "", "Semicolon-separated profile types to query. If empty, types are auto-discovered from the backend.")
valuesForLabelsStr := flag.String("values-for-labels", "", "Semicolon-separated label names to query values for (e.g., 'job;namespace'). If empty, values queries are skipped.")

flag.Parse()

Expand Down Expand Up @@ -85,6 +91,12 @@ func main() {
log.Fatalf("parse time range string error: %v", err)
}

labelSelectors := parseLabels(*labelsStr)

profileTypes := parseProfileTypes(*typesStr)

valuesForLabels := parseValuesForLabels(*valuesForLabelsStr)

customHeaders, err := parseHeaders(*customHeadersStr)
if err != nil {
log.Fatalf("parse custom headers error: %v", err)
Expand All @@ -110,7 +122,7 @@ func main() {
reg.MustRegister(collectors.NewGoCollector())
reg.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))

querier := NewQuerier(reg, client, queryRanges)
querier := NewQuerier(reg, client, queryRanges, labelSelectors, profileTypes, valuesForLabels)

var gr run.Group
gr.Add(run.SignalHandler(ctx, os.Interrupt, syscall.SIGTERM))
Expand Down Expand Up @@ -203,7 +215,7 @@ func (i *customHeadersInterceptor) WrapStreamingHandler(handler connect.Streamin
}

func parseTimeRanges(input string) ([]time.Duration, error) {
parts := strings.Split(input, ",")
parts := strings.Split(input, flagSeparator)
durations := make([]time.Duration, len(parts))
var err error

Expand All @@ -217,6 +229,54 @@ func parseTimeRanges(input string) ([]time.Duration, error) {
return durations, nil
}

func parseLabels(input string) []string {
if input == "" || input == "all" {
return []string{"all"}
}
parts := strings.Split(input, flagSeparator)
selectors := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
selectors = append(selectors, p)
}
}
if len(selectors) == 0 {
return []string{"all"}
}
return selectors
}

func parseProfileTypes(input string) []string {
if input == "" {
return nil
}
parts := strings.Split(input, flagSeparator)
types := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
types = append(types, p)
}
}
return types
}

func parseValuesForLabels(input string) []string {
if input == "" {
return nil
}
parts := strings.Split(input, flagSeparator)
labels := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
labels = append(labels, p)
}
}
return labels
}

func parseHeaders(input string) (map[string]string, error) {
if input == "" {
return nil, nil
Expand Down
Loading