Skip to content

Commit 585a625

Browse files
RamanKharcheeclaudegithub-actions[bot]
authored
fix(telemetry): report tracesUrl and explain trace disconnects (#534)
* fix(telemetry): report tracesUrl and explain trace disconnects The agent never populated tracesUrl, and never said why traces were down. tracesUrl: the legacy sink passed clickhouse_url (CLICKHOUSE_HOST) into get_trace_url(); the Go port hardcoded that path to "" on the premise that we no longer run a local ClickHouse. main.go does read and probe CLICKHOUSE_HOST, so the premise was stale and the field shipped empty for every otel_clickhouse cluster. That left agent_service.go's `TracesUrl != nil` gate permanently closed, so the trace-table config was never written and Last9 clusters resolved `otel_traces` instead of `otel.traces`. Add isClickHouseEnabled as the otel_clickhouse counterpart of isJaegerEnabled, and report the host. ClickHouse is checked last in traceURL rather than first as in the legacy: the legacy order returns the ClickHouse host even when TRACE_TABLE makes the provider bigquery, and the backend then quotes that host as a BigQuery table. It is also deliberately not used by traceStatus — gating tracesEnabled on the URL would turn traces off for TRACES_ENABLED=true-without-host, a config the agent supports for external ClickHouse it cannot probe. tracesConnectionError: the field was declared (agent_service.go) and rendered (agentHealth.jsx renderReason) but emitted by nobody, so a Disconnected Traces pill never showed a reason. probeClickhouse now returns one alongside the status; httpProbe is split over a new httpProbeErr so the failure survives, leaving its other callers untouched. Credentials in a URL-form CLICKHOUSE_HOST are stripped before the reason ships, since it renders verbatim in the UI. The field intentionally omits `omitempty`: the collector merges activity_stats into connection_status with jsonb `||`, so an omitted key leaves the previous value in place. A recovered ClickHouse must post an explicit "" to clear the stale reason. Fixes #34231 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: update image tags for main release --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 6d1cadc commit 585a625

5 files changed

Lines changed: 271 additions & 14 deletions

File tree

charts/nudgebee-agent/values.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ runnerServiceAccount:
6565
runner:
6666
image:
6767
repository: ghcr.io/nudgebee/nudgebee-agent
68-
tag: 2026-07-15T13-13-30_037b4a042ed03716596734435acec49d9f721274
68+
tag: 2026-07-15T16-09-55_6d1cadca59e43257a6b45048731c4a4c105b8699
6969
# Image template the pod_profiler action launches debugger pods from.
7070
# The agent substitutes `{}` for the variant (bpf, jvm, python, perf, ruby).
7171
# Surfaces as PROFILER_IMAGE; leave empty to fall back to the binary default.

runner/cmd/agent/main.go

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"maps"
1616
"net/http"
1717
"net/http/pprof"
18+
"net/url"
1819
"os"
1920
"os/signal"
2021
"runtime"
@@ -992,7 +993,7 @@ func run(ctx context.Context, logger *slog.Logger, cfg *config.Config) error {
992993
probeClient := &http.Client{Timeout: 5 * time.Second}
993994
logsProvider, logsURL, logsOK, logCfg := probeLogsProvider(probeCtx, cfg)
994995
as := telemetry.DetectAutoScaler(probeCtx, typedKube, providerInfo.Provider, logger)
995-
clickhouseStatus := probeClickhouse(probeCtx, probeClient, clickhouseHost, clickhousePort)
996+
clickhouseStatus, clickhouseErr := probeClickhouse(probeCtx, probeClient, clickhouseHost, clickhousePort)
996997
return telemetry.Datasources{
997998
PrometheusURL: cfg.PrometheusURL,
998999
AlertManagerURL: cfg.AlertManagerURL,
@@ -1013,6 +1014,8 @@ func run(ctx context.Context, logger *slog.Logger, cfg *config.Config) error {
10131014
ChronosphereTracesURL: chronosphereURL,
10141015
ChronosphereURL: cfg.ChronosphereURL,
10151016
ClickHouseStatus: clickhouseStatus,
1017+
ClickHouseURL: clickhouseHost,
1018+
ClickHouseError: clickhouseErr,
10161019
AgentURL: agentURL,
10171020
GrafanaEnabled: grafanaURL != "" && httpProbe(probeCtx, probeClient, grafanaURL+"/api/health"),
10181021
AutoScalerEnabled: as.Enabled,
@@ -1293,16 +1296,50 @@ func probeLogsProvider(ctx context.Context, cfg *config.Config) (provider, url s
12931296
// the host is set, hit `/ping` on the HTTP port and reflect the result.
12941297
// TRACES_ENABLED=true|false acts as an explicit override (some users run
12951298
// an external clickhouse the agent can't reach).
1296-
func probeClickhouse(ctx context.Context, c *http.Client, host, port string) bool {
1299+
//
1300+
// The second return is the reason traces are down, shipped as
1301+
// tracesConnectionError and rendered verbatim by the UI. It is empty whenever
1302+
// ClickHouse is healthy, and also when traces are off deliberately — an
1303+
// operator disabling a backend isn't a failure to explain.
1304+
func probeClickhouse(ctx context.Context, c *http.Client, host, port string) (bool, string) {
12971305
if v := os.Getenv("TRACES_ENABLED"); v == "true" {
1298-
return true
1306+
return true, ""
12991307
} else if v == "false" {
1300-
return false
1308+
return false, ""
13011309
}
13021310
if host == "" {
1303-
return false
1311+
return false, "CLICKHOUSE_HOST is not set: no traces backend is configured"
1312+
}
1313+
if err := httpProbeErr(ctx, c, fmt.Sprintf("http://%s:%s/ping", host, port)); err != nil {
1314+
return false, fmt.Sprintf("ClickHouse ping failed at %s:%s: %v", redactUserinfo(host), port, probeCause(err))
1315+
}
1316+
return true, ""
1317+
}
1318+
1319+
// probeCause unwraps the *url.Error net/http wraps around transport failures.
1320+
// The wrapper stringifies the whole request URL; the inner cause ("connection
1321+
// refused", "i/o timeout") is the half worth showing and carries no address.
1322+
func probeCause(err error) error {
1323+
var uerr *url.Error
1324+
if errors.As(err, &uerr) {
1325+
return uerr.Err
1326+
}
1327+
return err
1328+
}
1329+
1330+
// redactUserinfo strips a `user:pass@` prefix from a host. CLICKHOUSE_HOST may
1331+
// be a full URL rather than a bare host (pkg/clickhouse normalizes both forms),
1332+
// and the reason string built above lands in the agent's connection_status
1333+
// JSON, which the UI renders as-is — credentials must not ride along.
1334+
func redactUserinfo(host string) string {
1335+
at := strings.LastIndex(host, "@")
1336+
if at < 0 {
1337+
return host
13041338
}
1305-
return httpProbe(ctx, c, fmt.Sprintf("http://%s:%s/ping", host, port))
1339+
if scheme := strings.Index(host, "://"); scheme >= 0 && scheme+3 <= at {
1340+
return host[:scheme+3] + host[at+1:]
1341+
}
1342+
return host[at+1:]
13061343
}
13071344

13081345
// fetchSignozVersion GETs Signoz's /api/v1/version and returns the reported
@@ -1336,9 +1373,15 @@ func fetchSignozVersion(ctx context.Context, c *http.Client, baseURL string) str
13361373
// URL is sourced from operator-provided config (PROMETHEUS_URL, LOKI_URL,
13371374
// etc.), not request-derived — taint flow is operator → probe by design.
13381375
func httpProbe(ctx context.Context, c *http.Client, url string, headers ...map[string]string) bool {
1376+
return httpProbeErr(ctx, c, url, headers...) == nil
1377+
}
1378+
1379+
// httpProbeErr is httpProbe with the failure preserved, for the callers that
1380+
// report *why* a datasource is unreachable rather than just that it is.
1381+
func httpProbeErr(ctx context.Context, c *http.Client, url string, headers ...map[string]string) error {
13391382
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) //nolint:gosec // operator-provided URL
13401383
if err != nil {
1341-
return false
1384+
return err
13421385
}
13431386
for _, h := range headers {
13441387
for k, v := range h {
@@ -1347,10 +1390,13 @@ func httpProbe(ctx context.Context, c *http.Client, url string, headers ...map[s
13471390
}
13481391
resp, err := c.Do(req) //nolint:gosec // operator-provided URL
13491392
if err != nil {
1350-
return false
1393+
return err
13511394
}
13521395
defer func() { _ = resp.Body.Close() }()
1353-
return resp.StatusCode >= 200 && resp.StatusCode < 300
1396+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
1397+
return fmt.Errorf("HTTP %d", resp.StatusCode)
1398+
}
1399+
return nil
13541400
}
13551401

13561402
// esHTTPClient returns the HTTP client the ES query client uses. When

runner/cmd/agent/probe_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ import (
44
"context"
55
"encoding/base64"
66
"log/slog"
7+
"net"
78
"net/http"
89
"net/http/httptest"
10+
"strings"
911
"testing"
12+
"time"
1013

1114
"github.com/nudgebee/nudgebee-agent/pkg/config"
1215
"github.com/nudgebee/nudgebee-agent/pkg/observability/prometheus"
@@ -185,3 +188,83 @@ func TestSelectedLogsProvider_Precedence(t *testing.T) {
185188
})
186189
}
187190
}
191+
192+
// A ClickHouse that answers /ping is healthy and has no reason to report.
193+
func TestProbeClickhouse_HealthyReportsNoReason(t *testing.T) {
194+
t.Setenv("TRACES_ENABLED", "")
195+
ch := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
196+
w.WriteHeader(http.StatusOK)
197+
}))
198+
defer ch.Close()
199+
200+
host, port, _ := net.SplitHostPort(strings.TrimPrefix(ch.URL, "http://"))
201+
ok, reason := probeClickhouse(context.Background(), ch.Client(), host, port)
202+
if !ok {
203+
t.Errorf("probeClickhouse ok = false; want true")
204+
}
205+
if reason != "" {
206+
t.Errorf("reason = %q; want empty for a healthy ClickHouse", reason)
207+
}
208+
}
209+
210+
// An unreachable ClickHouse must explain itself — this is the string the UI
211+
// renders under the Traces "Disconnected" pill.
212+
func TestProbeClickhouse_UnreachableReportsReason(t *testing.T) {
213+
t.Setenv("TRACES_ENABLED", "")
214+
// Bind and immediately close, so the port is dead but well-formed.
215+
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
216+
host, port, _ := net.SplitHostPort(strings.TrimPrefix(dead.URL, "http://"))
217+
dead.Close()
218+
219+
ok, reason := probeClickhouse(context.Background(), &http.Client{Timeout: 2 * time.Second}, host, port)
220+
if ok {
221+
t.Errorf("probeClickhouse ok = true; want false for a dead ClickHouse")
222+
}
223+
if reason == "" {
224+
t.Fatal("reason = empty; want a failure explanation")
225+
}
226+
if !strings.Contains(reason, "ClickHouse ping failed") {
227+
t.Errorf("reason = %q; want it to name the failing probe", reason)
228+
}
229+
}
230+
231+
// Traces off by operator choice is not a failure, so there's nothing to explain.
232+
func TestProbeClickhouse_ExplicitlyDisabledReportsNoReason(t *testing.T) {
233+
t.Setenv("TRACES_ENABLED", "false")
234+
ok, reason := probeClickhouse(context.Background(), http.DefaultClient, "ch.example", "8123")
235+
if ok {
236+
t.Errorf("probeClickhouse ok = true; want false under TRACES_ENABLED=false")
237+
}
238+
if reason != "" {
239+
t.Errorf("reason = %q; want empty — disabled on purpose isn't a failure", reason)
240+
}
241+
}
242+
243+
// No host means traces were never wired up; say so rather than going silent.
244+
func TestProbeClickhouse_UnconfiguredExplainsItself(t *testing.T) {
245+
t.Setenv("TRACES_ENABLED", "")
246+
ok, reason := probeClickhouse(context.Background(), http.DefaultClient, "", "8123")
247+
if ok {
248+
t.Errorf("probeClickhouse ok = true; want false with no CLICKHOUSE_HOST")
249+
}
250+
if !strings.Contains(reason, "CLICKHOUSE_HOST") {
251+
t.Errorf("reason = %q; want it to name the missing env var", reason)
252+
}
253+
}
254+
255+
// The reason string ships to the backend and renders in the UI, so a URL-form
256+
// CLICKHOUSE_HOST must not leak its credentials into it.
257+
func TestRedactUserinfo(t *testing.T) {
258+
cases := []struct{ in, want string }{
259+
{"clickhouse.svc:8123", "clickhouse.svc:8123"},
260+
{"https://otel.last9.io:443", "https://otel.last9.io:443"},
261+
{"https://admin:hunter2@otel.last9.io:443", "https://otel.last9.io:443"},
262+
{"admin:hunter2@clickhouse.svc:8123", "clickhouse.svc:8123"},
263+
{"", ""},
264+
}
265+
for _, tc := range cases {
266+
if got := redactUserinfo(tc.in); got != tc.want {
267+
t.Errorf("redactUserinfo(%q) = %q; want %q", tc.in, got, tc.want)
268+
}
269+
}
270+
}

runner/pkg/telemetry/service.go

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ type ActivityStats struct {
6666
HealthCheckDuration float64 `json:"healthCheckDuration,omitempty"`
6767
TraceProvider string `json:"traceProvider,omitempty"`
6868
TraceProviderConfig map[string]any `json:"traceProviderConfig,omitempty"`
69+
// TracesConnectionError is the reason traces are disconnected, rendered by
70+
// the UI's Agent Health card under the Traces pill ("Reason - ...").
71+
//
72+
// Deliberately no `omitempty`: the collector merges activity_stats into
73+
// `agent.connection_status` with the jsonb `||` operator, so an omitted key
74+
// leaves the previous value in place. Once ClickHouse recovers we must post
75+
// an explicit "" to clear the stale reason — dropping the key would strand
76+
// it in the DB forever.
77+
TracesConnectionError string `json:"tracesConnectionError"`
6978
}
7079

7180
// ClusterStatus is the wire payload posted to /v1/k8s/telemetry.
@@ -142,6 +151,15 @@ type Datasources struct {
142151
// ClickHouseStatus is the `clickhouse_status` flag — used only as the
143152
// fallback for tracesEnabled when no other provider matches.
144153
ClickHouseStatus bool
154+
// ClickHouseURL is CLICKHOUSE_HOST verbatim (bare host, no scheme/port) —
155+
// same value the legacy checker put on `clickhouse_url`. Reported as
156+
// tracesUrl for the otel_clickhouse provider; the backend substring-matches
157+
// it to pick the trace table (`otel.traces` for Last9, else `otel_traces`).
158+
ClickHouseURL string
159+
// ClickHouseError is why the probe failed, already stripped of credentials
160+
// by the caller. Empty when ClickHouse is healthy or was never probed
161+
// (unconfigured / disabled). Surfaced as tracesConnectionError.
162+
ClickHouseError string
145163

146164
// Node-agent: count of `up{job=~"...nudgebee(-.*)?-node-agent"}` from
147165
// Prometheus, computed by the caller.
@@ -359,6 +377,13 @@ func (s *Service) probe(ctx context.Context, ds Datasources) ActivityStats {
359377
out.TracesEnabled = traceStatus(ds)
360378
out.TraceProvider = traceProvider(ds)
361379
out.TracesURL = traceURL(ds)
380+
// The reason slot only makes sense while traces are down, and ClickHouse is
381+
// the only trace backend the agent actually probes — the others (bigquery,
382+
// chronosphere, jaeger) are env-configured and force TracesEnabled true
383+
// without a health check, so they never have a failure to report.
384+
if !out.TracesEnabled {
385+
out.TracesConnectionError = ds.ClickHouseError
386+
}
362387
// traceProviderConfig: the legacy code queries ClickHouse for the
363388
// otel_traces materialized-column flag. The agent doesn't run a
364389
// local ClickHouse anymore; the backend computes this. Emit an
@@ -398,9 +423,12 @@ func traceProvider(ds Datasources) string {
398423
return "otel_clickhouse"
399424
}
400425

401-
// traceURL mirrors get_trace_url. Note the first
402-
// argument `url_from_prometheus` is what the legacy passes as
403-
// `clickhouse_url` — we don't run a local ClickHouse, so it's always "".
426+
// traceURL mirrors get_trace_url. The legacy's first argument
427+
// `url_from_prometheus` is what it passes as `clickhouse_url` (CLICKHOUSE_HOST)
428+
// — reported here via isClickHouseEnabled, but checked last rather than first:
429+
// the legacy order returns the ClickHouse host even when TRACE_TABLE makes the
430+
// provider `bigquery`, and the backend then quotes that host as a BigQuery
431+
// table. Checking it last keeps the URL consistent with the reported provider.
404432
func traceURL(ds Datasources) string {
405433
if ds.TraceTable != "" {
406434
return ds.TraceTable
@@ -417,6 +445,9 @@ func traceURL(ds Datasources) string {
417445
}
418446
return ds.ChronosphereURL
419447
}
448+
if isClickHouseEnabled(ds) {
449+
return ds.ClickHouseURL
450+
}
420451
return ""
421452
}
422453

@@ -436,6 +467,18 @@ func isJaegerEnabled(ds Datasources) bool {
436467
return ds.JaegerEnabled && ds.JaegerQueryURL != ""
437468
}
438469

470+
// isClickHouseEnabled is the otel_clickhouse counterpart of isJaegerEnabled:
471+
// a reachable ClickHouse we have an address for. The status flag alone isn't
472+
// enough to report a URL — TRACES_ENABLED=true forces the flag on without
473+
// probing, so it can be true with CLICKHOUSE_HOST unset.
474+
//
475+
// Deliberately not used by traceStatus: gating tracesEnabled on the URL would
476+
// turn traces off for exactly that TRACES_ENABLED=true-without-host config,
477+
// which the agent supports for external ClickHouse it can't probe.
478+
func isClickHouseEnabled(ds Datasources) bool {
479+
return ds.ClickHouseStatus && ds.ClickHouseURL != ""
480+
}
481+
439482
// httpHealth returns true iff GET <url> returns 2xx within 5s.
440483
func httpHealth(ctx context.Context, c *http.Client, url string) bool {
441484
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)

0 commit comments

Comments
 (0)