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
106 changes: 91 additions & 15 deletions runner/pkg/observability/jaeger/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
// - jaeger_query_services : GET /api/services
// - jaeger_query_trace_by_id : GET /api/traces/{id}
// - jaeger_query_operations : GET /api/services/{service}/operations
// - jaeger_query_metrics : GET /api/metrics/{type} (Jaeger SPM)
// - jaeger_query_metrics : fan-out to /api/metrics/{calls,errors,
// latencies?quantile=0.95,latencies?quantile=0.99} (Jaeger SPM)
package jaeger

import (
Expand Down Expand Up @@ -62,13 +63,76 @@ func (c *Client) Operations(ctx context.Context, service string) (json.RawMessag
return c.get(ctx, "/api/services/"+url.PathEscape(service)+"/operations", nil)
}

// Metrics queries Jaeger SPM metrics. metricType is one of latencies,
// call_rates, error_rates, min_step.
func (c *Client) Metrics(ctx context.Context, metricType string, params map[string]any) (json.RawMessage, error) {
if metricType == "" {
return nil, errors.New("jaeger: metric_type required")
// Metrics queries Jaeger SPM (Service Performance Monitoring) metrics. It
// replicates the legacy get_metrics: the backend composer sends `services`
// and `spanKinds` (plural) plus a time window and NO metric_type. We remap
// those to Jaeger's singular `service` / `spanKind` query params and fan out
// to the four SPM endpoints, assembling a single object the backend parser
// reads: {calls, errors, latencies_p95, latencies_p99}.
func (c *Client) Metrics(ctx context.Context, params map[string]any) (json.RawMessage, error) {
base := metricsQuery(params)

subs := []struct {
key string
metric string
quantile string
}{
{"calls", "calls", ""},
{"errors", "errors", ""},
{"latencies_p95", "latencies", "0.95"},
{"latencies_p99", "latencies", "0.99"},
}

out := make(map[string]json.RawMessage, len(subs))
for _, s := range subs {
q := cloneValues(base)
if s.quantile != "" {
q.Set("quantile", s.quantile)
}
raw, status, err := c.getRaw(ctx, "/api/metrics/"+s.metric, q)
if err != nil {
return nil, err
}
if status == http.StatusNotFound {
// Jaeger 404s /api/metrics/* when SPM isn't wired up (no
// monitor/OTel metrics storage). Preserve the legacy friendly
// message instead of leaking a raw 404.
return nil, fmt.Errorf("jaeger: SPM metrics not available (monitoring storage not configured)")
}
if status >= 400 {
return nil, fmt.Errorf("jaeger metrics %s: HTTP %d: %s", s.metric, status, string(raw))
}
out[s.key] = raw
}
return json.Marshal(out)
Comment on lines +86 to +107

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

The current implementation of Metrics queries the four SPM endpoints sequentially. Since each query is an independent HTTP request, executing them sequentially can significantly increase the total latency of the Metrics call (up to the sum of all four requests). Fanning out these requests concurrently using goroutines and a buffered channel will reduce the total latency to the maximum latency of a single request, greatly improving performance.

	type result struct {
		key string
		raw json.RawMessage
		err error
	}
	ch := make(chan result, len(subs))
	for _, s := range subs {
		go func(s struct {
			key      string
			metric   string
			quantile string
		}) {
			q := cloneValues(base)
			if s.quantile != "" {
				q.Set("quantile", s.quantile)
			}
			raw, status, err := c.getRaw(ctx, "/api/metrics/"+s.metric, q)
			if err != nil {
				ch <- result{err: err}
				return
			}
			if status == http.StatusNotFound {
				ch <- result{err: fmt.Errorf("jaeger: SPM metrics not available (monitoring storage not configured)")}
				return
			}
			if status >= 400 {
				ch <- result{err: fmt.Errorf("jaeger metrics %s: HTTP %d: %s", s.metric, status, string(raw))}
				return
			}
			ch <- result{key: s.key, raw: raw}
		}(s)
	}

	out := make(map[string]json.RawMessage, len(subs))
	for i := 0; i < len(subs); i++ {
		select {
		case res := <-ch:
			if res.err != nil {
				return nil, res.err
			}
			out[res.key] = res.raw
		case <-ctx.Done():
			return nil, ctx.Err()
		}
	}
	return json.Marshal(out)

}

// metricsQuery builds the shared SPM query params, remapping the composer's
// plural `services`/`spanKinds` to Jaeger's singular `service`/`spanKind` and
// dropping any legacy `metric_type` (the fan-out covers all four metrics).
func metricsQuery(params map[string]any) url.Values {
remapped := make(map[string]any, len(params))
for k, v := range params {
switch k {
case "services", "service":
remapped["service"] = v
case "spanKinds", "spanKind":
remapped["spanKind"] = v
case "metric_type":
// dropped
default:
remapped[k] = v
}
}
return c.get(ctx, "/api/metrics/"+url.PathEscape(metricType), paramsToQuery(params))
return paramsToQuery(remapped)
}

func cloneValues(v url.Values) url.Values {
out := make(url.Values, len(v))
for k, vs := range v {
out[k] = append([]string(nil), vs...)
}
return out
}

func paramsToQuery(params map[string]any) url.Values {
Expand Down Expand Up @@ -100,17 +164,32 @@ func paramsToQuery(params map[string]any) url.Values {
return v
}

// get issues a request and treats HTTP >= 400 as an error.
func (c *Client) get(ctx context.Context, path string, params url.Values) (json.RawMessage, error) {
raw, status, err := c.getRaw(ctx, path, params)
if err != nil {
return nil, err
}
if status >= 400 {
return nil, fmt.Errorf("jaeger %s: HTTP %d: %s", path, status, string(raw))
}
return raw, nil
}

// getRaw issues a request and returns the body + HTTP status without treating
// a 4xx as an error, so callers (Metrics) can act on a 404. err is non-nil
// only for transport/read failures.
func (c *Client) getRaw(ctx context.Context, path string, params url.Values) (json.RawMessage, int, error) {
if c.BaseURL == "" {
return nil, errors.New("jaeger: base URL not configured")
return nil, 0, errors.New("jaeger: base URL not configured")
}
u := c.BaseURL + path
if len(params) > 0 {
u += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
return nil, 0, err
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
Expand All @@ -122,15 +201,12 @@ func (c *Client) get(ctx context.Context, path string, params url.Values) (json.
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("jaeger get %s: %w", path, err)
return nil, 0, fmt.Errorf("jaeger get %s: %w", path, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("jaeger %s: HTTP %d: %s", path, resp.StatusCode, string(body))
return nil, 0, err
}
return json.RawMessage(body), nil
return json.RawMessage(body), resp.StatusCode, nil
}
12 changes: 3 additions & 9 deletions runner/pkg/observability/jaeger/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,7 @@ func handleOperations(ctx context.Context, c *Client, p map[string]any) (json.Ra
}

func handleMetrics(ctx context.Context, c *Client, p map[string]any) (json.RawMessage, error) {
metricType, _ := p["metric_type"].(string)
rest := map[string]any{}
for k, v := range p {
if k == "metric_type" {
continue
}
rest[k] = v
}
return c.Metrics(ctx, metricType, rest)
// The backend composer sends services/spanKinds (plural) and no
// metric_type; Metrics does the remap + four-endpoint fan-out.
return c.Metrics(ctx, p)
}
76 changes: 63 additions & 13 deletions runner/pkg/observability/jaeger/jaeger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package jaeger

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
Comment on lines 4 to 8

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

Import the sync package to support thread-safe operations in the test when Metrics is executed concurrently.

Suggested change
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"

Expand Down Expand Up @@ -95,25 +96,74 @@ func TestOperations_BuildsPath(t *testing.T) {
}
}

func TestMetrics_RequiresType(t *testing.T) {
c := New("http://x", nil)
if _, err := c.Metrics(context.Background(), "", map[string]any{}); err == nil {
t.Error("expected error")
// TestMetrics_FansOutAndRemaps verifies the SPM fan-out: four endpoints are
// hit (calls, errors, latencies@0.95, latencies@0.99), the composer's plural
// services/spanKinds are remapped to singular service/spanKind, metric_type is
// dropped, and the responses are assembled under the expected keys.
func TestMetrics_FansOutAndRemaps(t *testing.T) {
hits := map[string]string{} // metric path -> raw query
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
hits[r.URL.Path] = r.URL.RawQuery
// Echo the metric name so we can assert the assembly mapping.
_, _ = w.Write([]byte(`{"path":"` + r.URL.Path + `"}`))
})
defer srv.Close()

raw, err := c.Metrics(context.Background(), map[string]any{
"services": "frontend",
"spanKinds": "SPAN_KIND_SERVER",
"metric_type": "should-be-dropped",
"endTs": 1700000000,
})
if err != nil {
t.Fatal(err)
}

// All four endpoints hit.
for _, p := range []string{"/api/metrics/calls", "/api/metrics/errors", "/api/metrics/latencies"} {
if _, ok := hits[p]; !ok {
t.Errorf("missing request to %s (hits: %v)", p, hits)
}
}
// Remap: singular service/spanKind present, plurals + metric_type gone.
q := hits["/api/metrics/calls"]
if !strings.Contains(q, "service=frontend") || !strings.Contains(q, "spanKind=SPAN_KIND_SERVER") {
t.Errorf("calls query = %q; want remapped service/spanKind", q)
}
if strings.Contains(q, "services=") || strings.Contains(q, "spanKinds=") || strings.Contains(q, "metric_type=") {
t.Errorf("calls query = %q; plural/metric_type should be dropped", q)
}
if !strings.Contains(q, "endTs=1700000000") {
t.Errorf("calls query = %q; want endTs passed through", q)
}

// Assembled shape.
var out map[string]json.RawMessage
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("assembled result not JSON object: %v", err)
}
for _, k := range []string{"calls", "errors", "latencies_p95", "latencies_p99"} {
if _, ok := out[k]; !ok {
t.Errorf("assembled result missing key %q (got %v)", k, out)
}
}
// The two latency sub-queries carry the right quantiles.
if lat := hits["/api/metrics/latencies"]; !strings.Contains(lat, "quantile=0.9") {
t.Errorf("latencies query = %q; want a quantile", lat)
}
}
Comment on lines +103 to 154

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

The test TestMetrics_FansOutAndRemaps uses a shared map hits without synchronization. If Metrics is executed concurrently, multiple goroutines will write to hits concurrently, causing a panic due to concurrent map writes. Additionally, since both latency queries write to the same /api/metrics/latencies key, they overwrite each other, making it impossible to reliably verify both queries.

Protecting the map with a sync.Mutex and storing the queries in a slice solves both the concurrency panic and the overwriting issue.

func TestMetrics_FansOutAndRemaps(t *testing.T) {
	var mu sync.Mutex
	hits := map[string][]string{} // metric path -> raw queries
	c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
		mu.Lock()
		hits[r.URL.Path] = append(hits[r.URL.Path], r.URL.RawQuery)
		mu.Unlock()
		// Echo the metric name so we can assert the assembly mapping.
		_, _ = w.Write([]byte("{\"path\":\"" + r.URL.Path + "\"}"))
	})
	defer srv.Close()

	raw, err := c.Metrics(context.Background(), map[string]any{
		"services":    "frontend",
		"spanKinds":   "SPAN_KIND_SERVER",
		"metric_type": "should-be-dropped",
		"endTs":       1700000000,
	})
	if err != nil {
		t.Fatal(err)
	}

	// All four endpoints hit.
	mu.Lock()
	defer mu.Unlock()
	for _, p := range []string{"/api/metrics/calls", "/api/metrics/errors", "/api/metrics/latencies"} {
		if _, ok := hits[p]; !ok {
			t.Errorf("missing request to %s (hits: %v)", p, hits)
		}
	}
	// Remap: singular service/spanKind present, plurals + metric_type gone.
	callsQueries := hits["/api/metrics/calls"]
	if len(callsQueries) == 0 {
		t.Fatal("missing calls query")
	}
	q := callsQueries[0]
	if !strings.Contains(q, "service=frontend") || !strings.Contains(q, "spanKind=SPAN_KIND_SERVER") {
		t.Errorf("calls query = %q; want remapped service/spanKind", q)
	}
	if strings.Contains(q, "services=") || strings.Contains(q, "spanKinds=") || strings.Contains(q, "metric_type=") {
		t.Errorf("calls query = %q; plural/metric_type should be dropped", q)
	}
	if !strings.Contains(q, "endTs=1700000000") {
		t.Errorf("calls query = %q; want endTs passed through", q)
	}

	// Assembled shape.
	var out map[string]json.RawMessage
	if err := json.Unmarshal(raw, &out); err != nil {
		t.Fatalf("assembled result not JSON object: %v", err)
	}
	for _, k := range []string{\"calls\", \"errors\", \"latencies_p95\", \"latencies_p99\"} {
		if _, ok := out[k]; !ok {
			t.Errorf("assembled result missing key %q (got %v)", k, out)
		}
	}
	// The two latency sub-queries carry the right quantiles.
	latencies := hits["/api/metrics/latencies"]
	if len(latencies) != 2 {
		t.Errorf("expected 2 latency queries, got %d", len(latencies))
	}
	for _, lat := range latencies {
		if !strings.Contains(lat, "quantile=0.9") {
			t.Errorf("latencies query = %q; want a quantile", lat)
		}
	}
}


func TestMetrics_BuildsPath(t *testing.T) {
var path string
// TestMetrics_SPMNotAvailable: a 404 from Jaeger (SPM storage not wired up)
// surfaces the friendly legacy message, not a raw HTTP 404.
func TestMetrics_SPMNotAvailable(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.RequestURI()
_, _ = w.Write([]byte(`{}`))
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`404 page not found`))
})
defer srv.Close()
if _, err := c.Metrics(context.Background(), "latencies", map[string]any{"service": "frontend"}); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(path, "/api/metrics/latencies") {
t.Errorf("path = %q", path)
_, err := c.Metrics(context.Background(), map[string]any{"services": "frontend"})
if err == nil || !strings.Contains(err.Error(), "SPM metrics not available") {
t.Errorf("expected friendly SPM-unavailable error, got %v", err)
}
}

Expand Down
Loading