Skip to content

Commit e59df51

Browse files
authored
chore(auth): add unit tests for OpenTelemetry spans (googleapis#13513)
1 parent db65e79 commit e59df51

File tree

4 files changed

+478
-2
lines changed

4 files changed

+478
-2
lines changed

auth/go.mod

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ require (
1010
github.com/googleapis/gax-go/v2 v2.15.0
1111
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0
1212
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0
13+
go.opentelemetry.io/otel v1.37.0
14+
go.opentelemetry.io/otel/sdk v1.37.0
15+
go.opentelemetry.io/otel/trace v1.37.0
1316
golang.org/x/net v0.47.0
1417
golang.org/x/time v0.14.0
1518
google.golang.org/grpc v1.76.0
@@ -20,10 +23,9 @@ require (
2023
github.com/felixge/httpsnoop v1.0.4 // indirect
2124
github.com/go-logr/logr v1.4.3 // indirect
2225
github.com/go-logr/stdr v1.2.2 // indirect
26+
github.com/google/uuid v1.6.0 // indirect
2327
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
24-
go.opentelemetry.io/otel v1.37.0 // indirect
2528
go.opentelemetry.io/otel/metric v1.37.0 // indirect
26-
go.opentelemetry.io/otel/trace v1.37.0 // indirect
2729
golang.org/x/crypto v0.44.0 // indirect
2830
golang.org/x/oauth2 v0.30.0 // indirect
2931
golang.org/x/sync v0.18.0 // indirect

auth/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh
5050
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
5151
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
5252
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
53+
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
54+
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
5355
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
5456
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
5557
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package grpctransport
16+
17+
import (
18+
"context"
19+
"net"
20+
"net/http"
21+
"testing"
22+
23+
"github.com/google/go-cmp/cmp"
24+
"go.opentelemetry.io/otel"
25+
"go.opentelemetry.io/otel/attribute"
26+
"go.opentelemetry.io/otel/codes"
27+
sdktrace "go.opentelemetry.io/otel/sdk/trace"
28+
"go.opentelemetry.io/otel/sdk/trace/tracetest"
29+
oteltrace "go.opentelemetry.io/otel/trace"
30+
"google.golang.org/grpc"
31+
"google.golang.org/grpc/credentials/insecure"
32+
"google.golang.org/grpc/status"
33+
34+
echo "cloud.google.com/go/auth/grpctransport/testdata"
35+
36+
grpccodes "google.golang.org/grpc/codes"
37+
)
38+
39+
const (
40+
keyRPCMethod = attribute.Key("rpc.method")
41+
keyRPCService = attribute.Key("rpc.service")
42+
keyRPCSystem = attribute.Key("rpc.system")
43+
keyRPCStatusCode = attribute.Key("rpc.grpc.status_code")
44+
keyServerAddr = attribute.Key("server.address")
45+
keyServerPort = attribute.Key("server.port")
46+
47+
valRPCSystemGRPC = "grpc"
48+
valLocalhost = "127.0.0.1"
49+
)
50+
51+
func TestDial_OpenTelemetry(t *testing.T) {
52+
// Ensure any lingering HTTP/2 connections are closed to avoid goroutine leaks.
53+
defer http.DefaultTransport.(*http.Transport).CloseIdleConnections()
54+
55+
exporter := tracetest.NewInMemoryExporter()
56+
tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
57+
defer tp.Shutdown(context.Background())
58+
59+
// Restore the global tracer provider after the test to avoid side effects.
60+
defer func(prev oteltrace.TracerProvider) { otel.SetTracerProvider(prev) }(otel.GetTracerProvider())
61+
otel.SetTracerProvider(tp)
62+
63+
successfulEchoer := &fakeEchoService{
64+
Fn: func(ctx context.Context, req *echo.EchoRequest) (*echo.EchoReply, error) {
65+
return &echo.EchoReply{Message: req.Message}, nil
66+
},
67+
}
68+
errorEchoer := &fakeEchoService{
69+
Fn: func(ctx context.Context, req *echo.EchoRequest) (*echo.EchoReply, error) {
70+
return nil, status.Error(grpccodes.Internal, "test error")
71+
},
72+
}
73+
74+
tests := []struct {
75+
name string
76+
echoer echo.EchoerServer
77+
opts *Options
78+
wantErr bool
79+
wantSpans int
80+
wantSpan sdktrace.ReadOnlySpan
81+
wantAttrKeys []attribute.Key
82+
}{
83+
{
84+
name: "telemetry enabled success",
85+
echoer: successfulEchoer,
86+
opts: &Options{DisableAuthentication: true},
87+
wantSpans: 1,
88+
wantSpan: tracetest.SpanStub{
89+
Name: "echo.Echoer/Echo",
90+
SpanKind: oteltrace.SpanKindClient,
91+
Status: sdktrace.Status{
92+
Code: codes.Unset,
93+
},
94+
Attributes: []attribute.KeyValue{
95+
// Note on Events (Logs):
96+
// The otelgrpc instrumentation also records "message" events (Sent/Received)
97+
// containing message sizes (compressed/uncompressed). These appear in the
98+
// "Logs" or "Events" tab in Cloud Trace. This test does not explicitly verify
99+
// them, but they are present in the generated span.
100+
101+
// In Cloud Trace, this status code maps to the visual "Status" field
102+
// (e.g., a green checkmark for 0/OK, or an error icon for other codes).
103+
keyRPCStatusCode.Int64(0),
104+
// In Cloud Trace, "rpc.service" and "rpc.method" are combined to form
105+
// the Span Name (e.g., "echo.Echoer/Echo").
106+
keyRPCMethod.String("Echo"),
107+
keyRPCService.String("echo.Echoer"),
108+
// "rpc.system" is displayed as a standard attribute key in the "Attributes" tab.
109+
keyRPCSystem.String(valRPCSystemGRPC),
110+
// "server.address" and "server.port" are displayed as standard attribute keys.
111+
keyServerAddr.String(valLocalhost),
112+
},
113+
}.Snapshot(),
114+
wantAttrKeys: []attribute.Key{keyServerPort},
115+
},
116+
{
117+
name: "telemetry enabled error",
118+
echoer: errorEchoer,
119+
opts: &Options{DisableAuthentication: true},
120+
wantErr: true,
121+
wantSpans: 1,
122+
wantSpan: tracetest.SpanStub{
123+
Name: "echo.Echoer/Echo",
124+
SpanKind: oteltrace.SpanKindClient,
125+
Status: sdktrace.Status{
126+
Code: codes.Error,
127+
Description: "test error",
128+
},
129+
Attributes: []attribute.KeyValue{
130+
// Note on Events (Logs):
131+
// The otelgrpc instrumentation also records "message" events (Sent/Received)
132+
// containing message sizes (compressed/uncompressed). These appear in the
133+
// "Logs" or "Events" tab in Cloud Trace. This test does not explicitly verify
134+
// them, but they are present in the generated span.
135+
136+
// In Cloud Trace, non-zero status codes (like 13 for INTERNAL) are displayed
137+
// as errors in the "Status" field.
138+
keyRPCStatusCode.Int64(13),
139+
keyRPCMethod.String("Echo"),
140+
keyRPCService.String("echo.Echoer"),
141+
keyRPCSystem.String(valRPCSystemGRPC),
142+
keyServerAddr.String(valLocalhost),
143+
},
144+
}.Snapshot(),
145+
wantAttrKeys: []attribute.Key{keyServerPort},
146+
},
147+
{
148+
name: "telemetry disabled",
149+
echoer: successfulEchoer,
150+
opts: &Options{
151+
DisableAuthentication: true,
152+
DisableTelemetry: true,
153+
},
154+
wantSpans: 0,
155+
},
156+
}
157+
158+
for _, tt := range tests {
159+
t.Run(tt.name, func(t *testing.T) {
160+
exporter.Reset()
161+
162+
l, err := net.Listen("tcp", "127.0.0.1:0")
163+
if err != nil {
164+
t.Fatalf("Failed to listen: %v", err)
165+
}
166+
s := grpc.NewServer()
167+
echo.RegisterEchoerServer(s, tt.echoer)
168+
go s.Serve(l)
169+
defer s.Stop()
170+
171+
tt.opts.Endpoint = l.Addr().String()
172+
tt.opts.GRPCDialOpts = []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
173+
pool, err := Dial(context.Background(), false, tt.opts)
174+
if err != nil {
175+
t.Fatalf("Dial() = %v, want nil", err)
176+
}
177+
defer pool.Close()
178+
179+
client := echo.NewEchoerClient(pool)
180+
_, err = client.Echo(context.Background(), &echo.EchoRequest{Message: "hello"})
181+
if (err != nil) != tt.wantErr {
182+
t.Errorf("client.Echo() error = %v, wantErr %v", err, tt.wantErr)
183+
}
184+
185+
spans := exporter.GetSpans()
186+
if len(spans) != tt.wantSpans {
187+
t.Fatalf("len(spans) = %d, want %d", len(spans), tt.wantSpans)
188+
}
189+
190+
if tt.wantSpans > 0 {
191+
span := exporter.GetSpans()[0]
192+
if diff := cmp.Diff(tt.wantSpan.Name(), span.Name); diff != "" {
193+
t.Errorf("span.Name mismatch (-want +got):\n%s", diff)
194+
}
195+
// In Cloud Trace, SpanKind "Client" identifies this as an outgoing request,
196+
// often affecting the icon used in the trace visualization.
197+
if diff := cmp.Diff(tt.wantSpan.SpanKind(), span.SpanKind); diff != "" {
198+
t.Errorf("span.SpanKind mismatch (-want +got):\n%s", diff)
199+
}
200+
if diff := cmp.Diff(tt.wantSpan.Status(), span.Status); diff != "" {
201+
t.Errorf("span.Status mismatch (-want +got):\n%s", diff)
202+
}
203+
204+
// Note: Real-world spans in Cloud Trace will contain additional attributes
205+
// that are not present in this unit test.
206+
//
207+
// 1. Resource Attributes:
208+
// - "g.co/r/generic_node/location" (e.g., "global")
209+
// - "g.co/r/generic_node/namespace"
210+
// - "g.co/r/generic_node/node_id"
211+
// - "service.name" (e.g., "my-application")
212+
// - "telemetry.sdk.language" (e.g., "go")
213+
// - "telemetry.sdk.name" (e.g., "opentelemetry")
214+
// - "telemetry.sdk.version" (e.g., "1.20.0")
215+
// These are defined by the TracerProvider's Resource configuration. This test uses
216+
// a basic TracerProvider, so these attributes contain default values (e.g.,
217+
// service.name="unknown_service:grpctransport.test") rather than production values.
218+
//
219+
// 2. Instrumentation Scope:
220+
// - "otel.scope.name" (e.g., "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc")
221+
// - "otel.scope.version" (e.g., "0.46.0")
222+
// These identify the instrumentation library itself and are part of the
223+
// OpenTelemetry data model, separate from Span attributes.
224+
//
225+
// 3. Exporter Attributes:
226+
// - "g.co/agent" (e.g., "opentelemetry-go 1.20.0; google-cloud-trace-exporter 1.20.0")
227+
// These are injected by specific exporters (like the Google Cloud Trace exporter)
228+
// and are not present when using the InMemoryExporter.
229+
//
230+
// This test focuses on verifying the "rpc.*" and "server.*" attributes, which are
231+
// generated by the otelgrpc instrumentation library itself.
232+
233+
gotAttrs := map[attribute.Key]attribute.Value{}
234+
for _, attr := range span.Attributes {
235+
gotAttrs[attr.Key] = attr.Value
236+
}
237+
for _, wantAttr := range tt.wantSpan.Attributes() {
238+
if gotVal, ok := gotAttrs[wantAttr.Key]; !ok {
239+
t.Errorf("missing attribute: %s", wantAttr.Key)
240+
} else {
241+
// Use simple value comparison for non-dynamic fields
242+
if diff := cmp.Diff(wantAttr.Value, gotVal, cmp.AllowUnexported(attribute.Value{})); diff != "" {
243+
t.Errorf("attribute %s mismatch (-want +got):\n%s", wantAttr.Key, diff)
244+
}
245+
}
246+
}
247+
for _, wantKey := range tt.wantAttrKeys {
248+
if _, ok := gotAttrs[wantKey]; !ok {
249+
t.Errorf("missing attribute key: %s", wantKey)
250+
}
251+
}
252+
}
253+
})
254+
}
255+
}

0 commit comments

Comments
 (0)