From fbe6a3d5aff5140c776de403d2abe9ce8eb1275d Mon Sep 17 00:00:00 2001 From: Trevor Bramwell Date: Mon, 12 Jan 2026 09:20:45 -0800 Subject: [PATCH] Add OpenTelemetry tracing support Integrate slog-otel to automatically include trace_id and span_id from OpenTelemetry context in log output, enabling correlation between logs and distributed traces. Changes: - internal/log/log.go: Wrap JSON handler with slogotel.OtelHandler - pkg/utils/otel_test.go: Fix TestNewPropagator to pass OTelConfig - charts/: Bump version to 0.5.5 - go.mod: Add github.com/remychantenay/slog-otel v1.3.4 Issue: LFXV2-977 Co-Authored-By: Claude Opus 4.5 Signed-off-by: Trevor Bramwell --- charts/lfx-v2-project-service/Chart.yaml | 2 +- .../templates/deployment.yaml | 43 ++ charts/lfx-v2-project-service/values.yaml | 37 ++ cmd/project-api/main.go | 18 + go.mod | 32 +- go.sum | 80 +++- internal/log/log.go | 9 +- pkg/logging/context.go | 37 ++ pkg/utils/otel.go | 388 ++++++++++++++++++ pkg/utils/otel_test.go | 227 ++++++++++ 10 files changed, 867 insertions(+), 6 deletions(-) create mode 100644 pkg/logging/context.go create mode 100644 pkg/utils/otel.go create mode 100644 pkg/utils/otel_test.go diff --git a/charts/lfx-v2-project-service/Chart.yaml b/charts/lfx-v2-project-service/Chart.yaml index 2526b40..fdb5ddc 100644 --- a/charts/lfx-v2-project-service/Chart.yaml +++ b/charts/lfx-v2-project-service/Chart.yaml @@ -5,5 +5,5 @@ apiVersion: v2 name: lfx-v2-project-service description: LFX Platform V2 Project Service chart type: application -version: 0.5.4 +version: 0.5.5 appVersion: "latest" diff --git a/charts/lfx-v2-project-service/templates/deployment.yaml b/charts/lfx-v2-project-service/templates/deployment.yaml index b8e52d9..87735de 100644 --- a/charts/lfx-v2-project-service/templates/deployment.yaml +++ b/charts/lfx-v2-project-service/templates/deployment.yaml @@ -37,6 +37,49 @@ spec: value: {{ .Values.app.skipEtagValidation | quote }} - name: JWT_AUTH_DISABLED_MOCK_LOCAL_PRINCIPAL value: {{ .Values.app.jwtAuthDisabledMockLocalPrincipal }} + {{- with .Values.app.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if .Values.app.otel.serviceName }} + - name: OTEL_SERVICE_NAME + value: {{ .Values.app.otel.serviceName | quote }} + {{- end }} + {{- if .Values.app.otel.serviceVersion }} + - name: OTEL_SERVICE_VERSION + value: {{ .Values.app.otel.serviceVersion | quote }} + {{- end }} + {{- if .Values.app.otel.endpoint }} + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: {{ .Values.app.otel.endpoint | quote }} + {{- end }} + {{- if .Values.app.otel.protocol }} + - name: OTEL_EXPORTER_OTLP_PROTOCOL + value: {{ .Values.app.otel.protocol | quote }} + {{- end }} + {{- if .Values.app.otel.insecure }} + - name: OTEL_EXPORTER_OTLP_INSECURE + value: {{ .Values.app.otel.insecure | quote }} + {{- end }} + {{- if .Values.app.otel.tracesExporter }} + - name: OTEL_TRACES_EXPORTER + value: {{ .Values.app.otel.tracesExporter | quote }} + {{- end }} + {{- if .Values.app.otel.tracesSampleRatio }} + - name: OTEL_TRACES_SAMPLE_RATIO + value: {{ .Values.app.otel.tracesSampleRatio | quote }} + {{- end }} + {{- if .Values.app.otel.metricsExporter }} + - name: OTEL_METRICS_EXPORTER + value: {{ .Values.app.otel.metricsExporter | quote }} + {{- end }} + {{- if .Values.app.otel.logsExporter }} + - name: OTEL_LOGS_EXPORTER + value: {{ .Values.app.otel.logsExporter | quote }} + {{- end }} + {{- if .Values.app.otel.propagators }} + - name: OTEL_PROPAGATORS + value: {{ .Values.app.otel.propagators | quote }} + {{- end }} ports: - containerPort: {{ .Values.service.port }} name: web diff --git a/charts/lfx-v2-project-service/values.yaml b/charts/lfx-v2-project-service/values.yaml index f6ada19..11ba741 100644 --- a/charts/lfx-v2-project-service/values.yaml +++ b/charts/lfx-v2-project-service/values.yaml @@ -49,6 +49,43 @@ app: jwtAuthDisabledMockLocalPrincipal: "" # use_oidc_contextualizer is a boolean to determine if the OIDC contextualizer should be used use_oidc_contextualizer: true + # extraEnv is a list of additional environment variables to set in the container. + # Supports both simple key-value pairs and Kubernetes field references. + extraEnv: [] + # otel is the configuration for OpenTelemetry tracing + otel: + # serviceName is the service name for OpenTelemetry resource identification + # (default: "lfx-v2-project-service") + serviceName: "" + # serviceVersion is the service version for OpenTelemetry resource identification + # (default: "1.0.0") + serviceVersion: "" + # protocol specifies the OTLP protocol: "grpc" or "http" + # (default: "grpc") + protocol: "grpc" + # endpoint is the OTLP collector endpoint + # For gRPC: typically "host:4317", for HTTP: typically "host:4318" + endpoint: "" + # insecure disables TLS for the OTLP connection + # Set to "true" for in-cluster communication without TLS + insecure: "false" + # tracesExporter specifies the traces exporter: "otlp" or "none" + # (default: "none") + tracesExporter: "none" + # tracesSampleRatio specifies the sampling ratio for traces (0.0 to 1.0) + # A value of 1.0 means all traces are sampled, 0.5 means 50% are sampled + # (default: "1.0") + tracesSampleRatio: "1.0" + # metricsExporter specifies the metrics exporter: "otlp" or "none" + # (default: "none") + metricsExporter: "none" + # logsExporter specifies the logs exporter: "otlp" or "none" + # (default: "none") + logsExporter: "none" + # propagators specifies the propagators to use, comma-separated + # Supported values: "tracecontext", "baggage", "jaeger" + # (default: "tracecontext,baggage") + propagators: "tracecontext,baggage,jaeger" # traefik is the configuration for Traefik Gateway API routing traefik: diff --git a/cmd/project-api/main.go b/cmd/project-api/main.go index 6070088..2c609e8 100644 --- a/cmd/project-api/main.go +++ b/cmd/project-api/main.go @@ -19,6 +19,7 @@ import ( nats "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" goahttp "goa.design/goa/v3/http" genhttp "github.com/linuxfoundation/lfx-v2-project-service/api/project/v1/gen/http/project_service/server" @@ -29,6 +30,7 @@ import ( "github.com/linuxfoundation/lfx-v2-project-service/internal/middleware" "github.com/linuxfoundation/lfx-v2-project-service/internal/service" "github.com/linuxfoundation/lfx-v2-project-service/pkg/constants" + "github.com/linuxfoundation/lfx-v2-project-service/pkg/utils" ) const ( @@ -46,6 +48,20 @@ func main() { log.InitStructureLogConfig() + // Set up OpenTelemetry SDK. + ctx := context.Background() + otelConfig := utils.OTelConfigFromEnv() + otelShutdown, err := utils.SetupOTelSDKWithConfig(ctx, otelConfig) + if err != nil { + slog.With(errKey, err).Error("error setting up OpenTelemetry SDK") + os.Exit(1) + } + defer func() { + if shutdownErr := otelShutdown(context.Background()); shutdownErr != nil { + slog.With(errKey, shutdownErr).Error("error shutting down OpenTelemetry SDK") + } + }() + // Set up JWT validator needed by the [ProjectsService.JWTAuth] security handler. jwtAuthConfig := auth.JWTAuthConfig{ JWKSURL: os.Getenv("JWKS_URL"), @@ -200,6 +216,8 @@ func setupHTTPServer(flags flags, svc *ProjectsAPI, gracefulCloseWG *sync.WaitGr handler = middleware.RequestLoggerMiddleware()(handler) handler = middleware.RequestIDMiddleware()(handler) handler = middleware.AuthorizationMiddleware()(handler) + // Wrap the handler with OpenTelemetry instrumentation + handler = otelhttp.NewHandler(handler, "project-service") // Set up http listener in a goroutine using provided command line parameters. var addr string diff --git a/go.mod b/go.mod index 48c7727..dc8b02c 100644 --- a/go.mod +++ b/go.mod @@ -12,8 +12,23 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 github.com/google/uuid v1.6.0 github.com/nats-io/nats.go v1.47.0 + github.com/remychantenay/slog-otel v1.3.4 github.com/rustyoz/svg v0.0.0-20250705135709-8b1786137cb3 github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 + go.opentelemetry.io/contrib/propagators/jaeger v1.39.0 + go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 + go.opentelemetry.io/otel/log v0.15.0 + go.opentelemetry.io/otel/sdk v1.39.0 + go.opentelemetry.io/otel/sdk/log v0.15.0 + go.opentelemetry.io/otel/sdk/metric v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 goa.design/goa/v3 v3.22.6 golang.org/x/sync v0.18.0 ) @@ -36,11 +51,17 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.8 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 // indirect github.com/aws/smithy-go v1.23.2 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-chi/chi/v5 v5.2.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/gohugoio/hashstructure v0.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/klauspost/compress v1.18.1 // indirect github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d // indirect github.com/nats-io/nkeys v0.4.11 // indirect @@ -49,11 +70,20 @@ require ( github.com/rustyoz/Mtransform v0.0.0-20250628105438-00796a985d0a // indirect github.com/rustyoz/genericlexer v0.0.0-20250522144106-d3cfee480384 // indirect github.com/stretchr/objx v0.5.3 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/mod v0.30.0 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/tools v0.39.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.77.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9e5fe53..210ee0d 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 h1:GdGmKtG+/Krag7VfyOXV17xjTCz0 github.com/aws/aws-sdk-go-v2/service/sts v1.41.1/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso= github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +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/cheekybits/is v0.0.0-20150225183255-68e9c0620927 h1:SKI1/fuSdodxmNNyVBR8d7X/HuLnRpvvFO0AgyQk764= @@ -48,20 +50,35 @@ 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/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598 h1:MGKhKyiYrvMDZsmLR/+RGffQSXwEkXgfLSA08qDn9AI= github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598/go.mod h1:0FpDmbrt36utu8jEmeU05dPC9AB5tsLYVVi+ZHfyuwI= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/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-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +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/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d h1:Zj+PHjnhRYWBK6RqCDBcAhLXoi3TzC27Zad/Vn+gnVQ= github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d/go.mod h1:WZy8Q5coAB1zhY9AOBJP0O6J4BuDfbupUDavKY+I3+s= github.com/manveru/gobdd v0.0.0-20131210092515-f1a17fdd710b h1:3E44bLeN8uKYdfQqVQycPnaVviZdBLbizFhU49mtbe4= @@ -74,6 +91,10 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= 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/remychantenay/slog-otel v1.3.4 h1:xoM41ayLff2U8zlK5PH31XwD7Lk3W9wKfl4+RcmKom4= +github.com/remychantenay/slog-otel v1.3.4/go.mod h1:ZkazuFMICKGDrO0r1njxKRdjTt/YcXKn6v2+0q/b0+U= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rustyoz/Mtransform v0.0.0-20250628105438-00796a985d0a h1:iqc6IJquka4XBgVSqP+KaNe4nPk7n+pfTbpTx51IgJo= github.com/rustyoz/Mtransform v0.0.0-20250628105438-00796a985d0a/go.mod h1:/OCzi5mN2hO/GaVureeTqn0EJbOnZUdBF8zHxWvFlt8= github.com/rustyoz/genericlexer v0.0.0-20250522144106-d3cfee480384 h1:jrCaAewj72Bp+RHPzRB+CFOLRwCiluzaiwY0R1h0SNw= @@ -84,22 +105,75 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= 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/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/propagators/jaeger v1.39.0 h1:Gz3yKzfMSEFzF0Vy5eIpu9ndpo4DhXMCxsLMF0OOApo= +go.opentelemetry.io/contrib/propagators/jaeger v1.39.0/go.mod h1:2D/cxxCqTlrday0rZrPujjg5aoAdqk1NaNyoXn8FJn8= +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/exporters/otlp/otlplog/otlploggrpc v0.15.0 h1:W+m0g+/6v3pa5PgVf2xoFMi5YtNR06WtS7ve5pcvLtM= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0/go.mod h1:JM31r0GGZ/GU94mX8hN4D8v6e40aFlUECSQ48HaLgHM= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0 h1:EKpiGphOYq3CYnIe2eX9ftUkyU+Y8Dtte8OaWyHJ4+I= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0/go.mod h1:nWFP7C+T8TygkTjJ7mAyEaFaE7wNfms3nV/vexZ6qt0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0/go.mod h1:k1lzV5n5U3HkGvTCJHraTAGJ7MqsgL1wrGwTj1Isfiw= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0 h1:nKP4Z2ejtHn3yShBb+2KawiXgpn8In5cT7aO2wXuOTE= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0/go.mod h1:NwjeBbNigsO4Aj9WgM0C+cKIrxsZUaRmZUO7A8I7u8o= +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/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= +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/log v0.15.0 h1:0VqVnc3MgyYd7QqNVIldC3dsLFKgazR6P3P3+ypkyDY= +go.opentelemetry.io/otel/log v0.15.0/go.mod h1:9c/G1zbyZfgu1HmQD7Qj84QMmwTp2QCQsZH1aeoWDE4= +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/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/log v0.15.0 h1:WgMEHOUt5gjJE93yqfqJOkRflApNif84kxoHWS9VVHE= +go.opentelemetry.io/otel/sdk/log v0.15.0/go.mod h1:qDC/FlKQCXfH5hokGsNg9aUBGMJQsrUyeOiW5u+dKBQ= +go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM= +go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA= +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/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/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= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= goa.design/goa/v3 v3.22.6 h1:D2qDkAvdpf6ePr2iXKT+Ple5WDrjyes3iOfYD2yCpw0= goa.design/goa/v3 v3.22.6/go.mod h1:rhssEXxox3+sKnYp18hPNFCz65I4hLWHEtJKewoNJWk= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs= gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/log/log.go b/internal/log/log.go index 916046c..a6cae95 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -9,6 +9,8 @@ import ( "log" "log/slog" "os" + + slogotel "github.com/remychantenay/slog-otel" ) type ctxKey string @@ -97,7 +99,12 @@ func InitStructureLogConfig() slog.Handler { h = slog.NewJSONHandler(os.Stdout, logOptions) log.SetFlags(log.Llongfile) - logger := contextHandler{h} + + // Wrap with slog-otel handler to add trace_id and span_id from context + otelHandler := slogotel.OtelHandler{Next: h} + + // Wrap with contextHandler to support context-based attributes + logger := contextHandler{otelHandler} slog.SetDefault(slog.New(logger)) return h diff --git a/pkg/logging/context.go b/pkg/logging/context.go new file mode 100644 index 0000000..c1c604b --- /dev/null +++ b/pkg/logging/context.go @@ -0,0 +1,37 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package logging + +import ( + "context" + "log/slog" + + "go.opentelemetry.io/otel/trace" +) + +// LogAttrsFromContext extracts trace_id and span_id from the context and returns them as slog attributes. +// Use this to add tracing context to log messages for correlation. +func LogAttrsFromContext(ctx context.Context) []slog.Attr { + spanCtx := trace.SpanContextFromContext(ctx) + if !spanCtx.IsValid() { + return nil + } + return []slog.Attr{ + slog.String("trace_id", spanCtx.TraceID().String()), + slog.String("span_id", spanCtx.SpanID().String()), + } +} + +// LogWithContext returns a logger with trace context attributes added. +// Usage: logging.LogWithContext(ctx, slog.Default()).Info("message", "key", "value") +func LogWithContext(ctx context.Context, logger *slog.Logger) *slog.Logger { + spanCtx := trace.SpanContextFromContext(ctx) + if !spanCtx.IsValid() { + return logger + } + return logger.With( + slog.String("trace_id", spanCtx.TraceID().String()), + slog.String("span_id", spanCtx.SpanID().String()), + ) +} diff --git a/pkg/utils/otel.go b/pkg/utils/otel.go new file mode 100644 index 0000000..0360550 --- /dev/null +++ b/pkg/utils/otel.go @@ -0,0 +1,388 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "context" + "errors" + "log/slog" + "os" + "strconv" + "strings" + "time" + + "go.opentelemetry.io/contrib/propagators/jaeger" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/log/global" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/log" + "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.37.0" +) + +const ( + // OTelProtocolGRPC configures OTLP exporters to use gRPC protocol. + OTelProtocolGRPC = "grpc" + // OTelProtocolHTTP configures OTLP exporters to use HTTP protocol. + OTelProtocolHTTP = "http" + + // OTelExporterOTLP configures signals to export via OTLP. + OTelExporterOTLP = "otlp" + // OTelExporterNone disables exporting for a signal. + OTelExporterNone = "none" +) + +// OTelConfig holds OpenTelemetry configuration options. +type OTelConfig struct { + // ServiceName is the name of the service for resource identification. + // Env: OTEL_SERVICE_NAME (default: "lfx-v2-project-service") + ServiceName string + // ServiceVersion is the version of the service. + // Env: OTEL_SERVICE_VERSION + ServiceVersion string + // Protocol specifies the OTLP protocol to use: "grpc" or "http". + // Env: OTEL_EXPORTER_OTLP_PROTOCOL (default: "grpc") + Protocol string + // Endpoint is the OTLP collector endpoint. + // For gRPC: typically "localhost:4317" + // For HTTP: typically "localhost:4318" + // Env: OTEL_EXPORTER_OTLP_ENDPOINT + Endpoint string + // Insecure disables TLS for the connection. + // Env: OTEL_EXPORTER_OTLP_INSECURE (set to "true" for insecure connections) + Insecure bool + // TracesExporter specifies the traces exporter: "otlp" or "none". + // Env: OTEL_TRACES_EXPORTER (default: "none") + TracesExporter string + // TracesSampleRatio specifies the sampling ratio for traces (0.0 to 1.0). + // A value of 1.0 means all traces are sampled, 0.5 means 50% are sampled. + // Env: OTEL_TRACES_SAMPLE_RATIO (default: 1.0) + TracesSampleRatio float64 + // MetricsExporter specifies the metrics exporter: "otlp" or "none". + // Env: OTEL_METRICS_EXPORTER (default: "none") + MetricsExporter string + // LogsExporter specifies the logs exporter: "otlp" or "none". + // Env: OTEL_LOGS_EXPORTER (default: "none") + LogsExporter string + // Propagators specifies the propagators to use, comma-separated. + // Supported values: "tracecontext", "baggage", "jaeger" + // Env: OTEL_PROPAGATORS (default: "tracecontext,baggage") + Propagators string +} + +// OTelConfigFromEnv creates an OTelConfig from environment variables. +// See OTelConfig struct fields for supported environment variables. +func OTelConfigFromEnv() OTelConfig { + serviceName := os.Getenv("OTEL_SERVICE_NAME") + if serviceName == "" { + serviceName = "lfx-v2-project-service" + } + + serviceVersion := os.Getenv("OTEL_SERVICE_VERSION") + + protocol := os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL") + if protocol == "" { + protocol = OTelProtocolGRPC + } + + endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") + + insecure := os.Getenv("OTEL_EXPORTER_OTLP_INSECURE") == "true" + + tracesExporter := os.Getenv("OTEL_TRACES_EXPORTER") + if tracesExporter == "" { + tracesExporter = OTelExporterNone + } + + metricsExporter := os.Getenv("OTEL_METRICS_EXPORTER") + if metricsExporter == "" { + metricsExporter = OTelExporterNone + } + + logsExporter := os.Getenv("OTEL_LOGS_EXPORTER") + if logsExporter == "" { + logsExporter = OTelExporterNone + } + + propagators := os.Getenv("OTEL_PROPAGATORS") + if propagators == "" { + propagators = "tracecontext,baggage" + } + + tracesSampleRatio := 1.0 + if ratio := os.Getenv("OTEL_TRACES_SAMPLE_RATIO"); ratio != "" { + if parsed, err := strconv.ParseFloat(ratio, 64); err == nil { + if parsed >= 0.0 && parsed <= 1.0 { + tracesSampleRatio = parsed + } else { + slog.Warn("OTEL_TRACES_SAMPLE_RATIO must be between 0.0 and 1.0, using default 1.0", + "provided-value", ratio) + } + } else { + slog.Warn("invalid OTEL_TRACES_SAMPLE_RATIO value, using default 1.0", + "provided-value", ratio, "error", err) + } + } + + slog.With( + "service-name", serviceName, + "version", serviceVersion, + "protocol", protocol, + "endpoint", endpoint, + "insecure", insecure, + "traces-exporter", tracesExporter, + "traces-sample-ratio", tracesSampleRatio, + "metrics-exporter", metricsExporter, + "logs-exporter", logsExporter, + "propagators", propagators, + ).Debug("OTelConfig") + + return OTelConfig{ + ServiceName: serviceName, + ServiceVersion: serviceVersion, + Protocol: protocol, + Endpoint: endpoint, + Insecure: insecure, + TracesExporter: tracesExporter, + TracesSampleRatio: tracesSampleRatio, + MetricsExporter: metricsExporter, + LogsExporter: logsExporter, + Propagators: propagators, + } +} + +// SetupOTelSDK bootstraps the OpenTelemetry pipeline with OTLP exporters. +// If it does not return an error, make sure to call shutdown for proper cleanup. +func SetupOTelSDK(ctx context.Context) (shutdown func(context.Context) error, err error) { + return SetupOTelSDKWithConfig(ctx, OTelConfigFromEnv()) +} + +// SetupOTelSDKWithConfig bootstraps the OpenTelemetry pipeline with the provided configuration. +// If it does not return an error, make sure to call shutdown for proper cleanup. +func SetupOTelSDKWithConfig(ctx context.Context, cfg OTelConfig) (shutdown func(context.Context) error, err error) { + var shutdownFuncs []func(context.Context) error + + // shutdown calls cleanup functions registered via shutdownFuncs. + // The errors from the calls are joined. + // Each registered cleanup will be invoked once. + shutdown = func(ctx context.Context) error { + var err error + for _, fn := range shutdownFuncs { + err = errors.Join(err, fn(ctx)) + } + shutdownFuncs = nil + return err + } + + // handleErr calls shutdown for cleanup and makes sure that all errors are returned. + handleErr := func(inErr error) { + err = errors.Join(inErr, shutdown(ctx)) + } + + // Create resource with service information. + res, err := newResource(cfg) + if err != nil { + handleErr(err) + return + } + + // Set up propagator. + prop := newPropagator(cfg) + otel.SetTextMapPropagator(prop) + + // Set up trace provider if enabled. + if cfg.TracesExporter != OTelExporterNone { + var tracerProvider *trace.TracerProvider + tracerProvider, err = newTraceProvider(ctx, cfg, res) + if err != nil { + handleErr(err) + return + } + shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown) + otel.SetTracerProvider(tracerProvider) + } + + // Set up metrics provider if enabled. + if cfg.MetricsExporter != OTelExporterNone { + var metricsProvider *metric.MeterProvider + metricsProvider, err = newMetricsProvider(ctx, cfg, res) + if err != nil { + handleErr(err) + return + } + shutdownFuncs = append(shutdownFuncs, metricsProvider.Shutdown) + otel.SetMeterProvider(metricsProvider) + } + + // Set up logger provider if enabled. + if cfg.LogsExporter != OTelExporterNone { + var loggerProvider *log.LoggerProvider + loggerProvider, err = newLoggerProvider(ctx, cfg, res) + if err != nil { + handleErr(err) + return + } + shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown) + global.SetLoggerProvider(loggerProvider) + } + + return +} + +// newResource creates an OpenTelemetry resource with service name and version attributes. +func newResource(cfg OTelConfig) (*resource.Resource, error) { + return resource.Merge( + resource.Default(), + resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceName(cfg.ServiceName), + semconv.ServiceVersion(cfg.ServiceVersion), + ), + ) +} + +// newPropagator creates a composite text map propagator based on the configured propagators. +// Supported propagators: "tracecontext", "baggage", "jaeger". +func newPropagator(cfg OTelConfig) propagation.TextMapPropagator { + var propagators []propagation.TextMapPropagator + for _, p := range strings.Split(cfg.Propagators, ",") { + switch strings.TrimSpace(p) { + case "tracecontext": + propagators = append(propagators, propagation.TraceContext{}) + case "baggage": + propagators = append(propagators, propagation.Baggage{}) + case "jaeger": + propagators = append(propagators, jaeger.Jaeger{}) + default: + slog.Warn("unknown propagator, skipping", "propagator", p) + } + } + if len(propagators) == 0 { + // Default to tracecontext and baggage if no valid propagators configured + propagators = append(propagators, propagation.TraceContext{}, propagation.Baggage{}) + } + return propagation.NewCompositeTextMapPropagator(propagators...) +} + +// newTraceProvider creates a TracerProvider with an OTLP exporter configured based on the protocol setting. +func newTraceProvider(ctx context.Context, cfg OTelConfig, res *resource.Resource) (*trace.TracerProvider, error) { + var exporter trace.SpanExporter + var err error + + if cfg.Protocol == OTelProtocolHTTP { + opts := []otlptracehttp.Option{} + if cfg.Endpoint != "" { + opts = append(opts, otlptracehttp.WithEndpoint(cfg.Endpoint)) + } + if cfg.Insecure { + opts = append(opts, otlptracehttp.WithInsecure()) + } + exporter, err = otlptracehttp.New(ctx, opts...) + } else { + opts := []otlptracegrpc.Option{} + if cfg.Endpoint != "" { + opts = append(opts, otlptracegrpc.WithEndpoint(cfg.Endpoint)) + } + if cfg.Insecure { + opts = append(opts, otlptracegrpc.WithInsecure()) + } + exporter, err = otlptracegrpc.New(ctx, opts...) + } + + if err != nil { + return nil, err + } + + traceProvider := trace.NewTracerProvider( + trace.WithResource(res), + trace.WithSampler(trace.TraceIDRatioBased(cfg.TracesSampleRatio)), + trace.WithBatcher(exporter, + trace.WithBatchTimeout(time.Second), + ), + ) + return traceProvider, nil +} + +// newMetricsProvider creates a MeterProvider with an OTLP exporter configured based on the protocol setting. +func newMetricsProvider(ctx context.Context, cfg OTelConfig, res *resource.Resource) (*metric.MeterProvider, error) { + var exporter metric.Exporter + var err error + + if cfg.Protocol == OTelProtocolHTTP { + opts := []otlpmetrichttp.Option{} + if cfg.Endpoint != "" { + opts = append(opts, otlpmetrichttp.WithEndpoint(cfg.Endpoint)) + } + if cfg.Insecure { + opts = append(opts, otlpmetrichttp.WithInsecure()) + } + exporter, err = otlpmetrichttp.New(ctx, opts...) + } else { + opts := []otlpmetricgrpc.Option{} + if cfg.Endpoint != "" { + opts = append(opts, otlpmetricgrpc.WithEndpoint(cfg.Endpoint)) + } + if cfg.Insecure { + opts = append(opts, otlpmetricgrpc.WithInsecure()) + } + exporter, err = otlpmetricgrpc.New(ctx, opts...) + } + + if err != nil { + return nil, err + } + + metricsProvider := metric.NewMeterProvider( + metric.WithResource(res), + metric.WithReader(metric.NewPeriodicReader(exporter, + metric.WithInterval(30*time.Second), + )), + ) + return metricsProvider, nil +} + +// newLoggerProvider creates a LoggerProvider with an OTLP exporter configured based on the protocol setting. +func newLoggerProvider(ctx context.Context, cfg OTelConfig, res *resource.Resource) (*log.LoggerProvider, error) { + var exporter log.Exporter + var err error + + if cfg.Protocol == OTelProtocolHTTP { + opts := []otlploghttp.Option{} + if cfg.Endpoint != "" { + opts = append(opts, otlploghttp.WithEndpoint(cfg.Endpoint)) + } + if cfg.Insecure { + opts = append(opts, otlploghttp.WithInsecure()) + } + exporter, err = otlploghttp.New(ctx, opts...) + } else { + opts := []otlploggrpc.Option{} + if cfg.Endpoint != "" { + opts = append(opts, otlploggrpc.WithEndpoint(cfg.Endpoint)) + } + if cfg.Insecure { + opts = append(opts, otlploggrpc.WithInsecure()) + } + exporter, err = otlploggrpc.New(ctx, opts...) + } + + if err != nil { + return nil, err + } + + loggerProvider := log.NewLoggerProvider( + log.WithResource(res), + log.WithProcessor(log.NewBatchProcessor(exporter)), + ) + return loggerProvider, nil +} + diff --git a/pkg/utils/otel_test.go b/pkg/utils/otel_test.go new file mode 100644 index 0000000..f69c9cc --- /dev/null +++ b/pkg/utils/otel_test.go @@ -0,0 +1,227 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "context" + "testing" +) + +// TestOTelConfigFromEnv_Defaults verifies that OTelConfigFromEnv returns +// sensible default values when no environment variables are set. +func TestOTelConfigFromEnv_Defaults(t *testing.T) { + cfg := OTelConfigFromEnv() + + if cfg.ServiceName != "lfx-v2-project-service" { + t.Errorf("expected default ServiceName 'lfx-v2-project-service', got %q", cfg.ServiceName) + } + if cfg.ServiceVersion != "" { + t.Errorf("expected empty ServiceVersion, got %q", cfg.ServiceVersion) + } + if cfg.Protocol != OTelProtocolGRPC { + t.Errorf("expected default Protocol %q, got %q", OTelProtocolGRPC, cfg.Protocol) + } + if cfg.Endpoint != "" { + t.Errorf("expected empty Endpoint, got %q", cfg.Endpoint) + } + if cfg.Insecure != false { + t.Errorf("expected Insecure false, got %t", cfg.Insecure) + } + if cfg.TracesExporter != OTelExporterNone { + t.Errorf("expected default TracesExporter %q, got %q", OTelExporterNone, cfg.TracesExporter) + } + if cfg.TracesSampleRatio != 1.0 { + t.Errorf("expected default TracesSampleRatio 1.0, got %f", cfg.TracesSampleRatio) + } + if cfg.MetricsExporter != OTelExporterNone { + t.Errorf("expected default MetricsExporter %q, got %q", OTelExporterNone, cfg.MetricsExporter) + } + if cfg.LogsExporter != OTelExporterNone { + t.Errorf("expected default LogsExporter %q, got %q", OTelExporterNone, cfg.LogsExporter) + } +} + +// TestOTelConfigFromEnv_CustomValues verifies that OTelConfigFromEnv correctly +// reads and parses all supported OTEL_* environment variables. +func TestOTelConfigFromEnv_CustomValues(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "test-service") + t.Setenv("OTEL_SERVICE_VERSION", "1.2.3") + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http") + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4318") + t.Setenv("OTEL_EXPORTER_OTLP_INSECURE", "true") + t.Setenv("OTEL_TRACES_EXPORTER", "otlp") + t.Setenv("OTEL_TRACES_SAMPLE_RATIO", "0.5") + t.Setenv("OTEL_METRICS_EXPORTER", "otlp") + t.Setenv("OTEL_LOGS_EXPORTER", "otlp") + + cfg := OTelConfigFromEnv() + + if cfg.ServiceName != "test-service" { + t.Errorf("expected ServiceName 'test-service', got %q", cfg.ServiceName) + } + if cfg.ServiceVersion != "1.2.3" { + t.Errorf("expected ServiceVersion '1.2.3', got %q", cfg.ServiceVersion) + } + if cfg.Protocol != OTelProtocolHTTP { + t.Errorf("expected Protocol %q, got %q", OTelProtocolHTTP, cfg.Protocol) + } + if cfg.Endpoint != "localhost:4318" { + t.Errorf("expected Endpoint 'localhost:4318', got %q", cfg.Endpoint) + } + if cfg.Insecure != true { + t.Errorf("expected Insecure true, got %t", cfg.Insecure) + } + if cfg.TracesExporter != OTelExporterOTLP { + t.Errorf("expected TracesExporter %q, got %q", OTelExporterOTLP, cfg.TracesExporter) + } + if cfg.TracesSampleRatio != 0.5 { + t.Errorf("expected TracesSampleRatio 0.5, got %f", cfg.TracesSampleRatio) + } + if cfg.MetricsExporter != OTelExporterOTLP { + t.Errorf("expected MetricsExporter %q, got %q", OTelExporterOTLP, cfg.MetricsExporter) + } + if cfg.LogsExporter != OTelExporterOTLP { + t.Errorf("expected LogsExporter %q, got %q", OTelExporterOTLP, cfg.LogsExporter) + } +} + +// TestOTelConfigFromEnv_UnsupportedProtocol verifies that an unsupported protocol +// value is passed through as-is (defaults to gRPC behavior in the provider functions). +func TestOTelConfigFromEnv_UnsupportedProtocol(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "unsupported") + + cfg := OTelConfigFromEnv() + + if cfg.Protocol != "unsupported" { + t.Errorf("expected Protocol 'unsupported', got %q", cfg.Protocol) + } +} + +// TestSetupOTelSDKWithConfig_AllDisabled verifies that the SDK can be +// initialized successfully when all exporters (traces, metrics, logs) are +// disabled, and that the returned shutdown function works correctly. +func TestSetupOTelSDKWithConfig_AllDisabled(t *testing.T) { + cfg := OTelConfig{ + ServiceName: "test-service", + ServiceVersion: "1.0.0", + Protocol: OTelProtocolGRPC, + TracesExporter: OTelExporterNone, + TracesSampleRatio: 1.0, + MetricsExporter: OTelExporterNone, + LogsExporter: OTelExporterNone, + } + + ctx := context.Background() + shutdown, err := SetupOTelSDKWithConfig(ctx, cfg) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if shutdown == nil { + t.Fatal("expected non-nil shutdown function") + } + + // Call shutdown to ensure it works without error + err = shutdown(ctx) + if err != nil { + t.Errorf("shutdown returned unexpected error: %v", err) + } +} + +// TestNewResource verifies that newResource creates a valid OpenTelemetry +// resource with the expected service.name attribute for various input values. +func TestNewResource(t *testing.T) { + tests := []struct { + name string + serviceName string + serviceVersion string + }{ + {"basic", "test-service", "1.0.0"}, + {"empty version", "test-service", ""}, + {"special chars", "test-service-123", "1.0.0-beta.1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := OTelConfig{ + ServiceName: tt.serviceName, + ServiceVersion: tt.serviceVersion, + } + + res, err := newResource(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if res == nil { + t.Fatal("expected non-nil resource") + } + + // Verify resource contains expected attributes + attrs := res.Attributes() + found := false + for _, attr := range attrs { + if string(attr.Key) == "service.name" && attr.Value.AsString() == tt.serviceName { + found = true + break + } + } + if !found { + t.Errorf("resource missing service.name attribute with value %q", tt.serviceName) + } + }) + } +} + +// TestNewPropagator verifies that newPropagator returns a composite +// TextMapPropagator that includes the standard W3C trace context fields. +func TestNewPropagator(t *testing.T) { + cfg := OTelConfig{Propagators: "tracecontext,baggage"} + prop := newPropagator(cfg) + + if prop == nil { + t.Fatal("expected non-nil propagator") + } + + // Verify it's a composite propagator with expected fields + fields := prop.Fields() + if len(fields) == 0 { + t.Error("expected propagator to have fields") + } + + // Check for expected propagation fields (traceparent, tracestate, baggage) + expectedFields := map[string]bool{ + "traceparent": false, + "tracestate": false, + "baggage": false, + } + + for _, field := range fields { + expectedFields[field] = true + } + + for field, found := range expectedFields { + if !found { + t.Errorf("expected propagator to include field %q", field) + } + } +} + +// TestOTelConstants verifies that the exported OTel constants have their +// expected string values, ensuring API compatibility. +func TestOTelConstants(t *testing.T) { + if OTelProtocolGRPC != "grpc" { + t.Errorf("expected OTelProtocolGRPC to be 'grpc', got %q", OTelProtocolGRPC) + } + if OTelProtocolHTTP != "http" { + t.Errorf("expected OTelProtocolHTTP to be 'http', got %q", OTelProtocolHTTP) + } + if OTelExporterOTLP != "otlp" { + t.Errorf("expected OTelExporterOTLP to be 'otlp', got %q", OTelExporterOTLP) + } + if OTelExporterNone != "none" { + t.Errorf("expected OTelExporterNone to be 'none', got %q", OTelExporterNone) + } +}