diff --git a/.env.dev b/.env.dev index 91c8068..f2ffe2f 100644 --- a/.env.dev +++ b/.env.dev @@ -229,3 +229,11 @@ IMAGE_PROXY_CACHE_MAX_GB=5 # IMAGE_PROXY_CDN_URL= IMAGE_PROXY_FETCH_TIMEOUT_SECONDS=30 IMAGE_PROXY_MAX_SOURCE_SIZE_MB=10 + +# ============================================================================= +# OpenTelemetry Observability (Optional) +# ============================================================================= +# Disabled by default. Enable for local Jaeger: docker compose --profile observability up +OTEL_ENABLED=false +# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +# OTEL_SERVICE_NAME=coves-appview-dev diff --git a/.env.dev.example b/.env.dev.example index fc45645..94dcbda 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -105,3 +105,11 @@ IMAGE_PROXY_CACHE_MAX_GB=5 # IMAGE_PROXY_CDN_URL= IMAGE_PROXY_FETCH_TIMEOUT_SECONDS=30 IMAGE_PROXY_MAX_SOURCE_SIZE_MB=10 + +# ============================================================================= +# OpenTelemetry Observability (Optional) +# ============================================================================= +# Disabled by default. Enable for local Jaeger: docker compose --profile observability up +OTEL_ENABLED=false +# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +# OTEL_SERVICE_NAME=coves-appview-dev diff --git a/.env.prod.example b/.env.prod.example index 1e71e35..6d2fbcd 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -183,6 +183,16 @@ IMAGE_PROXY_CACHE_MAX_GB=10 IMAGE_PROXY_FETCH_TIMEOUT_SECONDS=30 IMAGE_PROXY_MAX_SOURCE_SIZE_MB=10 +# ============================================================================= +# OpenTelemetry Observability (Optional) +# ============================================================================= +# Enable with any OTLP-compatible backend (Axiom, Grafana, Jaeger, Honeycomb) +OTEL_ENABLED=false +# OTEL_EXPORTER_OTLP_ENDPOINT=https://api.axiom.co +# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer xapt-xxx,X-Axiom-Dataset=coves +# OTEL_SERVICE_NAME=coves-appview +# OTEL_TRACES_SAMPLER_ARG=0.1 + # ============================================================================= # Optional: Versioning # ============================================================================= diff --git a/Makefile b/Makefile index ed3daf0..c7ade29 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev-up dev-down dev-logs dev-status dev-reset test test-all e2e-test clean verify-stack create-test-account mobile-full-setup +.PHONY: help dev-up dev-up-otel dev-down dev-logs dev-status dev-reset test test-all e2e-test clean verify-stack create-test-account mobile-full-setup # Default target - show help .DEFAULT_GOAL := help @@ -45,12 +45,33 @@ dev-up: ## Start PDS + PostgreSQL + Jetstream + PLC Directory for local developm @echo " 1. Run: make run (starts AppView)" @echo " 2. AppView will auto-index users from Jetstream" @echo "" + @echo "$(CYAN)Optional:$(RESET) Run 'make dev-up-otel' to add Jaeger for tracing" @echo "$(CYAN)Note:$(RESET) Using local PLC directory - DIDs registered locally (won't pollute plc.directory)" @echo "Run 'make dev-logs' to view logs" -dev-down: ## Stop all development services +dev-up-otel: ## Start dev stack + Jaeger for OpenTelemetry tracing + @echo "$(GREEN)Starting Coves development stack with OpenTelemetry...$(RESET)" + @docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile jetstream --profile plc --profile observability up -d postgres postgres-plc plc-directory pds jetstream jaeger + @echo "" + @echo "$(GREEN)✓ Development stack with tracing started!$(RESET)" + @echo "" + @echo "Services available at:" + @echo " - PostgreSQL: localhost:5435" + @echo " - PDS (XRPC): http://localhost:3001" + @echo " - Jetstream: ws://localhost:6008/subscribe" + @echo " - PLC Directory: http://localhost:3002" + @echo " - $(CYAN)Jaeger UI: http://localhost:16686$(RESET) $(CYAN)(Trace viewer)$(RESET)" + @echo " - $(CYAN)OTLP Collector: localhost:4317$(RESET) $(CYAN)(gRPC endpoint)$(RESET)" + @echo "" + @echo "$(CYAN)To enable tracing in AppView:$(RESET)" + @echo " export OTEL_ENABLED=true" + @echo " export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317" + @echo " export OTEL_EXPORTER_OTLP_INSECURE=true" + @echo " make run" + +dev-down: ## Stop all development services (including Jaeger if running) @echo "$(YELLOW)Stopping Coves development stack...$(RESET)" - @docker-compose -f docker-compose.dev.yml --env-file .env.dev down --remove-orphans + @docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile jetstream --profile plc --profile observability --profile test down --remove-orphans @docker network rm coves-dev-network 2>/dev/null || true @echo "$(GREEN)✓ Development stack stopped$(RESET)" diff --git a/cmd/server/main.go b/cmd/server/main.go index 93f5543..86fa13f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,6 +23,7 @@ import ( "Coves/internal/atproto/identity" "Coves/internal/atproto/jetstream" "Coves/internal/atproto/oauth" + "Coves/internal/observability" imageproxyhandlers "Coves/internal/api/handlers/imageproxy" "Coves/internal/core/imageproxy" @@ -106,6 +107,24 @@ func main() { log.Println("Migrations completed successfully") + // Initialize optional OpenTelemetry observability + otelConfig := observability.ConfigFromEnv() + if err := otelConfig.Validate(); err != nil { + log.Fatalf("Invalid OpenTelemetry configuration: %v", err) + } + otelProvider, err := observability.NewProvider(context.Background(), otelConfig) + if err != nil { + log.Fatalf("Failed to initialize OpenTelemetry: %v", err) + } + defer func() { + if shutdownErr := otelProvider.Shutdown(context.Background()); shutdownErr != nil { + log.Printf("Error shutting down OpenTelemetry: %v", shutdownErr) + } + }() + if otelConfig.Enabled { + log.Printf("OpenTelemetry tracing enabled (endpoint: %s)", otelConfig.Endpoint) + } + r := chi.NewRouter() r.Use(chiMiddleware.Logger) @@ -116,6 +135,11 @@ func main() { rateLimiter := middleware.NewRateLimiter(100, 1*time.Minute) r.Use(rateLimiter.Middleware) + // Optional: OpenTelemetry HTTP tracing middleware + if otelMiddleware := observability.HTTPMiddleware(otelProvider); otelMiddleware != nil { + r.Use(otelMiddleware) + } + // Initialize identity resolver // IMPORTANT: In dev mode, identity resolution MUST use the same local PLC // directory as DID registration to ensure E2E tests work without hitting diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 572a0b4..c3fcc86 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -295,6 +295,22 @@ services: profiles: - relay + # Jaeger - Distributed tracing UI for OpenTelemetry + # Usage: docker compose --profile observability up + # Access UI at: http://localhost:16686 + jaeger: + image: jaegertracing/all-in-one:latest + container_name: coves-dev-jaeger + ports: + - "16686:16686" # Jaeger UI + - "4317:4317" # OTLP gRPC + environment: + COLLECTOR_OTLP_ENABLED: "true" + networks: + - coves-dev + profiles: + - observability + # Coves AppView (Your Go Application) # Subscribes to PDS firehose and indexes Coves-specific data # Note: Uncomment when you have a Dockerfile for the AppView diff --git a/go.mod b/go.mod index 79ad27c..01b817f 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.25.1 require ( github.com/bluesky-social/indigo v0.0.0-20251010013709-8f2296eee90f + github.com/disintegration/imaging v1.6.2 github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/cors v1.2.2 github.com/gorilla/websocket v1.5.3 @@ -15,23 +16,30 @@ require ( github.com/rivo/uniseg v0.4.7 github.com/stretchr/testify v1.10.0 github.com/xeipuuv/gojsonschema v1.2.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 + go.opentelemetry.io/otel v1.34.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.26.0 + go.opentelemetry.io/otel/sdk v1.34.0 + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 golang.org/x/net v0.46.0 golang.org/x/time v0.3.0 + google.golang.org/grpc v1.63.2 ) require ( github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/disintegration/imaging v1.6.2 // indirect github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.5 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect @@ -74,18 +82,21 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect - go.opentelemetry.io/otel v1.26.0 // indirect - go.opentelemetry.io/otel/metric v1.26.0 // indirect - go.opentelemetry.io/otel/trace v1.26.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect + go.opentelemetry.io/proto/otlp v1.2.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect golang.org/x/crypto v0.43.0 // indirect - golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect - golang.org/x/sync v0.10.0 // indirect + golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.2.1 // indirect diff --git a/go.sum b/go.sum index 264ebb3..1ecd8e1 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bluesky-social/indigo v0.0.0-20251010013709-8f2296eee90f h1:95XLfnirbj3yKv5zA2gYbUDrnZObG/1Irwmaw4ukLec= github.com/bluesky-social/indigo v0.0.0-20251010013709-8f2296eee90f/go.mod h1:GuGAU33qKulpZCZNPcUeIQ4RW6KzNvOy7s8MSUXbAng= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= @@ -23,8 +25,8 @@ github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hH github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/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-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= @@ -44,6 +46,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 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.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= @@ -144,8 +148,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= @@ -183,21 +187,31 @@ gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRyS gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= +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/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= -go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= -go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= -go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= -go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= -go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= -go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0 h1:1u/AyyOqAWzy+SkPxDpahCNZParHV8Vid1RnI2clyDE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0/go.mod h1:z46paqbJ9l7c9fIPCXTqTGwhQZ5XoTIsfeFYWboizjs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.26.0 h1:Waw9Wfpo/IXzOI8bCB7DIk+0JZcqqsyn1JFnAc+iam8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.26.0/go.mod h1:wnJIG4fOqyynOnnQF/eQb4/16VlX2EJAHhHgqIqWfAo= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94= +go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= -go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= -go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -232,8 +246,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -248,6 +262,8 @@ golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -266,6 +282,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= +google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:VUhTRKeHn9wwcdrk73nvdC9gF178Tzhmt/qyaFcPLSo= +google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de h1:jFNzHPIeuzhdRwVhbZdiym9q0ory/xY3sA+v2wPg8I0= +google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:5iCWqnniDlqZHrd3neWVTOwvh/v6s3232omMecelax8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= +google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/observability/config.go b/internal/observability/config.go new file mode 100644 index 0000000..a6e96e4 --- /dev/null +++ b/internal/observability/config.go @@ -0,0 +1,121 @@ +// Package observability provides OpenTelemetry tracing configuration and middleware for Coves. +package observability + +import ( + "errors" + "fmt" + "log/slog" + "os" + "strconv" +) + +// Config validation errors +var ( + // ErrInvalidSampleRatio is returned when SampleRatio is outside the valid range [0.0, 1.0] + ErrInvalidSampleRatio = errors.New("SampleRatio must be between 0.0 and 1.0") + // ErrMissingEndpoint is returned when Endpoint is empty while tracing is enabled + ErrMissingEndpoint = errors.New("Endpoint is required when observability is enabled") +) + +// Config holds the configuration for OpenTelemetry tracing. +type Config struct { + // Enabled determines whether OpenTelemetry tracing is active. + Enabled bool + + // ServiceName is the name of this service in traces. + ServiceName string + + // Endpoint is the OTLP collector endpoint (e.g., "http://localhost:4317"). + Endpoint string + + // Headers contains optional headers for the OTLP exporter (key=value,key2=value2 format). + Headers string + + // SampleRatio is the trace sampling ratio between 0.0 and 1.0. + // 1.0 means all traces are sampled, 0.5 means 50% are sampled. + SampleRatio float64 + + // Insecure determines whether to use an insecure gRPC connection to the collector. + Insecure bool +} + +// DefaultConfig returns a Config with sensible default values. +// By default, tracing is disabled to avoid unexpected overhead. +func DefaultConfig() Config { + return Config{ + Enabled: false, + ServiceName: "coves-appview", + Endpoint: "http://localhost:4317", + Headers: "", + SampleRatio: 1.0, + Insecure: false, + } +} + +// ConfigFromEnv creates a Config from environment variables. +// Uses defaults for any missing environment variables. +// +// Environment variables: +// - OTEL_ENABLED: "true"/"1" to enable, "false"/"0" to disable (default: false) +// - OTEL_SERVICE_NAME: service name for traces (default: "coves-appview") +// - OTEL_EXPORTER_OTLP_ENDPOINT: OTLP collector endpoint (default: "http://localhost:4317") +// - OTEL_EXPORTER_OTLP_HEADERS: headers for OTLP exporter in key=value,key2=value2 format (default: "") +// - OTEL_TRACES_SAMPLER_ARG: sampling ratio 0.0-1.0 (default: 1.0) +// - OTEL_EXPORTER_OTLP_INSECURE: "true"/"1" for insecure connection (default: false) +func ConfigFromEnv() Config { + cfg := DefaultConfig() + + if v := os.Getenv("OTEL_ENABLED"); v != "" { + cfg.Enabled = v == "true" || v == "1" + } + + if v := os.Getenv("OTEL_SERVICE_NAME"); v != "" { + cfg.ServiceName = v + } + + if v := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); v != "" { + cfg.Endpoint = v + } + + if v := os.Getenv("OTEL_EXPORTER_OTLP_HEADERS"); v != "" { + cfg.Headers = v + } + + if v := os.Getenv("OTEL_TRACES_SAMPLER_ARG"); v != "" { + if ratio, err := strconv.ParseFloat(v, 64); err == nil && ratio >= 0.0 && ratio <= 1.0 { + cfg.SampleRatio = ratio + } else { + slog.Warn("[OTEL] invalid OTEL_TRACES_SAMPLER_ARG value, using default", + "value", v, + "default", cfg.SampleRatio, + "error", err, + ) + } + } + + if v := os.Getenv("OTEL_EXPORTER_OTLP_INSECURE"); v != "" { + cfg.Insecure = v == "true" || v == "1" + } + + return cfg +} + +// Validate checks the configuration for invalid values. +// Returns nil if the configuration is valid, or an error describing the problem. +// When Enabled is false, only the SampleRatio is validated (for safety). +// When Enabled is true, all required fields must be set. +func (c Config) Validate() error { + // Always validate SampleRatio regardless of enabled state + if c.SampleRatio < 0.0 || c.SampleRatio > 1.0 { + return fmt.Errorf("%w: got %f", ErrInvalidSampleRatio, c.SampleRatio) + } + + // When enabled, validate required fields + if c.Enabled { + if c.Endpoint == "" { + return ErrMissingEndpoint + } + } + + return nil +} diff --git a/internal/observability/middleware.go b/internal/observability/middleware.go new file mode 100644 index 0000000..28f9b27 --- /dev/null +++ b/internal/observability/middleware.go @@ -0,0 +1,21 @@ +package observability + +import ( + "net/http" + + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +// HTTPMiddleware returns an HTTP middleware that instruments requests with OpenTelemetry tracing. +// If the provider is nil or tracing is not enabled, it returns nil (no middleware applied). +// The middleware adds trace context to incoming requests and creates spans for each request. +func HTTPMiddleware(p *Provider) func(http.Handler) http.Handler { + if p == nil || !p.Enabled() { + return nil + } + + // Use otelhttp for standard net/http instrumentation + return func(next http.Handler) http.Handler { + return otelhttp.NewHandler(next, "coves-appview") + } +} diff --git a/internal/observability/provider.go b/internal/observability/provider.go new file mode 100644 index 0000000..fa6a425 --- /dev/null +++ b/internal/observability/provider.go @@ -0,0 +1,146 @@ +package observability + +import ( + "context" + "fmt" + "strings" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + "google.golang.org/grpc/credentials/insecure" +) + +// Provider manages the OpenTelemetry TracerProvider lifecycle. +type Provider struct { + enabled bool + tracerProvider *trace.TracerProvider +} + +// NewProvider creates a new OpenTelemetry provider based on the given configuration. +// If tracing is not enabled, it returns a Provider with enabled=false and no active tracer. +// When enabled, it creates an OTLP gRPC exporter and configures the global tracer provider. +func NewProvider(ctx context.Context, cfg Config) (*Provider, error) { + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid observability config: %w", err) + } + + if !cfg.Enabled { + return &Provider{enabled: false}, nil + } + + // Build exporter options + opts := []otlptracegrpc.Option{ + otlptracegrpc.WithEndpoint(stripScheme(cfg.Endpoint)), + } + + if cfg.Insecure { + opts = append(opts, otlptracegrpc.WithTLSCredentials(insecure.NewCredentials())) + } + + // Parse and add headers if provided + if cfg.Headers != "" { + headers := parseHeaders(cfg.Headers) + if len(headers) > 0 { + opts = append(opts, otlptracegrpc.WithHeaders(headers)) + } + } + + // Create the OTLP gRPC exporter + exporter, err := otlptracegrpc.New(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("failed to create OTLP trace exporter: %w", err) + } + + // Create the resource with service information + res, err := resource.Merge( + resource.Default(), + resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceName(cfg.ServiceName), + ), + ) + if err != nil { + return nil, fmt.Errorf("failed to create resource: %w", err) + } + + // Create the sampler based on the sample ratio + var sampler trace.Sampler + if cfg.SampleRatio >= 1.0 { + sampler = trace.AlwaysSample() + } else if cfg.SampleRatio <= 0.0 { + sampler = trace.NeverSample() + } else { + sampler = trace.TraceIDRatioBased(cfg.SampleRatio) + } + + // Create the TracerProvider with the exporter and sampler + tp := trace.NewTracerProvider( + trace.WithBatcher(exporter), + trace.WithResource(res), + trace.WithSampler(sampler), + ) + + // Register the global tracer provider + otel.SetTracerProvider(tp) + + return &Provider{ + enabled: true, + tracerProvider: tp, + }, nil +} + +// Shutdown flushes any remaining spans and shuts down the tracer provider. +// It should be called when the application is shutting down to ensure +// all traces are exported before the process exits. +func (p *Provider) Shutdown(ctx context.Context) error { + if p == nil || !p.enabled || p.tracerProvider == nil { + return nil + } + + if err := p.tracerProvider.Shutdown(ctx); err != nil { + return fmt.Errorf("failed to shutdown tracer provider: %w", err) + } + + return nil +} + +// Enabled returns whether tracing is enabled for this provider. +func (p *Provider) Enabled() bool { + if p == nil { + return false + } + return p.enabled +} + +// stripScheme removes the http:// or https:// prefix from an endpoint URL. +// The gRPC exporter expects just host:port, not a full URL. +func stripScheme(endpoint string) string { + endpoint = strings.TrimPrefix(endpoint, "http://") + endpoint = strings.TrimPrefix(endpoint, "https://") + return endpoint +} + +// parseHeaders parses a comma-separated list of key=value pairs into a map. +// Example: "key1=value1,key2=value2" -> map[string]string{"key1": "value1", "key2": "value2"} +func parseHeaders(headers string) map[string]string { + result := make(map[string]string) + pairs := strings.Split(headers, ",") + for _, pair := range pairs { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + parts := strings.SplitN(pair, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + if key != "" { + result[key] = value + } + } + } + return result +}