test(observability): cover dial-timeout + ErrNotConfigured; close conn on exporter error

Unit coverage for NewTelemetryClient: an unset OTEL_HOST yields ErrNotConfigured,
and an unreachable collector returns within the dial timeout instead of hanging
(a TCP-accepting, silent listener drives the WithBlock path).

Also close the grpc.ClientConn on the exporter-creation error paths — a
successful dial followed by a failed exporter New() previously leaked it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-11 09:09:38 +07:00
co-authored by Claude Opus 4.8
parent 62a1445d32
commit d49e740821
2 changed files with 59 additions and 0 deletions
+3
View File
@@ -117,6 +117,7 @@ func NewTelemetryClient(ctx context.Context, cfg *config.Config) (TelemetryClien
logExporter, err := otlploggrpc.New(ctx, otlploggrpc.WithGRPCConn(conn))
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("failed to create log exporter: %w", err)
}
@@ -132,6 +133,7 @@ func NewTelemetryClient(ctx context.Context, cfg *config.Config) (TelemetryClien
metricExporter, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithGRPCConn(conn))
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("failed to create metric exporter: %w", err)
}
@@ -148,6 +150,7 @@ func NewTelemetryClient(ctx context.Context, cfg *config.Config) (TelemetryClien
spanExporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn))
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("failed to create tracer exporter: %w", err)
}
@@ -0,0 +1,56 @@
package observability
import (
"context"
"errors"
"net"
"testing"
"time"
"pentagi/pkg/config"
)
func TestNewTelemetryClient_EmptyEndpointReturnsErrNotConfigured(t *testing.T) {
_, err := NewTelemetryClient(context.Background(), &config.Config{TelemetryEndpoint: ""})
if !errors.Is(err, ErrNotConfigured) {
t.Fatalf("want ErrNotConfigured, got %v", err)
}
}
// H4 guard: a set-but-unreachable collector must not hang startup. The endpoint
// accepts the TCP connection but never completes the gRPC handshake, so a
// WithBlock dial would wait forever without the internal DefaultDialTimeout that
// this bounds — the caller's context has no deadline.
func TestNewTelemetryClient_UnreachableReturnsWithinDialTimeout(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
// hold the connection open and stay silent (no HTTP/2 handshake)
defer conn.Close()
}
}()
cfg := &config.Config{TelemetryEndpoint: ln.Addr().String()}
done := make(chan error, 1)
go func() {
_, e := NewTelemetryClient(context.Background(), cfg)
done <- e
}()
select {
case err := <-done:
if err == nil {
t.Fatal("expected an error for an unreachable collector")
}
case <-time.After(DefaultDialTimeout + 10*time.Second):
t.Fatal("NewTelemetryClient hung past the dial timeout (H4 regression)")
}
}