diff --git a/.github/workflows/ko-build-branch.yaml b/.github/workflows/ko-build-branch.yaml index 5aa945e..cbddc7f 100644 --- a/.github/workflows/ko-build-branch.yaml +++ b/.github/workflows/ko-build-branch.yaml @@ -35,7 +35,14 @@ jobs: container_tag=$(echo "$HEAD_REF" | sed 's/[^_0-9a-zA-Z]/-/g' | cut -c -127) echo tag="$container_tag" >> "$GITHUB_OUTPUT" - name: Build mailing-list-api for PR + env: + VERSION: ${{ steps.container_tag.outputs.tag }} + GIT_COMMIT: ${{ github.sha }} run: | + BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S') + export BUILD_TIME + GIT_COMMIT=${GIT_COMMIT:0:7} + export GIT_COMMIT ko build github.com/linuxfoundation/lfx-v2-mailing-list-service/cmd/mailing-list-api \ -B \ --platform linux/amd64,linux/arm64 \ diff --git a/.github/workflows/ko-build-main.yaml b/.github/workflows/ko-build-main.yaml index cd5b339..f6b02c0 100644 --- a/.github/workflows/ko-build-main.yaml +++ b/.github/workflows/ko-build-main.yaml @@ -27,7 +27,15 @@ jobs: - uses: ko-build/setup-ko@v0.8 with: version: v0.17.1 - - run: | + - name: Build and publish mailing-list-api image + env: + VERSION: development + GIT_COMMIT: ${{ github.sha }} + run: | + BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S') + export BUILD_TIME + GIT_COMMIT=${GIT_COMMIT:0:7} + export GIT_COMMIT ko build github.com/linuxfoundation/lfx-v2-mailing-list-service/cmd/mailing-list-api \ -B \ --platform linux/amd64,linux/arm64 \ diff --git a/.github/workflows/ko-build-tag.yaml b/.github/workflows/ko-build-tag.yaml index 40045d9..e8a046e 100644 --- a/.github/workflows/ko-build-tag.yaml +++ b/.github/workflows/ko-build-tag.yaml @@ -54,7 +54,14 @@ jobs: version: v0.17.1 - name: Build and publish mailing-list-api image + env: + VERSION: ${{ steps.prepare.outputs.app_version }} + GIT_COMMIT: ${{ github.sha }} run: | + BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S') + export BUILD_TIME + GIT_COMMIT=${GIT_COMMIT:0:7} + export GIT_COMMIT ko build github.com/linuxfoundation/lfx-v2-mailing-list-service/cmd/mailing-list-api \ -B \ --platform linux/amd64,linux/arm64 \ diff --git a/.ko.yaml b/.ko.yaml new file mode 100644 index 0000000..27d2c49 --- /dev/null +++ b/.ko.yaml @@ -0,0 +1,9 @@ +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT +builds: + - id: mailing-list-api + dir: ./cmd/mailing-list-api + ldflags: + - -X=main.Version={{.Env.VERSION}} + - -X=main.BuildTime={{.Env.BUILD_TIME}} + - -X=main.GitCommit={{.Env.GIT_COMMIT}} diff --git a/charts/lfx-v2-mailing-list-service/templates/deployment.yaml b/charts/lfx-v2-mailing-list-service/templates/deployment.yaml index 597be08..f3aea7d 100644 --- a/charts/lfx-v2-mailing-list-service/templates/deployment.yaml +++ b/charts/lfx-v2-mailing-list-service/templates/deployment.yaml @@ -36,6 +36,56 @@ spec: {{- toYaml $config.valueFrom | nindent 16 }} {{- end }} {{- end }} + {{- $otelServiceName := .Values.app.otel.serviceName | toString | trim }} + {{- if ne $otelServiceName "" }} + - name: OTEL_SERVICE_NAME + value: {{ $otelServiceName | quote }} + {{- end }} + {{- $otelServiceVersion := .Values.app.otel.serviceVersion | toString | trim }} + {{- if ne $otelServiceVersion "" }} + - name: OTEL_SERVICE_VERSION + value: {{ $otelServiceVersion | quote }} + {{- end }} + {{- $otelEndpoint := .Values.app.otel.endpoint | toString | trim }} + {{- if ne $otelEndpoint "" }} + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: {{ $otelEndpoint | quote }} + {{- end }} + {{- $otelProtocol := .Values.app.otel.protocol | toString | trim }} + {{- if ne $otelProtocol "" }} + - name: OTEL_EXPORTER_OTLP_PROTOCOL + value: {{ $otelProtocol | quote }} + {{- end }} + {{- $otelInsecure := .Values.app.otel.insecure | toString | trim }} + {{- if ne $otelInsecure "" }} + - name: OTEL_EXPORTER_OTLP_INSECURE + value: {{ $otelInsecure | quote }} + {{- end }} + {{- $otelTracesExporter := .Values.app.otel.tracesExporter | toString | trim }} + {{- if ne $otelTracesExporter "" }} + - name: OTEL_TRACES_EXPORTER + value: {{ $otelTracesExporter | quote }} + {{- end }} + {{- $otelTracesSampleRatio := .Values.app.otel.tracesSampleRatio | toString | trim }} + {{- if ne $otelTracesSampleRatio "" }} + - name: OTEL_TRACES_SAMPLE_RATIO + value: {{ $otelTracesSampleRatio | quote }} + {{- end }} + {{- $otelMetricsExporter := .Values.app.otel.metricsExporter | toString | trim }} + {{- if ne $otelMetricsExporter "" }} + - name: OTEL_METRICS_EXPORTER + value: {{ $otelMetricsExporter | quote }} + {{- end }} + {{- $otelLogsExporter := .Values.app.otel.logsExporter | toString | trim }} + {{- if ne $otelLogsExporter "" }} + - name: OTEL_LOGS_EXPORTER + value: {{ $otelLogsExporter | quote }} + {{- end }} + {{- $otelPropagators := .Values.app.otel.propagators | toString | trim }} + {{- if ne $otelPropagators "" }} + - name: OTEL_PROPAGATORS + value: {{ $otelPropagators | quote }} + {{- end }} {{- if .Values.externalSecretsOperator.enabled }} envFrom: - secretRef: diff --git a/charts/lfx-v2-mailing-list-service/values.yaml b/charts/lfx-v2-mailing-list-service/values.yaml index 7d4f362..504c2ce 100644 --- a/charts/lfx-v2-mailing-list-service/values.yaml +++ b/charts/lfx-v2-mailing-list-service/values.yaml @@ -197,6 +197,41 @@ app: # use_oidc_contextualizer is a boolean to determine if the OIDC contextualizer should be used use_oidc_contextualizer: true + # otel is the configuration for OpenTelemetry tracing + otel: + # serviceName is the service name for OpenTelemetry resource identification + # (default: "lfx-v2-mailing-list-service") + serviceName: "" + # serviceVersion is the service version for OpenTelemetry resource identification + # (default: build-time version from ldflags) + 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,jaeger") + propagators: "tracecontext,baggage,jaeger" + # environment contains all application environment variables # Each variable can have either a 'value' (for direct values) or 'valueFrom' (for secret references) environment: diff --git a/cmd/mailing-list-api/http.go b/cmd/mailing-list-api/http.go index fc43c36..f850148 100644 --- a/cmd/mailing-list-api/http.go +++ b/cmd/mailing-list-api/http.go @@ -15,6 +15,7 @@ import ( mailinglistservice "github.com/linuxfoundation/lfx-v2-mailing-list-service/gen/mailing_list" "github.com/linuxfoundation/lfx-v2-mailing-list-service/internal/middleware" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "goa.design/clue/debug" goahttp "goa.design/goa/v3/http" ) @@ -71,6 +72,8 @@ func handleHTTPServer(ctx context.Context, host string, mailingListServiceEndpoi // Log query and response bodies if debug logs are enabled. handler = debug.HTTP()(handler) } + // Add OpenTelemetry HTTP instrumentation (outermost to capture full request lifecycle) + handler = otelhttp.NewHandler(handler, "mailing-list-api") // Start HTTP server using default configuration, change the code to // configure the server as required by your service. diff --git a/cmd/mailing-list-api/main.go b/cmd/mailing-list-api/main.go index c2fa9f4..1f3f986 100644 --- a/cmd/mailing-list-api/main.go +++ b/cmd/mailing-list-api/main.go @@ -17,10 +17,18 @@ import ( "github.com/linuxfoundation/lfx-v2-mailing-list-service/cmd/mailing-list-api/service" mailinglistservice "github.com/linuxfoundation/lfx-v2-mailing-list-service/gen/mailing_list" logging "github.com/linuxfoundation/lfx-v2-mailing-list-service/pkg/log" + "github.com/linuxfoundation/lfx-v2-mailing-list-service/pkg/utils" "goa.design/clue/debug" ) +// Build-time variables set via ldflags +var ( + Version = "dev" + BuildTime = "unknown" + GitCommit = "unknown" +) + const ( defaultPort = "8080" // gracefulShutdownSeconds should be higher than NATS client @@ -49,10 +57,35 @@ func main() { flag.Parse() ctx := context.Background() - slog.InfoContext(ctx, "Starting query service", + + // Set up OpenTelemetry SDK. + // Command-line/environment OTEL_SERVICE_VERSION takes precedence over + // the build-time Version variable. + otelConfig := utils.OTelConfigFromEnv() + if otelConfig.ServiceVersion == "" { + otelConfig.ServiceVersion = Version + } + otelShutdown, err := utils.SetupOTelSDKWithConfig(ctx, otelConfig) + if err != nil { + slog.ErrorContext(ctx, "error setting up OpenTelemetry SDK", "error", err) + os.Exit(1) + } + // Handle shutdown properly so nothing leaks. + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), gracefulShutdownSeconds*time.Second) + defer cancel() + if shutdownErr := otelShutdown(shutdownCtx); shutdownErr != nil { + slog.ErrorContext(ctx, "error shutting down OpenTelemetry SDK", "error", shutdownErr) + } + }() + + slog.InfoContext(ctx, "Starting mailing list service", "bind", *bind, "http-port", *port, "graceful-shutdown-seconds", gracefulShutdownSeconds, + "version", Version, + "build-time", BuildTime, + "git-commit", GitCommit, ) // Validate provider configuration before initializing dependencies diff --git a/go.mod b/go.mod index d2c8b52..f105cf2 100644 --- a/go.mod +++ b/go.mod @@ -11,40 +11,61 @@ require ( github.com/google/go-querystring v1.1.0 github.com/google/uuid v1.6.0 github.com/nats-io/nats.go v1.31.0 - github.com/stretchr/testify v1.10.0 + github.com/remychantenay/slog-otel v1.3.4 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 + go.opentelemetry.io/contrib/propagators/jaeger v1.40.0 + go.opentelemetry.io/otel v1.40.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.40.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 + go.opentelemetry.io/otel/log v0.15.0 + go.opentelemetry.io/otel/sdk v1.40.0 + go.opentelemetry.io/otel/sdk/log v0.15.0 + go.opentelemetry.io/otel/sdk/metric v1.40.0 goa.design/clue v1.2.2 goa.design/goa/v3 v3.21.5 - golang.org/x/sync v0.16.0 - golang.org/x/text v0.27.0 + golang.org/x/sync v0.19.0 + golang.org/x/text v0.33.0 ) require ( github.com/aws/smithy-go v1.22.5 // 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.2 // indirect github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/gohugoio/hashstructure v0.5.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/klauspost/compress v1.17.0 // indirect - github.com/kr/pretty v0.1.0 // indirect github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d // indirect - github.com/nats-io/nkeys v0.4.5 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/objx v0.5.2 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/mod v0.26.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/term v0.33.0 // indirect - golang.org/x/tools v0.35.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074 // indirect - google.golang.org/grpc v1.74.2 // indirect - google.golang.org/protobuf v1.36.6 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/tools v0.40.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // 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 67a1424..c3a365f 100644 --- a/go.sum +++ b/go.sum @@ -2,14 +2,19 @@ github.com/auth0/go-jwt-middleware/v2 v2.3.0 h1:4QREj6cS3d8dS05bEm443jhnqQF97FX9 github.com/auth0/go-jwt-middleware/v2 v2.3.0/go.mod h1:dL4ObBs1/dj4/W4cYxd8rqAdDGXYyd5rqbpMIxcbVrU= github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +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/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.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/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= @@ -29,12 +34,12 @@ 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.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +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= @@ -43,60 +48,96 @@ github.com/manveru/gobdd v0.0.0-20131210092515-f1a17fdd710b h1:3E44bLeN8uKYdfQqV github.com/manveru/gobdd v0.0.0-20131210092515-f1a17fdd710b/go.mod h1:Bj8LjjP0ReT1eKt5QlKjwgi5AFm5mI6O1A2G4ChI0Ag= github.com/nats-io/nats.go v1.31.0 h1:/WFBHEc/dOKBF6qf1TZhrdEfTmOZ5JzdJ+Y3m6Y/p7E= github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8= -github.com/nats-io/nkeys v0.4.5 h1:Zdz2BUlFm4fJlierwvGK+yl20IAKUm7eV6AAZXEhkPk= -github.com/nats-io/nkeys v0.4.5/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= 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/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +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.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/contrib/propagators/jaeger v1.40.0 h1:aXl9uobjJs5vquMLt9ZkI/3zIuz8XQ3TqOKSWx0/xdU= +go.opentelemetry.io/contrib/propagators/jaeger v1.40.0/go.mod h1:ioMePqe6k6c/ovXSkmkMr1mbN5qRBGJxNTVop7/2XO0= +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/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.40.0 h1:NOyNnS19BF2SUDApbOKbDtWZ0IK7b8FJ2uAGdIWOGb0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0/go.mod h1:VL6EgVikRLcJa9ftukrHu/ZkkhFBSo1lzvdBC9CF1ss= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0 h1:G8Xec/SgZQricwWBJF/mHZc7A02YHedfFDENwJEdRA0= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0/go.mod h1:PD57idA/AiFD5aqoxGxCvT/ILJPeHy3MjqU/NS7KogY= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +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.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/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.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/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/clue v1.2.2 h1:rJDMdKnHyLecKEWvoapQ/1Fe4fcv1X91BrbllsBTtFM= goa.design/clue v1.2.2/go.mod h1:H0q8ayIEcotYUtN9Vi+82knSo1fMtiUz5G2juqPma6M= goa.design/goa/v3 v3.21.5 h1:eS6SHJ1KZ5q5bhT/llw0LMTCWbosBwlFX4v8MctYs38= goa.design/goa/v3 v3.21.5/go.mod h1:5THVDuChOIctYM+t3xmL4f2fJbFPzzwvrYMj3PQZg9g= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074 h1:qJW29YvkiJmXOYMu5Tf8lyrTp3dOS+K4z6IixtLaCf8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= -google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +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-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +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/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/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-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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/pkg/log/log.go b/pkg/log/log.go index e5ef2b7..e8d24ad 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -9,18 +9,25 @@ import ( "log" "log/slog" "os" + + slogotel "github.com/remychantenay/slog-otel" ) type ctxKey string +// Private constants const ( slogFields ctxKey = "slog_fields" logLevelDefault = slog.LevelDebug + // Log levels debug = "debug" warn = "warn" + err = "error" info = "info" + // Log field for critical errors. + // TODO: we will want logs with this field set to alert the team to take action. priorityCritical = "critical" ) @@ -47,8 +54,12 @@ func AppendCtx(parent context.Context, attr slog.Attr) context.Context { } if v, ok := parent.Value(slogFields).([]slog.Attr); ok { - v = append(v, attr) - return context.WithValue(parent, slogFields, v) + // Create a new slice to avoid race conditions when multiple goroutines + // append to the same parent context + newV := make([]slog.Attr, len(v), len(v)+1) + copy(newV, v) + newV = append(newV, attr) + return context.WithValue(parent, slogFields, newV) } v := []slog.Attr{} @@ -58,52 +69,41 @@ func AppendCtx(parent context.Context, attr slog.Attr) context.Context { // InitStructureLogConfig sets the structured log behavior func InitStructureLogConfig() { - logOptions := &slog.HandlerOptions{} - var h slog.Handler - - configurations := map[string]func(){ - "options-logLevel": func() { - logLevel := os.Getenv("LOG_LEVEL") - slog.Info("log config", - "logLevel", logLevel, - ) - switch logLevel { - case debug: - logOptions.Level = slog.LevelDebug - case warn: - logOptions.Level = slog.LevelWarn - case info: - logOptions.Level = slog.LevelInfo - default: - logOptions.Level = logLevelDefault - } - }, - "options-addSource": func() { - - addSourceBool := false - - addSource := os.Getenv("LOG_ADD_SOURCE") - if addSource == "true" || addSource == "false" { - addSourceBool = addSource == "true" - } - slog.Info("log config", - "LOG_ADD_SOURCE", addSourceBool, - ) - logOptions.AddSource = addSourceBool - }, - } - for name, f := range configurations { - slog.Info("setting logging configuration", - "name", name, - ) - f() + // Configure log level + logLevel := os.Getenv("LOG_LEVEL") + switch logLevel { + case debug: + logOptions.Level = slog.LevelDebug + case warn: + logOptions.Level = slog.LevelWarn + case err: + logOptions.Level = slog.LevelError + case info: + logOptions.Level = slog.LevelInfo + default: + logOptions.Level = logLevelDefault } - h = slog.NewJSONHandler(os.Stdout, logOptions) + + // Configure source information + addSource := os.Getenv("LOG_ADD_SOURCE") + logOptions.AddSource = addSource == "true" || addSource == "t" || addSource == "1" + + 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)) + + slog.Info("log config", + "logLevel", logOptions.Level, + "addSource", logOptions.AddSource, + ) } // Priority creates a slog.Attr for error priority classification diff --git a/pkg/log/log_test.go b/pkg/log/log_test.go index 1f766b7..8b2aeac 100644 --- a/pkg/log/log_test.go +++ b/pkg/log/log_test.go @@ -4,10 +4,281 @@ package log import ( + "bytes" + "context" + "encoding/json" "log/slog" + "os" "testing" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/sdk/trace" ) +func TestAppendCtx(t *testing.T) { + // Test with nil parent context + attr := slog.String("key1", "value1") + ctx := AppendCtx(context.TODO(), attr) + + if ctx == nil { + t.Fatal("expected non-nil context") + } + + // Check that the attribute was added + if attrs, ok := ctx.Value(slogFields).([]slog.Attr); ok { + if len(attrs) != 1 { + t.Errorf("expected 1 attribute, got %d", len(attrs)) + } + if attrs[0].Key != "key1" { + t.Errorf("expected key 'key1', got %q", attrs[0].Key) + } + if attrs[0].Value.String() != "value1" { + t.Errorf("expected value 'value1', got %q", attrs[0].Value.String()) + } + } else { + t.Error("expected slog attributes in context") + } +} + +func TestAppendCtx_WithParent(t *testing.T) { + // Create parent context with existing attribute + parentCtx := context.Background() + attr1 := slog.String("parent_key", "parent_value") + parentCtx = AppendCtx(parentCtx, attr1) + + // Add another attribute + attr2 := slog.String("child_key", "child_value") + childCtx := AppendCtx(parentCtx, attr2) + + // Check that both attributes are present + if attrs, ok := childCtx.Value(slogFields).([]slog.Attr); ok { + if len(attrs) != 2 { + t.Errorf("expected 2 attributes, got %d", len(attrs)) + } + + // Check first attribute + if attrs[0].Key != "parent_key" { + t.Errorf("expected first key 'parent_key', got %q", attrs[0].Key) + } + if attrs[0].Value.String() != "parent_value" { + t.Errorf("expected first value 'parent_value', got %q", attrs[0].Value.String()) + } + + // Check second attribute + if attrs[1].Key != "child_key" { + t.Errorf("expected second key 'child_key', got %q", attrs[1].Key) + } + if attrs[1].Value.String() != "child_value" { + t.Errorf("expected second value 'child_value', got %q", attrs[1].Value.String()) + } + } else { + t.Error("expected slog attributes in context") + } +} + +func TestAppendCtx_MultipleAttributes(t *testing.T) { + ctx := context.Background() + + // Add multiple attributes + attr1 := slog.String("key1", "value1") + attr2 := slog.Int("key2", 42) + attr3 := slog.Bool("key3", true) + + ctx = AppendCtx(ctx, attr1) + ctx = AppendCtx(ctx, attr2) + ctx = AppendCtx(ctx, attr3) + + // Check all attributes are present + if attrs, ok := ctx.Value(slogFields).([]slog.Attr); ok { + if len(attrs) != 3 { + t.Errorf("expected 3 attributes, got %d", len(attrs)) + } + + // Check each attribute + expectedKeys := []string{"key1", "key2", "key3"} + for i, expectedKey := range expectedKeys { + if attrs[i].Key != expectedKey { + t.Errorf("expected key[%d] %q, got %q", i, expectedKey, attrs[i].Key) + } + } + } else { + t.Error("expected slog attributes in context") + } +} + +func TestContextHandler_Handle(t *testing.T) { + // Create a test handler that captures records + var capturedRecord *slog.Record + testHandler := &testSlogHandler{ + handleFunc: func(ctx context.Context, r slog.Record) error { + capturedRecord = &r + return nil + }, + } + + handler := contextHandler{Handler: testHandler} + + // Create context with attributes + ctx := context.Background() + attr1 := slog.String("ctx_key", "ctx_value") + ctx = AppendCtx(ctx, attr1) + + // Create a record and handle it + record := slog.NewRecord(time.Now(), slog.LevelInfo, "test message", 0) + record.AddAttrs(slog.String("record_key", "record_value")) + + err := handler.Handle(ctx, record) + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + + if capturedRecord == nil { + t.Fatal("expected record to be captured") + } + + // The record should have been modified to include context attributes + // Note: This is a basic test - in a real implementation, you'd need to + // check that the attributes were actually added to the record +} + +func TestInitStructureLogConfig_DefaultLevel(t *testing.T) { + t.Setenv("LOG_LEVEL", "") + InitStructureLogConfig() +} + +func TestInitStructureLogConfig_WithLogLevel(t *testing.T) { + testCases := []struct { + name string + logLevel string + }{ + {"debug level", "debug"}, + {"warn level", "warn"}, + {"error level", "error"}, + {"info level", "info"}, + {"unknown level", "unknown"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("LOG_LEVEL", tc.logLevel) + InitStructureLogConfig() + }) + } +} + +func TestInitStructureLogConfig_WithAddSource(t *testing.T) { + testCases := []struct { + name string + addSource string + }{ + {"true", "true"}, + {"t", "t"}, + {"1", "1"}, + {"false", "false"}, + {"empty", ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("LOG_ADD_SOURCE", tc.addSource) + InitStructureLogConfig() + }) + } +} + +func TestInitStructureLogConfig_IncludesTraceAndSpanID(t *testing.T) { + // Capture stdout to verify log output + var buf bytes.Buffer + originalStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + defer func() { _ = r.Close() }() + os.Stdout = w + defer func() { os.Stdout = originalStdout }() + + // Set up a trace provider + prevTP := otel.GetTracerProvider() + tp := trace.NewTracerProvider() + otel.SetTracerProvider(tp) + defer otel.SetTracerProvider(prevTP) + defer func() { + if err := tp.Shutdown(context.Background()); err != nil { + t.Errorf("failed to shutdown trace provider: %v", err) + } + }() + + // Initialize logging (this sets up the slog-otel handler) + InitStructureLogConfig() + + // Create a span and log within its context + tracer := otel.Tracer("test-tracer") + ctx, span := tracer.Start(context.Background(), "test-span") + defer span.End() + + // Get the span context to verify IDs later + spanCtx := span.SpanContext() + expectedTraceID := spanCtx.TraceID().String() + expectedSpanID := spanCtx.SpanID().String() + + // Log a message with the span context + slog.InfoContext(ctx, "test log message with trace context") + + // Close writer and read captured output + if err := w.Close(); err != nil { + t.Fatalf("failed to close pipe writer: %v", err) + } + _, err = buf.ReadFrom(r) + if err != nil { + t.Fatalf("failed to read from pipe: %v", err) + } + + // Parse the JSON log output + logOutput := buf.String() + if logOutput == "" { + t.Fatal("expected log output, got empty string") + } + + // Find the test log message in output (there may be multiple log lines) + lines := bytes.Split(buf.Bytes(), []byte("\n")) + var testLogLine map[string]interface{} + for _, line := range lines { + if len(line) == 0 { + continue + } + var logEntry map[string]interface{} + if err := json.Unmarshal(line, &logEntry); err != nil { + continue + } + if msg, ok := logEntry["msg"].(string); ok && msg == "test log message with trace context" { + testLogLine = logEntry + break + } + } + + if testLogLine == nil { + t.Fatalf("could not find test log message in output: %s", logOutput) + } + + // Verify trace_id is present and matches + traceID, ok := testLogLine["trace_id"].(string) + if !ok { + t.Errorf("expected trace_id in log output, got: %v", testLogLine) + } else if traceID != expectedTraceID { + t.Errorf("expected trace_id %q, got %q", expectedTraceID, traceID) + } + + // Verify span_id is present and matches + spanID, ok := testLogLine["span_id"].(string) + if !ok { + t.Errorf("expected span_id in log output, got: %v", testLogLine) + } else if spanID != expectedSpanID { + t.Errorf("expected span_id %q, got %q", expectedSpanID, spanID) + } +} + func TestLogOptionalInt64(t *testing.T) { tests := []struct { name string @@ -72,3 +343,27 @@ func TestLogOptionalInt64(t *testing.T) { func ptrInt64(v int64) *int64 { return &v } + +// testSlogHandler is a helper for testing +type testSlogHandler struct { + handleFunc func(context.Context, slog.Record) error +} + +func (h *testSlogHandler) Enabled(ctx context.Context, level slog.Level) bool { + return true +} + +func (h *testSlogHandler) Handle(ctx context.Context, r slog.Record) error { + if h.handleFunc != nil { + return h.handleFunc(ctx, r) + } + return nil +} + +func (h *testSlogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return h +} + +func (h *testSlogHandler) WithGroup(name string) slog.Handler { + return h +} diff --git a/pkg/utils/otel.go b/pkg/utils/otel.go new file mode 100644 index 0000000..cb2f175 --- /dev/null +++ b/pkg/utils/otel.go @@ -0,0 +1,412 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "context" + "errors" + "fmt" + "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.39.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" + + // OTelPropagatorTraceContext is the W3C Trace Context propagator. + OTelPropagatorTraceContext = "tracecontext" + // OTelPropagatorBaggage is the W3C Baggage propagator. + OTelPropagatorBaggage = "baggage" + // OTelPropagatorJaeger is the Jaeger propagation format. + OTelPropagatorJaeger = "jaeger" + + // OTelDefaultPropagators is the default comma-separated propagator list. + OTelDefaultPropagators = "tracecontext,baggage,jaeger" +) + +// 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-mailing-list-service") + ServiceName string + // ServiceVersion is the version of the service. + // Env: OTEL_SERVICE_VERSION (default: build-time version from ldflags) + 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 is a comma-separated list of propagator names. + // Supported values: "tracecontext", "baggage", "jaeger". + // Env: OTEL_PROPAGATORS (default: "tracecontext,baggage,jaeger") + 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-mailing-list-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 = OTelDefaultPropagators + } + + 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()) +} + +// isExporterEnabled returns true if the exporter is configured to be enabled. +// Empty string is treated as disabled (same as "none") to handle zero-value OTelConfig. +func isExporterEnabled(exporter string) bool { + return exporter != OTelExporterNone && exporter != "" +} + +// 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, err := newPropagator(cfg) + if err != nil { + handleErr(err) + return + } + otel.SetTextMapPropagator(prop) + + // Set up trace provider if enabled. + if isExporterEnabled(cfg.TracesExporter) { + 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 isExporterEnabled(cfg.MetricsExporter) { + 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 isExporterEnabled(cfg.LogsExporter) { + 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 from the configured propagator names. +// Returns an error if any propagator name is unsupported. +func newPropagator(cfg OTelConfig) (propagation.TextMapPropagator, error) { + names := strings.Split(cfg.Propagators, ",") + var propagators []propagation.TextMapPropagator + + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + switch name { + case OTelPropagatorTraceContext: + propagators = append(propagators, propagation.TraceContext{}) + case OTelPropagatorBaggage: + propagators = append(propagators, propagation.Baggage{}) + case OTelPropagatorJaeger: + propagators = append(propagators, jaeger.Jaeger{}) + default: + return nil, fmt.Errorf("unsupported propagator %q: supported values are %q, %q, %q", + name, OTelPropagatorTraceContext, OTelPropagatorBaggage, OTelPropagatorJaeger) + } + } + + return propagation.NewCompositeTextMapPropagator(propagators...), nil +} + +// 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..02e82be --- /dev/null +++ b/pkg/utils/otel_test.go @@ -0,0 +1,516 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "context" + "testing" +) + +// otelEnvVars lists all OTEL-related environment variables used in tests. +var otelEnvVars = []string{ + "OTEL_SERVICE_NAME", + "OTEL_SERVICE_VERSION", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_INSECURE", + "OTEL_TRACES_EXPORTER", + "OTEL_TRACES_SAMPLE_RATIO", + "OTEL_METRICS_EXPORTER", + "OTEL_LOGS_EXPORTER", + "OTEL_PROPAGATORS", +} + +// clearOTelEnvVars clears all OTEL-related environment variables for the +// duration of the test using t.Setenv (auto-restored after the test). +func clearOTelEnvVars(t *testing.T) { + t.Helper() + for _, env := range otelEnvVars { + t.Setenv(env, "") + } +} + +// TestOTelConfigFromEnv_Defaults verifies that OTelConfigFromEnv returns +// sensible default values when no environment variables are set. +func TestOTelConfigFromEnv_Defaults(t *testing.T) { + // Clear all relevant environment variables + clearOTelEnvVars(t) + + cfg := OTelConfigFromEnv() + + if cfg.ServiceName != "lfx-v2-mailing-list-service" { + t.Errorf("expected default ServiceName 'lfx-v2-mailing-list-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) + } + if cfg.Propagators != OTelDefaultPropagators { + t.Errorf("expected default Propagators %q, got %q", OTelDefaultPropagators, cfg.Propagators) + } +} + +// TestOTelConfigFromEnv_CustomValues verifies that OTelConfigFromEnv correctly +// reads and parses all supported OTEL_* environment variables. +func TestOTelConfigFromEnv_CustomValues(t *testing.T) { + // Set all environment variables + 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") + t.Setenv("OTEL_PROPAGATORS", "tracecontext,baggage") + + 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) + } + if cfg.Propagators != "tracecontext,baggage" { + t.Errorf("expected Propagators 'tracecontext,baggage', got %q", cfg.Propagators) + } +} + +// TestOTelConfigFromEnv_TracesSampleRatio tests the parsing and validation of +// the OTEL_TRACES_SAMPLE_RATIO environment variable, including edge cases like +// invalid values, out-of-range numbers, and empty strings. +func TestOTelConfigFromEnv_TracesSampleRatio(t *testing.T) { + tests := []struct { + name string + envValue string + expectedRatio float64 + }{ + {"valid zero", "0.0", 0.0}, + {"valid half", "0.5", 0.5}, + {"valid one", "1.0", 1.0}, + {"valid small", "0.01", 0.01}, + {"invalid negative", "-0.5", 1.0}, // defaults to 1.0 + {"invalid above one", "1.5", 1.0}, // defaults to 1.0 + {"invalid non-number", "invalid", 1.0}, // defaults to 1.0 + {"empty string", "", 1.0}, // defaults to 1.0 + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("OTEL_TRACES_SAMPLE_RATIO", tt.envValue) + + cfg := OTelConfigFromEnv() + + if cfg.TracesSampleRatio != tt.expectedRatio { + t.Errorf("expected TracesSampleRatio %f, got %f", tt.expectedRatio, cfg.TracesSampleRatio) + } + }) + } +} + +// TestOTelConfigFromEnv_InsecureFlag tests the parsing of the +// OTEL_EXPORTER_OTLP_INSECURE environment variable. Only the literal string +// "true" enables insecure mode; all other values default to false. +func TestOTelConfigFromEnv_InsecureFlag(t *testing.T) { + tests := []struct { + name string + envValue string + expected bool + }{ + {"true", "true", true}, + {"false", "false", false}, + {"empty", "", false}, + {"TRUE uppercase", "TRUE", false}, // only "true" is recognized + {"1", "1", false}, // only "true" is recognized + {"yes", "yes", false}, // only "true" is recognized + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_INSECURE", tt.envValue) + + cfg := OTelConfigFromEnv() + + if cfg.Insecure != tt.expected { + t.Errorf("expected Insecure %t, got %t", tt.expected, cfg.Insecure) + } + }) + } +} + +// 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) + } +} + +// TestSetupOTelSDKWithConfig_ShutdownIdempotent verifies that the shutdown +// function can be called multiple times without error. This is important for +// graceful shutdown scenarios where shutdown may be triggered multiple times. +func TestSetupOTelSDKWithConfig_ShutdownIdempotent(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) + } + + // Call shutdown multiple times + err = shutdown(ctx) + if err != nil { + t.Errorf("first shutdown returned unexpected error: %v", err) + } + + // Second call should also succeed (shutdownFuncs is cleared) + err = shutdown(ctx) + if err != nil { + t.Errorf("second 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, +// including edge cases like empty versions and unicode characters. +func TestNewResource(t *testing.T) { + tests := []struct { + name string + serviceName string + serviceVersion string + }{ + {"basic", "test-service", "1.0.0"}, + {"empty version", "test-service", ""}, + {"unicode name", "测试服务", "2.0.0"}, + {"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_Defaults verifies that newPropagator with default config +// returns a composite TextMapPropagator that includes the standard W3C trace +// context fields (traceparent, tracestate), baggage, and jaeger (uber-trace-id). +func TestNewPropagator_Defaults(t *testing.T) { + cfg := OTelConfig{Propagators: OTelDefaultPropagators} + prop, err := newPropagator(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if prop == nil { + t.Fatal("expected non-nil propagator") + } + + fields := prop.Fields() + expectedFields := map[string]bool{ + "traceparent": false, + "tracestate": false, + "baggage": false, + "uber-trace-id": false, + } + + for _, field := range fields { + expectedFields[field] = true + } + + for field, found := range expectedFields { + if !found { + t.Errorf("expected propagator to include field %q", field) + } + } +} + +// TestNewPropagator_Override verifies that OTEL_PROPAGATORS can override +// the default propagator set to use only a subset. +func TestNewPropagator_Override(t *testing.T) { + cfg := OTelConfig{Propagators: "tracecontext"} + prop, err := newPropagator(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + fields := prop.Fields() + fieldSet := make(map[string]bool) + for _, f := range fields { + fieldSet[f] = true + } + + if !fieldSet["traceparent"] { + t.Error("expected traceparent field") + } + if fieldSet["baggage"] { + t.Error("did not expect baggage field with tracecontext-only config") + } + if fieldSet["uber-trace-id"] { + t.Error("did not expect uber-trace-id field with tracecontext-only config") + } +} + +// TestNewPropagator_UnsupportedError verifies that newPropagator returns an +// error when an unsupported propagator name is provided. +func TestNewPropagator_UnsupportedError(t *testing.T) { + tests := []struct { + name string + propagators string + }{ + {"unknown propagator", "b3"}, + {"mixed valid and invalid", "tracecontext,b3multi"}, + {"completely invalid", "zipkin"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := OTelConfig{Propagators: tt.propagators} + _, err := newPropagator(cfg) + if err == nil { + t.Errorf("expected error for propagators %q, got nil", tt.propagators) + } + }) + } +} + +// TestNewPropagator_EmptyString verifies that an empty propagators string +// results in a propagator with no fields (no-op composite). +func TestNewPropagator_EmptyString(t *testing.T) { + cfg := OTelConfig{Propagators: ""} + prop, err := newPropagator(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + fields := prop.Fields() + if len(fields) != 0 { + t.Errorf("expected no fields for empty propagators, got %v", fields) + } +} + +// TestOTelConstants verifies that the exported OTel constants have their +// expected string values, ensuring API compatibility. +func TestOTelConstants(t *testing.T) { + // Verify constants have expected values + 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) + } +} + +// TestSetupOTelSDK tests the convenience function SetupOTelSDK which reads +// configuration from environment variables. With no env vars set, it should +// use defaults and successfully initialize the SDK. +func TestSetupOTelSDK(t *testing.T) { + // Clear environment to use defaults + clearOTelEnvVars(t) + + ctx := context.Background() + shutdown, err := SetupOTelSDK(ctx) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if shutdown == nil { + t.Fatal("expected non-nil shutdown function") + } + + err = shutdown(ctx) + if err != nil { + t.Errorf("shutdown returned unexpected error: %v", err) + } +} + +// TestOTelConfig_ZeroValue verifies that a zero-value OTelConfig is safe to use. +// Empty string exporter values are treated as disabled (same as "none"). +func TestOTelConfig_ZeroValue(t *testing.T) { + // A zero-value OTelConfig should be safe to use - empty strings are treated as disabled + cfg := OTelConfig{} + + // Verify isExporterEnabled treats empty string as disabled + if isExporterEnabled(cfg.TracesExporter) { + t.Error("expected zero-value TracesExporter to be treated as disabled") + } + if isExporterEnabled(cfg.MetricsExporter) { + t.Error("expected zero-value MetricsExporter to be treated as disabled") + } + if isExporterEnabled(cfg.LogsExporter) { + t.Error("expected zero-value LogsExporter to be treated as disabled") + } + + // Verify that a zero-value config can be used to initialize the SDK without error + ctx := context.Background() + shutdown, err := SetupOTelSDKWithConfig(ctx, cfg) + if err != nil { + t.Fatalf("unexpected error with zero-value config: %v", err) + } + if shutdown == nil { + t.Fatal("expected non-nil shutdown function") + } + if err := shutdown(ctx); err != nil { + t.Errorf("shutdown returned unexpected error: %v", err) + } +} + +// TestIsExporterEnabled verifies the isExporterEnabled helper function correctly +// identifies when an exporter should be enabled or disabled. +func TestIsExporterEnabled(t *testing.T) { + tests := []struct { + name string + exporter string + expected bool + }{ + {"otlp enabled", OTelExporterOTLP, true}, + {"none disabled", OTelExporterNone, false}, + {"empty string disabled", "", false}, + {"custom exporter enabled", "custom", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isExporterEnabled(tt.exporter) + if result != tt.expected { + t.Errorf("isExporterEnabled(%q) = %t, want %t", tt.exporter, result, tt.expected) + } + }) + } +} + +// TestOTelConfig_MinimalConfig verifies that the SDK can be initialized with +// a minimal configuration where only the exporter settings are specified. +func TestOTelConfig_MinimalConfig(t *testing.T) { + // Test minimal config with all exporters explicitly disabled + cfg := OTelConfig{ + TracesExporter: OTelExporterNone, + MetricsExporter: OTelExporterNone, + LogsExporter: OTelExporterNone, + } + + ctx := context.Background() + shutdown, err := SetupOTelSDKWithConfig(ctx, cfg) + + if err != nil { + t.Fatalf("unexpected error with minimal config: %v", err) + } + + if shutdown == nil { + t.Fatal("expected non-nil shutdown function") + } + + err = shutdown(ctx) + if err != nil { + t.Errorf("shutdown returned unexpected error: %v", err) + } +}