From 996f614aea6d321d09368c9672aeaa40b085141f Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Fri, 10 Apr 2026 22:25:41 +0000 Subject: [PATCH 01/22] impl(showcase): port observability fixture for mock OTLP testing --- showcase/go.mod | 25 +- showcase/go.sum | 41 +++ showcase/observability_fixture_test.go | 352 +++++++++++++++++++++++++ 3 files changed, 412 insertions(+), 6 deletions(-) create mode 100644 showcase/observability_fixture_test.go diff --git a/showcase/go.mod b/showcase/go.mod index 84ec1010ad..483c771ab9 100644 --- a/showcase/go.mod +++ b/showcase/go.mod @@ -11,8 +11,8 @@ require ( golang.org/x/oauth2 v0.36.0 google.golang.org/api v0.272.0 google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 - google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 - google.golang.org/grpc v1.79.3 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 + google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 ) @@ -21,6 +21,8 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/longrunning v0.8.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -28,17 +30,28 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect + go.opentelemetry.io/otel/log v0.19.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect ) diff --git a/showcase/go.sum b/showcase/go.sum index 5a7b9f99b5..3661f33884 100644 --- a/showcase/go.sum +++ b/showcase/go.sum @@ -10,12 +10,17 @@ cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= @@ -44,14 +49,19 @@ github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8 github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE= github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= @@ -60,18 +70,42 @@ go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= +go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= +go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= +go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= @@ -96,6 +130,7 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= @@ -108,12 +143,18 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/showcase/observability_fixture_test.go b/showcase/observability_fixture_test.go new file mode 100644 index 0000000000..3a70b56868 --- /dev/null +++ b/showcase/observability_fixture_test.go @@ -0,0 +1,352 @@ +package showcase + +import ( + "context" + "net" + "sync" + "testing" + "time" + + "go.opentelemetry.io/contrib/detectors/gcp" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/sdk/resource" + sdklog "go.opentelemetry.io/otel/sdk/log" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + pblog "go.opentelemetry.io/proto/otlp/collector/logs/v1" + olog "go.opentelemetry.io/proto/otlp/logs/v1" + pbmetric "go.opentelemetry.io/proto/otlp/collector/metrics/v1" + pb "go.opentelemetry.io/proto/otlp/collector/trace/v1" + v1common "go.opentelemetry.io/proto/otlp/common/v1" + "google.golang.org/grpc" +) + +type mockTraceServer struct { + pb.UnimplementedTraceServiceServer + mu sync.Mutex + requests []*pb.ExportTraceServiceRequest +} + +func (s *mockTraceServer) Export(ctx context.Context, req *pb.ExportTraceServiceRequest) (*pb.ExportTraceServiceResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.requests = append(s.requests, req) + return &pb.ExportTraceServiceResponse{}, nil +} + +type CapturedSpan struct { + Name string + Scope string + TraceID []byte + Attributes map[string]any +} + +func (s *mockTraceServer) getRequests() []*pb.ExportTraceServiceRequest { + s.mu.Lock() + defer s.mu.Unlock() + reqs := make([]*pb.ExportTraceServiceRequest, len(s.requests)) + copy(reqs, s.requests) + return reqs +} + +func (s *mockTraceServer) GetCapturedSpans() []CapturedSpan { + reqs := s.getRequests() + var spans []CapturedSpan + for _, req := range reqs { + for _, rs := range req.ResourceSpans { + for _, ss := range rs.ScopeSpans { + for _, s := range ss.Spans { + attrs := make(map[string]any) + for _, kv := range s.Attributes { + if kv.Value != nil { + switch v := kv.Value.Value.(type) { + case *v1common.AnyValue_StringValue: + attrs[kv.Key] = v.StringValue + case *v1common.AnyValue_IntValue: + attrs[kv.Key] = v.IntValue + case *v1common.AnyValue_BoolValue: + attrs[kv.Key] = v.BoolValue + case *v1common.AnyValue_DoubleValue: + attrs[kv.Key] = v.DoubleValue + // other types omitted for brevity, but easily added later if needed + default: + attrs[kv.Key] = kv.Value.String() + } + } + } + spans = append(spans, CapturedSpan{ + Name: s.Name, + Scope: ss.Scope.Name, + TraceID: s.TraceId, + Attributes: attrs, + }) + } + } + } + } + return spans +} + +type mockMetricServer struct { + pbmetric.UnimplementedMetricsServiceServer + mu sync.Mutex + requests []*pbmetric.ExportMetricsServiceRequest +} + +func (s *mockMetricServer) Export(ctx context.Context, req *pbmetric.ExportMetricsServiceRequest) (*pbmetric.ExportMetricsServiceResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.requests = append(s.requests, req) + return &pbmetric.ExportMetricsServiceResponse{}, nil +} + +type CapturedMetric struct { + Name string + Scope string + Attributes map[string]any + DataPoints []float64 // Simplifying for histograms +} + +func (s *mockMetricServer) getRequests() []*pbmetric.ExportMetricsServiceRequest { + s.mu.Lock() + defer s.mu.Unlock() + reqs := make([]*pbmetric.ExportMetricsServiceRequest, len(s.requests)) + copy(reqs, s.requests) + return reqs +} + +func (s *mockMetricServer) GetCapturedMetrics() []CapturedMetric { + reqs := s.getRequests() + var metrics []CapturedMetric + for _, req := range reqs { + for _, rm := range req.ResourceMetrics { + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if hist := m.GetHistogram(); hist != nil { + for _, dp := range hist.DataPoints { + cmDp := CapturedMetric{ + Name: m.Name, + Scope: sm.Scope.Name, + } + var dps []float64 + if dp.Sum != nil { + dps = append(dps, *dp.Sum) + } else { + dps = append(dps, 0.0) + } + cmDp.DataPoints = dps + + var attrsMap = make(map[string]any) + // Extract Scope Attributes + for _, kv := range sm.Scope.Attributes { + if kv.Value != nil { + switch v := kv.Value.Value.(type) { + case *v1common.AnyValue_StringValue: + attrsMap[kv.Key] = v.StringValue + case *v1common.AnyValue_IntValue: + attrsMap[kv.Key] = v.IntValue + } + } + } + for _, kv := range dp.Attributes { + if kv.Value != nil { + switch v := kv.Value.Value.(type) { + case *v1common.AnyValue_StringValue: + attrsMap[kv.Key] = v.StringValue + case *v1common.AnyValue_IntValue: + attrsMap[kv.Key] = v.IntValue + } + } + } + cmDp.Attributes = attrsMap + metrics = append(metrics, cmDp) + } + } + } + } + } + } + return metrics +} + +type mockLogServer struct { + pblog.UnimplementedLogsServiceServer + mu sync.Mutex + requests []*pblog.ExportLogsServiceRequest +} + +func (s *mockLogServer) Export(ctx context.Context, req *pblog.ExportLogsServiceRequest) (*pblog.ExportLogsServiceResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.requests = append(s.requests, req) + return &pblog.ExportLogsServiceResponse{}, nil +} + +type CapturedLog struct { + Body string + Scope string + Severity olog.SeverityNumber + TraceID []byte + Attributes map[string]any +} + +func (s *mockLogServer) getRequests() []*pblog.ExportLogsServiceRequest { + s.mu.Lock() + defer s.mu.Unlock() + reqs := make([]*pblog.ExportLogsServiceRequest, len(s.requests)) + copy(reqs, s.requests) + return reqs +} + +func (s *mockLogServer) GetCapturedLogs() []CapturedLog { + reqs := s.getRequests() + var logs []CapturedLog + for _, req := range reqs { + for _, rl := range req.ResourceLogs { + for _, sl := range rl.ScopeLogs { + for _, l := range sl.LogRecords { + cl := CapturedLog{ + Scope: sl.Scope.Name, + Severity: l.SeverityNumber, + TraceID: l.TraceId, + } + if l.Body != nil { + cl.Body = l.Body.GetStringValue() + } + + attrs := make(map[string]any) + for _, kv := range l.Attributes { + if kv.Value != nil { + switch v := kv.Value.Value.(type) { + case *v1common.AnyValue_StringValue: + attrs[kv.Key] = v.StringValue + case *v1common.AnyValue_IntValue: + attrs[kv.Key] = v.IntValue + case *v1common.AnyValue_BoolValue: + attrs[kv.Key] = v.BoolValue + } + } + } + cl.Attributes = attrs + logs = append(logs, cl) + } + } + } + } + return logs +} + +type observabilityFixture struct { + grpcServer *grpc.Server + traceServer *mockTraceServer + metricServer *mockMetricServer + logServer *mockLogServer + provider *sdktrace.TracerProvider + meterProvider *sdkmetric.MeterProvider + loggerProvider *sdklog.LoggerProvider +} + +// setupObservabilityFixture creates an in-memory OTLP trace server and configures the OTel SDK to export to it. +func setupObservabilityFixture(t *testing.T) *observabilityFixture { + t.Helper() + + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + + grpcServer := grpc.NewServer() + traceServer := &mockTraceServer{} + metricServer := &mockMetricServer{} + logServer := &mockLogServer{} + pb.RegisterTraceServiceServer(grpcServer, traceServer) + pbmetric.RegisterMetricsServiceServer(grpcServer, metricServer) + pblog.RegisterLogsServiceServer(grpcServer, logServer) + + go func() { + if err := grpcServer.Serve(lis); err != nil { + t.Logf("grpc server serve err: %v", err) + } + }() + t.Cleanup(func() { + grpcServer.Stop() + }) + + ctx := context.Background() + exp, err := otlptracegrpc.New(ctx, + otlptracegrpc.WithEndpoint(lis.Addr().String()), + otlptracegrpc.WithInsecure(), + ) + if err != nil { + t.Fatalf("failed to create exporter: %v", err) + } + + metricExp, err := otlpmetricgrpc.New(ctx, + otlpmetricgrpc.WithEndpoint(lis.Addr().String()), + otlpmetricgrpc.WithInsecure(), + ) + if err != nil { + t.Fatalf("failed to create metric exporter: %v", err) + } + + logExp, err := otlploggrpc.New(ctx, + otlploggrpc.WithEndpoint(lis.Addr().String()), + otlploggrpc.WithInsecure(), + ) + if err != nil { + t.Fatalf("failed to create log exporter: %v", err) + } + + res, err := resource.New(ctx, + resource.WithDetectors(gcp.NewDetector()), + resource.WithTelemetrySDK(), + resource.WithAttributes( + semconv.ServiceNameKey.String("test-app"), + ), + ) + if err != nil { + t.Fatalf("failed to create resource: %v", err) + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exp), + sdktrace.WithResource(res), + ) + + mp := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp, sdkmetric.WithInterval(10*time.Hour))), + sdkmetric.WithResource(res), + ) + + lp := sdklog.NewLoggerProvider( + sdklog.WithProcessor(sdklog.NewBatchProcessor(logExp)), + sdklog.WithResource(res), + ) + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := tp.Shutdown(ctx); err != nil { + t.Logf("Failed to shutdown tracer provider: %v", err) + } + if err := mp.Shutdown(ctx); err != nil { + t.Logf("Failed to shutdown meter provider: %v", err) + } + if err := lp.Shutdown(ctx); err != nil { + t.Logf("Failed to shutdown logger provider: %v", err) + } + }) + + return &observabilityFixture{ + grpcServer: grpcServer, + traceServer: traceServer, + metricServer: metricServer, + logServer: logServer, + provider: tp, + meterProvider: mp, + loggerProvider: lp, + } +} From 18edd84b6a8740344a3a6befa8d14fc2179e9572 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Fri, 10 Apr 2026 22:28:56 +0000 Subject: [PATCH 02/22] impl(showcase): port tracing scenarios for integration tests --- showcase/tracing_scenarios_test.go | 258 +++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 showcase/tracing_scenarios_test.go diff --git a/showcase/tracing_scenarios_test.go b/showcase/tracing_scenarios_test.go new file mode 100644 index 0000000000..8f97df401c --- /dev/null +++ b/showcase/tracing_scenarios_test.go @@ -0,0 +1,258 @@ +package showcase + +import ( + "context" + "testing" + "time" + + showcase "github.com/googleapis/gapic-showcase/client" + showcasepb "github.com/googleapis/gapic-showcase/server/genproto" + gax "github.com/googleapis/gax-go/v2" + "go.opentelemetry.io/otel" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/durationpb" +) + +func runTracingSuccessScenario(ctx context.Context, t *testing.T, seqClient *showcase.SequenceClient) *showcasepb.Sequence { + responses := []*showcasepb.Sequence_Response{ + {Status: status.New(codes.OK, "OK").Proto()}, + } + seq, err := seqClient.CreateSequence(ctx, &showcasepb.CreateSequenceRequest{ + Sequence: &showcasepb.Sequence{Responses: responses}, + }) + if err != nil { + t.Fatalf("CreateSequence failed: %v", err) + } + + err = seqClient.AttemptSequence(ctx, &showcasepb.AttemptSequenceRequest{Name: seq.GetName()}, seqClient.CallOptions.AttemptSequence...) + if err != nil { + t.Fatalf("AttemptSequence RPC failed: %v", err) + } + + return seq +} + +func runTracingServerFailureScenario(ctx context.Context, t *testing.T, seqClient *showcase.SequenceClient) *showcasepb.Sequence { + responses := []*showcasepb.Sequence_Response{ + {Status: status.New(codes.NotFound, "not found").Proto()}, + } + seq, err := seqClient.CreateSequence(ctx, &showcasepb.CreateSequenceRequest{ + Sequence: &showcasepb.Sequence{Responses: responses}, + }) + if err != nil { + t.Fatalf("CreateSequence failed: %v", err) + } + + err = seqClient.AttemptSequence(ctx, &showcasepb.AttemptSequenceRequest{Name: seq.GetName()}, seqClient.CallOptions.AttemptSequence...) + if err == nil { + t.Fatalf("Expected error, got nil") + } + + return seq +} + +func runTracingClientFailureScenario(ctx context.Context, t *testing.T, seqClient *showcase.SequenceClient) *showcasepb.Sequence { + responses := []*showcasepb.Sequence_Response{ + { + Status: status.New(codes.OK, "OK").Proto(), + Delay: durationpb.New(1 * time.Second), + }, + } + seq, err := seqClient.CreateSequence(ctx, &showcasepb.CreateSequenceRequest{ + Sequence: &showcasepb.Sequence{Responses: responses}, + }) + if err != nil { + t.Fatalf("CreateSequence failed: %v", err) + } + + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP") + + timeoutCtx, cancelTimeout := context.WithTimeout(ctxSpan, 1*time.Millisecond) + defer cancelTimeout() + + err = seqClient.AttemptSequence(timeoutCtx, &showcasepb.AttemptSequenceRequest{Name: seq.GetName()}, seqClient.CallOptions.AttemptSequence...) + if err == nil { + t.Fatalf("Expected error, got nil") + } + span.End() + + return seq +} + +func runTracingRetryScenario(ctx context.Context, t *testing.T, seqClient *showcase.SequenceClient) *showcasepb.Sequence { + responses := []*showcasepb.Sequence_Response{ + {Status: status.New(codes.Unavailable, "Unavailable").Proto()}, + {Status: status.New(codes.Unavailable, "Unavailable").Proto()}, + {Status: status.New(codes.Unavailable, "Unavailable").Proto()}, + {Status: status.New(codes.OK, "OK").Proto()}, + } + + seq, err := seqClient.CreateSequence(ctx, &showcasepb.CreateSequenceRequest{ + Sequence: &showcasepb.Sequence{Responses: responses}, + }) + if err != nil { + t.Fatalf("CreateSequence failed: %v", err) + } + + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP") + + retryCtx, cancel := context.WithTimeout(ctxSpan, 5*time.Second) + defer cancel() + + bo := gax.Backoff{ + Initial: 10 * time.Millisecond, + Max: 100 * time.Millisecond, + Multiplier: 2.00, + } + retryOpt := gax.WithRetry(func() gax.Retryer { + return gax.OnCodes([]codes.Code{codes.Unavailable}, bo) + }) + + opts := append(seqClient.CallOptions.AttemptSequence, retryOpt) + err = seqClient.AttemptSequence(retryCtx, &showcasepb.AttemptSequenceRequest{Name: seq.GetName()}, opts...) + if err != nil { + t.Fatalf("AttemptSequence failed: %v", err) + } + span.End() + + return seq +} + +func runTracingDisablementScenario(ctx context.Context, t *testing.T, seqClient *showcase.SequenceClient) { + responses := []*showcasepb.Sequence_Response{ + {Status: status.New(codes.OK, "OK").Proto()}, + } + seq, err := seqClient.CreateSequence(ctx, &showcasepb.CreateSequenceRequest{ + Sequence: &showcasepb.Sequence{Responses: responses}, + }) + if err != nil { + t.Fatalf("CreateSequence failed: %v", err) + } + + err = seqClient.AttemptSequence(ctx, &showcasepb.AttemptSequenceRequest{Name: seq.GetName()}, seqClient.CallOptions.AttemptSequence...) + if err != nil { + t.Fatalf("AttemptSequence RPC failed: %v", err) + } +} +func runTracingSuccessScenarioREST(ctx context.Context, t *testing.T, seqClient *showcase.SequenceClient) *showcasepb.Sequence { + responses := []*showcasepb.Sequence_Response{ + {Status: status.New(codes.OK, "OK").Proto()}, + } + seq, err := seqClient.CreateSequence(ctx, &showcasepb.CreateSequenceRequest{ + Sequence: &showcasepb.Sequence{Responses: responses}, + }) + if err != nil { + t.Fatalf("CreateSequence failed: %v", err) + } + + err = seqClient.AttemptSequence(ctx, &showcasepb.AttemptSequenceRequest{Name: seq.GetName()}, seqClient.CallOptions.AttemptSequence...) + if err != nil { + t.Fatalf("AttemptSequence RPC failed: %v", err) + } + + return seq +} + +func runTracingServerFailureScenarioREST(ctx context.Context, t *testing.T, seqClient *showcase.SequenceClient) *showcasepb.Sequence { + responses := []*showcasepb.Sequence_Response{ + {Status: status.New(codes.NotFound, "not found").Proto()}, + } + seq, err := seqClient.CreateSequence(ctx, &showcasepb.CreateSequenceRequest{ + Sequence: &showcasepb.Sequence{Responses: responses}, + }) + if err != nil { + t.Fatalf("CreateSequence failed: %v", err) + } + + err = seqClient.AttemptSequence(ctx, &showcasepb.AttemptSequenceRequest{Name: seq.GetName()}, seqClient.CallOptions.AttemptSequence...) + if err == nil { + t.Fatalf("Expected error, got nil") + } + + return seq +} + +func runTracingClientFailureScenarioREST(ctx context.Context, t *testing.T, seqClient *showcase.SequenceClient) *showcasepb.Sequence { + responses := []*showcasepb.Sequence_Response{ + { + Status: status.New(codes.OK, "OK").Proto(), + Delay: durationpb.New(1 * time.Second), + }, + } + seq, err := seqClient.CreateSequence(ctx, &showcasepb.CreateSequenceRequest{ + Sequence: &showcasepb.Sequence{Responses: responses}, + }) + if err != nil { + t.Fatalf("CreateSequence failed: %v", err) + } + + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP") + + timeoutCtx, cancelTimeout := context.WithTimeout(ctxSpan, 1*time.Millisecond) + defer cancelTimeout() + + err = seqClient.AttemptSequence(timeoutCtx, &showcasepb.AttemptSequenceRequest{Name: seq.GetName()}, seqClient.CallOptions.AttemptSequence...) + if err == nil { + t.Fatalf("Expected error, got nil") + } + span.End() + + return seq +} + +func runTracingRetryScenarioREST(ctx context.Context, t *testing.T, seqClient *showcase.SequenceClient) *showcasepb.Sequence { + responses := []*showcasepb.Sequence_Response{ + {Status: status.New(codes.Unavailable, "Unavailable").Proto()}, + {Status: status.New(codes.Unavailable, "Unavailable").Proto()}, + {Status: status.New(codes.Unavailable, "Unavailable").Proto()}, + {Status: status.New(codes.OK, "OK").Proto()}, + } + + seq, err := seqClient.CreateSequence(ctx, &showcasepb.CreateSequenceRequest{ + Sequence: &showcasepb.Sequence{Responses: responses}, + }) + if err != nil { + t.Fatalf("CreateSequence failed: %v", err) + } + + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP") + + retryCtx, cancel := context.WithTimeout(ctxSpan, 5*time.Second) + defer cancel() + + bo := gax.Backoff{ + Initial: 10 * time.Millisecond, + Max: 100 * time.Millisecond, + Multiplier: 2.00, + } + retryOpt := gax.WithRetry(func() gax.Retryer { + return gax.OnCodes([]codes.Code{codes.Unavailable}, bo) + }) + + opts := append(seqClient.CallOptions.AttemptSequence, retryOpt) + err = seqClient.AttemptSequence(retryCtx, &showcasepb.AttemptSequenceRequest{Name: seq.GetName()}, opts...) + if err != nil { + t.Fatalf("AttemptSequence failed: %v", err) + } + span.End() + + return seq +} + +func runTracingDisablementScenarioREST(ctx context.Context, t *testing.T, seqClient *showcase.SequenceClient) { + responses := []*showcasepb.Sequence_Response{ + {Status: status.New(codes.OK, "OK").Proto()}, + } + seq, err := seqClient.CreateSequence(ctx, &showcasepb.CreateSequenceRequest{ + Sequence: &showcasepb.Sequence{Responses: responses}, + }) + if err != nil { + t.Fatalf("CreateSequence failed: %v", err) + } + + err = seqClient.AttemptSequence(ctx, &showcasepb.AttemptSequenceRequest{Name: seq.GetName()}, seqClient.CallOptions.AttemptSequence...) + if err != nil { + t.Fatalf("AttemptSequence RPC failed: %v", err) + } +} From 5e07264e378d9a9abbbcee181813d1b7734bd5a4 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Fri, 10 Apr 2026 22:31:58 +0000 Subject: [PATCH 03/22] impl(showcase): add tracing tests and update go.mod --- showcase/go.mod | 24 ++- showcase/trace_test.go | 480 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 493 insertions(+), 11 deletions(-) create mode 100644 showcase/trace_test.go diff --git a/showcase/go.mod b/showcase/go.mod index 483c771ab9..604a66616a 100644 --- a/showcase/go.mod +++ b/showcase/go.mod @@ -4,10 +4,21 @@ go 1.25.0 require ( cloud.google.com/go v0.123.0 + cloud.google.com/go/auth v0.18.2 cloud.google.com/go/iam v1.5.3 github.com/google/go-cmp v0.7.0 github.com/googleapis/gapic-showcase v0.38.0 github.com/googleapis/gax-go/v2 v2.19.0 + go.opentelemetry.io/contrib/detectors/gcp v1.43.0 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/sdk/log v0.19.0 + go.opentelemetry.io/otel/sdk/metric v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 + go.opentelemetry.io/proto/otlp v1.10.0 golang.org/x/oauth2 v0.36.0 google.golang.org/api v0.272.0 google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 @@ -17,7 +28,6 @@ require ( ) require ( - cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/longrunning v0.8.0 // indirect @@ -32,21 +42,11 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect go.opentelemetry.io/otel/log v0.19.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect @@ -55,3 +55,5 @@ require ( golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect ) + +replace github.com/googleapis/gapic-showcase => ./gen/github.com/googleapis/gapic-showcase diff --git a/showcase/trace_test.go b/showcase/trace_test.go new file mode 100644 index 0000000000..5b00e59962 --- /dev/null +++ b/showcase/trace_test.go @@ -0,0 +1,480 @@ +package showcase + +import ( + "context" + "os" + "testing" + "time" + + "cloud.google.com/go/auth" + "github.com/google/go-cmp/cmp" + showcase "github.com/googleapis/gapic-showcase/client" + gax "github.com/googleapis/gax-go/v2" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + "golang.org/x/oauth2" + "google.golang.org/api/option" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +type dummyTokenProvider struct{} + +func (d dummyTokenProvider) Token(ctx context.Context) (*auth.Token, error) { + return &auth.Token{Value: "dummy-token"}, nil +} + +func setupTracingTest(t *testing.T, enableTracing bool, transport string) (*observabilityFixture, []option.ClientOption) { + // Reset feature cache just in case something else evaluated it + gax.TestOnlyResetIsFeatureEnabled() + t.Cleanup(gax.TestOnlyResetIsFeatureEnabled) + + if enableTracing { + os.Setenv("GOOGLE_SDK_GO_EXPERIMENTAL_TRACING", "true") + } else { + os.Setenv("GOOGLE_SDK_GO_EXPERIMENTAL_TRACING", "false") + } + t.Cleanup(func() { os.Unsetenv("GOOGLE_SDK_GO_EXPERIMENTAL_TRACING") }) + + fix := setupObservabilityFixture(t) + oldTP := otel.GetTracerProvider() + t.Cleanup(func() { otel.SetTracerProvider(oldTP) }) + otel.SetTracerProvider(fix.provider) + + var clientOpts []option.ClientOption + if transport == "grpc" { + clientOpts = []option.ClientOption{ + option.WithEndpoint("127.0.0.1:7469"), + option.WithAuthCredentials(auth.NewCredentials(&auth.CredentialsOptions{ + TokenProvider: dummyTokenProvider{}, + })), + option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), + } + } else { + clientOpts = []option.ClientOption{ + option.WithEndpoint("http://127.0.0.1:7469"), + option.WithTokenSource(oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "dummy-token"})), + } + } + + return fix, clientOpts +} + +func verifyInMemorySpan(t *testing.T, fix *observabilityFixture, expectedName string, traceID trace.TraceID, wantAttrs map[string]any, unexpectedAttrs []string) { + t.Helper() + + // Force flush the provider to ensure traces are exported + ctxFlush, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := fix.provider.ForceFlush(ctxFlush); err != nil { + t.Fatalf("failed to flush provider: %v", err) + } + + // Give a little time for the export to arrive + time.Sleep(100 * time.Millisecond) + + spans := fix.traceServer.GetCapturedSpans() + if len(spans) == 0 { + t.Fatalf("expected to receive trace exports, got none") + } + + var gotSpan *CapturedSpan + for _, s := range spans { + if string(s.TraceID) == string(traceID[:]) && s.Name == expectedName { + gotSpan = &s + break + } + } + + if gotSpan == nil { + t.Fatalf("did not find the expected client span") + } + + if wantAttrs != nil { + if _, ok := gotSpan.Attributes["gcp.client.version"]; ok { + gotSpan.Attributes["gcp.client.version"] = "DYNAMIC" + } + if _, ok := gotSpan.Attributes["gcp.resource.destination.id"]; ok { + gotSpan.Attributes["gcp.resource.destination.id"] = "DYNAMIC" + } + if _, ok := gotSpan.Attributes["url.full"]; ok { + gotSpan.Attributes["url.full"] = "DYNAMIC" + } + if _, ok := gotSpan.Attributes["exception.message"]; ok { + // ignore exception message as it contains arbitrary text sometimes + gotSpan.Attributes["exception.message"] = "DYNAMIC" + } + + // Keep only the attributes we expect for diffing + filteredGot := make(map[string]any) + for k, v := range gotSpan.Attributes { + if _, expected := wantAttrs[k]; expected { + filteredGot[k] = v + } + } + + if diff := cmp.Diff(wantAttrs, filteredGot); diff != "" { + t.Errorf("Client span attributes mismatch (-want +got):\n%s", diff) + } + } + + for _, attr := range unexpectedAttrs { + if _, ok := gotSpan.Attributes[attr]; ok { + t.Errorf("expected attribute %q to be NOT SET, but it was present", attr) + } + } +} + +func TestObservability_Tracing_Success(t *testing.T) { + transports := []string{"grpc", "rest"} + for _, transport := range transports { + t.Run(transport, func(t *testing.T) { + fix, clientOpts := setupTracingTest(t, true, transport) + ctx := context.Background() + + var seqClient interface { + Close() error + } + var err error + + if transport == "grpc" { + seqClient, err = showcase.NewSequenceClient(ctx, clientOpts...) + } else { + seqClient, err = showcase.NewSequenceRESTClient(ctx, clientOpts...) + } + if err != nil { + t.Fatalf("failed to create sequence client: %v", err) + } + t.Cleanup(func() { seqClient.Close() }) + + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP") + + if transport == "grpc" { + _ = runTracingSuccessScenario(ctxSpan, t, seqClient.(*showcase.SequenceClient)) + } else { + _ = runTracingSuccessScenarioREST(ctxSpan, t, seqClient.(*showcase.SequenceClient)) + } + span.End() + traceID := span.SpanContext().TraceID() + + var wantAttrs map[string]any + var unexpectedAttrs []string + var expectedName string + + if transport == "grpc" { + expectedName = "google.showcase.v1beta1.SequenceService/AttemptSequence" + wantAttrs = map[string]any{ + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "gcp.client.version": "DYNAMIC", + "gcp.resource.destination.id": "DYNAMIC", + "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", + "rpc.response.status_code": "OK", + "rpc.system.name": "grpc", + "server.address": "127.0.0.1", + "server.port": int64(7469), + "url.domain": "showcase.googleapis.com", + } + unexpectedAttrs = []string{"status.message", "error.type", "gcp.grpc.resend_count"} + } else { + expectedName = "POST /v1beta1/{name=sequences/*}" + wantAttrs = map[string]any{ + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "gcp.client.version": "DYNAMIC", + "gcp.resource.destination.id": "DYNAMIC", + "http.request.method": "POST", + "http.response.status_code": int64(200), + "server.address": "127.0.0.1", + "server.port": int64(7469), + "url.domain": "showcase.googleapis.com", + "url.full": "DYNAMIC", + "url.template": "/v1beta1/{name=sequences/*}", + } + unexpectedAttrs = []string{"status.message", "error.type", "exception.type", "http.request.resend_count"} + } + + verifyInMemorySpan(t, fix, expectedName, traceID, wantAttrs, unexpectedAttrs) + }) + } +} + +func TestObservability_Tracing_Failure(t *testing.T) { + transports := []string{"grpc", "rest"} + for _, transport := range transports { + t.Run(transport, func(t *testing.T) { + fix, clientOpts := setupTracingTest(t, true, transport) + ctx := context.Background() + + var seqClient interface { + Close() error + } + var err error + + if transport == "grpc" { + seqClient, err = showcase.NewSequenceClient(ctx, clientOpts...) + } else { + seqClient, err = showcase.NewSequenceRESTClient(ctx, clientOpts...) + } + if err != nil { + t.Fatalf("failed to create sequence client: %v", err) + } + t.Cleanup(func() { seqClient.Close() }) + + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP") + if transport == "grpc" { + _ = runTracingServerFailureScenario(ctxSpan, t, seqClient.(*showcase.SequenceClient)) + } else { + _ = runTracingServerFailureScenarioREST(ctxSpan, t, seqClient.(*showcase.SequenceClient)) + } + span.End() + traceID := span.SpanContext().TraceID() + + var wantAttrs map[string]any + var unexpectedAttrs []string + var expectedName string + + if transport == "grpc" { + expectedName = "google.showcase.v1beta1.SequenceService/AttemptSequence" + wantAttrs = map[string]any{ + "error.type": "NOT_FOUND", + "exception.type": "*status.Error", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "gcp.client.version": "DYNAMIC", + "gcp.resource.destination.id": "DYNAMIC", + "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", + "rpc.response.status_code": "NOT_FOUND", + "rpc.system.name": "grpc", + "server.address": "127.0.0.1", + "server.port": int64(7469), + "status.message": "not found", + "url.domain": "showcase.googleapis.com", + } + unexpectedAttrs = []string{} + } else { + expectedName = "POST /v1beta1/{name=sequences/*}" + wantAttrs = map[string]any{ + "error.type": "404", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "gcp.client.version": "DYNAMIC", + "gcp.resource.destination.id": "DYNAMIC", + "http.request.method": "POST", + "http.response.status_code": int64(404), + "server.address": "127.0.0.1", + "server.port": int64(7469), + "status.message": "not found", + "url.domain": "showcase.googleapis.com", + "url.full": "DYNAMIC", + "url.template": "/v1beta1/{name=sequences/*}", + } + unexpectedAttrs = []string{} + } + + verifyInMemorySpan(t, fix, expectedName, traceID, wantAttrs, unexpectedAttrs) + }) + } +} + +func TestObservability_Tracing_ClientFailure(t *testing.T) { + transports := []string{"grpc", "rest"} + for _, transport := range transports { + t.Run(transport, func(t *testing.T) { + fix, clientOpts := setupTracingTest(t, true, transport) + ctx := context.Background() + + var seqClient interface { + Close() error + } + var err error + + if transport == "grpc" { + seqClient, err = showcase.NewSequenceClient(ctx, clientOpts...) + } else { + seqClient, err = showcase.NewSequenceRESTClient(ctx, clientOpts...) + } + if err != nil { + t.Fatalf("failed to create sequence client: %v", err) + } + t.Cleanup(func() { seqClient.Close() }) + + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP") + if transport == "grpc" { + _ = runTracingClientFailureScenario(ctxSpan, t, seqClient.(*showcase.SequenceClient)) + } else { + _ = runTracingClientFailureScenarioREST(ctxSpan, t, seqClient.(*showcase.SequenceClient)) + } + span.End() + traceID := span.SpanContext().TraceID() + + var wantAttrs map[string]any + var unexpectedAttrs []string + var expectedName string + + if transport == "grpc" { + expectedName = "google.showcase.v1beta1.SequenceService/AttemptSequence" + wantAttrs = map[string]any{ + "error.type": "CLIENT_TIMEOUT", + "exception.type": "*status.Error", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "gcp.client.version": "DYNAMIC", + "gcp.resource.destination.id": "DYNAMIC", + "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", + "rpc.system.name": "grpc", + "server.address": "127.0.0.1", + "server.port": int64(7469), + "status.message": "context deadline exceeded", + "url.domain": "showcase.googleapis.com", + } + unexpectedAttrs = []string{"rpc.response.status_code"} + } else { + expectedName = "POST /v1beta1/{name=sequences/*}" + wantAttrs = map[string]any{ + "error.type": "context.deadlineExceededError", + "exception.type": "context.deadlineExceededError", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "gcp.client.version": "DYNAMIC", + "gcp.resource.destination.id": "DYNAMIC", + "http.request.method": "POST", + "server.address": "127.0.0.1", + "server.port": int64(7469), + "url.domain": "showcase.googleapis.com", + "url.full": "DYNAMIC", + "url.template": "/v1beta1/{name=sequences/*}", + } + unexpectedAttrs = []string{"http.response.status_code"} + } + + verifyInMemorySpan(t, fix, expectedName, traceID, wantAttrs, unexpectedAttrs) + }) + } +} + +func TestObservability_Tracing_Disablement(t *testing.T) { + transports := []string{"grpc", "rest"} + for _, transport := range transports { + t.Run(transport, func(t *testing.T) { + fix, clientOpts := setupTracingTest(t, false, transport) + ctx := context.Background() + + var seqClient interface { + Close() error + } + var err error + if transport == "grpc" { + seqClient, err = showcase.NewSequenceClient(ctx, clientOpts...) + } else { + seqClient, err = showcase.NewSequenceRESTClient(ctx, clientOpts...) + } + if err != nil { + t.Fatalf("failed to create sequence client: %v", err) + } + t.Cleanup(func() { seqClient.Close() }) + + ctxSpan, span := otel.Tracer("test-tracer").Start(context.Background(), "APP") + if transport == "grpc" { + runTracingDisablementScenario(ctxSpan, t, seqClient.(*showcase.SequenceClient)) + } else { + runTracingDisablementScenarioREST(ctxSpan, t, seqClient.(*showcase.SequenceClient)) + } + span.End() + + ctxFlush, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := fix.provider.ForceFlush(ctxFlush); err != nil { + t.Fatalf("failed to flush provider: %v", err) + } + + time.Sleep(100 * time.Millisecond) + + spans := fix.traceServer.GetCapturedSpans() + + for _, s := range spans { + if _, ok := s.Attributes["gcp.client.artifact"]; ok { + t.Errorf("found gcp.client.artifact attribute, but tracing telemetry should be disabled") + } + } + }) + } +} + +func TestObservability_Tracing_Retry(t *testing.T) { + transports := []string{"grpc", "rest"} + for _, transport := range transports { + t.Run(transport, func(t *testing.T) { + fix, clientOpts := setupTracingTest(t, true, transport) + ctx := context.Background() + + var seqClient interface { + Close() error + } + var err error + + if transport == "grpc" { + seqClient, err = showcase.NewSequenceClient(ctx, clientOpts...) + } else { + seqClient, err = showcase.NewSequenceRESTClient(ctx, clientOpts...) + } + if err != nil { + t.Fatalf("failed to create sequence client: %v", err) + } + t.Cleanup(func() { seqClient.Close() }) + + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP") + if transport == "grpc" { + _ = runTracingRetryScenario(ctxSpan, t, seqClient.(*showcase.SequenceClient)) + } else { + _ = runTracingRetryScenarioREST(ctxSpan, t, seqClient.(*showcase.SequenceClient)) + } + span.End() + traceID := span.SpanContext().TraceID() + + ctxFlush, cancelFlush := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFlush() + if err := fix.provider.ForceFlush(ctxFlush); err != nil { + t.Fatalf("failed to flush provider: %v", err) + } + + time.Sleep(100 * time.Millisecond) + + spans := fix.traceServer.GetCapturedSpans() + var attemptSpans []CapturedSpan + expectedName := "google.showcase.v1beta1.SequenceService/AttemptSequence" + if transport == "rest" { + expectedName = "POST /v1beta1/{name=sequences/*}" + } + for _, s := range spans { + if string(s.TraceID) == string(traceID[:]) && s.Name == expectedName { + attemptSpans = append(attemptSpans, s) + } + } + + if len(attemptSpans) != 4 { + t.Errorf("expected 4 attempt spans (3 failures + 1 success), got %d", len(attemptSpans)) + } + + // Verify last span has correct attributes + if len(attemptSpans) > 0 { + lastSpan := attemptSpans[len(attemptSpans)-1] + if transport == "rest" { + if resend, ok := lastSpan.Attributes["http.request.resend_count"]; !ok || resend.(int64) != 3 { + t.Errorf("expected http.request.resend_count to be 3, got %v", resend) + } + } else if transport == "grpc" { + if resend, ok := lastSpan.Attributes["gcp.grpc.resend_count"]; !ok || resend.(int64) != 3 { + t.Errorf("expected gcp.grpc.resend_count to be 3, got %v", resend) + } + } + } + }) + } +} From c995e562b9fbdd4a733b035e12df8231b7153974 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Fri, 10 Apr 2026 22:32:38 +0000 Subject: [PATCH 04/22] impl(showcase): add cloud trace test, update makefile and showcase.bash --- Makefile | 3 + showcase/cloud_trace_test.go | 193 +++++++++++++++++++++++++++++++++++ showcase/showcase.bash | 2 +- 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 showcase/cloud_trace_test.go diff --git a/Makefile b/Makefile index 2421292e67..cf2a5c8c9f 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,9 @@ test: go install ./cmd/protoc-gen-go_gapic cd showcase && ./showcase.bash && cd .. && ./test.sh +test-telemetry: + cd showcase && ./showcase.bash -tags=telemetry -run TestObservability_Tracing_CloudTrace_Integration + install: go install ./cmd/protoc-gen-go_gapic go install ./cmd/protoc-gen-go_cli diff --git a/showcase/cloud_trace_test.go b/showcase/cloud_trace_test.go new file mode 100644 index 0000000000..90bf4aba85 --- /dev/null +++ b/showcase/cloud_trace_test.go @@ -0,0 +1,193 @@ +//go:build telemetry +// +build telemetry + +package showcase + +import ( + "context" + "encoding/hex" + "os" + "testing" + "time" + + trace "cloud.google.com/go/trace/apiv1" + "cloud.google.com/go/trace/apiv1/tracepb" + showcase "github.com/googleapis/gapic-showcase/client" + gax "github.com/googleapis/gax-go/v2" + "go.opentelemetry.io/contrib/detectors/gcp" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "google.golang.org/api/option" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/credentials/oauth" +) + +func setupCloudTrace(t *testing.T) string { + ctx := context.Background() + creds, err := google.FindDefaultCredentials(ctx, "https://www.googleapis.com/auth/cloud-platform") + if err != nil { + t.Skipf("Skipping Cloud Trace integration test: %v", err) + } + projectID := os.Getenv("GCLOUD_TESTS_GOLANG_PROJECT_ID") + if projectID == "" { + projectID = creds.ProjectID + } + if projectID == "" { + t.Skip("Skipping Cloud Trace integration test: no project ID found in GCLOUD_TESTS_GOLANG_PROJECT_ID or default credentials") + } + + gax.TestOnlyResetIsFeatureEnabled() + t.Cleanup(gax.TestOnlyResetIsFeatureEnabled) + os.Setenv("GOOGLE_SDK_GO_EXPERIMENTAL_TRACING", "true") + t.Cleanup(func() { os.Unsetenv("GOOGLE_SDK_GO_EXPERIMENTAL_TRACING") }) + + // Set OTEL_RESOURCE_ATTRIBUTES with project_id for the telemetry endpoint + os.Setenv("OTEL_RESOURCE_ATTRIBUTES", "gcp.project_id="+projectID) + t.Cleanup(func() { os.Unsetenv("OTEL_RESOURCE_ATTRIBUTES") }) + + // The telemetry endpoint requires a quota project when using ADC user credentials + os.Setenv("GOOGLE_CLOUD_QUOTA_PROJECT", projectID) + t.Cleanup(func() { os.Unsetenv("GOOGLE_CLOUD_QUOTA_PROJECT") }) + + grpcCreds, err := oauth.NewApplicationDefault(ctx) + if err != nil { + t.Fatalf("failed to create gRPC credentials: %v", err) + } + + // Initialize the OTLP exporter to point to telemetry.googleapis.com + exp, err := otlptracegrpc.New(ctx, + otlptracegrpc.WithEndpoint("telemetry.googleapis.com:443"), + otlptracegrpc.WithDialOption(grpc.WithPerRPCCredentials(grpcCreds)), + otlptracegrpc.WithTLSCredentials(credentials.NewClientTLSFromCert(nil, "")), + otlptracegrpc.WithHeaders(map[string]string{"x-goog-user-project": projectID}), + ) + if err != nil { + t.Fatalf("failed to create OTLP exporter: %v", err) + } + + res, err := resource.New(ctx, + resource.WithDetectors(gcp.NewDetector()), + resource.WithTelemetrySDK(), + resource.WithAttributes( + semconv.ServiceNameKey.String("test-app"), + ), + ) + if err != nil { + t.Fatalf("failed to create resource: %v", err) + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exp), + sdktrace.WithResource(res), + ) + oldTP := otel.GetTracerProvider() + t.Cleanup(func() { otel.SetTracerProvider(oldTP) }) + otel.SetTracerProvider(tp) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + tp.Shutdown(ctx) + }) + + return projectID +} + +func verifyTrace(t *testing.T, ctx context.Context, traceClient *trace.Client, projectID string, traceID [16]byte) { + traceIDStr := hex.EncodeToString(traceID[:]) + t.Logf("Looking for trace %s in project %s", traceIDStr, projectID) + + var found bool + for i := 0; i < 15; i++ { + req := &tracepb.GetTraceRequest{ + ProjectId: projectID, + TraceId: traceIDStr, + } + _, err := traceClient.GetTrace(ctx, req) + if err == nil { + found = true + break + } + t.Logf("Attempt %d: trace %s not found yet, retrying...", i+1, traceIDStr) + time.Sleep(2 * time.Second) + } + + if !found { + t.Errorf("Trace %s was not found in Cloud Trace backend", traceIDStr) + } +} + +func TestObservability_Tracing_CloudTrace_Integration(t *testing.T) { + projectID := setupCloudTrace(t) + ctx := context.Background() + + grpcClientOpts := []option.ClientOption{ + option.WithEndpoint("127.0.0.1:7469"), + option.WithTokenSource(oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "dummy-token"})), + option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), + } + + seqClient, err := showcase.NewSequenceClient(ctx, grpcClientOpts...) + if err != nil { + t.Fatalf("failed to create sequence client: %v", err) + } + t.Cleanup(func() { seqClient.Close() }) + + echoClient, err := showcase.NewEchoClient(ctx, grpcClientOpts...) + if err != nil { + t.Fatalf("failed to create echo client: %v", err) + } + t.Cleanup(func() { echoClient.Close() }) + + traceClient, err := trace.NewClient(ctx) + if err != nil { + t.Fatalf("failed to create trace client: %v", err) + } + t.Cleanup(func() { traceClient.Close() }) + + // 1. Success Scenario + t.Run("Success", func(t *testing.T) { + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-Success") + _ = runTracingSuccessScenario(ctxSpan, t, seqClient) + span.End() + traceID := span.SpanContext().TraceID() + otel.GetTracerProvider().(*sdktrace.TracerProvider).ForceFlush(ctx) + verifyTrace(t, ctx, traceClient, projectID, traceID) + }) + + // 2. Server Failure Scenario + t.Run("ServerFailure", func(t *testing.T) { + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-ServerFailure") + _ = runTracingServerFailureScenario(ctxSpan, t, seqClient) + span.End() + traceID := span.SpanContext().TraceID() + otel.GetTracerProvider().(*sdktrace.TracerProvider).ForceFlush(ctx) + verifyTrace(t, ctx, traceClient, projectID, traceID) + }) + + // 3. Client Failure Scenario + t.Run("ClientFailure", func(t *testing.T) { + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-ClientFailure") + _ = runTracingClientFailureScenario(ctxSpan, t, seqClient) + span.End() + traceID := span.SpanContext().TraceID() + otel.GetTracerProvider().(*sdktrace.TracerProvider).ForceFlush(ctx) + verifyTrace(t, ctx, traceClient, projectID, traceID) + }) + + // 4. Retry Scenario + t.Run("Retry", func(t *testing.T) { + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-Retry") + _ = runTracingRetryScenario(ctxSpan, t, seqClient) + span.End() + traceID := span.SpanContext().TraceID() + otel.GetTracerProvider().(*sdktrace.TracerProvider).ForceFlush(ctx) + verifyTrace(t, ctx, traceClient, projectID, traceID) + }) +} diff --git a/showcase/showcase.bash b/showcase/showcase.bash index 5d6b25ac5b..25bf971c6f 100755 --- a/showcase/showcase.bash +++ b/showcase/showcase.bash @@ -93,5 +93,5 @@ cleanup() { } trap cleanup EXIT -go test -mod=mod -count=1 ./... +go test -mod=mod -count=1 "$@" ./... exit_code=$? From 0c1d1af6a8611298280773aeb23f39735609b08a Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Mon, 13 Apr 2026 21:07:10 +0000 Subject: [PATCH 05/22] impl(showcase): stabilize tracing tests and upgrade dependencies --- showcase/go.mod | 11 ++++++----- showcase/go.sum | 12 ++++++++++++ showcase/observability_fixture_test.go | 21 ++++++++++++++++++++- showcase/trace_test.go | 8 ++++---- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/showcase/go.mod b/showcase/go.mod index 604a66616a..16a4565ffa 100644 --- a/showcase/go.mod +++ b/showcase/go.mod @@ -4,11 +4,12 @@ go 1.25.0 require ( cloud.google.com/go v0.123.0 - cloud.google.com/go/auth v0.18.2 + cloud.google.com/go/auth v0.20.0 cloud.google.com/go/iam v1.5.3 + cloud.google.com/go/trace v1.11.7 github.com/google/go-cmp v0.7.0 github.com/googleapis/gapic-showcase v0.38.0 - github.com/googleapis/gax-go/v2 v2.19.0 + github.com/googleapis/gax-go/v2 v2.21.0 go.opentelemetry.io/contrib/detectors/gcp v1.43.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 @@ -20,7 +21,7 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 go.opentelemetry.io/proto/otlp v1.10.0 golang.org/x/oauth2 v0.36.0 - google.golang.org/api v0.272.0 + google.golang.org/api v0.274.0 google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 google.golang.org/grpc v1.80.0 @@ -42,8 +43,8 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/log v0.19.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect diff --git a/showcase/go.sum b/showcase/go.sum index 3661f33884..690af57832 100644 --- a/showcase/go.sum +++ b/showcase/go.sum @@ -2,6 +2,8 @@ cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -10,6 +12,8 @@ cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -49,6 +53,8 @@ github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8 github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE= github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= +github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= @@ -64,8 +70,12 @@ go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmf go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= @@ -135,6 +145,8 @@ google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= +google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= +google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c h1:ZhFDeBMmFc/4g8/GwxnJ4rzB3O4GwQVNr+8Mh7Y5z4g= google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c/go.mod h1:hf4r/rBuzaTkLUWRO03771Xvcs6P5hwdQK3UUEJjqo0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= diff --git a/showcase/observability_fixture_test.go b/showcase/observability_fixture_test.go index 3a70b56868..011a466d48 100644 --- a/showcase/observability_fixture_test.go +++ b/showcase/observability_fixture_test.go @@ -60,6 +60,26 @@ func (s *mockTraceServer) GetCapturedSpans() []CapturedSpan { for _, ss := range rs.ScopeSpans { for _, s := range ss.Spans { attrs := make(map[string]any) + // Extract Resource attributes + if rs.Resource != nil { + for _, kv := range rs.Resource.Attributes { + if kv.Value != nil { + switch v := kv.Value.Value.(type) { + case *v1common.AnyValue_StringValue: + attrs[kv.Key] = v.StringValue + case *v1common.AnyValue_IntValue: + attrs[kv.Key] = v.IntValue + case *v1common.AnyValue_BoolValue: + attrs[kv.Key] = v.BoolValue + case *v1common.AnyValue_DoubleValue: + attrs[kv.Key] = v.DoubleValue + default: + attrs[kv.Key] = kv.Value.String() + } + } + } + } + // Extract Span attributes for _, kv := range s.Attributes { if kv.Value != nil { switch v := kv.Value.Value.(type) { @@ -71,7 +91,6 @@ func (s *mockTraceServer) GetCapturedSpans() []CapturedSpan { attrs[kv.Key] = v.BoolValue case *v1common.AnyValue_DoubleValue: attrs[kv.Key] = v.DoubleValue - // other types omitted for brevity, but easily added later if needed default: attrs[kv.Key] = kv.Value.String() } diff --git a/showcase/trace_test.go b/showcase/trace_test.go index 5b00e59962..e3042ac32c 100644 --- a/showcase/trace_test.go +++ b/showcase/trace_test.go @@ -176,7 +176,7 @@ func TestObservability_Tracing_Success(t *testing.T) { "server.port": int64(7469), "url.domain": "showcase.googleapis.com", } - unexpectedAttrs = []string{"status.message", "error.type", "gcp.grpc.resend_count"} + unexpectedAttrs = []string{"status.message", "error.type"} } else { expectedName = "POST /v1beta1/{name=sequences/*}" wantAttrs = map[string]any{ @@ -193,7 +193,7 @@ func TestObservability_Tracing_Success(t *testing.T) { "url.full": "DYNAMIC", "url.template": "/v1beta1/{name=sequences/*}", } - unexpectedAttrs = []string{"status.message", "error.type", "exception.type", "http.request.resend_count"} + unexpectedAttrs = []string{"status.message", "error.type", "exception.type"} } verifyInMemorySpan(t, fix, expectedName, traceID, wantAttrs, unexpectedAttrs) @@ -333,7 +333,7 @@ func TestObservability_Tracing_ClientFailure(t *testing.T) { "status.message": "context deadline exceeded", "url.domain": "showcase.googleapis.com", } - unexpectedAttrs = []string{"rpc.response.status_code"} + unexpectedAttrs = []string{} } else { expectedName = "POST /v1beta1/{name=sequences/*}" wantAttrs = map[string]any{ @@ -351,7 +351,7 @@ func TestObservability_Tracing_ClientFailure(t *testing.T) { "url.full": "DYNAMIC", "url.template": "/v1beta1/{name=sequences/*}", } - unexpectedAttrs = []string{"http.response.status_code"} + unexpectedAttrs = []string{} } verifyInMemorySpan(t, fix, expectedName, traceID, wantAttrs, unexpectedAttrs) From b8dbbce51de777500538a0edb413f076b472f64d Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Mon, 13 Apr 2026 21:35:07 +0000 Subject: [PATCH 06/22] docs(showcase): document how telemetry tests work --- showcase/README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/showcase/README.md b/showcase/README.md index e420869d0b..c68ad5bff8 100644 --- a/showcase/README.md +++ b/showcase/README.md @@ -54,3 +54,38 @@ and ensures all the necessary dependencies are up to date. One can use `make clean` to revert any changes and delete any of the test artifacts created by `showcase.bash`. + +## How the telemetry tests work + +The telemetry tests in `cloud_trace_test.go` are integration tests that verify end-to-end tracing to the real Google Cloud Trace backend. They require specific setup and are separated from normal unit tests. + +### Prerequisites and Environment Variables + +These tests require a real Google Cloud project to send traces to. +* **`GCLOUD_TESTS_GOLANG_PROJECT_ID`**: Set this environment variable to your GCP project ID before running the tests. +* **Application Default Credentials (ADC)**: You must have valid credentials configured in your environment (e.g., via `gcloud auth application-default login`). + +### Build Tag + +The test file `cloud_trace_test.go` uses the `//go:build telemetry` constraint. This ensures that these tests are not run during normal `go test` invocations, as they require external network access and specific credentials. + +### Running the Tests + +You can run these tests using the specific target in the repository root: +```bash +make test-telemetry +``` +This target internally runs `./showcase.bash -tags=telemetry -run TestObservability_Tracing_CloudTrace_Integration`. + +### Viewing the Traces + +To verify that the tests successfully sent spans to Cloud Trace: +1. Go to the **Trace Explorer** in the Google Cloud Console for your project. +2. Adjust the time range to **"Last 1 hour"** (or a narrow window around when you ran the test) to ensure recent spans are visible. +3. Look for spans with `service.name: test-app`. +4. You should see root spans named `APP-Success`, `APP-ServerFailure`, `APP-ClientFailure`, and `APP-Retry`. Spans generated by the Showcase client will have `rpc.method` set to `google.showcase.v1beta1.SequenceService/AttemptSequence` (or similar) and contain `gcp.client.*` telemetry attributes. +5. Note that you may also see failing spans for `TraceService/GetTrace` due to the test polling the backend before the traces were fully indexed. + +### Cleaning up + +The tests use the standard `showcase.bash` infrastructure, which automatically starts and stops the local Showcase server. Any artifacts created during generation can be cleaned up by running `make clean` in the repository root. From 565a811bdecb25523004e3e9e6fdc58c975036fa Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Mon, 13 Apr 2026 21:36:48 +0000 Subject: [PATCH 07/22] update showcase/README.md --- showcase/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/showcase/README.md b/showcase/README.md index c68ad5bff8..4f7f612e7e 100644 --- a/showcase/README.md +++ b/showcase/README.md @@ -57,7 +57,7 @@ artifacts created by `showcase.bash`. ## How the telemetry tests work -The telemetry tests in `cloud_trace_test.go` are integration tests that verify end-to-end tracing to the real Google Cloud Trace backend. They require specific setup and are separated from normal unit tests. +The telemetry tests in `cloud_trace_test.go` are integration tests that verify end-to-end tracing to the real Google Cloud Trace backend. They require specific setup and are separated from normal Showcase tests. ### Prerequisites and Environment Variables From f31c1bde31c6a311f41019a879ff3b0ce8f4af6e Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Mon, 13 Apr 2026 22:40:35 +0000 Subject: [PATCH 08/22] fix(showcase): remove replace directive from go.mod to fix CI apidiff --- showcase/go.mod | 2 -- 1 file changed, 2 deletions(-) diff --git a/showcase/go.mod b/showcase/go.mod index 16a4565ffa..0050bb9b4e 100644 --- a/showcase/go.mod +++ b/showcase/go.mod @@ -56,5 +56,3 @@ require ( golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect ) - -replace github.com/googleapis/gapic-showcase => ./gen/github.com/googleapis/gapic-showcase From 48b9737df429b03f6ae7c99d91c5cdbb50073353 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Mon, 13 Apr 2026 22:46:02 +0000 Subject: [PATCH 09/22] gofmt -w . --- showcase/cloud_trace_test.go | 4 +- showcase/observability_fixture_test.go | 4 +- showcase/trace_test.go | 122 ++++++++++++------------- 3 files changed, 65 insertions(+), 65 deletions(-) diff --git a/showcase/cloud_trace_test.go b/showcase/cloud_trace_test.go index 90bf4aba85..7de3f3d7ad 100644 --- a/showcase/cloud_trace_test.go +++ b/showcase/cloud_trace_test.go @@ -95,7 +95,7 @@ func setupCloudTrace(t *testing.T) string { defer cancel() tp.Shutdown(ctx) }) - + return projectID } @@ -138,7 +138,7 @@ func TestObservability_Tracing_CloudTrace_Integration(t *testing.T) { t.Fatalf("failed to create sequence client: %v", err) } t.Cleanup(func() { seqClient.Close() }) - + echoClient, err := showcase.NewEchoClient(ctx, grpcClientOpts...) if err != nil { t.Fatalf("failed to create echo client: %v", err) diff --git a/showcase/observability_fixture_test.go b/showcase/observability_fixture_test.go index 011a466d48..8d341d5ea3 100644 --- a/showcase/observability_fixture_test.go +++ b/showcase/observability_fixture_test.go @@ -11,16 +11,16 @@ import ( "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" - "go.opentelemetry.io/otel/sdk/resource" sdklog "go.opentelemetry.io/otel/sdk/log" sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" pblog "go.opentelemetry.io/proto/otlp/collector/logs/v1" - olog "go.opentelemetry.io/proto/otlp/logs/v1" pbmetric "go.opentelemetry.io/proto/otlp/collector/metrics/v1" pb "go.opentelemetry.io/proto/otlp/collector/trace/v1" v1common "go.opentelemetry.io/proto/otlp/common/v1" + olog "go.opentelemetry.io/proto/otlp/logs/v1" "google.golang.org/grpc" ) diff --git a/showcase/trace_test.go b/showcase/trace_test.go index e3042ac32c..39528a4d02 100644 --- a/showcase/trace_test.go +++ b/showcase/trace_test.go @@ -28,7 +28,7 @@ func setupTracingTest(t *testing.T, enableTracing bool, transport string) (*obse // Reset feature cache just in case something else evaluated it gax.TestOnlyResetIsFeatureEnabled() t.Cleanup(gax.TestOnlyResetIsFeatureEnabled) - + if enableTracing { os.Setenv("GOOGLE_SDK_GO_EXPERIMENTAL_TRACING", "true") } else { @@ -117,7 +117,7 @@ func verifyInMemorySpan(t *testing.T, fix *observabilityFixture, expectedName st t.Errorf("Client span attributes mismatch (-want +got):\n%s", diff) } } - + for _, attr := range unexpectedAttrs { if _, ok := gotSpan.Attributes[attr]; ok { t.Errorf("expected attribute %q to be NOT SET, but it was present", attr) @@ -131,12 +131,12 @@ func TestObservability_Tracing_Success(t *testing.T) { t.Run(transport, func(t *testing.T) { fix, clientOpts := setupTracingTest(t, true, transport) ctx := context.Background() - + var seqClient interface { Close() error } var err error - + if transport == "grpc" { seqClient, err = showcase.NewSequenceClient(ctx, clientOpts...) } else { @@ -148,7 +148,7 @@ func TestObservability_Tracing_Success(t *testing.T) { t.Cleanup(func() { seqClient.Close() }) ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP") - + if transport == "grpc" { _ = runTracingSuccessScenario(ctxSpan, t, seqClient.(*showcase.SequenceClient)) } else { @@ -170,9 +170,9 @@ func TestObservability_Tracing_Success(t *testing.T) { "gcp.client.version": "DYNAMIC", "gcp.resource.destination.id": "DYNAMIC", "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", - "rpc.response.status_code": "OK", + "rpc.response.status_code": "OK", "rpc.system.name": "grpc", - "server.address": "127.0.0.1", + "server.address": "127.0.0.1", "server.port": int64(7469), "url.domain": "showcase.googleapis.com", } @@ -207,12 +207,12 @@ func TestObservability_Tracing_Failure(t *testing.T) { t.Run(transport, func(t *testing.T) { fix, clientOpts := setupTracingTest(t, true, transport) ctx := context.Background() - + var seqClient interface { Close() error } var err error - + if transport == "grpc" { seqClient, err = showcase.NewSequenceClient(ctx, clientOpts...) } else { @@ -239,39 +239,39 @@ func TestObservability_Tracing_Failure(t *testing.T) { if transport == "grpc" { expectedName = "google.showcase.v1beta1.SequenceService/AttemptSequence" wantAttrs = map[string]any{ - "error.type": "NOT_FOUND", - "exception.type": "*status.Error", - "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", - "gcp.client.repo": "googleapis/google-cloud-go", - "gcp.client.service": "showcase", - "gcp.client.version": "DYNAMIC", + "error.type": "NOT_FOUND", + "exception.type": "*status.Error", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "gcp.client.version": "DYNAMIC", "gcp.resource.destination.id": "DYNAMIC", - "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", - "rpc.response.status_code": "NOT_FOUND", + "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", + "rpc.response.status_code": "NOT_FOUND", "rpc.system.name": "grpc", - "server.address": "127.0.0.1", - "server.port": int64(7469), - "status.message": "not found", - "url.domain": "showcase.googleapis.com", + "server.address": "127.0.0.1", + "server.port": int64(7469), + "status.message": "not found", + "url.domain": "showcase.googleapis.com", } unexpectedAttrs = []string{} } else { expectedName = "POST /v1beta1/{name=sequences/*}" wantAttrs = map[string]any{ - "error.type": "404", - "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", - "gcp.client.repo": "googleapis/google-cloud-go", - "gcp.client.service": "showcase", - "gcp.client.version": "DYNAMIC", + "error.type": "404", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "gcp.client.version": "DYNAMIC", "gcp.resource.destination.id": "DYNAMIC", - "http.request.method": "POST", - "http.response.status_code": int64(404), + "http.request.method": "POST", + "http.response.status_code": int64(404), "server.address": "127.0.0.1", - "server.port": int64(7469), - "status.message": "not found", - "url.domain": "showcase.googleapis.com", - "url.full": "DYNAMIC", - "url.template": "/v1beta1/{name=sequences/*}", + "server.port": int64(7469), + "status.message": "not found", + "url.domain": "showcase.googleapis.com", + "url.full": "DYNAMIC", + "url.template": "/v1beta1/{name=sequences/*}", } unexpectedAttrs = []string{} } @@ -287,12 +287,12 @@ func TestObservability_Tracing_ClientFailure(t *testing.T) { t.Run(transport, func(t *testing.T) { fix, clientOpts := setupTracingTest(t, true, transport) ctx := context.Background() - + var seqClient interface { Close() error } var err error - + if transport == "grpc" { seqClient, err = showcase.NewSequenceClient(ctx, clientOpts...) } else { @@ -319,37 +319,37 @@ func TestObservability_Tracing_ClientFailure(t *testing.T) { if transport == "grpc" { expectedName = "google.showcase.v1beta1.SequenceService/AttemptSequence" wantAttrs = map[string]any{ - "error.type": "CLIENT_TIMEOUT", - "exception.type": "*status.Error", - "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", - "gcp.client.repo": "googleapis/google-cloud-go", - "gcp.client.service": "showcase", - "gcp.client.version": "DYNAMIC", + "error.type": "CLIENT_TIMEOUT", + "exception.type": "*status.Error", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "gcp.client.version": "DYNAMIC", "gcp.resource.destination.id": "DYNAMIC", - "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", + "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", "rpc.system.name": "grpc", "server.address": "127.0.0.1", - "server.port": int64(7469), - "status.message": "context deadline exceeded", - "url.domain": "showcase.googleapis.com", + "server.port": int64(7469), + "status.message": "context deadline exceeded", + "url.domain": "showcase.googleapis.com", } unexpectedAttrs = []string{} } else { expectedName = "POST /v1beta1/{name=sequences/*}" wantAttrs = map[string]any{ - "error.type": "context.deadlineExceededError", - "exception.type": "context.deadlineExceededError", - "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", - "gcp.client.repo": "googleapis/google-cloud-go", - "gcp.client.service": "showcase", - "gcp.client.version": "DYNAMIC", + "error.type": "context.deadlineExceededError", + "exception.type": "context.deadlineExceededError", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "gcp.client.version": "DYNAMIC", "gcp.resource.destination.id": "DYNAMIC", "http.request.method": "POST", "server.address": "127.0.0.1", - "server.port": int64(7469), - "url.domain": "showcase.googleapis.com", - "url.full": "DYNAMIC", - "url.template": "/v1beta1/{name=sequences/*}", + "server.port": int64(7469), + "url.domain": "showcase.googleapis.com", + "url.full": "DYNAMIC", + "url.template": "/v1beta1/{name=sequences/*}", } unexpectedAttrs = []string{} } @@ -365,7 +365,7 @@ func TestObservability_Tracing_Disablement(t *testing.T) { t.Run(transport, func(t *testing.T) { fix, clientOpts := setupTracingTest(t, false, transport) ctx := context.Background() - + var seqClient interface { Close() error } @@ -379,7 +379,7 @@ func TestObservability_Tracing_Disablement(t *testing.T) { t.Fatalf("failed to create sequence client: %v", err) } t.Cleanup(func() { seqClient.Close() }) - + ctxSpan, span := otel.Tracer("test-tracer").Start(context.Background(), "APP") if transport == "grpc" { runTracingDisablementScenario(ctxSpan, t, seqClient.(*showcase.SequenceClient)) @@ -397,7 +397,7 @@ func TestObservability_Tracing_Disablement(t *testing.T) { time.Sleep(100 * time.Millisecond) spans := fix.traceServer.GetCapturedSpans() - + for _, s := range spans { if _, ok := s.Attributes["gcp.client.artifact"]; ok { t.Errorf("found gcp.client.artifact attribute, but tracing telemetry should be disabled") @@ -413,12 +413,12 @@ func TestObservability_Tracing_Retry(t *testing.T) { t.Run(transport, func(t *testing.T) { fix, clientOpts := setupTracingTest(t, true, transport) ctx := context.Background() - + var seqClient interface { Close() error } var err error - + if transport == "grpc" { seqClient, err = showcase.NewSequenceClient(ctx, clientOpts...) } else { @@ -461,7 +461,7 @@ func TestObservability_Tracing_Retry(t *testing.T) { if len(attemptSpans) != 4 { t.Errorf("expected 4 attempt spans (3 failures + 1 success), got %d", len(attemptSpans)) } - + // Verify last span has correct attributes if len(attemptSpans) > 0 { lastSpan := attemptSpans[len(attemptSpans)-1] From 02c9e73cc4b3cad2b6ddf2a3a08a1614c96acbb3 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Mon, 13 Apr 2026 22:52:40 +0000 Subject: [PATCH 10/22] fix(showcase): normalize status.message in tests to handle environment variance --- showcase/trace_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/showcase/trace_test.go b/showcase/trace_test.go index 39528a4d02..97d861bcbc 100644 --- a/showcase/trace_test.go +++ b/showcase/trace_test.go @@ -104,6 +104,12 @@ func verifyInMemorySpan(t *testing.T, fix *observabilityFixture, expectedName st // ignore exception message as it contains arbitrary text sometimes gotSpan.Attributes["exception.message"] = "DYNAMIC" } + if msg, ok := gotSpan.Attributes["status.message"].(string); ok { + // Normalize timeout messages that can vary by environment + if msg == "stream terminated by RST_STREAM with error code: CANCEL" { + gotSpan.Attributes["status.message"] = "context deadline exceeded" + } + } // Keep only the attributes we expect for diffing filteredGot := make(map[string]any) From 5c154c8a893d1792398e220d174ea9d54e813ae1 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Mon, 13 Apr 2026 22:56:43 +0000 Subject: [PATCH 11/22] chore(showcase): add license headers to new test files --- showcase/cloud_trace_test.go | 14 ++++++++++++++ showcase/observability_fixture_test.go | 14 ++++++++++++++ showcase/trace_test.go | 14 ++++++++++++++ showcase/tracing_scenarios_test.go | 14 ++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/showcase/cloud_trace_test.go b/showcase/cloud_trace_test.go index 7de3f3d7ad..a4aedbea5c 100644 --- a/showcase/cloud_trace_test.go +++ b/showcase/cloud_trace_test.go @@ -1,3 +1,17 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + //go:build telemetry // +build telemetry diff --git a/showcase/observability_fixture_test.go b/showcase/observability_fixture_test.go index 8d341d5ea3..b9fb0cc8b0 100644 --- a/showcase/observability_fixture_test.go +++ b/showcase/observability_fixture_test.go @@ -1,3 +1,17 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package showcase import ( diff --git a/showcase/trace_test.go b/showcase/trace_test.go index 97d861bcbc..e496095718 100644 --- a/showcase/trace_test.go +++ b/showcase/trace_test.go @@ -1,3 +1,17 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package showcase import ( diff --git a/showcase/tracing_scenarios_test.go b/showcase/tracing_scenarios_test.go index 8f97df401c..da26242064 100644 --- a/showcase/tracing_scenarios_test.go +++ b/showcase/tracing_scenarios_test.go @@ -1,3 +1,17 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package showcase import ( From 62548d9bd7c80877971bffd43dd17350c6260f76 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 14 Apr 2026 15:18:02 +0000 Subject: [PATCH 12/22] add assertion for rpc.system.name: http --- showcase/trace_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/showcase/trace_test.go b/showcase/trace_test.go index e496095718..f30c6575a7 100644 --- a/showcase/trace_test.go +++ b/showcase/trace_test.go @@ -207,6 +207,7 @@ func TestObservability_Tracing_Success(t *testing.T) { "gcp.resource.destination.id": "DYNAMIC", "http.request.method": "POST", "http.response.status_code": int64(200), + "rpc.system.name": "http", "server.address": "127.0.0.1", "server.port": int64(7469), "url.domain": "showcase.googleapis.com", From 3e483c38db454c58f539a43741a2b77da2845110 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 14 Apr 2026 15:24:46 +0000 Subject: [PATCH 13/22] add REST assertion for status.message --- showcase/trace_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/showcase/trace_test.go b/showcase/trace_test.go index f30c6575a7..7d01a8cf50 100644 --- a/showcase/trace_test.go +++ b/showcase/trace_test.go @@ -368,6 +368,7 @@ func TestObservability_Tracing_ClientFailure(t *testing.T) { "http.request.method": "POST", "server.address": "127.0.0.1", "server.port": int64(7469), + "status.message": "context deadline exceeded", "url.domain": "showcase.googleapis.com", "url.full": "DYNAMIC", "url.template": "/v1beta1/{name=sequences/*}", From 3858618e7ab9993429906cd56c1163c780ac5f14 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 14 Apr 2026 15:30:37 +0000 Subject: [PATCH 14/22] add assertions for last span in a retry sequence --- showcase/trace_test.go | 56 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/showcase/trace_test.go b/showcase/trace_test.go index 7d01a8cf50..45ea7ac24b 100644 --- a/showcase/trace_test.go +++ b/showcase/trace_test.go @@ -487,15 +487,63 @@ func TestObservability_Tracing_Retry(t *testing.T) { // Verify last span has correct attributes if len(attemptSpans) > 0 { lastSpan := attemptSpans[len(attemptSpans)-1] + var wantAttrs map[string]any if transport == "rest" { - if resend, ok := lastSpan.Attributes["http.request.resend_count"]; !ok || resend.(int64) != 3 { - t.Errorf("expected http.request.resend_count to be 3, got %v", resend) + wantAttrs = map[string]any{ + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "gcp.client.version": "DYNAMIC", + "gcp.resource.destination.id": "DYNAMIC", + "http.request.method": "POST", + "http.response.status_code": int64(200), + "rpc.system.name": "http", + "server.address": "127.0.0.1", + "server.port": int64(7469), + "url.domain": "showcase.googleapis.com", + "url.full": "DYNAMIC", + "url.template": "/v1beta1/{name=sequences/*}", + "http.request.resend_count": int64(3), } } else if transport == "grpc" { - if resend, ok := lastSpan.Attributes["gcp.grpc.resend_count"]; !ok || resend.(int64) != 3 { - t.Errorf("expected gcp.grpc.resend_count to be 3, got %v", resend) + wantAttrs = map[string]any{ + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "gcp.client.version": "DYNAMIC", + "gcp.resource.destination.id": "DYNAMIC", + "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", + "rpc.response.status_code": "OK", + "rpc.system.name": "grpc", + "server.address": "127.0.0.1", + "server.port": int64(7469), + "url.domain": "showcase.googleapis.com", + "gcp.grpc.resend_count": int64(3), } } + + // Normalize dynamic attributes + if _, ok := lastSpan.Attributes["gcp.client.version"]; ok { + lastSpan.Attributes["gcp.client.version"] = "DYNAMIC" + } + if _, ok := lastSpan.Attributes["gcp.resource.destination.id"]; ok { + lastSpan.Attributes["gcp.resource.destination.id"] = "DYNAMIC" + } + if _, ok := lastSpan.Attributes["url.full"]; ok { + lastSpan.Attributes["url.full"] = "DYNAMIC" + } + + // Filter and compare + filteredGot := make(map[string]any) + for k, v := range lastSpan.Attributes { + if _, expected := wantAttrs[k]; expected { + filteredGot[k] = v + } + } + + if diff := cmp.Diff(wantAttrs, filteredGot); diff != "" { + t.Errorf("Last retry span attributes mismatch (-want +got):\n%s", diff) + } } }) } From e65ea612041002ba1b78751c0c5fb673e9cfb579 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 14 Apr 2026 22:53:18 +0000 Subject: [PATCH 15/22] test: add REST coverage to Cloud Trace integration tests --- showcase/cloud_trace_test.go | 171 ++++++++++++++++++++++------------- 1 file changed, 108 insertions(+), 63 deletions(-) diff --git a/showcase/cloud_trace_test.go b/showcase/cloud_trace_test.go index a4aedbea5c..a51c73f121 100644 --- a/showcase/cloud_trace_test.go +++ b/showcase/cloud_trace_test.go @@ -43,6 +43,11 @@ import ( "google.golang.org/grpc/credentials/oauth" ) +// setupCloudTrace configures and installs a new tracer provider with a GCP +// resource detector that points to telemetry.googleapis.com. It retrieves the +// project ID from the environment or default credentials, configures telemetry +// environment variables, and initializes the OTLP exporter to send traces to +// Cloud Trace. func setupCloudTrace(t *testing.T) string { ctx := context.Background() creds, err := google.FindDefaultCredentials(ctx, "https://www.googleapis.com/auth/cloud-platform") @@ -113,7 +118,7 @@ func setupCloudTrace(t *testing.T) string { return projectID } -func verifyTrace(t *testing.T, ctx context.Context, traceClient *trace.Client, projectID string, traceID [16]byte) { +func verifyTraceExists(t *testing.T, ctx context.Context, traceClient *trace.Client, projectID string, traceID [16]byte) { traceIDStr := hex.EncodeToString(traceID[:]) t.Logf("Looking for trace %s in project %s", traceIDStr, projectID) @@ -141,67 +146,107 @@ func TestObservability_Tracing_CloudTrace_Integration(t *testing.T) { projectID := setupCloudTrace(t) ctx := context.Background() - grpcClientOpts := []option.ClientOption{ - option.WithEndpoint("127.0.0.1:7469"), - option.WithTokenSource(oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "dummy-token"})), - option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), + transports := []string{"grpc", "rest"} + for _, transport := range transports { + t.Run(transport, func(t *testing.T) { + var clientOpts []option.ClientOption + if transport == "grpc" { + clientOpts = []option.ClientOption{ + option.WithEndpoint("127.0.0.1:7469"), + option.WithTokenSource(oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "dummy-token"})), + option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), + } + } else { + clientOpts = []option.ClientOption{ + option.WithEndpoint("http://127.0.0.1:7469"), + option.WithTokenSource(oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "dummy-token"})), + } + } + + var seqClient *showcase.SequenceClient + var err error + if transport == "grpc" { + seqClient, err = showcase.NewSequenceClient(ctx, clientOpts...) + } else { + seqClient, err = showcase.NewSequenceRESTClient(ctx, clientOpts...) + } + if err != nil { + t.Fatalf("failed to create sequence client: %v", err) + } + t.Cleanup(func() { seqClient.Close() }) + + var echoClient *showcase.EchoClient + if transport == "grpc" { + echoClient, err = showcase.NewEchoClient(ctx, clientOpts...) + } else { + echoClient, err = showcase.NewEchoRESTClient(ctx, clientOpts...) + } + if err != nil { + t.Fatalf("failed to create echo client: %v", err) + } + t.Cleanup(func() { echoClient.Close() }) + + traceClient, err := trace.NewClient(ctx) + if err != nil { + t.Fatalf("failed to create trace client: %v", err) + } + t.Cleanup(func() { traceClient.Close() }) + + // 1. Success Scenario + t.Run("Success", func(t *testing.T) { + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-Success-"+transport) + if transport == "grpc" { + _ = runTracingSuccessScenario(ctxSpan, t, seqClient) + } else { + _ = runTracingSuccessScenarioREST(ctxSpan, t, seqClient) + } + span.End() + traceID := span.SpanContext().TraceID() + otel.GetTracerProvider().(*sdktrace.TracerProvider).ForceFlush(ctx) + verifyTraceExists(t, ctx, traceClient, projectID, traceID) + }) + + // 2. Server Failure Scenario + t.Run("ServerFailure", func(t *testing.T) { + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-ServerFailure-"+transport) + if transport == "grpc" { + _ = runTracingServerFailureScenario(ctxSpan, t, seqClient) + } else { + _ = runTracingServerFailureScenarioREST(ctxSpan, t, seqClient) + } + span.End() + traceID := span.SpanContext().TraceID() + otel.GetTracerProvider().(*sdktrace.TracerProvider).ForceFlush(ctx) + verifyTraceExists(t, ctx, traceClient, projectID, traceID) + }) + + // 3. Client Failure Scenario + t.Run("ClientFailure", func(t *testing.T) { + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-ClientFailure-"+transport) + if transport == "grpc" { + _ = runTracingClientFailureScenario(ctxSpan, t, seqClient) + } else { + _ = runTracingClientFailureScenarioREST(ctxSpan, t, seqClient) + } + span.End() + traceID := span.SpanContext().TraceID() + otel.GetTracerProvider().(*sdktrace.TracerProvider).ForceFlush(ctx) + verifyTraceExists(t, ctx, traceClient, projectID, traceID) + }) + + // 4. Retry Scenario + t.Run("Retry", func(t *testing.T) { + ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-Retry-"+transport) + if transport == "grpc" { + _ = runTracingRetryScenario(ctxSpan, t, seqClient) + } else { + _ = runTracingRetryScenarioREST(ctxSpan, t, seqClient) + } + span.End() + traceID := span.SpanContext().TraceID() + otel.GetTracerProvider().(*sdktrace.TracerProvider).ForceFlush(ctx) + verifyTraceExists(t, ctx, traceClient, projectID, traceID) + }) + }) } - - seqClient, err := showcase.NewSequenceClient(ctx, grpcClientOpts...) - if err != nil { - t.Fatalf("failed to create sequence client: %v", err) - } - t.Cleanup(func() { seqClient.Close() }) - - echoClient, err := showcase.NewEchoClient(ctx, grpcClientOpts...) - if err != nil { - t.Fatalf("failed to create echo client: %v", err) - } - t.Cleanup(func() { echoClient.Close() }) - - traceClient, err := trace.NewClient(ctx) - if err != nil { - t.Fatalf("failed to create trace client: %v", err) - } - t.Cleanup(func() { traceClient.Close() }) - - // 1. Success Scenario - t.Run("Success", func(t *testing.T) { - ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-Success") - _ = runTracingSuccessScenario(ctxSpan, t, seqClient) - span.End() - traceID := span.SpanContext().TraceID() - otel.GetTracerProvider().(*sdktrace.TracerProvider).ForceFlush(ctx) - verifyTrace(t, ctx, traceClient, projectID, traceID) - }) - - // 2. Server Failure Scenario - t.Run("ServerFailure", func(t *testing.T) { - ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-ServerFailure") - _ = runTracingServerFailureScenario(ctxSpan, t, seqClient) - span.End() - traceID := span.SpanContext().TraceID() - otel.GetTracerProvider().(*sdktrace.TracerProvider).ForceFlush(ctx) - verifyTrace(t, ctx, traceClient, projectID, traceID) - }) - - // 3. Client Failure Scenario - t.Run("ClientFailure", func(t *testing.T) { - ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-ClientFailure") - _ = runTracingClientFailureScenario(ctxSpan, t, seqClient) - span.End() - traceID := span.SpanContext().TraceID() - otel.GetTracerProvider().(*sdktrace.TracerProvider).ForceFlush(ctx) - verifyTrace(t, ctx, traceClient, projectID, traceID) - }) - - // 4. Retry Scenario - t.Run("Retry", func(t *testing.T) { - ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-Retry") - _ = runTracingRetryScenario(ctxSpan, t, seqClient) - span.End() - traceID := span.SpanContext().TraceID() - otel.GetTracerProvider().(*sdktrace.TracerProvider).ForceFlush(ctx) - verifyTrace(t, ctx, traceClient, projectID, traceID) - }) } From e2de8c5b6387317c18964eae0ed7de34d03f62ce Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Wed, 15 Apr 2026 22:19:34 +0000 Subject: [PATCH 16/22] remove unused metrics and logging types from observability_fixture_test.go --- showcase/observability_fixture_test.go | 203 +------------------------ 1 file changed, 6 insertions(+), 197 deletions(-) diff --git a/showcase/observability_fixture_test.go b/showcase/observability_fixture_test.go index b9fb0cc8b0..bd0adb165a 100644 --- a/showcase/observability_fixture_test.go +++ b/showcase/observability_fixture_test.go @@ -22,22 +22,17 @@ import ( "time" "go.opentelemetry.io/contrib/detectors/gcp" - "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc" - "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" - sdklog "go.opentelemetry.io/otel/sdk/log" - sdkmetric "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" - pblog "go.opentelemetry.io/proto/otlp/collector/logs/v1" - pbmetric "go.opentelemetry.io/proto/otlp/collector/metrics/v1" pb "go.opentelemetry.io/proto/otlp/collector/trace/v1" v1common "go.opentelemetry.io/proto/otlp/common/v1" - olog "go.opentelemetry.io/proto/otlp/logs/v1" "google.golang.org/grpc" ) +// mockTraceServer implements an OTLP service that receives traces in-memory +// for assertions in tests. type mockTraceServer struct { pb.UnimplementedTraceServiceServer mu sync.Mutex @@ -51,6 +46,8 @@ func (s *mockTraceServer) Export(ctx context.Context, req *pb.ExportTraceService return &pb.ExportTraceServiceResponse{}, nil } +// CapturedSpan represents a simplified OpenTelemetry span captured by the mock trace +// server for testing assertions. type CapturedSpan struct { Name string Scope string @@ -123,163 +120,15 @@ func (s *mockTraceServer) GetCapturedSpans() []CapturedSpan { return spans } -type mockMetricServer struct { - pbmetric.UnimplementedMetricsServiceServer - mu sync.Mutex - requests []*pbmetric.ExportMetricsServiceRequest -} -func (s *mockMetricServer) Export(ctx context.Context, req *pbmetric.ExportMetricsServiceRequest) (*pbmetric.ExportMetricsServiceResponse, error) { - s.mu.Lock() - defer s.mu.Unlock() - s.requests = append(s.requests, req) - return &pbmetric.ExportMetricsServiceResponse{}, nil -} -type CapturedMetric struct { - Name string - Scope string - Attributes map[string]any - DataPoints []float64 // Simplifying for histograms -} - -func (s *mockMetricServer) getRequests() []*pbmetric.ExportMetricsServiceRequest { - s.mu.Lock() - defer s.mu.Unlock() - reqs := make([]*pbmetric.ExportMetricsServiceRequest, len(s.requests)) - copy(reqs, s.requests) - return reqs -} - -func (s *mockMetricServer) GetCapturedMetrics() []CapturedMetric { - reqs := s.getRequests() - var metrics []CapturedMetric - for _, req := range reqs { - for _, rm := range req.ResourceMetrics { - for _, sm := range rm.ScopeMetrics { - for _, m := range sm.Metrics { - if hist := m.GetHistogram(); hist != nil { - for _, dp := range hist.DataPoints { - cmDp := CapturedMetric{ - Name: m.Name, - Scope: sm.Scope.Name, - } - var dps []float64 - if dp.Sum != nil { - dps = append(dps, *dp.Sum) - } else { - dps = append(dps, 0.0) - } - cmDp.DataPoints = dps - - var attrsMap = make(map[string]any) - // Extract Scope Attributes - for _, kv := range sm.Scope.Attributes { - if kv.Value != nil { - switch v := kv.Value.Value.(type) { - case *v1common.AnyValue_StringValue: - attrsMap[kv.Key] = v.StringValue - case *v1common.AnyValue_IntValue: - attrsMap[kv.Key] = v.IntValue - } - } - } - for _, kv := range dp.Attributes { - if kv.Value != nil { - switch v := kv.Value.Value.(type) { - case *v1common.AnyValue_StringValue: - attrsMap[kv.Key] = v.StringValue - case *v1common.AnyValue_IntValue: - attrsMap[kv.Key] = v.IntValue - } - } - } - cmDp.Attributes = attrsMap - metrics = append(metrics, cmDp) - } - } - } - } - } - } - return metrics -} - -type mockLogServer struct { - pblog.UnimplementedLogsServiceServer - mu sync.Mutex - requests []*pblog.ExportLogsServiceRequest -} - -func (s *mockLogServer) Export(ctx context.Context, req *pblog.ExportLogsServiceRequest) (*pblog.ExportLogsServiceResponse, error) { - s.mu.Lock() - defer s.mu.Unlock() - s.requests = append(s.requests, req) - return &pblog.ExportLogsServiceResponse{}, nil -} - -type CapturedLog struct { - Body string - Scope string - Severity olog.SeverityNumber - TraceID []byte - Attributes map[string]any -} - -func (s *mockLogServer) getRequests() []*pblog.ExportLogsServiceRequest { - s.mu.Lock() - defer s.mu.Unlock() - reqs := make([]*pblog.ExportLogsServiceRequest, len(s.requests)) - copy(reqs, s.requests) - return reqs -} - -func (s *mockLogServer) GetCapturedLogs() []CapturedLog { - reqs := s.getRequests() - var logs []CapturedLog - for _, req := range reqs { - for _, rl := range req.ResourceLogs { - for _, sl := range rl.ScopeLogs { - for _, l := range sl.LogRecords { - cl := CapturedLog{ - Scope: sl.Scope.Name, - Severity: l.SeverityNumber, - TraceID: l.TraceId, - } - if l.Body != nil { - cl.Body = l.Body.GetStringValue() - } - - attrs := make(map[string]any) - for _, kv := range l.Attributes { - if kv.Value != nil { - switch v := kv.Value.Value.(type) { - case *v1common.AnyValue_StringValue: - attrs[kv.Key] = v.StringValue - case *v1common.AnyValue_IntValue: - attrs[kv.Key] = v.IntValue - case *v1common.AnyValue_BoolValue: - attrs[kv.Key] = v.BoolValue - } - } - } - cl.Attributes = attrs - logs = append(logs, cl) - } - } - } - } - return logs -} +// observabilityFixture encapsulates an in-memory OTLP gRPC server and the +// OpenTelemetry provider configurations for integration testing. type observabilityFixture struct { grpcServer *grpc.Server traceServer *mockTraceServer - metricServer *mockMetricServer - logServer *mockLogServer provider *sdktrace.TracerProvider - meterProvider *sdkmetric.MeterProvider - loggerProvider *sdklog.LoggerProvider } // setupObservabilityFixture creates an in-memory OTLP trace server and configures the OTel SDK to export to it. @@ -293,11 +142,7 @@ func setupObservabilityFixture(t *testing.T) *observabilityFixture { grpcServer := grpc.NewServer() traceServer := &mockTraceServer{} - metricServer := &mockMetricServer{} - logServer := &mockLogServer{} pb.RegisterTraceServiceServer(grpcServer, traceServer) - pbmetric.RegisterMetricsServiceServer(grpcServer, metricServer) - pblog.RegisterLogsServiceServer(grpcServer, logServer) go func() { if err := grpcServer.Serve(lis); err != nil { @@ -317,22 +162,6 @@ func setupObservabilityFixture(t *testing.T) *observabilityFixture { t.Fatalf("failed to create exporter: %v", err) } - metricExp, err := otlpmetricgrpc.New(ctx, - otlpmetricgrpc.WithEndpoint(lis.Addr().String()), - otlpmetricgrpc.WithInsecure(), - ) - if err != nil { - t.Fatalf("failed to create metric exporter: %v", err) - } - - logExp, err := otlploggrpc.New(ctx, - otlploggrpc.WithEndpoint(lis.Addr().String()), - otlploggrpc.WithInsecure(), - ) - if err != nil { - t.Fatalf("failed to create log exporter: %v", err) - } - res, err := resource.New(ctx, resource.WithDetectors(gcp.NewDetector()), resource.WithTelemetrySDK(), @@ -349,37 +178,17 @@ func setupObservabilityFixture(t *testing.T) *observabilityFixture { sdktrace.WithResource(res), ) - mp := sdkmetric.NewMeterProvider( - sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp, sdkmetric.WithInterval(10*time.Hour))), - sdkmetric.WithResource(res), - ) - - lp := sdklog.NewLoggerProvider( - sdklog.WithProcessor(sdklog.NewBatchProcessor(logExp)), - sdklog.WithResource(res), - ) - t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := tp.Shutdown(ctx); err != nil { t.Logf("Failed to shutdown tracer provider: %v", err) } - if err := mp.Shutdown(ctx); err != nil { - t.Logf("Failed to shutdown meter provider: %v", err) - } - if err := lp.Shutdown(ctx); err != nil { - t.Logf("Failed to shutdown logger provider: %v", err) - } }) return &observabilityFixture{ grpcServer: grpcServer, traceServer: traceServer, - metricServer: metricServer, - logServer: logServer, provider: tp, - meterProvider: mp, - loggerProvider: lp, } } From 3c4ffab19d4b95122eda441af9b6e254771f9b94 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Wed, 15 Apr 2026 22:46:42 +0000 Subject: [PATCH 17/22] test: refactor trace assertions to use lookup and assert helpers --- showcase/trace_test.go | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/showcase/trace_test.go b/showcase/trace_test.go index 45ea7ac24b..26820b4f62 100644 --- a/showcase/trace_test.go +++ b/showcase/trace_test.go @@ -25,7 +25,6 @@ import ( showcase "github.com/googleapis/gapic-showcase/client" gax "github.com/googleapis/gax-go/v2" "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" "google.golang.org/api/option" "google.golang.org/grpc" @@ -74,7 +73,7 @@ func setupTracingTest(t *testing.T, enableTracing bool, transport string) (*obse return fix, clientOpts } -func verifyInMemorySpan(t *testing.T, fix *observabilityFixture, expectedName string, traceID trace.TraceID, wantAttrs map[string]any, unexpectedAttrs []string) { +func findInMemorySpan(t *testing.T, fix *observabilityFixture, expectedName string, traceID [16]byte) *CapturedSpan { t.Helper() // Force flush the provider to ensure traces are exported @@ -92,17 +91,18 @@ func verifyInMemorySpan(t *testing.T, fix *observabilityFixture, expectedName st t.Fatalf("expected to receive trace exports, got none") } - var gotSpan *CapturedSpan for _, s := range spans { if string(s.TraceID) == string(traceID[:]) && s.Name == expectedName { - gotSpan = &s - break + return &s } } - if gotSpan == nil { - t.Fatalf("did not find the expected client span") - } + t.Fatalf("did not find the expected client span %s", expectedName) + return nil +} + +func assertInMemorySpan(t *testing.T, gotSpan *CapturedSpan, wantAttrs map[string]any, unexpectedAttrs []string) { + t.Helper() if wantAttrs != nil { if _, ok := gotSpan.Attributes["gcp.client.version"]; ok { @@ -115,11 +115,9 @@ func verifyInMemorySpan(t *testing.T, fix *observabilityFixture, expectedName st gotSpan.Attributes["url.full"] = "DYNAMIC" } if _, ok := gotSpan.Attributes["exception.message"]; ok { - // ignore exception message as it contains arbitrary text sometimes gotSpan.Attributes["exception.message"] = "DYNAMIC" } if msg, ok := gotSpan.Attributes["status.message"].(string); ok { - // Normalize timeout messages that can vary by environment if msg == "stream terminated by RST_STREAM with error code: CANCEL" { gotSpan.Attributes["status.message"] = "context deadline exceeded" } @@ -217,7 +215,8 @@ func TestObservability_Tracing_Success(t *testing.T) { unexpectedAttrs = []string{"status.message", "error.type", "exception.type"} } - verifyInMemorySpan(t, fix, expectedName, traceID, wantAttrs, unexpectedAttrs) + capturedSpan := findInMemorySpan(t, fix, expectedName, traceID) + assertInMemorySpan(t, capturedSpan, wantAttrs, unexpectedAttrs) }) } } @@ -297,7 +296,8 @@ func TestObservability_Tracing_Failure(t *testing.T) { unexpectedAttrs = []string{} } - verifyInMemorySpan(t, fix, expectedName, traceID, wantAttrs, unexpectedAttrs) + capturedSpan := findInMemorySpan(t, fix, expectedName, traceID) + assertInMemorySpan(t, capturedSpan, wantAttrs, unexpectedAttrs) }) } } @@ -376,7 +376,8 @@ func TestObservability_Tracing_ClientFailure(t *testing.T) { unexpectedAttrs = []string{} } - verifyInMemorySpan(t, fix, expectedName, traceID, wantAttrs, unexpectedAttrs) + capturedSpan := findInMemorySpan(t, fix, expectedName, traceID) + assertInMemorySpan(t, capturedSpan, wantAttrs, unexpectedAttrs) }) } } From b6d0a91a69e739ca0bbb1a7a2472664b0d804abd Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Wed, 15 Apr 2026 22:55:01 +0000 Subject: [PATCH 18/22] test: eliminate DYNAMIC placeholder asserts constraints on individual tests --- showcase/observability_fixture_test.go | 15 +- showcase/trace_test.go | 221 ++++++++++++++----------- 2 files changed, 126 insertions(+), 110 deletions(-) diff --git a/showcase/observability_fixture_test.go b/showcase/observability_fixture_test.go index bd0adb165a..175ae2ce2f 100644 --- a/showcase/observability_fixture_test.go +++ b/showcase/observability_fixture_test.go @@ -120,15 +120,12 @@ func (s *mockTraceServer) GetCapturedSpans() []CapturedSpan { return spans } - - - // observabilityFixture encapsulates an in-memory OTLP gRPC server and the // OpenTelemetry provider configurations for integration testing. type observabilityFixture struct { - grpcServer *grpc.Server - traceServer *mockTraceServer - provider *sdktrace.TracerProvider + grpcServer *grpc.Server + traceServer *mockTraceServer + provider *sdktrace.TracerProvider } // setupObservabilityFixture creates an in-memory OTLP trace server and configures the OTel SDK to export to it. @@ -187,8 +184,8 @@ func setupObservabilityFixture(t *testing.T) *observabilityFixture { }) return &observabilityFixture{ - grpcServer: grpcServer, - traceServer: traceServer, - provider: tp, + grpcServer: grpcServer, + traceServer: traceServer, + provider: tp, } } diff --git a/showcase/trace_test.go b/showcase/trace_test.go index 26820b4f62..378d54b59a 100644 --- a/showcase/trace_test.go +++ b/showcase/trace_test.go @@ -16,7 +16,9 @@ package showcase import ( "context" + "fmt" "os" + "strings" "testing" "time" @@ -101,28 +103,10 @@ func findInMemorySpan(t *testing.T, fix *observabilityFixture, expectedName stri return nil } -func assertInMemorySpan(t *testing.T, gotSpan *CapturedSpan, wantAttrs map[string]any, unexpectedAttrs []string) { +func assertCapturedSpan(t *testing.T, gotSpan *CapturedSpan, wantAttrs map[string]any, unexpectedAttrs []string, constraints map[string]func(any) error) { t.Helper() if wantAttrs != nil { - if _, ok := gotSpan.Attributes["gcp.client.version"]; ok { - gotSpan.Attributes["gcp.client.version"] = "DYNAMIC" - } - if _, ok := gotSpan.Attributes["gcp.resource.destination.id"]; ok { - gotSpan.Attributes["gcp.resource.destination.id"] = "DYNAMIC" - } - if _, ok := gotSpan.Attributes["url.full"]; ok { - gotSpan.Attributes["url.full"] = "DYNAMIC" - } - if _, ok := gotSpan.Attributes["exception.message"]; ok { - gotSpan.Attributes["exception.message"] = "DYNAMIC" - } - if msg, ok := gotSpan.Attributes["status.message"].(string); ok { - if msg == "stream terminated by RST_STREAM with error code: CANCEL" { - gotSpan.Attributes["status.message"] = "context deadline exceeded" - } - } - // Keep only the attributes we expect for diffing filteredGot := make(map[string]any) for k, v := range gotSpan.Attributes { @@ -141,6 +125,31 @@ func assertInMemorySpan(t *testing.T, gotSpan *CapturedSpan, wantAttrs map[strin t.Errorf("expected attribute %q to be NOT SET, but it was present", attr) } } + + for attr, check := range constraints { + val := gotSpan.Attributes[attr] + if err := check(val); err != nil { + t.Errorf("constraint failed for attribute %q: %v", attr, err) + } + } +} + +func checkNonEmpty(v any) error { + s, ok := v.(string) + if !ok || s == "" { + return fmt.Errorf("expected non-empty string, got %v", v) + } + return nil +} + +func checkPrefix(prefix string) func(any) error { + return func(v any) error { + s, ok := v.(string) + if !ok || !strings.HasPrefix(s, prefix) { + return fmt.Errorf("expected string with prefix %q, got %v", prefix, v) + } + return nil + } } func TestObservability_Tracing_Success(t *testing.T) { @@ -182,41 +191,45 @@ func TestObservability_Tracing_Success(t *testing.T) { if transport == "grpc" { expectedName = "google.showcase.v1beta1.SequenceService/AttemptSequence" wantAttrs = map[string]any{ - "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", - "gcp.client.repo": "googleapis/google-cloud-go", - "gcp.client.service": "showcase", - "gcp.client.version": "DYNAMIC", - "gcp.resource.destination.id": "DYNAMIC", - "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", - "rpc.response.status_code": "OK", - "rpc.system.name": "grpc", - "server.address": "127.0.0.1", - "server.port": int64(7469), - "url.domain": "showcase.googleapis.com", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", + "rpc.response.status_code": "OK", + "rpc.system.name": "grpc", + "server.address": "127.0.0.1", + "server.port": int64(7469), + "url.domain": "showcase.googleapis.com", } unexpectedAttrs = []string{"status.message", "error.type"} } else { expectedName = "POST /v1beta1/{name=sequences/*}" wantAttrs = map[string]any{ - "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", - "gcp.client.repo": "googleapis/google-cloud-go", - "gcp.client.service": "showcase", - "gcp.client.version": "DYNAMIC", - "gcp.resource.destination.id": "DYNAMIC", - "http.request.method": "POST", - "http.response.status_code": int64(200), - "rpc.system.name": "http", - "server.address": "127.0.0.1", - "server.port": int64(7469), - "url.domain": "showcase.googleapis.com", - "url.full": "DYNAMIC", - "url.template": "/v1beta1/{name=sequences/*}", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "http.request.method": "POST", + "http.response.status_code": int64(200), + "rpc.system.name": "http", + "server.address": "127.0.0.1", + "server.port": int64(7469), + "url.domain": "showcase.googleapis.com", + "url.template": "/v1beta1/{name=sequences/*}", } unexpectedAttrs = []string{"status.message", "error.type", "exception.type"} } capturedSpan := findInMemorySpan(t, fix, expectedName, traceID) - assertInMemorySpan(t, capturedSpan, wantAttrs, unexpectedAttrs) + + constraints := map[string]func(any) error{ + "gcp.client.version": checkNonEmpty, + "gcp.resource.destination.id": checkNonEmpty, + } + if transport == "rest" { + constraints["url.full"] = checkNonEmpty + } + + assertCapturedSpan(t, capturedSpan, wantAttrs, unexpectedAttrs, constraints) }) } } @@ -259,45 +272,48 @@ func TestObservability_Tracing_Failure(t *testing.T) { if transport == "grpc" { expectedName = "google.showcase.v1beta1.SequenceService/AttemptSequence" wantAttrs = map[string]any{ - "error.type": "NOT_FOUND", - "exception.type": "*status.Error", - "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", - "gcp.client.repo": "googleapis/google-cloud-go", - "gcp.client.service": "showcase", - "gcp.client.version": "DYNAMIC", - "gcp.resource.destination.id": "DYNAMIC", - "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", - "rpc.response.status_code": "NOT_FOUND", - "rpc.system.name": "grpc", - "server.address": "127.0.0.1", - "server.port": int64(7469), - "status.message": "not found", - "url.domain": "showcase.googleapis.com", + "error.type": "NOT_FOUND", + "exception.type": "*status.Error", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", + "rpc.response.status_code": "NOT_FOUND", + "rpc.system.name": "grpc", + "server.address": "127.0.0.1", + "server.port": int64(7469), + "status.message": "not found", + "url.domain": "showcase.googleapis.com", } unexpectedAttrs = []string{} } else { expectedName = "POST /v1beta1/{name=sequences/*}" wantAttrs = map[string]any{ - "error.type": "404", - "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", - "gcp.client.repo": "googleapis/google-cloud-go", - "gcp.client.service": "showcase", - "gcp.client.version": "DYNAMIC", - "gcp.resource.destination.id": "DYNAMIC", - "http.request.method": "POST", - "http.response.status_code": int64(404), - "server.address": "127.0.0.1", - "server.port": int64(7469), - "status.message": "not found", - "url.domain": "showcase.googleapis.com", - "url.full": "DYNAMIC", - "url.template": "/v1beta1/{name=sequences/*}", + "error.type": "404", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "http.request.method": "POST", + "http.response.status_code": int64(404), + "server.address": "127.0.0.1", + "server.port": int64(7469), + "status.message": "not found", + "url.domain": "showcase.googleapis.com", + "url.template": "/v1beta1/{name=sequences/*}", } unexpectedAttrs = []string{} } capturedSpan := findInMemorySpan(t, fix, expectedName, traceID) - assertInMemorySpan(t, capturedSpan, wantAttrs, unexpectedAttrs) + constraints := map[string]func(any) error{ + "gcp.client.version": checkNonEmpty, + "gcp.resource.destination.id": checkNonEmpty, + } + if transport == "rest" { + constraints["url.full"] = checkNonEmpty + } + + assertCapturedSpan(t, capturedSpan, wantAttrs, unexpectedAttrs, constraints) }) } } @@ -340,44 +356,47 @@ func TestObservability_Tracing_ClientFailure(t *testing.T) { if transport == "grpc" { expectedName = "google.showcase.v1beta1.SequenceService/AttemptSequence" wantAttrs = map[string]any{ - "error.type": "CLIENT_TIMEOUT", - "exception.type": "*status.Error", - "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", - "gcp.client.repo": "googleapis/google-cloud-go", - "gcp.client.service": "showcase", - "gcp.client.version": "DYNAMIC", - "gcp.resource.destination.id": "DYNAMIC", - "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", - "rpc.system.name": "grpc", - "server.address": "127.0.0.1", - "server.port": int64(7469), - "status.message": "context deadline exceeded", - "url.domain": "showcase.googleapis.com", + "error.type": "CLIENT_TIMEOUT", + "exception.type": "*status.Error", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", + "rpc.system.name": "grpc", + "server.address": "127.0.0.1", + "server.port": int64(7469), + "status.message": "context deadline exceeded", + "url.domain": "showcase.googleapis.com", } unexpectedAttrs = []string{} } else { expectedName = "POST /v1beta1/{name=sequences/*}" wantAttrs = map[string]any{ - "error.type": "context.deadlineExceededError", - "exception.type": "context.deadlineExceededError", - "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", - "gcp.client.repo": "googleapis/google-cloud-go", - "gcp.client.service": "showcase", - "gcp.client.version": "DYNAMIC", - "gcp.resource.destination.id": "DYNAMIC", - "http.request.method": "POST", - "server.address": "127.0.0.1", - "server.port": int64(7469), - "status.message": "context deadline exceeded", - "url.domain": "showcase.googleapis.com", - "url.full": "DYNAMIC", - "url.template": "/v1beta1/{name=sequences/*}", + "error.type": "context.deadlineExceededError", + "exception.type": "context.deadlineExceededError", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "http.request.method": "POST", + "server.address": "127.0.0.1", + "server.port": int64(7469), + "status.message": "context deadline exceeded", + "url.domain": "showcase.googleapis.com", + "url.template": "/v1beta1/{name=sequences/*}", } unexpectedAttrs = []string{} } capturedSpan := findInMemorySpan(t, fix, expectedName, traceID) - assertInMemorySpan(t, capturedSpan, wantAttrs, unexpectedAttrs) + constraints := map[string]func(any) error{ + "gcp.client.version": checkNonEmpty, + "gcp.resource.destination.id": checkNonEmpty, + } + if transport == "rest" { + constraints["url.full"] = checkNonEmpty + } + + assertCapturedSpan(t, capturedSpan, wantAttrs, unexpectedAttrs, constraints) }) } } From 8a4d38421bfdef40c9f33876a2cc86e5cf4d06bc Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Fri, 17 Apr 2026 16:57:03 +0000 Subject: [PATCH 19/22] remove unneeded comments --- showcase/cloud_trace_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/showcase/cloud_trace_test.go b/showcase/cloud_trace_test.go index a51c73f121..2084407e06 100644 --- a/showcase/cloud_trace_test.go +++ b/showcase/cloud_trace_test.go @@ -192,7 +192,6 @@ func TestObservability_Tracing_CloudTrace_Integration(t *testing.T) { } t.Cleanup(func() { traceClient.Close() }) - // 1. Success Scenario t.Run("Success", func(t *testing.T) { ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-Success-"+transport) if transport == "grpc" { @@ -206,7 +205,6 @@ func TestObservability_Tracing_CloudTrace_Integration(t *testing.T) { verifyTraceExists(t, ctx, traceClient, projectID, traceID) }) - // 2. Server Failure Scenario t.Run("ServerFailure", func(t *testing.T) { ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-ServerFailure-"+transport) if transport == "grpc" { @@ -220,7 +218,6 @@ func TestObservability_Tracing_CloudTrace_Integration(t *testing.T) { verifyTraceExists(t, ctx, traceClient, projectID, traceID) }) - // 3. Client Failure Scenario t.Run("ClientFailure", func(t *testing.T) { ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-ClientFailure-"+transport) if transport == "grpc" { @@ -234,7 +231,6 @@ func TestObservability_Tracing_CloudTrace_Integration(t *testing.T) { verifyTraceExists(t, ctx, traceClient, projectID, traceID) }) - // 4. Retry Scenario t.Run("Retry", func(t *testing.T) { ctxSpan, span := otel.Tracer("test-tracer").Start(ctx, "APP-Retry-"+transport) if transport == "grpc" { From d727d5b1e1442cad928f65d0c02f67c46762c697 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Fri, 17 Apr 2026 16:57:05 +0000 Subject: [PATCH 20/22] test: implement strict attribute assertions and constraint callbacks --- showcase/trace_test.go | 103 ++++++++++++++++++++++++++++++++--------- 1 file changed, 81 insertions(+), 22 deletions(-) diff --git a/showcase/trace_test.go b/showcase/trace_test.go index 378d54b59a..fffc7e551e 100644 --- a/showcase/trace_test.go +++ b/showcase/trace_test.go @@ -132,6 +132,26 @@ func assertCapturedSpan(t *testing.T, gotSpan *CapturedSpan, wantAttrs map[strin t.Errorf("constraint failed for attribute %q: %v", attr, err) } } + + // Report any "new" attribute not covered by wantAttrs or constraints as a failure + for k := range gotSpan.Attributes { + _, inWant := wantAttrs[k] + _, inConstraints := constraints[k] + + if !inWant && !inConstraints { + // Check if it was explicitly expected to be absent + isUnexpected := false + for _, u := range unexpectedAttrs { + if u == k { + isUnexpected = true + break + } + } + if !isUnexpected { + t.Errorf("unexpected attribute found: %q", k) + } + } + } } func checkNonEmpty(v any) error { @@ -152,6 +172,29 @@ func checkPrefix(prefix string) func(any) error { } } +func addResourceConstraints(constraints map[string]func(any) error) { + resourceAttrs := []string{ + "cloud.provider", + "cloud.platform", + "cloud.account.id", + "cloud.region", + "cloud.availability_zone", + "host.id", + "host.name", + "host.type", + "gcp.gce.instance.name", + "gcp.gce.instance.hostname", + "service.name", + "telemetry.sdk.name", + "telemetry.sdk.language", + "telemetry.sdk.version", + "gcp.client.language", + } + for _, attr := range resourceAttrs { + constraints[attr] = checkNonEmpty + } +} + func TestObservability_Tracing_Success(t *testing.T) { transports := []string{"grpc", "rest"} for _, transport := range transports { @@ -200,6 +243,7 @@ func TestObservability_Tracing_Success(t *testing.T) { "server.address": "127.0.0.1", "server.port": int64(7469), "url.domain": "showcase.googleapis.com", + "gcp.grpc.resend_count": int64(0), } unexpectedAttrs = []string{"status.message", "error.type"} } else { @@ -215,6 +259,7 @@ func TestObservability_Tracing_Success(t *testing.T) { "server.port": int64(7469), "url.domain": "showcase.googleapis.com", "url.template": "/v1beta1/{name=sequences/*}", + "http.request.resend_count": int64(0), } unexpectedAttrs = []string{"status.message", "error.type", "exception.type"} } @@ -225,8 +270,10 @@ func TestObservability_Tracing_Success(t *testing.T) { "gcp.client.version": checkNonEmpty, "gcp.resource.destination.id": checkNonEmpty, } + addResourceConstraints(constraints) if transport == "rest" { constraints["url.full"] = checkNonEmpty + constraints["network.protocol.version"] = checkNonEmpty } assertCapturedSpan(t, capturedSpan, wantAttrs, unexpectedAttrs, constraints) @@ -284,6 +331,7 @@ func TestObservability_Tracing_Failure(t *testing.T) { "server.port": int64(7469), "status.message": "not found", "url.domain": "showcase.googleapis.com", + "gcp.grpc.resend_count": int64(0), } unexpectedAttrs = []string{} } else { @@ -295,11 +343,13 @@ func TestObservability_Tracing_Failure(t *testing.T) { "gcp.client.service": "showcase", "http.request.method": "POST", "http.response.status_code": int64(404), + "rpc.system.name": "http", "server.address": "127.0.0.1", "server.port": int64(7469), "status.message": "not found", "url.domain": "showcase.googleapis.com", "url.template": "/v1beta1/{name=sequences/*}", + "http.request.resend_count": int64(0), } unexpectedAttrs = []string{} } @@ -309,8 +359,10 @@ func TestObservability_Tracing_Failure(t *testing.T) { "gcp.client.version": checkNonEmpty, "gcp.resource.destination.id": checkNonEmpty, } + addResourceConstraints(constraints) if transport == "rest" { constraints["url.full"] = checkNonEmpty + constraints["network.protocol.version"] = checkNonEmpty } assertCapturedSpan(t, capturedSpan, wantAttrs, unexpectedAttrs, constraints) @@ -356,33 +408,36 @@ func TestObservability_Tracing_ClientFailure(t *testing.T) { if transport == "grpc" { expectedName = "google.showcase.v1beta1.SequenceService/AttemptSequence" wantAttrs = map[string]any{ - "error.type": "CLIENT_TIMEOUT", - "exception.type": "*status.Error", - "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", - "gcp.client.repo": "googleapis/google-cloud-go", - "gcp.client.service": "showcase", - "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", - "rpc.system.name": "grpc", - "server.address": "127.0.0.1", - "server.port": int64(7469), - "status.message": "context deadline exceeded", - "url.domain": "showcase.googleapis.com", + "error.type": "CLIENT_TIMEOUT", + "exception.type": "*status.Error", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "rpc.method": "google.showcase.v1beta1.SequenceService/AttemptSequence", + "rpc.system.name": "grpc", + "server.address": "127.0.0.1", + "server.port": int64(7469), + "status.message": "context deadline exceeded", + "url.domain": "showcase.googleapis.com", + "gcp.grpc.resend_count": int64(0), } unexpectedAttrs = []string{} } else { expectedName = "POST /v1beta1/{name=sequences/*}" wantAttrs = map[string]any{ - "error.type": "context.deadlineExceededError", - "exception.type": "context.deadlineExceededError", - "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", - "gcp.client.repo": "googleapis/google-cloud-go", - "gcp.client.service": "showcase", - "http.request.method": "POST", - "server.address": "127.0.0.1", - "server.port": int64(7469), - "status.message": "context deadline exceeded", - "url.domain": "showcase.googleapis.com", - "url.template": "/v1beta1/{name=sequences/*}", + "error.type": "context.deadlineExceededError", + "exception.type": "context.deadlineExceededError", + "gcp.client.artifact": "github.com/googleapis/gapic-showcase/client", + "gcp.client.repo": "googleapis/google-cloud-go", + "gcp.client.service": "showcase", + "http.request.method": "POST", + "rpc.system.name": "http", + "server.address": "127.0.0.1", + "server.port": int64(7469), + "status.message": "context deadline exceeded", + "url.domain": "showcase.googleapis.com", + "url.template": "/v1beta1/{name=sequences/*}", + "http.request.resend_count": int64(0), } unexpectedAttrs = []string{} } @@ -392,8 +447,12 @@ func TestObservability_Tracing_ClientFailure(t *testing.T) { "gcp.client.version": checkNonEmpty, "gcp.resource.destination.id": checkNonEmpty, } + addResourceConstraints(constraints) if transport == "rest" { constraints["url.full"] = checkNonEmpty + constraints["network.protocol.version"] = checkNonEmpty + } else { + constraints["rpc.response.status_code"] = checkNonEmpty } assertCapturedSpan(t, capturedSpan, wantAttrs, unexpectedAttrs, constraints) From 3571b0be5e014afcc0a37f317f371e48afe404a6 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Fri, 17 Apr 2026 17:05:24 +0000 Subject: [PATCH 21/22] test: make resource assertions conditional on GCP environment --- showcase/trace_test.go | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/showcase/trace_test.go b/showcase/trace_test.go index fffc7e551e..249ae71dc4 100644 --- a/showcase/trace_test.go +++ b/showcase/trace_test.go @@ -172,7 +172,19 @@ func checkPrefix(prefix string) func(any) error { } } -func addResourceConstraints(constraints map[string]func(any) error) { +func addResourceConstraints(gotAttrs map[string]any, constraints map[string]func(any) error) { + // Only enforce GCP resource attributes if we are actually running on GCP + // and the detector found them. In non-GCP environments like standard CI + // runners (e.g., GitHub Actions), the gcp.NewDetector() (which queries the + // GCP metadata server) will fail to find metadata and return an empty resource, + // causing these attributes to be missing. + // We use the presence of "cloud.provider" (set to "gcp" by gcp.NewDetector() + // when successful) as a heuristic to detect if we are running in an + // environment that successfully resolved GCP resource metadata. + if _, ok := gotAttrs["cloud.provider"]; !ok { + return + } + resourceAttrs := []string{ "cloud.provider", "cloud.platform", @@ -270,7 +282,7 @@ func TestObservability_Tracing_Success(t *testing.T) { "gcp.client.version": checkNonEmpty, "gcp.resource.destination.id": checkNonEmpty, } - addResourceConstraints(constraints) + addResourceConstraints(capturedSpan.Attributes, constraints) if transport == "rest" { constraints["url.full"] = checkNonEmpty constraints["network.protocol.version"] = checkNonEmpty @@ -359,7 +371,7 @@ func TestObservability_Tracing_Failure(t *testing.T) { "gcp.client.version": checkNonEmpty, "gcp.resource.destination.id": checkNonEmpty, } - addResourceConstraints(constraints) + addResourceConstraints(capturedSpan.Attributes, constraints) if transport == "rest" { constraints["url.full"] = checkNonEmpty constraints["network.protocol.version"] = checkNonEmpty @@ -447,7 +459,7 @@ func TestObservability_Tracing_ClientFailure(t *testing.T) { "gcp.client.version": checkNonEmpty, "gcp.resource.destination.id": checkNonEmpty, } - addResourceConstraints(constraints) + addResourceConstraints(capturedSpan.Attributes, constraints) if transport == "rest" { constraints["url.full"] = checkNonEmpty constraints["network.protocol.version"] = checkNonEmpty From 6beb98a5f735f6a63e600047dee22afe6f8e5a1c Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Fri, 17 Apr 2026 17:16:32 +0000 Subject: [PATCH 22/22] test: use checkAbsent constraint to simplify assertCapturedSpan --- showcase/trace_test.go | 45 ++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/showcase/trace_test.go b/showcase/trace_test.go index 249ae71dc4..d91e6613eb 100644 --- a/showcase/trace_test.go +++ b/showcase/trace_test.go @@ -103,7 +103,7 @@ func findInMemorySpan(t *testing.T, fix *observabilityFixture, expectedName stri return nil } -func assertCapturedSpan(t *testing.T, gotSpan *CapturedSpan, wantAttrs map[string]any, unexpectedAttrs []string, constraints map[string]func(any) error) { +func assertCapturedSpan(t *testing.T, gotSpan *CapturedSpan, wantAttrs map[string]any, constraints map[string]func(any) error) { t.Helper() if wantAttrs != nil { @@ -120,12 +120,6 @@ func assertCapturedSpan(t *testing.T, gotSpan *CapturedSpan, wantAttrs map[strin } } - for _, attr := range unexpectedAttrs { - if _, ok := gotSpan.Attributes[attr]; ok { - t.Errorf("expected attribute %q to be NOT SET, but it was present", attr) - } - } - for attr, check := range constraints { val := gotSpan.Attributes[attr] if err := check(val); err != nil { @@ -139,17 +133,7 @@ func assertCapturedSpan(t *testing.T, gotSpan *CapturedSpan, wantAttrs map[strin _, inConstraints := constraints[k] if !inWant && !inConstraints { - // Check if it was explicitly expected to be absent - isUnexpected := false - for _, u := range unexpectedAttrs { - if u == k { - isUnexpected = true - break - } - } - if !isUnexpected { - t.Errorf("unexpected attribute found: %q", k) - } + t.Errorf("unexpected attribute found: %q", k) } } } @@ -172,6 +156,13 @@ func checkPrefix(prefix string) func(any) error { } } +func checkAbsent(v any) error { + if v != nil { + return fmt.Errorf("expected attribute to be NOT SET, but it was present") + } + return nil +} + func addResourceConstraints(gotAttrs map[string]any, constraints map[string]func(any) error) { // Only enforce GCP resource attributes if we are actually running on GCP // and the detector found them. In non-GCP environments like standard CI @@ -288,7 +279,11 @@ func TestObservability_Tracing_Success(t *testing.T) { constraints["network.protocol.version"] = checkNonEmpty } - assertCapturedSpan(t, capturedSpan, wantAttrs, unexpectedAttrs, constraints) + for _, attr := range unexpectedAttrs { + constraints[attr] = checkAbsent + } + + assertCapturedSpan(t, capturedSpan, wantAttrs, constraints) }) } } @@ -377,7 +372,11 @@ func TestObservability_Tracing_Failure(t *testing.T) { constraints["network.protocol.version"] = checkNonEmpty } - assertCapturedSpan(t, capturedSpan, wantAttrs, unexpectedAttrs, constraints) + for _, attr := range unexpectedAttrs { + constraints[attr] = checkAbsent + } + + assertCapturedSpan(t, capturedSpan, wantAttrs, constraints) }) } } @@ -467,7 +466,11 @@ func TestObservability_Tracing_ClientFailure(t *testing.T) { constraints["rpc.response.status_code"] = checkNonEmpty } - assertCapturedSpan(t, capturedSpan, wantAttrs, unexpectedAttrs, constraints) + for _, attr := range unexpectedAttrs { + constraints[attr] = checkAbsent + } + + assertCapturedSpan(t, capturedSpan, wantAttrs, constraints) }) } }