diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5afe091 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Output of the go coverage tool +cmd-cover.out + +# Vim +*.swp +*.swo + +# GoLand IDE and VSCode +.idea +.vscode diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..51886c3 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +.DEFAULT_GOAL := help + +.PHONY: help +help: Makefile ## Display this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "; printf "Usage:\n\n make \033[36m\033[0m [VARIABLE=value...]\n\nTargets:\n\n"}; {printf " \033[36m%-30s\033[0m %s\n", $$1, $$2}' + @grep -E '^(override )?[a-zA-Z_-]+ \??\+?= .*?## .*$$' $< | sort | awk 'BEGIN {FS = " \\??\\+?= .*?## "; printf "\nVariables:\n\n"}; {gsub(/override /, "", $$1); printf " \033[36m%-30s\033[0m %s\n", $$1, $$2}' + +.PHONY: unit-test +unit-test: + go test ./... -race -coverprofile cmd-cover.out + +.PHONY: clean-go-cache +clean-go-cache: ## Clean go cache + @go clean -modcache + +.PHONY: deps +deps: ## Add missing and remove unused modules, verify deps and download them to local cache + @go mod tidy && go mod verify && go mod download + +.PHONY: fmt +fmt: ## Run go fmt against code + go fmt ./... + +.PHONY: vet +vet: ## Run go vet against code + go vet ./... + +.PHONY: lint +lint: ## Run golangci-lint against code + docker run --pull always --rm -v $(shell pwd):/telemetry-exporter -w /telemetry-exporter -v $(shell go env GOCACHE):/cache/go -e GOCACHE=/cache/go -e GOLANGCI_LINT_CACHE=/cache/go -v $(shell go env GOPATH)/pkg:/go/pkg golangci/golangci-lint:latest golangci-lint --color always run + +.PHONY: dev-all +dev-all: deps fmt vet lint unit-test ## Run all the development checks + +.PHONY: generate +generate: ## Run go generate + go generate ./... diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d3415f2 --- /dev/null +++ b/go.mod @@ -0,0 +1,37 @@ +module github.com/nginxinc/telemetry-exporter + +go 1.22.0 + +require ( + github.com/go-logr/logr v1.4.1 + github.com/maxbrunsfeld/counterfeiter/v6 v6.8.1 + github.com/onsi/ginkgo/v2 v2.15.0 + github.com/onsi/gomega v1.31.1 + go.opentelemetry.io/otel v1.23.1 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.23.1 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.23.1 + go.opentelemetry.io/otel/sdk v1.23.1 +) + +require ( + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect + go.opentelemetry.io/otel/metric v1.23.1 // indirect + go.opentelemetry.io/otel/trace v1.23.1 // indirect + go.opentelemetry.io/proto/otlp v1.1.0 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/net v0.20.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.17.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 // indirect + google.golang.org/grpc v1.61.0 // indirect + google.golang.org/protobuf v1.32.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..96d5079 --- /dev/null +++ b/go.sum @@ -0,0 +1,94 @@ +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/maxbrunsfeld/counterfeiter/v6 v6.8.1 h1:NicmruxkeqHjDv03SfSxqmaLuisddudfP3h5wdXFbhM= +github.com/maxbrunsfeld/counterfeiter/v6 v6.8.1/go.mod h1:eyp4DdUJAKkr9tvxR3jWhw2mDK7CWABMG5r9uyaKC7I= +github.com/onsi/ginkgo/v2 v2.15.0 h1:79HwNRBAZHOEwrczrgSOPy+eFTTlIGELKy5as+ClttY= +github.com/onsi/ginkgo/v2 v2.15.0/go.mod h1:HlxMHtYF57y6Dpf+mc5529KKmSq9h2FpCF+/ZkwUxKM= +github.com/onsi/gomega v1.31.1 h1:KYppCUK+bUgAZwHOu7EXVBKyQA6ILvOESHkn/tgoqvo= +github.com/onsi/gomega v1.31.1/go.mod h1:y40C95dwAD1Nz36SsEnxvfFe8FFfNxzI5eJ0EYGyAy0= +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/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8= +github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.opentelemetry.io/otel v1.23.1 h1:Za4UzOqJYS+MUczKI320AtqZHZb7EqxO00jAHE0jmQY= +go.opentelemetry.io/otel v1.23.1/go.mod h1:Td0134eafDLcTS4y+zQ26GE8u3dEuRBiBCTUIRHaikA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.23.1 h1:o8iWeVFa1BcLtVEV0LzrCxV2/55tB3xLxADr6Kyoey4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.23.1/go.mod h1:SEVfdK4IoBnbT2FXNM/k8yC08MrfbhWk3U4ljM8B3HE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.23.1 h1:p3A5+f5l9e/kuEBwLOrnpkIDHQFlHmbiVxMURWRK6gQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.23.1/go.mod h1:OClrnXUjBqQbInvjJFjYSnMxBSCXBF8r3b34WqjiIrQ= +go.opentelemetry.io/otel/metric v1.23.1 h1:PQJmqJ9u2QaJLBOELl1cxIdPcpbwzbkjfEyelTl2rlo= +go.opentelemetry.io/otel/metric v1.23.1/go.mod h1:mpG2QPlAfnK8yNhNJAxDZruU9Y1/HubbC+KyH8FaCWI= +go.opentelemetry.io/otel/sdk v1.23.1 h1:O7JmZw0h76if63LQdsBMKQDWNb5oEcOThG9IrxscV+E= +go.opentelemetry.io/otel/sdk v1.23.1/go.mod h1:LzdEVR5am1uKOOwfBWFef2DCi1nu3SA8XQxx2IerWFk= +go.opentelemetry.io/otel/trace v1.23.1 h1:4LrmmEd8AU2rFvU1zegmvqW7+kWarxtNOPyeL6HmYY8= +go.opentelemetry.io/otel/trace v1.23.1/go.mod h1:4IpnpJFwr1mo/6HL8XIPJaE9y0+u1KcVmuW7dwFSVrI= +go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= +go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20231212172506-995d672761c0 h1:YJ5pD9rF8o9Qtta0Cmy9rdBwkSjrTCT6XTiUQVOtIos= +google.golang.org/genproto v0.0.0-20231212172506-995d672761c0/go.mod h1:l/k7rMz0vFTBPy+tFSGvXEd3z+BcoG1k7EHbqm+YBsY= +google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 h1:rcS6EyEaoCO52hQDupoSfrxI3R6C2Tq741is7X8OvnM= +google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917/go.mod h1:CmlNWB9lSezaYELKS5Ym1r44VrrbPUa7JTvw+6MbpJ0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 h1:6G8oQ016D88m1xAKljMlBOOGWDZkes4kMhgGFlf8WcQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917/go.mod h1:xtjpI3tXFPP051KaWnhvxkiubL/6dJ18vLVf7q2pTOU= +google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0= +google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/telemetry/error_handler.go b/pkg/telemetry/error_handler.go new file mode 100644 index 0000000..7849408 --- /dev/null +++ b/pkg/telemetry/error_handler.go @@ -0,0 +1,47 @@ +package telemetry + +import ( + "sync" + + "go.opentelemetry.io/otel" +) + +// ErrorHandler capture errors generated by the OpenTelemetry SDK. +// If multiple errors occur, the last error is captured. +type ErrorHandler struct { + lastError error + lock *sync.Mutex +} + +var _ otel.ErrorHandler = &ErrorHandler{} + +// NewErrorHandler creates a new ErrorHandler. +func NewErrorHandler() *ErrorHandler { + return &ErrorHandler{ + lock: &sync.Mutex{}, + } +} + +// Handle captures the error. +func (e *ErrorHandler) Handle(err error) { + e.lock.Lock() + defer e.lock.Unlock() + + e.lastError = err +} + +// Error returns the last error captured. +func (e *ErrorHandler) Error() error { + e.lock.Lock() + defer e.lock.Unlock() + + return e.lastError +} + +// Clear clears the error. +func (e *ErrorHandler) Clear() { + e.lock.Lock() + defer e.lock.Unlock() + + e.lastError = nil +} diff --git a/pkg/telemetry/error_handler_test.go b/pkg/telemetry/error_handler_test.go new file mode 100644 index 0000000..12cb637 --- /dev/null +++ b/pkg/telemetry/error_handler_test.go @@ -0,0 +1,30 @@ +package telemetry + +import ( + "errors" + "testing" + + . "github.com/onsi/gomega" +) + +func TestErrorHandler(t *testing.T) { + g := NewWithT(t) + + testErr1 := errors.New("test error 1") + testErr2 := errors.New("test error 2") + + handler := NewErrorHandler() + g.Expect(handler.Error()).ToNot(HaveOccurred()) + + handler.Clear() + g.Expect(handler.Error()).ToNot(HaveOccurred()) + + handler.Handle(testErr1) + g.Expect(handler.Error()).To(Equal(testErr1)) + + handler.Handle(testErr2) + g.Expect(handler.Error()).To(Equal(testErr2)) + + handler.Clear() + g.Expect(handler.Error()).ToNot(HaveOccurred()) +} diff --git a/pkg/telemetry/exporter.go b/pkg/telemetry/exporter.go new file mode 100644 index 0000000..44dad43 --- /dev/null +++ b/pkg/telemetry/exporter.go @@ -0,0 +1,159 @@ +package telemetry + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +// Exportable allows exporting telemetry data using the Exporter. +type Exportable interface { + // Attributes returns a list of key-value pairs that represent the telemetry data. + Attributes() []attribute.KeyValue +} + +// ExporterConfig contains the configuration for the Exporter. +type ExporterConfig struct { + // SpanProvider contains SpanProvider for exporting spans. + SpanProvider SpanProvider +} + +//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 . SpanExporter + +// SpanExporter is used to generate a fake for the unit test. +type SpanExporter interface { + sdktrace.SpanExporter +} + +// Exporter exports telemetry data. +type Exporter struct { + spanProvider SpanProvider + traceProvider *sdktrace.TracerProvider + handler *ErrorHandler +} + +type optionsCfg struct { + errorHandler *ErrorHandler + logger logr.Logger +} + +// Option is a configuration option for the Exporter. +type Option func(*optionsCfg) + +// WithGlobalOTelErrorHandler sets the global OpenTelemetry error handler. +// +// Note that the error handler captures all errors generated by the OpenTelemetry SDK. +// The Exporter uses it to catch errors that occur during exporting. +// If this option is not used, the Exporter will not be able to catch errors that occur during the export process. +// +// Warning: This option changes the global OpenTelemetry state. If OpenTelemetry is used in other parts of +// your application, the error handler will catch errors from those parts as well. As a result, the Exporter might +// return errors when exporting telemetry data, even if no error occurred. +func WithGlobalOTelErrorHandler(errorHandler *ErrorHandler) Option { + return func(o *optionsCfg) { + o.errorHandler = errorHandler + } +} + +// WithGlobalOTelLogger sets the global OpenTelemetry logger. +// The logger is used by the OpenTelemetry SDK to log messages. +// +// Warning: This option changes the global OpenTelemetry state. If OpenTelemetry is used in other parts of your +// application, the logger will be used for those parts as well. +func WithGlobalOTelLogger(logger logr.Logger) Option { + return func(o *optionsCfg) { + o.logger = logger + } +} + +// NewExporter creates a new Exporter. +func NewExporter(cfg ExporterConfig, options ...Option) (*Exporter, error) { + var optCfg optionsCfg + for _, opt := range options { + opt(&optCfg) + } + + if optCfg.errorHandler != nil { + otel.SetErrorHandler(optCfg.errorHandler) + } + if (optCfg.logger != logr.Logger{}) { + otel.SetLogger(optCfg.logger) + } + + res, err := resource.Merge( + resource.Default(), + resource.NewSchemaless(), + ) + if err != nil { + return nil, fmt.Errorf("failed to create an OTel resource: %w", err) + } + + tracerProvider := sdktrace.NewTracerProvider( + sdktrace.WithResource(res), + ) + + return &Exporter{ + spanProvider: cfg.SpanProvider, + traceProvider: tracerProvider, + handler: optCfg.errorHandler, + }, nil +} + +// Export exports telemetry data. +func (e *Exporter) Export(ctx context.Context, exportable Exportable) error { + spanExporter, err := e.spanProvider(ctx) + if err != nil { + return fmt.Errorf("failed to create span exporter: %w", err) + } + + // We create a new span processor for each export to ensure the Exporter doesn't keep a GRPC connection to + // the OTLP endpoint in between exports. + + // We create a SpanProcessor that synchronously exports spans to the OTLP endpoint. + // As mentioned in the NewSimpleSpanProcessor doc, it is not recommended to use this in production, + // because it is synchronous. However, in our case, we only send one span and we want to catch errors during + // sending, so synchronous is good for us. + spanProcessor := sdktrace.NewSimpleSpanProcessor(spanExporter) + defer func() { + // This error is ignored because it happens after the span has been exported, so it is not useful. + _ = spanProcessor.Shutdown(ctx) + }() + + e.traceProvider.RegisterSpanProcessor(spanProcessor) + defer e.traceProvider.UnregisterSpanProcessor(spanProcessor) + + if e.handler != nil { + e.handler.Clear() + } + + tracer := e.traceProvider.Tracer("product-telemetry") + + _, span := tracer.Start(ctx, "report") + + span.SetAttributes(exportable.Attributes()...) + + // Because we use a synchronous span processor, the span is exported immediately and synchronously. + // Any error will be caught by the error handler. + span.End() + + if e.handler != nil { + if handlerErr := e.handler.Error(); handlerErr != nil { + return fmt.Errorf("failed to export telemetry: %w", handlerErr) + } + } + + return nil +} + +// Shutdown shuts down the Exporter. +func (e *Exporter) Shutdown(ctx context.Context) error { + if err := e.traceProvider.Shutdown(ctx); err != nil { + return fmt.Errorf("failed to shutdown OTel trace provider: %w", err) + } + return nil +} diff --git a/pkg/telemetry/exporter_test.go b/pkg/telemetry/exporter_test.go new file mode 100644 index 0000000..7b5514f --- /dev/null +++ b/pkg/telemetry/exporter_test.go @@ -0,0 +1,121 @@ +package telemetry_test + +import ( + "context" + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.opentelemetry.io/otel/attribute" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + + "github.com/nginxinc/telemetry-exporter/pkg/telemetry" + "github.com/nginxinc/telemetry-exporter/pkg/telemetry/telemetryfakes" +) + +type exportableData struct { + attributes []attribute.KeyValue +} + +func (d exportableData) Attributes() []attribute.KeyValue { + return d.attributes +} + +var _ = Describe("Exporter", func() { + When("SpanProvider works correctly", func() { + var ( + fakeSpanExporter *telemetryfakes.FakeSpanExporter + exporter *telemetry.Exporter + data exportableData + ) + + BeforeEach(func() { + fakeSpanExporter = &telemetryfakes.FakeSpanExporter{} + provideSpanExporter := func(_ context.Context) (sdktrace.SpanExporter, error) { + return fakeSpanExporter, nil + } + + errorHandler := telemetry.NewErrorHandler() + + var err error + exporter, err = telemetry.NewExporter( + telemetry.ExporterConfig{ + SpanProvider: provideSpanExporter, + }, + telemetry.WithGlobalOTelErrorHandler(errorHandler), + ) + + Expect(err).ToNot(HaveOccurred()) + + data = exportableData{ + attributes: []attribute.KeyValue{ + attribute.String("key", "value"), + }, + } + }) + + When("no errors occur", func() { + It("exports data successfully", func() { + Expect(exporter.Export(context.Background(), data)).To(Succeed()) + + Expect(fakeSpanExporter.ExportSpansCallCount()).To(Equal(1)) + + _, res := fakeSpanExporter.ExportSpansArgsForCall(0) + + Expect(res).To(HaveLen(1)) + Expect(res[0].Attributes()).To(Equal(data.attributes)) + + Expect(fakeSpanExporter.ShutdownCallCount()).To(Equal(1)) + }) + }) + + When("SpanExporter returns an error", func() { + It("fails to export data", func() { + testError := errors.New("test error") + + fakeSpanExporter.ExportSpansReturns(testError) + + err := exporter.Export(context.Background(), data) + + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(testError)) + Expect(fakeSpanExporter.ShutdownCallCount()).To(Equal(1)) + }) + }) + + AfterEach(func() { + err := exporter.Shutdown(context.Background()) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + When("SpanProvider returns an error", func() { + It("fails to export data", func() { + testError := errors.New("test error") + provideSpanExporter := func(_ context.Context) (sdktrace.SpanExporter, error) { + return nil, testError + } + + exporter, err := telemetry.NewExporter( + telemetry.ExporterConfig{ + SpanProvider: provideSpanExporter, + }, + ) + Expect(err).ToNot(HaveOccurred()) + + data := exportableData{ + attributes: []attribute.KeyValue{ + attribute.String("key", "value"), + }, + } + + err = exporter.Export(context.Background(), data) + + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(testError)) + + err = exporter.Shutdown(context.Background()) + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/pkg/telemetry/span_provider.go b/pkg/telemetry/span_provider.go new file mode 100644 index 0000000..4a44b96 --- /dev/null +++ b/pkg/telemetry/span_provider.go @@ -0,0 +1,27 @@ +package telemetry + +import ( + "context" + + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +// SpanProvider provides a span exporter. +type SpanProvider func(ctx context.Context) (sdktrace.SpanExporter, error) + +// CreateOTLPSpanProvider creates a new gRPC OTLP span provider. +// The options allow you to configure the remote endpoint and tune the behavior of the exporter. +// See https://pkg.go.dev/go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc#Option for details. +func CreateOTLPSpanProvider(options ...otlptracegrpc.Option) SpanProvider { + return func(ctx context.Context) (sdktrace.SpanExporter, error) { + return newOTLPExporter(ctx, options...) + } +} + +// newOTLPExporter creates a new gRPC OTLP exporter. +func newOTLPExporter(ctx context.Context, options ...otlptracegrpc.Option) (*otlptrace.Exporter, error) { + traceClient := otlptracegrpc.NewClient(options...) + return otlptrace.New(ctx, traceClient) +} diff --git a/pkg/telemetry/telemetry_suite_test.go b/pkg/telemetry/telemetry_suite_test.go new file mode 100644 index 0000000..04c897a --- /dev/null +++ b/pkg/telemetry/telemetry_suite_test.go @@ -0,0 +1,13 @@ +package telemetry_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTelemetry(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Telemetry Suite") +} diff --git a/pkg/telemetry/telemetryfakes/fake_span_exporter.go b/pkg/telemetry/telemetryfakes/fake_span_exporter.go new file mode 100644 index 0000000..0fd25c7 --- /dev/null +++ b/pkg/telemetry/telemetryfakes/fake_span_exporter.go @@ -0,0 +1,194 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package telemetryfakes + +import ( + "context" + "sync" + + "github.com/nginxinc/telemetry-exporter/pkg/telemetry" + "go.opentelemetry.io/otel/sdk/trace" +) + +type FakeSpanExporter struct { + ExportSpansStub func(context.Context, []trace.ReadOnlySpan) error + exportSpansMutex sync.RWMutex + exportSpansArgsForCall []struct { + arg1 context.Context + arg2 []trace.ReadOnlySpan + } + exportSpansReturns struct { + result1 error + } + exportSpansReturnsOnCall map[int]struct { + result1 error + } + ShutdownStub func(context.Context) error + shutdownMutex sync.RWMutex + shutdownArgsForCall []struct { + arg1 context.Context + } + shutdownReturns struct { + result1 error + } + shutdownReturnsOnCall map[int]struct { + result1 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakeSpanExporter) ExportSpans(arg1 context.Context, arg2 []trace.ReadOnlySpan) error { + var arg2Copy []trace.ReadOnlySpan + if arg2 != nil { + arg2Copy = make([]trace.ReadOnlySpan, len(arg2)) + copy(arg2Copy, arg2) + } + fake.exportSpansMutex.Lock() + ret, specificReturn := fake.exportSpansReturnsOnCall[len(fake.exportSpansArgsForCall)] + fake.exportSpansArgsForCall = append(fake.exportSpansArgsForCall, struct { + arg1 context.Context + arg2 []trace.ReadOnlySpan + }{arg1, arg2Copy}) + stub := fake.ExportSpansStub + fakeReturns := fake.exportSpansReturns + fake.recordInvocation("ExportSpans", []interface{}{arg1, arg2Copy}) + fake.exportSpansMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeSpanExporter) ExportSpansCallCount() int { + fake.exportSpansMutex.RLock() + defer fake.exportSpansMutex.RUnlock() + return len(fake.exportSpansArgsForCall) +} + +func (fake *FakeSpanExporter) ExportSpansCalls(stub func(context.Context, []trace.ReadOnlySpan) error) { + fake.exportSpansMutex.Lock() + defer fake.exportSpansMutex.Unlock() + fake.ExportSpansStub = stub +} + +func (fake *FakeSpanExporter) ExportSpansArgsForCall(i int) (context.Context, []trace.ReadOnlySpan) { + fake.exportSpansMutex.RLock() + defer fake.exportSpansMutex.RUnlock() + argsForCall := fake.exportSpansArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeSpanExporter) ExportSpansReturns(result1 error) { + fake.exportSpansMutex.Lock() + defer fake.exportSpansMutex.Unlock() + fake.ExportSpansStub = nil + fake.exportSpansReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeSpanExporter) ExportSpansReturnsOnCall(i int, result1 error) { + fake.exportSpansMutex.Lock() + defer fake.exportSpansMutex.Unlock() + fake.ExportSpansStub = nil + if fake.exportSpansReturnsOnCall == nil { + fake.exportSpansReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.exportSpansReturnsOnCall[i] = struct { + result1 error + }{result1} +} + +func (fake *FakeSpanExporter) Shutdown(arg1 context.Context) error { + fake.shutdownMutex.Lock() + ret, specificReturn := fake.shutdownReturnsOnCall[len(fake.shutdownArgsForCall)] + fake.shutdownArgsForCall = append(fake.shutdownArgsForCall, struct { + arg1 context.Context + }{arg1}) + stub := fake.ShutdownStub + fakeReturns := fake.shutdownReturns + fake.recordInvocation("Shutdown", []interface{}{arg1}) + fake.shutdownMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeSpanExporter) ShutdownCallCount() int { + fake.shutdownMutex.RLock() + defer fake.shutdownMutex.RUnlock() + return len(fake.shutdownArgsForCall) +} + +func (fake *FakeSpanExporter) ShutdownCalls(stub func(context.Context) error) { + fake.shutdownMutex.Lock() + defer fake.shutdownMutex.Unlock() + fake.ShutdownStub = stub +} + +func (fake *FakeSpanExporter) ShutdownArgsForCall(i int) context.Context { + fake.shutdownMutex.RLock() + defer fake.shutdownMutex.RUnlock() + argsForCall := fake.shutdownArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeSpanExporter) ShutdownReturns(result1 error) { + fake.shutdownMutex.Lock() + defer fake.shutdownMutex.Unlock() + fake.ShutdownStub = nil + fake.shutdownReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeSpanExporter) ShutdownReturnsOnCall(i int, result1 error) { + fake.shutdownMutex.Lock() + defer fake.shutdownMutex.Unlock() + fake.ShutdownStub = nil + if fake.shutdownReturnsOnCall == nil { + fake.shutdownReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.shutdownReturnsOnCall[i] = struct { + result1 error + }{result1} +} + +func (fake *FakeSpanExporter) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.exportSpansMutex.RLock() + defer fake.exportSpansMutex.RUnlock() + fake.shutdownMutex.RLock() + defer fake.shutdownMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakeSpanExporter) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ telemetry.SpanExporter = new(FakeSpanExporter) diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..97875e7 --- /dev/null +++ b/tools.go @@ -0,0 +1,11 @@ +//go:build tools +// +build tools + +// This file just exists to ensure we download the tools we need for building +// See https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module + +package tools + +import ( + _ "github.com/maxbrunsfeld/counterfeiter/v6" +)