From 43766b8236c0a0de76e9497baf1e4fea8588add6 Mon Sep 17 00:00:00 2001 From: Pavel <177363085+pkcll@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:31:34 -0400 Subject: [PATCH] chipingress: add WithClientName option Sets the gRPC user-agent and a client_name attribute on the otelgrpc spans and metrics so callers can be identified by chip-ingress and in telemetry. An empty name is a no-op. --- pkg/chipingress/README.md | 2 ++ pkg/chipingress/client.go | 20 +++++++++++++++++++ pkg/chipingress/client_test.go | 35 ++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/pkg/chipingress/README.md b/pkg/chipingress/README.md index b4c64a38aa..3a61ffaade 100644 --- a/pkg/chipingress/README.md +++ b/pkg/chipingress/README.md @@ -130,6 +130,8 @@ client, err := chipingress.NewClient("example.com:9090", chipingress.WithTracerProvider(tracerProvider)) ``` +Use `chipingress.WithClientName("my_service")` to identify the client: the name is sent as the gRPC user-agent and recorded as a `client_name` attribute on the otelgrpc spans and metrics. + The client uses `otelgrpc.NewClientHandler()` to automatically create spans for all gRPC calls, including metrics for request duration, message sizes, and error rates. ## Dependencies diff --git a/pkg/chipingress/client.go b/pkg/chipingress/client.go index c4aaf5f85d..6929456b30 100644 --- a/pkg/chipingress/client.go +++ b/pkg/chipingress/client.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" "google.golang.org/grpc" @@ -52,6 +53,7 @@ type clientConfig struct { meterProvider metric.MeterProvider tracerProvider trace.TracerProvider nopInfoHeaderProvider HeaderProvider + clientName string } func newClientConfig(host string) *clientConfig { @@ -89,6 +91,14 @@ func NewClient(address string, opts ...Opt) (Client, error) { otelOpts = append(otelOpts, otelgrpc.WithTracerProvider(cfg.tracerProvider)) } + if cfg.clientName != "" { + clientNameAttr := attribute.String("client_name", cfg.clientName) + otelOpts = append(otelOpts, + otelgrpc.WithSpanAttributes(clientNameAttr), + otelgrpc.WithMetricAttributes(clientNameAttr), + ) + } + grpcOpts := []grpc.DialOption{ grpc.WithTransportCredentials(cfg.transportCredentials), grpc.WithStatsHandler(otelgrpc.NewClientHandler(otelOpts...)), @@ -99,6 +109,9 @@ func NewClient(address string, opts ...Opt) (Client, error) { PermitWithoutStream: true, }), } + if cfg.clientName != "" { + grpcOpts = append(grpcOpts, grpc.WithUserAgent(cfg.clientName)) + } // Retry policy retryPolicy := `{ "maxAttempts": 3, @@ -252,6 +265,13 @@ func WithMeterProvider(provider metric.MeterProvider) Opt { return func(c *clientConfig) { c.meterProvider = provider } } +// WithClientName identifies this client to chip-ingress and in telemetry: it is sent as the gRPC +// user-agent and recorded as a client_name attribute on the otelgrpc spans and metrics. An empty +// name is a no-op. +func WithClientName(name string) Opt { + return func(c *clientConfig) { c.clientName = name } +} + // WithTracerProvider sets a custom OpenTelemetry TracerProvider for distributed tracing. // If not set, the global tracer provider will be used. func WithTracerProvider(provider trace.TracerProvider) Opt { diff --git a/pkg/chipingress/client_test.go b/pkg/chipingress/client_test.go index a50f6f9bd8..9b90393a48 100644 --- a/pkg/chipingress/client_test.go +++ b/pkg/chipingress/client_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net" + "strings" "testing" "time" @@ -641,6 +642,40 @@ func TestOptions(t *testing.T) { require.NoError(t, err) assert.Equal(t, "true", headers["x-include-nop-info"]) }) + + t.Run("WithClientName", func(t *testing.T) { + config := defaultCfg + WithClientName("durable_emitter")(&config) + assert.Equal(t, "durable_emitter", config.clientName) + }) +} + +// WithClientName must reach the server as part of the gRPC user-agent, and an unset name must +// leave the default user-agent untouched. +func TestClient_ClientNameUserAgent(t *testing.T) { + userAgent := func(t *testing.T, opts ...Opt) string { + lis, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + defer lis.Close() + + srv := gp.NewServer() + capture := &capturingServer{} + pb.RegisterChipIngressServer(srv, capture) + go func() { _ = srv.Serve(lis) }() + defer srv.Stop() + + client, err := NewClient(lis.Addr().String(), append([]Opt{WithInsecureConnection()}, opts...)...) + require.NoError(t, err) + defer client.Close() //nolint:errcheck + + _, err = client.Ping(t.Context(), &EmptyRequest{}) + require.NoError(t, err) + require.NotNil(t, capture.lastMD) + return strings.Join(capture.lastMD.Get("user-agent"), " ") + } + + assert.Contains(t, userAgent(t, WithClientName("durable_emitter")), "durable_emitter") + assert.NotContains(t, userAgent(t, WithClientName("")), "durable_emitter") } func TestHeaderInterceptor(t *testing.T) {