From 92ea16e9a1a5833287836a058df8b1e4ce7f7681 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 16:53:09 +0000 Subject: [PATCH 1/2] fix(runner/jaeger): fan out SPM metrics to match the backend composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jaeger_query_metrics required a metric_type param and hit a single /api/metrics/{type} endpoint, but the backend composer sends services/spanKinds (plural) and NO metric_type, and its parser expects the combined four-metric blob — so every call errored. Rewrite Metrics to replicate the legacy get_metrics: remap services->service and spanKinds->spanKind, drop metric_type, and fan out to /api/metrics/{calls,errors,latencies?quantile=0.95, latencies?quantile=0.99}, assembling {calls, errors, latencies_p95, latencies_p99}. A 404 (SPM storage not configured) surfaces the friendly legacy message instead of a raw HTTP 404. Note: the response key names and remapping mirror the legacy agent; the jaeger_query_metrics composer/parser contract should be confirmed against the backend repo before release. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01L2HAXQH1gEtX7h4sUJsi9j (cherry picked from commit 1db8d311daa35dc5f75fd14dc91738971917cc7c) --- runner/pkg/observability/jaeger/client.go | 106 +++++++++++++++--- runner/pkg/observability/jaeger/handlers.go | 12 +- .../pkg/observability/jaeger/jaeger_test.go | 76 ++++++++++--- 3 files changed, 157 insertions(+), 37 deletions(-) diff --git a/runner/pkg/observability/jaeger/client.go b/runner/pkg/observability/jaeger/client.go index d83446e4..01c9cd85 100644 --- a/runner/pkg/observability/jaeger/client.go +++ b/runner/pkg/observability/jaeger/client.go @@ -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 ( @@ -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) +} + +// 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 { @@ -100,9 +164,24 @@ 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 { @@ -110,7 +189,7 @@ func (c *Client) get(ctx context.Context, path string, params url.Values) (json. } 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) @@ -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 } diff --git a/runner/pkg/observability/jaeger/handlers.go b/runner/pkg/observability/jaeger/handlers.go index 6b7cd077..9093cd46 100644 --- a/runner/pkg/observability/jaeger/handlers.go +++ b/runner/pkg/observability/jaeger/handlers.go @@ -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) } diff --git a/runner/pkg/observability/jaeger/jaeger_test.go b/runner/pkg/observability/jaeger/jaeger_test.go index 013bed59..c03afc99 100644 --- a/runner/pkg/observability/jaeger/jaeger_test.go +++ b/runner/pkg/observability/jaeger/jaeger_test.go @@ -2,6 +2,7 @@ package jaeger import ( "context" + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -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) } } -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) } } From 964747dcbcf365a23da3bff3861c172e63e6ac2b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 12 Aug 2026 08:31:24 +0000 Subject: [PATCH 2/2] chore: update image tags for main release --- charts/nudgebee-agent/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/nudgebee-agent/values.yaml b/charts/nudgebee-agent/values.yaml index bbf14b10..e3beaa5d 100644 --- a/charts/nudgebee-agent/values.yaml +++ b/charts/nudgebee-agent/values.yaml @@ -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.