From 2cfff3743d56c54b3c7bfb6b77a1f05bf6af0135 Mon Sep 17 00:00:00 2001 From: xueli Date: Mon, 23 Mar 2026 18:04:05 +0800 Subject: [PATCH 1/2] HYPERFLEET-789 - feat: add OpenTelemetry span exporter to adapter Add OTLP span exporter so traces are actually sent to a backend instead of being discarded. When OTEL_EXPORTER_OTLP_ENDPOINT is set, uses OTLP gRPC (default) or HTTP exporter. When no endpoint is configured, TracerProvider still generates trace IDs for log correlation. Spans are batched via BatchSpanProcessor for efficient export. --- cmd/adapter/main.go | 2 +- go.mod | 23 ++++++----- go.sum | 60 ++++++++++++++------------- pkg/otel/tracer.go | 90 ++++++++++++++++++++++++++++++++++++----- pkg/otel/tracer_test.go | 80 ++++++++++++++++++++++++++++++++++++ 5 files changed, 205 insertions(+), 50 deletions(-) create mode 100644 pkg/otel/tracer_test.go diff --git a/cmd/adapter/main.go b/cmd/adapter/main.go index beb542c..8933ac4 100644 --- a/cmd/adapter/main.go +++ b/cmd/adapter/main.go @@ -444,7 +444,7 @@ func runServe(flags *pflag.FlagSet) error { // Initialize OpenTelemetry sampleRatio := otel.GetTraceSampleRatio(log, ctx) - tp, err := otel.InitTracer(config.Adapter.Name, version.Version, sampleRatio) + tp, err := otel.InitTracer(log, config.Adapter.Name, version.Version, sampleRatio) if err != nil { errCtx := logger.WithErrorField(ctx, err) log.Errorf(errCtx, "Failed to initialize OpenTelemetry") diff --git a/go.mod b/go.mod index 3fd91fd..8838cf2 100644 --- a/go.mod +++ b/go.mod @@ -7,20 +7,24 @@ require ( github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/docker/go-connections v0.6.0 github.com/go-playground/validator/v10 v10.30.1 + github.com/go-viper/mapstructure/v2 v2.5.0 github.com/google/cel-go v0.26.1 github.com/mitchellh/copystructure v1.2.0 github.com/openshift-hyperfleet/hyperfleet-broker v1.1.0 github.com/openshift-online/maestro v0.0.0-20260202062555-48b47506a254 github.com/openshift-online/ocm-sdk-go v0.1.493 github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.40.0 - go.opentelemetry.io/otel v1.40.0 - go.opentelemetry.io/otel/sdk v1.40.0 - go.opentelemetry.io/otel/trace v1.40.0 + go.opentelemetry.io/otel v1.42.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 + go.opentelemetry.io/otel/sdk v1.42.0 + go.opentelemetry.io/otel/trace v1.42.0 golang.org/x/text v0.34.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.34.3 @@ -32,7 +36,7 @@ require ( ) require ( - cel.dev/expr v0.24.0 // indirect + cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.18.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect @@ -78,7 +82,6 @@ require ( github.com/go-openapi/swag v0.23.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/glog v1.2.5 // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -88,6 +91,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -118,7 +122,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/rabbitmq/amqp091-go v1.10.0 // indirect @@ -139,14 +142,16 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect - golang.org/x/net v0.50.0 // indirect + golang.org/x/net v0.51.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect @@ -156,7 +161,7 @@ require ( google.golang.org/genproto v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect - google.golang.org/grpc v1.78.0 // indirect + google.golang.org/grpc v1.79.2 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 5ae844c..051c687 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= @@ -49,8 +49,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= -github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= +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/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -83,12 +83,12 @@ github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRr github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= -github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= -github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= +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= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= @@ -181,8 +181,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1Znvmczt github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8 h1:NpbJl/eVbvrGE0MJ6X16X9SAifesl6Fwxg/YmCvubRI= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8/go.mod h1:mi7YA+gCzVem12exXy46ZespvGtX/lZmD/RLnQhVW7U= +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/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -348,20 +348,22 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +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/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc= +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/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +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/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -398,8 +400,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= @@ -462,8 +464,8 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/pkg/otel/tracer.go b/pkg/otel/tracer.go index 218c4c9..4724451 100644 --- a/pkg/otel/tracer.go +++ b/pkg/otel/tracer.go @@ -6,9 +6,12 @@ import ( "fmt" "os" "strconv" + "strings" "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/logger" "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" @@ -23,6 +26,15 @@ const ( // DefaultTraceSampleRatio is the default trace sampling ratio (10% of traces) // Can be overridden via TRACE_SAMPLE_RATIO env var DefaultTraceSampleRatio = 0.1 + + // envOtelExporterOtlpEndpoint is the standard OTel env var for the OTLP endpoint + envOtelExporterOtlpEndpoint = "OTEL_EXPORTER_OTLP_ENDPOINT" + + // envOtelExporterOtlpProtocol is the standard OTel env var for the OTLP protocol + envOtelExporterOtlpProtocol = "OTEL_EXPORTER_OTLP_PROTOCOL" + + // defaultOtlpProtocol is the default OTLP protocol when none is specified + defaultOtlpProtocol = "grpc" ) // GetTraceSampleRatio reads the trace sample ratio from TRACE_SAMPLE_RATIO env var. @@ -65,21 +77,67 @@ func GetTraceSampleRatio(log logger.Logger, ctx context.Context) float64 { return ratio } -// InitTracer initializes OpenTelemetry TracerProvider for generating trace_id and span_id. -// These IDs are used for: -// 1. Log correlation (via logger.WithOTelTraceContext) -// 2. HTTP request propagation (via W3C Trace Context headers) +// createExporter creates an OTLP SpanExporter when OTEL_EXPORTER_OTLP_ENDPOINT is set. +// Returns nil when no endpoint is configured (spans remain local-only for trace ID generation). +// The protocol defaults to gRPC, configurable via OTEL_EXPORTER_OTLP_PROTOCOL. +func createExporter(ctx context.Context, log logger.Logger) (sdktrace.SpanExporter, error) { + otlpEndpoint := os.Getenv(envOtelExporterOtlpEndpoint) + if otlpEndpoint == "" { + log.Infof(ctx, "No %s configured, traces will not be exported (trace IDs still generated for log correlation)", + envOtelExporterOtlpEndpoint) + return nil, nil + } + + protocol := os.Getenv(envOtelExporterOtlpProtocol) + var exporter sdktrace.SpanExporter + var err error + + switch strings.ToLower(protocol) { + case "http", "http/protobuf": + exporter, err = otlptracehttp.New(ctx) + case defaultOtlpProtocol, "": + protocol = defaultOtlpProtocol + exporter, err = otlptracegrpc.New(ctx) + default: + log.Warnf(ctx, "Unrecognized %s value %q, using default %s", + envOtelExporterOtlpProtocol, protocol, defaultOtlpProtocol) + protocol = defaultOtlpProtocol + exporter, err = otlptracegrpc.New(ctx) + } + if err != nil { + return nil, fmt.Errorf("failed to create OTLP exporter (protocol=%s): %w", protocol, err) + } + + log.Infof(ctx, "OTLP trace exporter configured: protocol=%s", protocol) + return exporter, nil +} + +// InitTracer initializes OpenTelemetry TracerProvider with optional span export. +// +// When OTEL_EXPORTER_OTLP_ENDPOINT is set, spans are batched and exported via OTLP +// (gRPC by default, or HTTP via OTEL_EXPORTER_OTLP_PROTOCOL). +// When no endpoint is configured, the TracerProvider still generates trace IDs and span IDs +// for log correlation and W3C context propagation, but spans are not exported. // // The sampler uses ParentBased(TraceIDRatioBased(sampleRatio)) which: -// - Respects the parent span's sampling decision when present (from traceparent header) -// - Applies probabilistic sampling for root spans based on sampleRatio -// This allows distributed tracing visibility while controlling observability costs. -func InitTracer(serviceName, serviceVersion string, sampleRatio float64) (*sdktrace.TracerProvider, error) { +// - Respects the parent span's sampling decision when present (from traceparent header) +// - Applies probabilistic sampling for root spans based on sampleRatio +func InitTracer( + log logger.Logger, serviceName, serviceVersion string, sampleRatio float64, +) (*sdktrace.TracerProvider, error) { + ctx := context.Background() + + // Create exporter (nil when no OTLP endpoint configured) + exporter, err := createExporter(ctx, log) + if err != nil { + return nil, fmt.Errorf("failed to create trace exporter: %w", err) + } + // Create resource with service attributes. // Note: We don't merge with resource.Default() to avoid schema URL conflicts // between the SDK's bundled semconv version and our imported version. res, err := resource.New( - context.Background(), + ctx, resource.WithAttributes( semconv.ServiceName(serviceName), semconv.ServiceVersion(serviceVersion), @@ -89,6 +147,11 @@ func InitTracer(serviceName, serviceVersion string, sampleRatio float64) (*sdktr resource.WithHost(), ) if err != nil { + if exporter != nil { + if shutdownErr := exporter.Shutdown(ctx); shutdownErr != nil { + log.Warnf(ctx, "Failed to shutdown exporter during cleanup: %v", shutdownErr) + } + } return nil, fmt.Errorf("failed to create resource: %w", err) } @@ -98,10 +161,15 @@ func InitTracer(serviceName, serviceVersion string, sampleRatio float64) (*sdktr // This enables proper sampling propagation across service boundaries sampler := sdktrace.ParentBased(sdktrace.TraceIDRatioBased(sampleRatio)) - tp := sdktrace.NewTracerProvider( + opts := []sdktrace.TracerProviderOption{ sdktrace.WithResource(res), sdktrace.WithSampler(sampler), - ) + } + if exporter != nil { + opts = append(opts, sdktrace.WithBatcher(exporter)) + } + + tp := sdktrace.NewTracerProvider(opts...) otel.SetTracerProvider(tp) // TraceContext propagator handles W3C traceparent/tracestate headers // ensuring sampling decisions propagate through message headers diff --git a/pkg/otel/tracer_test.go b/pkg/otel/tracer_test.go new file mode 100644 index 0000000..afb8988 --- /dev/null +++ b/pkg/otel/tracer_test.go @@ -0,0 +1,80 @@ +package otel + +import ( + "context" + "testing" + + "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testLogger() logger.Logger { + log, _ := logger.NewLogger(logger.Config{Level: "error", Output: "stdout", Format: "json"}) + return log +} + +func TestGetTraceSampleRatio(t *testing.T) { + log := testLogger() + ctx := context.Background() + + t.Run("default when not set", func(t *testing.T) { + t.Setenv(EnvTraceSampleRatio, "") + ratio := GetTraceSampleRatio(log, ctx) + assert.Equal(t, DefaultTraceSampleRatio, ratio) + }) + + t.Run("valid ratio", func(t *testing.T) { + t.Setenv(EnvTraceSampleRatio, "0.5") + ratio := GetTraceSampleRatio(log, ctx) + assert.Equal(t, 0.5, ratio) + }) + + t.Run("invalid string", func(t *testing.T) { + t.Setenv(EnvTraceSampleRatio, "notanumber") + ratio := GetTraceSampleRatio(log, ctx) + assert.Equal(t, DefaultTraceSampleRatio, ratio) + }) + + t.Run("out of range", func(t *testing.T) { + t.Setenv(EnvTraceSampleRatio, "2.0") + ratio := GetTraceSampleRatio(log, ctx) + assert.Equal(t, DefaultTraceSampleRatio, ratio) + }) + + t.Run("zero is valid", func(t *testing.T) { + t.Setenv(EnvTraceSampleRatio, "0.0") + ratio := GetTraceSampleRatio(log, ctx) + assert.Equal(t, 0.0, ratio) + }) + + t.Run("one is valid", func(t *testing.T) { + t.Setenv(EnvTraceSampleRatio, "1.0") + ratio := GetTraceSampleRatio(log, ctx) + assert.Equal(t, 1.0, ratio) + }) +} + +func TestCreateExporter(t *testing.T) { + log := testLogger() + ctx := context.Background() + + t.Run("nil exporter when no endpoint set", func(t *testing.T) { + t.Setenv(envOtelExporterOtlpEndpoint, "") + exporter, err := createExporter(ctx, log) + require.NoError(t, err) + assert.Nil(t, exporter) + }) +} + +func TestInitTracer(t *testing.T) { + log := testLogger() + + t.Run("initializes without exporter when no endpoint", func(t *testing.T) { + t.Setenv(envOtelExporterOtlpEndpoint, "") + tp, err := InitTracer(log, "test-service", "0.0.1", 1.0) + require.NoError(t, err) + require.NotNil(t, tp) + assert.NoError(t, tp.Shutdown(context.Background())) + }) +} From d86197ef6935fe2a3590c461bb1617bc98c669ff Mon Sep 17 00:00:00 2001 From: xueli Date: Mon, 23 Mar 2026 19:25:42 +0800 Subject: [PATCH 2/2] HYPERFLEET-789 - feat: support signal-specific OTLP env vars and add exporter tests Prefer OTEL_EXPORTER_OTLP_TRACES_ENDPOINT and OTEL_EXPORTER_OTLP_TRACES_PROTOCOL over generic variants per OTel spec. Add tests for exporter protocol selection, fallback behavior, and negative sample ratio. --- pkg/otel/tracer.go | 48 ++++++++++++++------- pkg/otel/tracer_test.go | 93 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 124 insertions(+), 17 deletions(-) diff --git a/pkg/otel/tracer.go b/pkg/otel/tracer.go index 4724451..171915b 100644 --- a/pkg/otel/tracer.go +++ b/pkg/otel/tracer.go @@ -30,11 +30,19 @@ const ( // envOtelExporterOtlpEndpoint is the standard OTel env var for the OTLP endpoint envOtelExporterOtlpEndpoint = "OTEL_EXPORTER_OTLP_ENDPOINT" + // envOtelExporterOtlpTracesEndpoint is the signal-specific OTel env var for the traces endpoint + envOtelExporterOtlpTracesEndpoint = "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT" + // envOtelExporterOtlpProtocol is the standard OTel env var for the OTLP protocol envOtelExporterOtlpProtocol = "OTEL_EXPORTER_OTLP_PROTOCOL" - // defaultOtlpProtocol is the default OTLP protocol when none is specified - defaultOtlpProtocol = "grpc" + // envOtelExporterOtlpTracesProtocol is the signal-specific OTel env var for the traces protocol + envOtelExporterOtlpTracesProtocol = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL" + + // defaultOtlpProtocol is the default OTLP protocol when none is specified. + // Per OTel spec, the default SHOULD be "http/protobuf". + // See: https://opentelemetry.io/docs/specs/otel/protocol/exporter/ + defaultOtlpProtocol = "http/protobuf" ) // GetTraceSampleRatio reads the trace sample ratio from TRACE_SAMPLE_RATIO env var. @@ -79,30 +87,42 @@ func GetTraceSampleRatio(log logger.Logger, ctx context.Context) float64 { // createExporter creates an OTLP SpanExporter when OTEL_EXPORTER_OTLP_ENDPOINT is set. // Returns nil when no endpoint is configured (spans remain local-only for trace ID generation). -// The protocol defaults to gRPC, configurable via OTEL_EXPORTER_OTLP_PROTOCOL. +// The protocol defaults to http/protobuf (per OTel spec), configurable via OTEL_EXPORTER_OTLP_PROTOCOL. func createExporter(ctx context.Context, log logger.Logger) (sdktrace.SpanExporter, error) { - otlpEndpoint := os.Getenv(envOtelExporterOtlpEndpoint) + // Check if an OTLP endpoint is configured (presence check only). + // The actual endpoint value is read by the OTel SDK from env vars directly, + // so we don't pass otlpEndpoint to the exporter constructors. + otlpEndpoint := os.Getenv(envOtelExporterOtlpTracesEndpoint) + if otlpEndpoint == "" { + otlpEndpoint = os.Getenv(envOtelExporterOtlpEndpoint) + } if otlpEndpoint == "" { - log.Infof(ctx, "No %s configured, traces will not be exported (trace IDs still generated for log correlation)", - envOtelExporterOtlpEndpoint) + log.Infof(ctx, "No %s or %s configured, traces will not be exported"+ + " (trace IDs still generated for log correlation)", + envOtelExporterOtlpTracesEndpoint, envOtelExporterOtlpEndpoint) return nil, nil } - protocol := os.Getenv(envOtelExporterOtlpProtocol) + protocol := os.Getenv(envOtelExporterOtlpTracesProtocol) + protocolSource := envOtelExporterOtlpTracesProtocol + if protocol == "" { + protocol = os.Getenv(envOtelExporterOtlpProtocol) + protocolSource = envOtelExporterOtlpProtocol + } var exporter sdktrace.SpanExporter var err error switch strings.ToLower(protocol) { - case "http", "http/protobuf": - exporter, err = otlptracehttp.New(ctx) - case defaultOtlpProtocol, "": - protocol = defaultOtlpProtocol + case "grpc": exporter, err = otlptracegrpc.New(ctx) + case defaultOtlpProtocol, "http", "": // http/protobuf (default per OTel spec), http, or unset + protocol = defaultOtlpProtocol + exporter, err = otlptracehttp.New(ctx) default: log.Warnf(ctx, "Unrecognized %s value %q, using default %s", - envOtelExporterOtlpProtocol, protocol, defaultOtlpProtocol) + protocolSource, protocol, defaultOtlpProtocol) protocol = defaultOtlpProtocol - exporter, err = otlptracegrpc.New(ctx) + exporter, err = otlptracehttp.New(ctx) } if err != nil { return nil, fmt.Errorf("failed to create OTLP exporter (protocol=%s): %w", protocol, err) @@ -115,7 +135,7 @@ func createExporter(ctx context.Context, log logger.Logger) (sdktrace.SpanExport // InitTracer initializes OpenTelemetry TracerProvider with optional span export. // // When OTEL_EXPORTER_OTLP_ENDPOINT is set, spans are batched and exported via OTLP -// (gRPC by default, or HTTP via OTEL_EXPORTER_OTLP_PROTOCOL). +// (http/protobuf by default, or gRPC via OTEL_EXPORTER_OTLP_PROTOCOL). // When no endpoint is configured, the TracerProvider still generates trace IDs and span IDs // for log correlation and W3C context propagation, but spans are not exported. // diff --git a/pkg/otel/tracer_test.go b/pkg/otel/tracer_test.go index afb8988..0ee0fc1 100644 --- a/pkg/otel/tracer_test.go +++ b/pkg/otel/tracer_test.go @@ -36,12 +36,18 @@ func TestGetTraceSampleRatio(t *testing.T) { assert.Equal(t, DefaultTraceSampleRatio, ratio) }) - t.Run("out of range", func(t *testing.T) { + t.Run("out of range positive", func(t *testing.T) { t.Setenv(EnvTraceSampleRatio, "2.0") ratio := GetTraceSampleRatio(log, ctx) assert.Equal(t, DefaultTraceSampleRatio, ratio) }) + t.Run("out of range negative", func(t *testing.T) { + t.Setenv(EnvTraceSampleRatio, "-0.5") + ratio := GetTraceSampleRatio(log, ctx) + assert.Equal(t, DefaultTraceSampleRatio, ratio) + }) + t.Run("zero is valid", func(t *testing.T) { t.Setenv(EnvTraceSampleRatio, "0.0") ratio := GetTraceSampleRatio(log, ctx) @@ -59,8 +65,73 @@ func TestCreateExporter(t *testing.T) { log := testLogger() ctx := context.Background() - t.Run("nil exporter when no endpoint set", func(t *testing.T) { + // clearOtelEnv ensures all 4 OTel env vars are cleared to prevent + // interference from the local shell environment. + clearOtelEnv := func(t *testing.T) { t.Setenv(envOtelExporterOtlpEndpoint, "") + t.Setenv(envOtelExporterOtlpTracesEndpoint, "") + t.Setenv(envOtelExporterOtlpProtocol, "") + t.Setenv(envOtelExporterOtlpTracesProtocol, "") + } + + t.Run("nil exporter when no endpoint set", func(t *testing.T) { + clearOtelEnv(t) + exporter, err := createExporter(ctx, log) + require.NoError(t, err) + assert.Nil(t, exporter) + }) + + t.Run("http exporter when endpoint set with default protocol", func(t *testing.T) { + clearOtelEnv(t) + t.Setenv(envOtelExporterOtlpEndpoint, "http://localhost:4318") + exporter, err := createExporter(ctx, log) + require.NoError(t, err) + assert.NotNil(t, exporter) + assert.NoError(t, exporter.Shutdown(ctx)) + }) + + t.Run("grpc exporter when protocol is grpc", func(t *testing.T) { + clearOtelEnv(t) + t.Setenv(envOtelExporterOtlpEndpoint, "localhost:4317") + t.Setenv(envOtelExporterOtlpProtocol, "grpc") + exporter, err := createExporter(ctx, log) + require.NoError(t, err) + assert.NotNil(t, exporter) + assert.NoError(t, exporter.Shutdown(ctx)) + }) + + t.Run("falls back to http/protobuf for unrecognized protocol", func(t *testing.T) { + clearOtelEnv(t) + t.Setenv(envOtelExporterOtlpEndpoint, "http://localhost:4318") + t.Setenv(envOtelExporterOtlpProtocol, "unknown-protocol") + exporter, err := createExporter(ctx, log) + require.NoError(t, err) + assert.NotNil(t, exporter) + assert.NoError(t, exporter.Shutdown(ctx)) + }) + + t.Run("traces-specific endpoint takes precedence", func(t *testing.T) { + clearOtelEnv(t) + t.Setenv(envOtelExporterOtlpTracesEndpoint, "http://localhost:4318") + exporter, err := createExporter(ctx, log) + require.NoError(t, err) + assert.NotNil(t, exporter) + assert.NoError(t, exporter.Shutdown(ctx)) + }) + + t.Run("traces-specific protocol takes precedence", func(t *testing.T) { + clearOtelEnv(t) + t.Setenv(envOtelExporterOtlpEndpoint, "http://localhost:4318") + t.Setenv(envOtelExporterOtlpProtocol, "grpc") + t.Setenv(envOtelExporterOtlpTracesProtocol, "http/protobuf") + exporter, err := createExporter(ctx, log) + require.NoError(t, err) + assert.NotNil(t, exporter) + assert.NoError(t, exporter.Shutdown(ctx)) + }) + + t.Run("nil when neither endpoint is set", func(t *testing.T) { + clearOtelEnv(t) exporter, err := createExporter(ctx, log) require.NoError(t, err) assert.Nil(t, exporter) @@ -70,8 +141,24 @@ func TestCreateExporter(t *testing.T) { func TestInitTracer(t *testing.T) { log := testLogger() - t.Run("initializes without exporter when no endpoint", func(t *testing.T) { + clearOtelEnv := func(t *testing.T) { t.Setenv(envOtelExporterOtlpEndpoint, "") + t.Setenv(envOtelExporterOtlpTracesEndpoint, "") + t.Setenv(envOtelExporterOtlpProtocol, "") + t.Setenv(envOtelExporterOtlpTracesProtocol, "") + } + + t.Run("initializes without exporter when no endpoint", func(t *testing.T) { + clearOtelEnv(t) + tp, err := InitTracer(log, "test-service", "0.0.1", 1.0) + require.NoError(t, err) + require.NotNil(t, tp) + assert.NoError(t, tp.Shutdown(context.Background())) + }) + + t.Run("initializes with exporter when endpoint is set", func(t *testing.T) { + clearOtelEnv(t) + t.Setenv(envOtelExporterOtlpEndpoint, "http://localhost:4318") tp, err := InitTracer(log, "test-service", "0.0.1", 1.0) require.NoError(t, err) require.NotNil(t, tp)