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
2 changes: 1 addition & 1 deletion charts/nudgebee-agent/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ runnerServiceAccount:
runner:
image:
repository: ghcr.io/nudgebee/nudgebee-agent
tag: 2026-08-12T06-01-54_7ec58f7701909a3ce172ad2a9235f8b15255e363
tag: 2026-08-12T08-10-04_e13ce92bc8c238e883d050b1276c6128b2c8fb44
# Image template the pod_profiler action launches debugger pods from.
# The agent substitutes `{}` for the variant (bpf, jvm, python, perf, ruby).
# Surfaces as PROFILER_IMAGE; leave empty to fall back to the binary default.
Expand Down
20 changes: 17 additions & 3 deletions runner/cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,8 @@ func run(ctx context.Context, logger *slog.Logger, cfg *config.Config) error {
var promClient *prometheus.Client
if cfg.PrometheusURL != "" {
promClient = prometheus.New(cfg.PrometheusURL, nil)
promClient.ExtraHeaders = config.ParseHeaders(cfg.PrometheusHeaders)
promClient.ExtraHeaders = promHeadersWithAuth(cfg)
promClient.URLQueryString = cfg.PrometheusURLQueryString
// Managed-provider auth, same precedence as the legacy
// generate_prometheus_config: AWS SigV4 → Coralogix token → Azure AD.
// Plain header/basic auth stays in PROMETHEUS_HEADERS above.
Expand Down Expand Up @@ -762,7 +763,8 @@ func run(ctx context.Context, logger *slog.Logger, cfg *config.Config) error {
// Prometheus path the collector's `/prometheus-v2/*` route forwards
// through (relay-server/pkg/utils/utils.go:77).
gp := grafana.New(grafanaURL, grafanaUser, grafanaPass, extraHeaders,
cfg.PrometheusURL, config.ParseHeaders(cfg.PrometheusHeaders), nil)
cfg.PrometheusURL, promHeadersWithAuth(cfg), nil)
gp.PrometheusQueryString = cfg.PrometheusURLQueryString
disp.SetGrafana(&grafanaAdapter{p: gp})
if grafanaURL != "" {
logger.Info("grafana proxy enabled", "url", grafanaURL)
Expand Down Expand Up @@ -1005,7 +1007,7 @@ func run(ctx context.Context, logger *slog.Logger, cfg *config.Config) error {
LogProviderConfig: logCfg,
PrometheusConnected: prometheusConnected(probeCtx, promClient, logger),
NodeAgentCount: queryNodeAgentCount(probeCtx, promClient, logger),
PrometheusRetentionTime: telemetry.PrometheusRetention(probeCtx, promClient, logger),
PrometheusRetentionTime: telemetry.PrometheusRetention(probeCtx, promClient, cfg.PrometheusRetentionTime, logger),
PrometheusAdditionalLabels: promExtraLabels,
TraceTable: traceTable,
JaegerEnabled: jaegerEnabled,
Expand Down Expand Up @@ -1403,6 +1405,18 @@ func httpProbeErr(ctx context.Context, c *http.Client, url string, headers ...ma
// sslVerify is false (the legacy default), it disables TLS certificate
// verification to match the legacy client's verify_certs=False. For plain
// http URLs the TLS config is inert.
// promHeadersWithAuth parses PROMETHEUS_HEADERS and, when PROMETHEUS_AUTH is
// set, overlays it as the Authorization header (legacy prometheus_auth). Set,
// not Add, so PROMETHEUS_AUTH takes precedence over any Authorization carried
// in PROMETHEUS_HEADERS instead of doubling it.
func promHeadersWithAuth(cfg *config.Config) http.Header {
h := config.ParseHeaders(cfg.PrometheusHeaders)
if cfg.PrometheusAuth != "" {
h.Set("Authorization", cfg.PrometheusAuth)
}
return h
}

func esHTTPClient(sslVerify bool) *http.Client {
if sslVerify {
return nil // nil → elasticsearch.New builds a default verifying client
Expand Down
5 changes: 4 additions & 1 deletion runner/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ If a K8s subsystem is enabled but the agent fails to build a K8s client (no kube
| Variable | Required | Description |
|---|---|---|
| `PROMETHEUS_URL` | recommended | Enables `prometheus_*` actions and `service_map` |
| `PROMETHEUS_HEADERS` | optional | Comma-separated `Header: value` pairs (e.g. `X-Scope-OrgID: tenant-1`); use for static basic/bearer auth |
| `PROMETHEUS_HEADERS` | optional | Semicolon-separated `Header: value` pairs (e.g. `X-Scope-OrgID: tenant-1`); use for static basic/bearer auth |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The documentation states that PROMETHEUS_HEADERS is semicolon-separated, but the underlying parser config.ParseHeaders in runner/pkg/config/config.go splits headers by commas (,):

for _, part := range strings.Split(s, ",")

If users configure this variable using semicolons, it will fail to parse correctly (e.g., the second header will be treated as part of the first header's value). Please revert this to 'Comma-separated' or update the parser to support semicolons.

| `PROMETHEUS_AUTH` | optional | Static `Authorization` header value (e.g. `Basic dXNlcjpwYXNz` or `Bearer tok`); applied to every request, overriding any `Authorization` in `PROMETHEUS_HEADERS` |
| `PROMETHEUS_URL_QUERY_STRING` | optional | Query-string fragment appended to every Prometheus request (e.g. `extra_label=foo`) |
| `PROMETHEUS_RETENTION_TIME` | optional | Retention value reported to the UI when `/status/flags` is unavailable (e.g. VictoriaMetrics vmsingle) |
| `AWS_ACCESS_KEY` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION` | optional | Managed Prometheus: sign requests with AWS SigV4. `AWS_SERVICE_NAME` defaults to `aps` |
| `CORALOGIX_PROMETHEUS_TOKEN` | optional | Managed Prometheus: sent as `token` header |
| `AZURE_USE_MANAGED_ID` / `AZURE_CLIENT_SECRET` (+ `AZURE_CLIENT_ID` / `AZURE_TENANT_ID`) | optional | Managed Prometheus: Azure AD Bearer token (managed identity or client-secret). Precedence: AWS → Coralogix → Azure |
Expand Down
48 changes: 32 additions & 16 deletions runner/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,23 @@ type Config struct {
// Optional datasource URLs. Empty = subsystem disabled.
PrometheusURL string
PrometheusHeaders string // raw "Header: value" string, parsed into http.Header
LokiURL string
LokiHeaders string
LokiUsername string // optional Basic-Auth (LOKI_USERNAME)
LokiPassword string // optional Basic-Auth (LOKI_PASSWORD)
// PrometheusAuth (PROMETHEUS_AUTH) is a static Authorization header value
// (e.g. "Basic dXNlcjpwYXNz" or "Bearer tok"). Applied to every Prometheus
// request, matching the legacy prometheus_auth. Takes precedence over any
// Authorization set via PrometheusHeaders.
PrometheusAuth string
// PrometheusURLQueryString (PROMETHEUS_URL_QUERY_STRING) is appended to the
// query string of every Prometheus request (e.g. a tenant selector the
// upstream requires). Mirrors the legacy prometheus_url_query_string.
PrometheusURLQueryString string
// PrometheusRetentionTime (PROMETHEUS_RETENTION_TIME) is a fallback
// retention value reported to the UI when /status/flags is unavailable
// (e.g. VictoriaMetrics' vmsingle 404s that endpoint).
PrometheusRetentionTime string
LokiURL string
LokiHeaders string
LokiUsername string // optional Basic-Auth (LOKI_USERNAME)
LokiPassword string // optional Basic-Auth (LOKI_PASSWORD)

// Prometheus managed-provider auth (mirrors the legacy prometrix configs).
// Coralogix uses a `token` header; AWS signs each request with SigV4;
Expand Down Expand Up @@ -200,18 +213,21 @@ type Config struct {
// produce an error; missing optional fields are left blank.
func FromEnv() (*Config, error) {
c := &Config{
AuthSecretKey: os.Getenv("NUDGEBEE_AUTH_SECRET_KEY"),
RelaySigningPublicKey: os.Getenv("RELAY_SIGNING_PUBLIC_KEY"),
RelayURL: os.Getenv("WEBSOCKET_RELAY_ADDRESS"),
BackendEndpoint: os.Getenv("NUDGEBEE_ENDPOINT"),
AccountID: os.Getenv("ACCOUNT_ID"),
ClusterName: os.Getenv("CLUSTER_NAME"),
PrometheusURL: os.Getenv("PROMETHEUS_URL"),
PrometheusHeaders: os.Getenv("PROMETHEUS_HEADERS"), // matches runner.yaml secret
LokiURL: os.Getenv("LOKI_URL"),
LokiHeaders: os.Getenv("LOKI_EXTRA_HEADER"), // matches runner.yaml secret
LokiUsername: os.Getenv("LOKI_USERNAME"),
LokiPassword: os.Getenv("LOKI_PASSWORD"),
AuthSecretKey: os.Getenv("NUDGEBEE_AUTH_SECRET_KEY"),
RelaySigningPublicKey: os.Getenv("RELAY_SIGNING_PUBLIC_KEY"),
RelayURL: os.Getenv("WEBSOCKET_RELAY_ADDRESS"),
BackendEndpoint: os.Getenv("NUDGEBEE_ENDPOINT"),
AccountID: os.Getenv("ACCOUNT_ID"),
ClusterName: os.Getenv("CLUSTER_NAME"),
PrometheusURL: os.Getenv("PROMETHEUS_URL"),
PrometheusHeaders: os.Getenv("PROMETHEUS_HEADERS"), // matches runner.yaml secret
PrometheusAuth: os.Getenv("PROMETHEUS_AUTH"),
PrometheusURLQueryString: os.Getenv("PROMETHEUS_URL_QUERY_STRING"),
PrometheusRetentionTime: os.Getenv("PROMETHEUS_RETENTION_TIME"),
LokiURL: os.Getenv("LOKI_URL"),
LokiHeaders: os.Getenv("LOKI_EXTRA_HEADER"), // matches runner.yaml secret
LokiUsername: os.Getenv("LOKI_USERNAME"),
LokiPassword: os.Getenv("LOKI_PASSWORD"),
// Prometheus managed-provider auth. Defaults mirror the legacy
// prometrix env handling (utils.py): AWS service defaults to "aps",
// Azure resource/endpoints have the same fallbacks.
Expand Down
27 changes: 24 additions & 3 deletions runner/pkg/grafana/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,16 @@ type Proxy struct {
PrometheusURL string

// PrometheusHeaders is the parsed PROMETHEUS_HEADERS env (raw
// "Header: value, Header: value" string) — applied to every
// "Header: value; Header: value" string) — applied to every

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This comment describes PROMETHEUS_HEADERS as semicolon-separated (Header: value; Header: value), but config.ParseHeaders actually parses them using commas (,) as delimiters. Please update the comment to reflect the actual comma-separated format to avoid confusion.

Suggested change
// "Header: value; Header: value" string) — applied to every
// "Header: value, Header: value" string) — applied to every

// Prometheus proxy request so X-Scope-OrgID / tenant headers reach
// the upstream. Same shape ExtraHeaders uses for Grafana.
// the upstream. Same shape ExtraHeaders uses for Grafana. When
// PROMETHEUS_AUTH is set the caller overlays it here as Authorization.
PrometheusHeaders http.Header

// PrometheusQueryString (PROMETHEUS_URL_QUERY_STRING) is appended to the
// query string of every proxied Prometheus request. Empty is a no-op.
PrometheusQueryString string

// Username/Password for Grafana basic auth (GRAFANA_USERNAME /
// GRAFANA_PASSWORD env). Empty disables.
Username string
Expand Down Expand Up @@ -134,7 +139,23 @@ func (p *Proxy) HandlePrometheus(ctx context.Context, req *Request) *Response {
}
enriched := *req
enriched.Header = mergeHeaders(req.Header, p.PrometheusHeaders)
return p.do(ctx, p.PrometheusURL+req.URL, &enriched)
return p.do(ctx, appendQueryString(p.PrometheusURL+req.URL, p.PrometheusQueryString), &enriched)
}

// appendQueryString appends extra (a "k=v&k2=v2" fragment, optional leading
// "?") to rawurl's query, using "?" or "&" depending on whether rawurl already
// has a query. Empty extra is a no-op.
func appendQueryString(rawurl, extra string) string {
extra = strings.TrimSpace(extra)
extra = strings.TrimPrefix(extra, "?")
if extra == "" {
return rawurl
}
sep := "?"
if strings.Contains(rawurl, "?") {
sep = "&"
}
return rawurl + sep + extra
}

// mergeHeaders returns a new header map containing every entry from
Expand Down
31 changes: 31 additions & 0 deletions runner/pkg/grafana/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,37 @@ func TestProxy_HandlePrometheus_ForwardsToPrometheusURL(t *testing.T) {
}
}

// TestProxy_HandlePrometheus_QueryStringAndAuth verifies
// PROMETHEUS_URL_QUERY_STRING is appended and a PROMETHEUS_AUTH-derived
// Authorization header (carried in PrometheusHeaders) reaches upstream.
func TestProxy_HandlePrometheus_QueryStringAndAuth(t *testing.T) {
var gotQuery, gotAuth string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
gotAuth = r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"status":"success"}`))
}))
defer upstream.Close()

p := New("", "", "", nil, upstream.URL,
http.Header{"Authorization": {"Bearer tok-1"}}, nil)
p.PrometheusQueryString = "extra_label=foo"

resp := p.HandlePrometheus(context.Background(), &Request{
Method: "GET",
URL: "/api/v1/query?query=up",
})
if resp.StatusCode != 200 {
t.Fatalf("status = %d; want 200", resp.StatusCode)
}
if !strings.Contains(gotQuery, "query=up") || !strings.Contains(gotQuery, "extra_label=foo") {
t.Errorf("upstream query = %q; want both query=up and extra_label=foo", gotQuery)
}
if gotAuth != "Bearer tok-1" {
t.Errorf("Authorization = %q; want Bearer tok-1", gotAuth)
}
}

// TestProxy_HandlePrometheus_UnconfiguredReturns503 — without
// PROMETHEUS_URL the proxy must not panic. Same posture as
// TestProxy_UnconfiguredGrafana for the Grafana path.
Expand Down
22 changes: 22 additions & 0 deletions runner/pkg/observability/prometheus/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,13 @@ type Client struct {
// ExtraHeaders are sent on every request. Used for X-Scope-OrgID
// (Cortex/Mimir multi-tenant) and any auth headers the operator
// configures via the runner secret today (LOKI_EXTRA_HEADER pattern).
// PROMETHEUS_AUTH is delivered here as an Authorization header.
ExtraHeaders http.Header

// URLQueryString (PROMETHEUS_URL_QUERY_STRING) is appended to the query
// string of every request. Empty is a no-op. Leading "?" is optional.
URLQueryString string

// Auth applies a managed-provider auth scheme (AWS SigV4 / Azure AD /
// Coralogix) to each request. Nil when the backend uses plain headers
// or no auth. See auth.go.
Expand Down Expand Up @@ -171,6 +176,22 @@ func (c *Client) Flags(ctx context.Context) (json.RawMessage, error) {
return c.get(ctx, "/api/v1/status/flags", nil)
}

// appendQueryString appends extra (a "k=v&k2=v2" fragment, with an optional
// leading "?") to rawurl's query, choosing "?" or "&" based on whether
// rawurl already has a query. Empty extra is a no-op.
func appendQueryString(rawurl, extra string) string {
extra = strings.TrimSpace(extra)
extra = strings.TrimPrefix(extra, "?")
if extra == "" {
return rawurl
}
sep := "?"
if strings.Contains(rawurl, "?") {
sep = "&"
}
return rawurl + sep + extra
}
Comment on lines +182 to +193

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using manual string manipulation to append query parameters can be fragile (e.g., if the URL already ends with ? or &, or contains fragments). Since net/url is already imported in this file, we can leverage url.Parse to safely and robustly append the query string.

func appendQueryString(rawurl, extra string) string {
	extra = strings.TrimSpace(extra)
	extra = strings.TrimPrefix(extra, "?")
	if extra == "" {
		return rawurl
	}
	u, err := url.Parse(rawurl)
	if err != nil {
		sep := "?"
		if strings.Contains(rawurl, "?") {
			sep = "&"
		}
		return rawurl + sep + extra
	}
	if u.RawQuery == "" {
		u.RawQuery = extra
	} else {
		u.RawQuery += "&" + extra
	}
	return u.String()
}


func (c *Client) get(ctx context.Context, path string, params url.Values) (json.RawMessage, error) {
if c.BaseURL == "" {
return nil, errors.New("prometheus: base URL not configured")
Expand All @@ -179,6 +200,7 @@ func (c *Client) get(ctx context.Context, path string, params url.Values) (json.
if len(params) > 0 {
u += "?" + params.Encode()
}
u = appendQueryString(u, c.URLQueryString)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
Expand Down
37 changes: 37 additions & 0 deletions runner/pkg/observability/prometheus/prometheus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,43 @@ func TestExtraHeaders_Sent(t *testing.T) {
}
}

// TestURLQueryString_Appended verifies PROMETHEUS_URL_QUERY_STRING is merged
// into the request query string alongside the query params.
func TestURLQueryString_Appended(t *testing.T) {
var gotRegion, gotQuery string
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
gotRegion = r.URL.Query().Get("region")
gotQuery = r.URL.Query().Get("query")
_, _ = w.Write([]byte(`{}`))
})
defer srv.Close()
c.URLQueryString = "?region=us-east" // leading ? tolerated

if _, err := c.Query(context.Background(), "up", "", ""); err != nil {
t.Fatal(err)
}
if gotRegion != "us-east" {
t.Errorf("region = %q; want us-east", gotRegion)
}
if gotQuery != "up" {
t.Errorf("query = %q; want up (existing params preserved)", gotQuery)
}
}

func TestAppendQueryString(t *testing.T) {
cases := []struct{ url, extra, want string }{
{"http://h/api/v1/query?query=up", "region=us", "http://h/api/v1/query?query=up&region=us"},
{"http://h/api/v1/labels", "region=us", "http://h/api/v1/labels?region=us"},
{"http://h/api/v1/labels", "?region=us", "http://h/api/v1/labels?region=us"},
{"http://h/api/v1/labels", "", "http://h/api/v1/labels"},
}
for _, tc := range cases {
if got := appendQueryString(tc.url, tc.extra); got != tc.want {
t.Errorf("appendQueryString(%q,%q) = %q; want %q", tc.url, tc.extra, got, tc.want)
}
}
}

func TestHandlers_DispatchesViaActionName(t *testing.T) {
wantBody := `{"status":"success","data":[]}`
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
Expand Down
32 changes: 17 additions & 15 deletions runner/pkg/telemetry/prom_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,30 @@ type prometheusFlagsResponse struct {
Data map[string]string `json:"data"`
}

// PrometheusRetention queries /api/v1/status/flags and returns the
// `storage.tsdb.retention.time` value. Empty string on any error.
// VictoriaMetrics' vmsingle returns 404 for this endpoint — caller must
// tolerate the empty return.
func PrometheusRetention(ctx context.Context, c *prometheus.Client, logger *slog.Logger) string {
// PrometheusRetention queries /api/v1/status/flags and returns the retention
// value. It reads Prometheus' `storage.tsdb.retention.time`, then the compat
// `retentionTime`, then VictoriaMetrics' `-retentionPeriod` (vmsingle exposes
// its flags under CLI-style keys). When the endpoint is unavailable (vmsingle
// 404s it) or none of those keys are present, it falls back to the caller-
// supplied value (PROMETHEUS_RETENTION_TIME). Empty string when nothing
// resolves.
func PrometheusRetention(ctx context.Context, c *prometheus.Client, fallback string, logger *slog.Logger) string {
if c == nil || c.BaseURL == "" {
return ""
return fallback
}
raw, err := c.Flags(ctx)
if err != nil {
logger.Debug("prometheus flags probe failed", "err", err)
return ""
logger.Debug("prometheus flags probe failed; using retention fallback", "err", err)
return fallback
}
var resp prometheusFlagsResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return ""
return fallback
}
if v := resp.Data["storage.tsdb.retention.time"]; v != "" {
return v
for _, k := range []string{"storage.tsdb.retention.time", "retentionTime", "-retentionPeriod"} {
if v := resp.Data[k]; v != "" {
return v
}
}
if v := resp.Data["retentionTime"]; v != "" {
return v
}
return ""
return fallback
}
Loading
Loading