From 3f93a7258ff755ee6e6047f8f7a3f7c4514203cf Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:01:04 +0200 Subject: [PATCH] fix: keep telemetry running when resource detection is incomplete The runtime image sets USER 65532:65532 but never creates a matching account, so the process owner detector's user.Current call fails and resource.New returns an error. Init treated that as fatal, which disabled OpenTelemetry entirely in every container. resource.New still returns the attributes its other detectors produced, so an incomplete resource is a degraded one, not an invalid one. Log the detection failure and carry on. Checking errors.Is(err, resource.ErrPartialResource) would not have helped here: the process owner detector returns user.Current's error verbatim, unwrapped. Also create the nonroot account the numeric USER refers to, so the lookup succeeds and process.owner is populated instead of merely tolerated. Co-Authored-By: Claude Opus 5 (1M context) --- Dockerfile | 9 +++++- internal/telemetry/otel.go | 11 +++++-- internal/telemetry/otel_test.go | 53 +++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index cdfed35..03f8004 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,11 +18,18 @@ ARG TARGETOS TARGETARCH RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /out/proxy-sampler ./cmd/app FROM debian:bookworm-slim +# The nonroot account matches the numeric USER below. Without it the runtime UID +# resolves to no account at all, which breaks every os/user lookup in the +# process -- including the OpenTelemetry process.owner resource detector. RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates tzdata \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 65532 nonroot \ + && useradd --system --uid 65532 --gid 65532 \ + --home-dir /home/nonroot --create-home --shell /usr/sbin/nologin nonroot COPY --from=go-builder /out/proxy-sampler /usr/local/bin/proxy-sampler ENV HTTP_ADDR=:8080 EXPOSE 8080 +# Numeric so Kubernetes runAsNonRoot can verify it without resolving the name. USER 65532:65532 ENTRYPOINT ["/usr/local/bin/proxy-sampler"] diff --git a/internal/telemetry/otel.go b/internal/telemetry/otel.go index 992574e..19b11e4 100644 --- a/internal/telemetry/otel.go +++ b/internal/telemetry/otel.go @@ -43,6 +43,7 @@ var ( newLogExporter = func(ctx context.Context) (sdklog.Exporter, error) { return otlploghttp.New(ctx) } + newResource = buildResource ) // Init installs OTLP/HTTP trace, metric, and log providers. With no common @@ -58,9 +59,15 @@ func Init(ctx context.Context, getenv func(string) string, logger *slog.Logger) logger = slog.Default() } - res, err := buildResource(ctx) + // resource.New reports every detector failure but still returns the + // attributes the remaining detectors produced, so a failure here degrades + // the resource rather than invalidating it. Note that not all of these + // errors wrap resource.ErrPartialResource: the process owner detector + // returns user.Current's error verbatim, which is how a container UID + // without an /etc/passwd entry surfaces. + res, err := newResource(ctx) if err != nil { - return nil, fmt.Errorf("build telemetry resource: %w", err) + logger.Warn("incomplete telemetry resource", "error", err) } metricExporter, err := newMetricExporter(ctx) if err != nil { diff --git a/internal/telemetry/otel_test.go b/internal/telemetry/otel_test.go index 51c4736..ce9e9a7 100644 --- a/internal/telemetry/otel_test.go +++ b/internal/telemetry/otel_test.go @@ -17,6 +17,7 @@ import ( sdklog "go.opentelemetry.io/otel/sdk/log" sdkmetric "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/trace" ) @@ -123,6 +124,51 @@ func TestInitCleansMetricExporterWhenTraceSetupFails(t *testing.T) { } } +func TestInitKeepsPartialResourceWhenDetectionFails(t *testing.T) { + // resource.New reports detector failures but still returns everything the + // remaining detectors produced. The process owner detector returns + // user.Current's error verbatim rather than wrapping ErrPartialResource, so + // a container UID with no /etc/passwd entry yields a plain error here. This + // is the wording the CGO_ENABLED=0 build emits; cgo builds instead report + // "user: unknown userid 65532". + detectionErr := errors.New("error detecting resource: user: Current requires cgo or $USER set in environment") + partial := resource.NewSchemaless(attribute.String("service.name", "proxy-sampler")) + restoreResource := replaceResourceFactory(t, func(context.Context) (*resource.Resource, error) { + return partial, detectionErr + }) + defer restoreResource() + restore := replaceExporterFactories(t, + func(context.Context) (sdkmetric.Exporter, error) { return &fakeMetricExporter{}, nil }, + func(context.Context) (sdktrace.SpanExporter, error) { return &fakeTraceExporter{}, nil }, + func(context.Context) (sdklog.Exporter, error) { return &fakeLogExporter{}, nil }, + ) + defer restore() + + originalTracer := otel.GetTracerProvider() + originalMeter := otel.GetMeterProvider() + originalLogger := global.GetLoggerProvider() + t.Cleanup(func() { + otel.SetTracerProvider(originalTracer) + otel.SetMeterProvider(originalMeter) + global.SetLoggerProvider(originalLogger) + }) + + var logs bytes.Buffer + shutdown, err := Init(context.Background(), endpointGetenv, slog.New(slog.NewJSONHandler(&logs, nil))) + if err != nil { + t.Fatalf("Init with an incomplete resource: %v", err) + } + if otel.GetTracerProvider() == originalTracer || otel.GetMeterProvider() == originalMeter { + t.Fatal("incomplete resource detection suppressed the trace and metric providers") + } + if !strings.Contains(logs.String(), "Current requires cgo") { + t.Fatalf("incomplete resource detection was not reported: %s", logs.String()) + } + if err := shutdown(context.Background()); err != nil { + t.Fatalf("shutdown: %v", err) + } +} + func TestBuildResourceForcesProxySamplerServiceName(t *testing.T) { t.Setenv("OTEL_SERVICE_NAME", "wrong-service") res, err := buildResource(context.Background()) @@ -144,6 +190,13 @@ func endpointGetenv(name string) string { type exporterFactoryRestore func() +func replaceResourceFactory(t *testing.T, factory func(context.Context) (*resource.Resource, error)) exporterFactoryRestore { + t.Helper() + original := newResource + newResource = factory + return func() { newResource = original } +} + func replaceExporterFactories( t *testing.T, metricFactory func(context.Context) (sdkmetric.Exporter, error),