diff --git a/README.md b/README.md index 3bac80f..af289db 100755 --- a/README.md +++ b/README.md @@ -87,14 +87,17 @@ For full documentation, visit https://docs.coldbrew.cloud - [Constants](<#constants>) - [func ConfigureInterceptors\(DoNotLogGRPCReflection bool, traceHeaderName string, responseTimeLogLevel string, responseTimeLogErrorOnly bool\)](<#ConfigureInterceptors>) - [func InitializeVTProto\(\)](<#InitializeVTProto>) +- [func OTELMeterProvider\(\) otelmetric.MeterProvider](<#OTELMeterProvider>) - [func SetOTELGRPCClientOptions\(opts ...otelgrpc.Option\)](<#SetOTELGRPCClientOptions>) - [func SetOTELGRPCServerOptions\(opts ...otelgrpc.Option\)](<#SetOTELGRPCServerOptions>) +- [func SetOTELOptions\(opts grpcotel.Options\)](<#SetOTELOptions>) - [func SetupAutoMaxProcs\(\)](<#SetupAutoMaxProcs>) - [func SetupEnvironment\(env string\)](<#SetupEnvironment>) - [func SetupHystrixPrometheus\(\)](<#SetupHystrixPrometheus>) - [func SetupLogger\(logLevel string, jsonlogs bool\) error](<#SetupLogger>) - [func SetupNROpenTelemetry\(serviceName, license, version string, ratio float64\) error](<#SetupNROpenTelemetry>) - [func SetupNewRelic\(serviceName, apiKey string, tracing bool\) error](<#SetupNewRelic>) +- [func SetupOTELMetrics\(config OTLPConfig, interval time.Duration\) \(\*sdkmetric.MeterProvider, error\)](<#SetupOTELMetrics>) - [func SetupOpenTelemetry\(config OTLPConfig\) error](<#SetupOpenTelemetry>) - [func SetupReleaseName\(rel string\)](<#SetupReleaseName>) - [func SetupSentry\(dsn string\)](<#SetupSentry>) @@ -115,7 +118,7 @@ const SupportPackageIsVersion1 = true ``` -## func [ConfigureInterceptors]() +## func [ConfigureInterceptors]() ```go func ConfigureInterceptors(DoNotLogGRPCReflection bool, traceHeaderName string, responseTimeLogLevel string, responseTimeLogErrorOnly bool) @@ -124,7 +127,7 @@ func ConfigureInterceptors(DoNotLogGRPCReflection bool, traceHeaderName string, ConfigureInterceptors configures the interceptors package with the provided settings. -## func [InitializeVTProto]() +## func [InitializeVTProto]() ```go func InitializeVTProto() @@ -134,26 +137,44 @@ InitializeVTProto initializes the vtproto package for use with the service https://github.com/planetscale/vtprotobuf?tab=readme-ov-file#mixing-protobuf-implementations-with-grpc + +## func [OTELMeterProvider]() + +```go +func OTELMeterProvider() otelmetric.MeterProvider +``` + +OTELMeterProvider returns the global OTel MeterProvider. This is a convenience accessor for code that needs the interface type. + -## func [SetOTELGRPCClientOptions]() +## func [SetOTELGRPCClientOptions]() ```go func SetOTELGRPCClientOptions(opts ...otelgrpc.Option) ``` -SetOTELGRPCClientOptions sets options for the OTEL gRPC client stats handler. Must be called during init, before the gRPC client is created. +Deprecated: Use SetOTELOptions instead. Only applies when OTEL\_USE\_LEGACY\_INSTRUMENTATION=true. -## func [SetOTELGRPCServerOptions]() +## func [SetOTELGRPCServerOptions]() ```go func SetOTELGRPCServerOptions(opts ...otelgrpc.Option) ``` -SetOTELGRPCServerOptions sets options for the OTEL gRPC server stats handler. Must be called during init, before the gRPC server starts. Example: core.SetOTELGRPCServerOptions\(otelgrpc.WithFilter\(...\)\) +Deprecated: Use SetOTELOptions instead. Only applies when OTEL\_USE\_LEGACY\_INSTRUMENTATION=true. + + +## func [SetOTELOptions]() + +```go +func SetOTELOptions(opts grpcotel.Options) +``` + +SetOTELOptions configures the native gRPC stats/opentelemetry integration. Must be called during init, before the gRPC server starts. When set, processConfig\(\) will NOT overwrite these with auto\-built options. -## func [SetupAutoMaxProcs]() +## func [SetupAutoMaxProcs]() ```go func SetupAutoMaxProcs() @@ -162,7 +183,7 @@ func SetupAutoMaxProcs() SetupAutoMaxProcs sets up the GOMAXPROCS to match Linux container CPU quota This is used to set the GOMAXPROCS to the number of CPUs allocated to the container -## func [SetupEnvironment]() +## func [SetupEnvironment]() ```go func SetupEnvironment(env string) @@ -171,7 +192,7 @@ func SetupEnvironment(env string) SetupEnvironment sets the environment This is used to identify the environment in Sentry and New Relic env is the environment to set for the service \(e.g. prod, staging, dev\) -## func [SetupHystrixPrometheus]() +## func [SetupHystrixPrometheus]() ```go func SetupHystrixPrometheus() @@ -180,7 +201,7 @@ func SetupHystrixPrometheus() SetupHystrixPrometheus sets up the hystrix metrics This is a workaround for hystrix\-go not supporting the prometheus registry It uses sync.Once to ensure the Prometheus collectors are only registered once, since duplicate registration panics. -## func [SetupLogger]() +## func [SetupLogger]() ```go func SetupLogger(logLevel string, jsonlogs bool) error @@ -189,7 +210,7 @@ func SetupLogger(logLevel string, jsonlogs bool) error SetupLogger sets up the logger It uses the coldbrew logger to log messages to stdout logLevel is the log level to set for the logger jsonlogs is a boolean to enable or disable json logs -## func [SetupNROpenTelemetry]() +## func [SetupNROpenTelemetry]() ```go func SetupNROpenTelemetry(serviceName, license, version string, ratio float64) error @@ -207,7 +228,7 @@ Parameters: - ratio: the sampling ratio to use for traces \(0.0 to 1.0\) -## func [SetupNewRelic]() +## func [SetupNewRelic]() ```go func SetupNewRelic(serviceName, apiKey string, tracing bool) error @@ -215,27 +236,37 @@ func SetupNewRelic(serviceName, apiKey string, tracing bool) error SetupNewRelic sets up the New Relic tracing and monitoring agent for the service It uses the New Relic Go Agent to send traces to New Relic One APM and Insights serviceName is the name of the service apiKey is the New Relic license key tracing is a boolean to enable or disable tracing + +## func [SetupOTELMetrics]() + +```go +func SetupOTELMetrics(config OTLPConfig, interval time.Duration) (*sdkmetric.MeterProvider, error) +``` + +SetupOTELMetrics creates a MeterProvider with an OTLP gRPC exporter that reuses the same resource as the TracerProvider \(set by SetupOpenTelemetry\). The MeterProvider is set as the global OTel MeterProvider. + +Call this after SetupOpenTelemetry so the shared resource is available. + -## func [SetupOpenTelemetry]() +## func [SetupOpenTelemetry]() ```go func SetupOpenTelemetry(config OTLPConfig) error ``` -SetupOpenTelemetry sets up OpenTelemetry tracing with a generic OTLP exporter +SetupOpenTelemetry sets up OpenTelemetry tracing with a generic OTLP exporter. -This function provides a flexible way to configure OpenTelemetry tracing with any OTLP\-compatible backend. It sets up the trace provider, configures sampling, and optionally sets up an OpenTracing bridge for compatibility. +It configures a TracerProvider with the given sampling ratio and OTLP backend, sets it as the global provider, and stores it for graceful shutdown. Example usage with Jaeger: ``` config := OTLPConfig{ - Endpoint: "localhost:4317", - ServiceName: "my-service", - ServiceVersion: "v1.0.0", - SamplingRatio: 0.1, - UseOpenTracingBridge: true, - Insecure: true, // for local development + Endpoint: "localhost:4317", + ServiceName: "my-service", + ServiceVersion: "v1.0.0", + SamplingRatio: 0.1, + Insecure: true, // for local development } err := SetupOpenTelemetry(config) ``` @@ -254,7 +285,7 @@ err := SetupOpenTelemetry(config) ``` -## func [SetupReleaseName]() +## func [SetupReleaseName]() ```go func SetupReleaseName(rel string) @@ -263,7 +294,7 @@ func SetupReleaseName(rel string) SetupReleaseName sets the release name This is used to identify the release in Sentry rel is the release name to set for the service \(e.g. v1.0.0\) -## func [SetupSentry]() +## func [SetupSentry]() ```go func SetupSentry(dsn string) @@ -293,7 +324,7 @@ type CB interface { ``` -### func [New]() +### func [New]() ```go func New(c config.Config) CB @@ -346,7 +377,7 @@ type CBStopper interface { ``` -## type [OTLPConfig]() +## type [OTLPConfig]() OTLPConfig holds configuration for OpenTelemetry OTLP exporter @@ -378,10 +409,6 @@ type OTLPConfig struct { // If empty, defaults to "gzip" Compression string - // UseOpenTracingBridge determines whether to set up OpenTracing compatibility bridge - // This allows using OpenTracing instrumentation with OpenTelemetry - UseOpenTracingBridge bool - // Insecure disables TLS verification for the connection // Only use this for local development or testing Insecure bool diff --git a/config/README.md b/config/README.md index 98786b6..7f87c78 100755 --- a/config/README.md +++ b/config/README.md @@ -66,7 +66,7 @@ import "github.com/go-coldbrew/core/config" -## type [Config]() +## type [Config]() Config is the configuration for the Coldbrew server It is populated from environment variables and has sensible defaults for all fields so that you can just use it as is without any configuration The following environment variables are supported and can be used to override the defaults for the fields @@ -199,11 +199,22 @@ type Config struct { // OTLPSamplingRatio is the ratio of traces to sample (0.0 to 1.0) // 1.0 means sample all traces, 0.1 means sample 10% of traces OTLPSamplingRatio float64 `envconfig:"OTLP_SAMPLING_RATIO" default:"0.1"` - // Deprecated: OpenTracing bridge is provided for backwards compatibility only. - // New services should leave this false (the default). Set to true only if you - // have existing OpenTracing instrumentation that hasn't been migrated to OTEL. + // Deprecated: OpenTracing bridge has been removed. This field is ignored. + // If set to true, a warning is logged at startup. OTLPUseOpenTracingBridge bool `envconfig:"OTLP_USE_OPENTRACING_BRIDGE" default:"false"` + // OTELUseLegacyInstrumentation reverts to the deprecated otelgrpc contrib + // package for gRPC OpenTelemetry instrumentation. Default false (uses native + // grpc stats/opentelemetry). Set to true only for rollback. + OTELUseLegacyInstrumentation bool `envconfig:"OTEL_USE_LEGACY_INSTRUMENTATION" default:"false"` + + // EnableOTELMetrics enables OpenTelemetry metrics export via OTLP alongside + // Prometheus. Does not replace Prometheus. Default false. + EnableOTELMetrics bool `envconfig:"ENABLE_OTEL_METRICS" default:"false"` + // OTELMetricsInterval controls the export interval in seconds for OTEL + // metrics. Default 60. + OTELMetricsInterval int `envconfig:"OTEL_METRICS_INTERVAL" default:"60"` + // DisableHTTPCompression disables gzip/zstd compression for HTTP gateway responses DisableHTTPCompression bool `envconfig:"DISABLE_HTTP_COMPRESSION" default:"false"` // HTTPCompressionMinSize is the minimum response body size (bytes) before compression is applied. @@ -223,7 +234,7 @@ type Config struct { ``` -### func \(Config\) [Validate]() +### func \(Config\) [Validate]() ```go func (c Config) Validate() []string diff --git a/config/config.go b/config/config.go index e90c08e..b54d4c2 100644 --- a/config/config.go +++ b/config/config.go @@ -134,11 +134,22 @@ type Config struct { // OTLPSamplingRatio is the ratio of traces to sample (0.0 to 1.0) // 1.0 means sample all traces, 0.1 means sample 10% of traces OTLPSamplingRatio float64 `envconfig:"OTLP_SAMPLING_RATIO" default:"0.1"` - // Deprecated: OpenTracing bridge is provided for backwards compatibility only. - // New services should leave this false (the default). Set to true only if you - // have existing OpenTracing instrumentation that hasn't been migrated to OTEL. + // Deprecated: OpenTracing bridge has been removed. This field is ignored. + // If set to true, a warning is logged at startup. OTLPUseOpenTracingBridge bool `envconfig:"OTLP_USE_OPENTRACING_BRIDGE" default:"false"` + // OTELUseLegacyInstrumentation reverts to the deprecated otelgrpc contrib + // package for gRPC OpenTelemetry instrumentation. Default false (uses native + // grpc stats/opentelemetry). Set to true only for rollback. + OTELUseLegacyInstrumentation bool `envconfig:"OTEL_USE_LEGACY_INSTRUMENTATION" default:"false"` + + // EnableOTELMetrics enables OpenTelemetry metrics export via OTLP alongside + // Prometheus. Does not replace Prometheus. Default false. + EnableOTELMetrics bool `envconfig:"ENABLE_OTEL_METRICS" default:"false"` + // OTELMetricsInterval controls the export interval in seconds for OTEL + // metrics. Default 60. + OTELMetricsInterval int `envconfig:"OTEL_METRICS_INTERVAL" default:"60"` + // Throughput tuning // DisableHTTPCompression disables gzip/zstd compression for HTTP gateway responses @@ -189,6 +200,9 @@ func (c Config) Validate() []string { if c.HTTPCompressionMinSize < 0 { warnings = append(warnings, "HTTPCompressionMinSize is negative; this may cause unexpected behavior") } + if c.EnableOTELMetrics && c.OTELMetricsInterval <= 0 { + warnings = append(warnings, "OTELMetricsInterval should be positive when ENABLE_OTEL_METRICS is true") + } return warnings } diff --git a/core.go b/core.go index cb2b0f2..2694d3d 100644 --- a/core.go +++ b/core.go @@ -31,15 +31,18 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/propagation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" semconv "go.opentelemetry.io/otel/semconv/v1.40.0" oteltrace "go.opentelemetry.io/otel/trace" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" _ "google.golang.org/grpc/encoding/gzip" + experimental "google.golang.org/grpc/experimental/opentelemetry" "google.golang.org/grpc/keepalive" "google.golang.org/grpc/reflection" "google.golang.org/grpc/stats" + grpcotel "google.golang.org/grpc/stats/opentelemetry" ) // SupportPackageIsVersion1 is a compile-time assertion constant. @@ -153,25 +156,29 @@ func (c *cb) processConfig() { } } + // Warn if deprecated OpenTracing bridge env var is still set. + if c.config.OTLPUseOpenTracingBridge { //nolint:staticcheck // reading deprecated field to emit warning + log.Warn(context.Background(), "msg", "OTLP_USE_OPENTRACING_BRIDGE is set but OpenTracing bridge has been removed; this setting is ignored") + } + // Setup OpenTelemetry - custom OTLP takes precedence over New Relic + prevTP := otelTracerProvider // track whether this call initializes a new provider + var otlpConfig OTLPConfig if c.config.OTLPEndpoint != "" { - // Use custom OTLP configuration headers := parseHeaders(c.config.OTLPHeaders) - otlpConfig := OTLPConfig{ - Endpoint: c.config.OTLPEndpoint, - Headers: headers, - ServiceName: c.config.AppName, - ServiceVersion: c.config.ReleaseName, - SamplingRatio: c.config.OTLPSamplingRatio, - Compression: c.config.OTLPCompression, - UseOpenTracingBridge: c.config.OTLPUseOpenTracingBridge, //nolint:staticcheck // reading deprecated field for backward compat - Insecure: c.config.OTLPInsecure, + otlpConfig = OTLPConfig{ + Endpoint: c.config.OTLPEndpoint, + Headers: headers, + ServiceName: c.config.AppName, + ServiceVersion: c.config.ReleaseName, + SamplingRatio: c.config.OTLPSamplingRatio, + Compression: c.config.OTLPCompression, + Insecure: c.config.OTLPInsecure, } if err := SetupOpenTelemetry(otlpConfig); err != nil { log.Error(context.Background(), "msg", "Failed to setup custom OTLP", "err", err) } } else if c.config.NewRelicOpentelemetry { - // Fall back to New Relic OpenTelemetry if no custom OTLP is configured err := SetupNROpenTelemetry( nrName, c.config.NewRelicLicenseKey, @@ -181,6 +188,58 @@ func (c *cb) processConfig() { if err != nil { log.Error(context.Background(), "msg", "Failed to setup New Relic OpenTelemetry", "err", err) } + // Build otlpConfig for NR path so OTEL metrics can reuse the endpoint. + // Only populate when the license key is non-empty (SetupNROpenTelemetry + // no-ops without it, so metrics would just get auth failures). + if strings.TrimSpace(c.config.NewRelicLicenseKey) != "" { + otlpConfig = OTLPConfig{ + Endpoint: nrOTLPEndpoint, + Headers: map[string]string{"api-key": c.config.NewRelicLicenseKey}, + ServiceName: nrName, + ServiceVersion: c.config.ReleaseName, + Compression: "gzip", + } + } + } + + // Register TracerProvider for graceful shutdown — only if this + // processConfig() call actually initialized a new one. + if tp := otelTracerProvider; tp != nil && tp != prevTP { + c.closers = append(c.closers, closerFunc(func() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return tp.Shutdown(ctx) + })) + } + + if c.config.EnableOTELMetrics { + if otlpConfig.Endpoint == "" { + log.Error(context.Background(), "msg", "ENABLE_OTEL_METRICS is true but no OTLP endpoint is configured; OTEL metrics will not be exported") + } else { + interval := time.Duration(c.config.OTELMetricsInterval) * time.Second + mp, err := SetupOTELMetrics(otlpConfig, interval) + if err != nil { + log.Error(context.Background(), "msg", "Failed to setup OTEL metrics", "err", err) + } else { + otelMeterProvider = mp + c.closers = append(c.closers, closerFunc(func() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return mp.Shutdown(ctx) + })) + } + } + } + + // Record legacy preference so getGRPCServerOptions/initHTTP respect it + // even if SetOTELOptions() was called during init. + otelUseLegacy = c.config.OTELUseLegacyInstrumentation + + // Build native stats/opentelemetry options unless user already called + // SetOTELOptions() or legacy instrumentation is requested. + if !otelUseLegacy && !otelGRPCOptionsSet { + otelGRPCOptions = buildOTELOptions() + otelGRPCOptionsSet = true } } @@ -399,13 +458,18 @@ func (c *cb) initHTTP(ctx context.Context) (*http.Server, error) { opts := []grpc.DialOption{ grpc.WithTransportCredentials(dialCreds), - grpc.WithStatsHandler(otelgrpc.NewClientHandler(otelGRPCClientOpts...)), - grpc.WithUnaryInterceptor( - interceptors.DefaultClientInterceptor( - interceptors.WithoutHystrix(), - ), - ), } + // Use native stats/opentelemetry unless legacy mode is forced via config. + if !otelUseLegacy && otelGRPCOptionsSet { + opts = append(opts, grpcotel.DialOption(otelGRPCOptions)) + } else { + opts = append(opts, grpc.WithStatsHandler(otelgrpc.NewClientHandler(otelGRPCClientOpts...))) + } + opts = append(opts, grpc.WithUnaryInterceptor( + interceptors.DefaultClientInterceptor( + interceptors.WithoutHystrix(), + ), + )) // Mirror configured limits on the client side used by the gateway. if c.config.GRPCMaxRecvMsgSize > 0 { opts = append(opts, @@ -479,8 +543,15 @@ func (c *cb) runHTTP(ctx context.Context, svr *http.Server) error { return svr.ListenAndServe() } -// otelgrpc options configured during init via SetOTELGRPCServerOptions/SetOTELGRPCClientOptions. -// Defaults filter out health/ready/reflection RPCs to reduce noise. +// Native stats/opentelemetry options, built during processConfig(). +var ( + otelGRPCOptionsSet bool // true after processConfig builds or user calls SetOTELOptions + otelGRPCOptions grpcotel.Options // value used by getGRPCServerOptions / initHTTP + otelMeterProvider *sdkmetric.MeterProvider + otelUseLegacy bool // set from config; forces legacy otelgrpc even if SetOTELOptions was called +) + +// Legacy otelgrpc options — only used when OTEL_USE_LEGACY_INSTRUMENTATION=true. var otelGRPCServerOpts = []otelgrpc.Option{ otelgrpc.WithFilter(defaultOTELFilter), } @@ -489,30 +560,61 @@ var otelGRPCClientOpts = []otelgrpc.Option{ otelgrpc.WithFilter(defaultOTELFilter), } -// defaultOTELFilter excludes health checks, readiness probes, and gRPC -// reflection from tracing to reduce noise — matching the previous -// grpc_opentracing filter behavior. func defaultOTELFilter(info *stats.RPCTagInfo) bool { return interceptors.FilterMethodsFunc(context.Background(), info.FullMethodName) } -// SetOTELGRPCServerOptions sets options for the OTEL gRPC server stats handler. -// Must be called during init, before the gRPC server starts. -// Example: core.SetOTELGRPCServerOptions(otelgrpc.WithFilter(...)) +// Deprecated: Use SetOTELOptions instead. Only applies when +// OTEL_USE_LEGACY_INSTRUMENTATION=true. func SetOTELGRPCServerOptions(opts ...otelgrpc.Option) { otelGRPCServerOpts = opts } -// SetOTELGRPCClientOptions sets options for the OTEL gRPC client stats handler. -// Must be called during init, before the gRPC client is created. +// Deprecated: Use SetOTELOptions instead. Only applies when +// OTEL_USE_LEGACY_INSTRUMENTATION=true. func SetOTELGRPCClientOptions(opts ...otelgrpc.Option) { otelGRPCClientOpts = opts } +// SetOTELOptions configures the native gRPC stats/opentelemetry integration. +// Must be called during init, before the gRPC server starts. +// When set, processConfig() will NOT overwrite these with auto-built options. +func SetOTELOptions(opts grpcotel.Options) { + otelGRPCOptions = opts + otelGRPCOptionsSet = true +} + +// buildOTELOptions constructs grpcotel.Options from the global TracerProvider +// and TextMapPropagator. If SetupOpenTelemetry was not called, the no-op +// defaults from the OTel SDK are used. +func buildOTELOptions() grpcotel.Options { + opts := grpcotel.Options{ + MetricsOptions: grpcotel.MetricsOptions{ + MethodAttributeFilter: func(method string) bool { + return interceptors.FilterMethodsFunc(context.Background(), method) + }, + }, + TraceOptions: experimental.TraceOptions{ + TracerProvider: otel.GetTracerProvider(), + TextMapPropagator: otel.GetTextMapPropagator(), + }, + } + if otelMeterProvider != nil { + opts.MetricsOptions.MeterProvider = otelMeterProvider + } + return opts +} + func (c *cb) getGRPCServerOptions() []grpc.ServerOption { so := make([]grpc.ServerOption, 0) + + // Use native stats/opentelemetry unless legacy mode is forced via config. + if !otelUseLegacy && otelGRPCOptionsSet { + so = append(so, grpcotel.ServerOption(otelGRPCOptions)) + } else { + so = append(so, grpc.StatsHandler(otelgrpc.NewServerHandler(otelGRPCServerOpts...))) + } so = append(so, - grpc.StatsHandler(otelgrpc.NewServerHandler(otelGRPCServerOpts...)), grpc.ChainUnaryInterceptor(interceptors.DefaultInterceptors()...), grpc.ChainStreamInterceptor(interceptors.DefaultStreamInterceptors()...), ) diff --git a/go.mod b/go.mod index 7724882..ab5026d 100644 --- a/go.mod +++ b/go.mod @@ -15,14 +15,15 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 github.com/klauspost/compress v1.18.5 github.com/newrelic/go-agent/v3 v3.42.0 - github.com/opentracing/opentracing-go v1.2.0 github.com/prometheus/client_golang v1.23.2 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 go.opentelemetry.io/otel v1.43.0 - go.opentelemetry.io/otel/bridge/opentracing v1.42.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 + go.opentelemetry.io/otel/metric v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/sdk/metric v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 go.uber.org/automaxprocs v1.6.0 golang.org/x/sync v0.20.0 @@ -36,6 +37,7 @@ require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect buf.build/go/protovalidate v1.1.3 // indirect cel.dev/expr v0.25.1 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect codeberg.org/chavacava/garif v0.2.0 // indirect codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect dario.cat/mergo v1.0.0 // indirect @@ -268,7 +270,6 @@ require ( go.augendre.info/arangolint v0.4.0 // indirect go.augendre.info/fatcontext v0.9.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect @@ -285,8 +286,8 @@ require ( golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.43.0 // indirect golang.org/x/vuln v1.1.4 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 421f4d7..687c98a 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ buf.build/go/protovalidate v1.1.3 h1:m2GVEgQWd7rk+vIoAZ+f0ygGjvQTuqPQapBBdcpWVPE buf.build/go/protovalidate v1.1.3/go.mod h1:9XIuohWz+kj+9JVn3WQneHA5LZP50mjvneZMnbLkiIE= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY= codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ= codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI= @@ -136,6 +138,8 @@ github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7Lsp github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= @@ -158,6 +162,14 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= @@ -205,6 +217,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.17.1 h1:WnljyxIzSj9BRRUlnmAU35ohDsjRK0EKmL0evDqi5Jk= github.com/go-git/go-git/v5 v5.17.1/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= @@ -458,12 +472,6 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= -github.com/opentracing-contrib/go-grpc v0.1.2 h1:MP16Ozc59kqqwn1v18aQxpeGZhsBanJ2iurZYaQSZ+g= -github.com/opentracing-contrib/go-grpc v0.1.2/go.mod h1:glU6rl1Fhfp9aXUHkE36K2mR4ht8vih0ekOVlWKEUHM= -github.com/opentracing-contrib/go-grpc/test v0.0.0-20260228010633-d566b4d40932 h1:1+AfhHiwUpTWyL3fF7o+tkfbh03kXhx98mhppRQBS5Y= -github.com/opentracing-contrib/go-grpc/test v0.0.0-20260228010633-d566b4d40932/go.mod h1:jJHh2WhnCgGzDzRe6hS7AQ3o3pGq7idF7P0Y+obDB9s= -github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= -github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= @@ -482,6 +490,8 @@ github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxu github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= @@ -573,6 +583,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ= github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= @@ -648,8 +660,8 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/bridge/opentracing v1.42.0 h1:gR6G6tW10WBrzfDJxTn30zhjO5jVCPAxYhUfWoYDnAY= -go.opentelemetry.io/otel/bridge/opentracing v1.42.0/go.mod h1:2cOhnkRuhaijeY9vjtwGWtuZeSaPO3AVNg0haIVwtEg= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= @@ -721,6 +733,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -808,10 +822,10 @@ 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= 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-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= -google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/initializers.go b/initializers.go index 321e15f..a832ddc 100644 --- a/initializers.go +++ b/initializers.go @@ -22,15 +22,16 @@ import ( nrutil "github.com/go-coldbrew/tracing/newrelic" protov1 "github.com/golang/protobuf/proto" //nolint:staticcheck newrelic "github.com/newrelic/go-agent/v3/newrelic" - "github.com/opentracing/opentracing-go" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" - otelBridge "go.opentelemetry.io/otel/bridge/opentracing" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" "go.opentelemetry.io/otel/exporters/otlp/otlptrace" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + otelmetric "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/sdk/resource" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.40.0" "go.uber.org/automaxprocs/maxprocs" @@ -138,30 +139,75 @@ type OTLPConfig struct { // If empty, defaults to "gzip" Compression string - // UseOpenTracingBridge determines whether to set up OpenTracing compatibility bridge - // This allows using OpenTracing instrumentation with OpenTelemetry - UseOpenTracingBridge bool - // Insecure disables TLS verification for the connection // Only use this for local development or testing Insecure bool } -// SetupOpenTelemetry sets up OpenTelemetry tracing with a generic OTLP exporter +// nrOTLPEndpoint is the New Relic OTLP gRPC endpoint. +const nrOTLPEndpoint = "otlp.nr-data.net:4317" + +// otelResource is the shared resource used by both TracerProvider and +// MeterProvider so that traces and metrics correlate in backends. +var otelResource *resource.Resource + +// otelTracerProvider stores the concrete TracerProvider for shutdown. +var otelTracerProvider *sdktrace.TracerProvider + +// buildOTELResource builds a resource with service name, version, build info, +// and VCS metadata. The result is cached in otelResource for reuse. +func buildOTELResource(serviceName, serviceVersion string) (*resource.Resource, error) { + if otelResource != nil { + return otelResource, nil + } + d := resource.Default() + attrs := []attribute.KeyValue{ + semconv.ServiceName(serviceName), + semconv.ServiceVersion(serviceVersion), + } + if bi, ok := debug.ReadBuildInfo(); ok { + attrs = append(attrs, + semconv.ProcessExecutableName(filepath.Base(os.Args[0])), + semconv.ProcessRuntimeVersion(bi.GoVersion), + ) + for _, s := range bi.Settings { + switch s.Key { + case "vcs.revision": + attrs = append(attrs, semconv.VCSRefHeadRevision(s.Value)) + case "vcs.time": + attrs = append(attrs, attribute.String("vcs.time", s.Value)) + case "vcs.modified": + attrs = append(attrs, attribute.Bool("vcs.modified", s.Value == "true")) + } + } + } + res, err := resource.New(context.Background(), + resource.WithAttributes(attrs...), + ) + if err != nil { + return nil, fmt.Errorf("creating OTLP resource: %w", err) + } + r, err := resource.Merge(d, res) + if err != nil { + return nil, fmt.Errorf("merging OTLP resource: %w", err) + } + otelResource = r + return r, nil +} + +// SetupOpenTelemetry sets up OpenTelemetry tracing with a generic OTLP exporter. // -// This function provides a flexible way to configure OpenTelemetry tracing -// with any OTLP-compatible backend. It sets up the trace provider, configures -// sampling, and optionally sets up an OpenTracing bridge for compatibility. +// It configures a TracerProvider with the given sampling ratio and OTLP backend, +// sets it as the global provider, and stores it for graceful shutdown. // // Example usage with Jaeger: // // config := OTLPConfig{ -// Endpoint: "localhost:4317", -// ServiceName: "my-service", -// ServiceVersion: "v1.0.0", -// SamplingRatio: 0.1, -// UseOpenTracingBridge: true, -// Insecure: true, // for local development +// Endpoint: "localhost:4317", +// ServiceName: "my-service", +// ServiceVersion: "v1.0.0", +// SamplingRatio: 0.1, +// Insecure: true, // for local development // } // err := SetupOpenTelemetry(config) // @@ -185,23 +231,17 @@ func SetupOpenTelemetry(config OTLPConfig) error { return nil } - // Default compression to gzip if not specified if config.Compression == "" { config.Compression = "gzip" } - // Build client options clientOpts := []otlptracegrpc.Option{ otlptracegrpc.WithEndpoint(config.Endpoint), otlptracegrpc.WithHeaders(config.Headers), } - - // Add compression if specified if config.Compression != "none" { clientOpts = append(clientOpts, otlptracegrpc.WithCompressor(config.Compression)) } - - // Add insecure option if needed if config.Insecure { clientOpts = append(clientOpts, otlptracegrpc.WithInsecure()) } @@ -212,39 +252,12 @@ func SetupOpenTelemetry(config OTLPConfig) error { return err } - d := resource.Default() - attrs := []attribute.KeyValue{ - semconv.ServiceName(config.ServiceName), - semconv.ServiceVersion(config.ServiceVersion), - } - if bi, ok := debug.ReadBuildInfo(); ok { - attrs = append(attrs, - semconv.ProcessExecutableName(filepath.Base(os.Args[0])), - semconv.ProcessRuntimeVersion(bi.GoVersion), - ) - for _, s := range bi.Settings { - switch s.Key { - case "vcs.revision": - attrs = append(attrs, semconv.VCSRefHeadRevision(s.Value)) - case "vcs.time": - attrs = append(attrs, attribute.String("vcs.time", s.Value)) - case "vcs.modified": - attrs = append(attrs, attribute.Bool("vcs.modified", s.Value == "true")) - } - } - } - res, err := resource.New(context.Background(), - resource.WithAttributes(attrs...), - ) - if err != nil { - log.Error(context.Background(), "msg", "creating OTLP resource", "err", err) - return err - } - r, err := resource.Merge(d, res) + r, err := buildOTELResource(config.ServiceName, config.ServiceVersion) if err != nil { - log.Error(context.Background(), "msg", "merging OTLP resource", "err", err) + log.Error(context.Background(), "msg", "building OTLP resource", "err", err) return err } + // Default sampling ratio when not explicitly set (negative) or invalid (> 1). // 0 is a valid value meaning "sample nothing". ratio := config.SamplingRatio @@ -257,6 +270,7 @@ func SetupOpenTelemetry(config OTLPConfig) error { sdktrace.WithBatcher(otlpExporter), sdktrace.WithResource(r), ) + otelTracerProvider = tracerProvider // Set global propagator for W3C trace context + baggage propagation. // This is required for linking spans across HTTP→gRPC boundaries. @@ -265,21 +279,72 @@ func SetupOpenTelemetry(config OTLPConfig) error { propagation.Baggage{}, )) - if config.UseOpenTracingBridge { - otelTracer := tracerProvider.Tracer(config.ServiceName) - // Use the bridgeTracer as your OpenTracing tracer. - bridgeTracer, wrapperTracerProvider := otelBridge.NewTracerPair(otelTracer) - - otel.SetTracerProvider(wrapperTracerProvider) - opentracing.SetGlobalTracer(bridgeTracer) - } else { - otel.SetTracerProvider(tracerProvider) - } + otel.SetTracerProvider(tracerProvider) log.Info(context.Background(), "msg", "Initialized opentelemetry tracing", "endpoint", config.Endpoint) return nil } +// SetupOTELMetrics creates a MeterProvider with an OTLP gRPC exporter that +// reuses the same resource as the TracerProvider (set by SetupOpenTelemetry). +// The MeterProvider is set as the global OTel MeterProvider. +// +// Call this after SetupOpenTelemetry so the shared resource is available. +func SetupOTELMetrics(config OTLPConfig, interval time.Duration) (*sdkmetric.MeterProvider, error) { + if config.Endpoint == "" { + return nil, fmt.Errorf("OTLP endpoint is required for OTEL metrics") + } + if interval <= 0 { + return nil, fmt.Errorf("OTEL metrics interval must be positive, got %v", interval) + } + if config.Compression == "" { + config.Compression = "gzip" + } + + exporterOpts := []otlpmetricgrpc.Option{ + otlpmetricgrpc.WithEndpoint(config.Endpoint), + otlpmetricgrpc.WithHeaders(config.Headers), + } + if config.Compression != "none" { + exporterOpts = append(exporterOpts, otlpmetricgrpc.WithCompressor(config.Compression)) + } + if config.Insecure { + exporterOpts = append(exporterOpts, otlpmetricgrpc.WithInsecure()) + } + + exporter, err := otlpmetricgrpc.New(context.Background(), exporterOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create OTLP metric exporter: %w", err) + } + + r := otelResource + if r == nil { + // Fallback: build resource if SetupOpenTelemetry wasn't called first. + if config.ServiceName == "" { + return nil, fmt.Errorf("OTEL service name is required when tracing resource is not initialized") + } + r, err = buildOTELResource(config.ServiceName, config.ServiceVersion) + if err != nil { + return nil, fmt.Errorf("building OTLP resource for metrics: %w", err) + } + } + + mp := sdkmetric.NewMeterProvider( + sdkmetric.WithResource(r), + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter, + sdkmetric.WithInterval(interval), + )), + ) + otel.SetMeterProvider(mp) + return mp, nil +} + +// OTELMeterProvider returns the global OTel MeterProvider. This is a convenience +// accessor for code that needs the interface type. +func OTELMeterProvider() otelmetric.MeterProvider { + return otel.GetMeterProvider() +} + // SetupNROpenTelemetry sets up OpenTelemetry tracing with New Relic // // This function configures OpenTelemetry to send traces to New Relic's OTLP endpoint. @@ -297,13 +362,12 @@ func SetupNROpenTelemetry(serviceName, license, version string, ratio float64) e } // Use the generic SetupOpenTelemetry with New Relic specific configuration config := OTLPConfig{ - Endpoint: "otlp.nr-data.net:4317", - Headers: map[string]string{"api-key": license}, - ServiceName: serviceName, - ServiceVersion: version, - SamplingRatio: ratio, - Compression: "gzip", - UseOpenTracingBridge: true, + Endpoint: nrOTLPEndpoint, + Headers: map[string]string{"api-key": license}, + ServiceName: serviceName, + ServiceVersion: version, + SamplingRatio: ratio, + Compression: "gzip", } return SetupOpenTelemetry(config) } diff --git a/initializers_test.go b/initializers_test.go new file mode 100644 index 0000000..46608b1 --- /dev/null +++ b/initializers_test.go @@ -0,0 +1,375 @@ +package core + +import ( + "context" + "testing" + "time" + + "github.com/go-coldbrew/core/config" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + experimental "google.golang.org/grpc/experimental/opentelemetry" + grpcotel "google.golang.org/grpc/stats/opentelemetry" +) + +func TestBuildOTELResource(t *testing.T) { + oldRes := otelResource + otelResource = nil // force rebuild + defer func() { otelResource = oldRes }() + + r, err := buildOTELResource("test-service", "v1.0.0") + if err != nil { + t.Fatalf("buildOTELResource() error: %v", err) + } + if r == nil { + t.Fatal("buildOTELResource() returned nil resource") + } + + // Verify shared var is set + if otelResource == nil { + t.Fatal("otelResource should be set after buildOTELResource()") + } + + // Verify service.name attribute exists + found := false + for _, attr := range r.Attributes() { + if string(attr.Key) == "service.name" && attr.Value.AsString() == "test-service" { + found = true + break + } + } + if !found { + t.Error("resource should contain service.name=test-service") + } +} + +func TestSetupOpenTelemetry_StoresTracerProvider(t *testing.T) { + // Save and restore all globals mutated by SetupOpenTelemetry. + oldTP := otelTracerProvider + oldRes := otelResource + oldGlobalTP := otel.GetTracerProvider() + oldGlobalProp := otel.GetTextMapPropagator() + defer func() { + otelTracerProvider = oldTP + otelResource = oldRes + otel.SetTracerProvider(oldGlobalTP) + otel.SetTextMapPropagator(oldGlobalProp) + }() + + otelTracerProvider = nil + otelResource = nil + + // Use a real OTLP endpoint that will fail to connect but won't error during setup + // (the batcher exporter is async, so the exporter setup succeeds even with invalid endpoints) + err := SetupOpenTelemetry(OTLPConfig{ + ServiceName: "test-service", + Endpoint: "localhost:4317", + Insecure: true, + }) + if err != nil { + t.Fatalf("SetupOpenTelemetry() error: %v", err) + } + + if otelTracerProvider == nil { + t.Fatal("otelTracerProvider should be set after SetupOpenTelemetry()") + } + if otelResource == nil { + t.Fatal("otelResource should be set after SetupOpenTelemetry()") + } + + // Clean up + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = otelTracerProvider.Shutdown(ctx) +} + +func TestSetupOTELMetrics(t *testing.T) { + // Reset globals + oldRes := otelResource + defer func() { otelResource = oldRes }() + + // Build a resource first + _, err := buildOTELResource("test-metrics", "v1.0.0") + if err != nil { + t.Fatalf("buildOTELResource() error: %v", err) + } + + // Save and restore global MeterProvider. + oldGlobalMP := otel.GetMeterProvider() + defer otel.SetMeterProvider(oldGlobalMP) + + mp, err := SetupOTELMetrics(OTLPConfig{ + Endpoint: "localhost:4317", + Insecure: true, + }, 60*time.Second) + if err != nil { + t.Fatalf("SetupOTELMetrics() error: %v", err) + } + if mp == nil { + t.Fatal("SetupOTELMetrics() returned nil MeterProvider") + } + + // Verify it's a concrete *sdkmetric.MeterProvider + var _ *sdkmetric.MeterProvider = mp + + // Shutdown may fail because no OTLP collector is running — that's expected. + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = mp.Shutdown(ctx) +} + +func TestSetupOTELMetrics_NoEndpoint(t *testing.T) { + // Ensure resource exists so test validates endpoint check, not resource fallback. + oldRes := otelResource + defer func() { otelResource = oldRes }() + if _, err := buildOTELResource("test-no-endpoint", "v1.0.0"); err != nil { + t.Fatalf("buildOTELResource() error: %v", err) + } + + _, err := SetupOTELMetrics(OTLPConfig{}, 60*time.Second) + if err == nil { + t.Fatal("SetupOTELMetrics with empty endpoint should error") + } +} + +func TestSetupOTELMetrics_SharedResource(t *testing.T) { + // Save and restore globals + oldRes := otelResource + oldGlobalMP := otel.GetMeterProvider() + defer func() { + otelResource = oldRes + otel.SetMeterProvider(oldGlobalMP) + }() + + // Set up tracing first to populate shared resource + otelResource = nil + _, err := buildOTELResource("shared-svc", "v2.0.0") + if err != nil { + t.Fatalf("buildOTELResource() error: %v", err) + } + + mp, err := SetupOTELMetrics(OTLPConfig{ + Endpoint: "localhost:4317", + Insecure: true, + }, 60*time.Second) + if err != nil { + t.Fatalf("SetupOTELMetrics() error: %v", err) + } + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + mp.Shutdown(ctx) + }() + + // The MeterProvider should have been created with the shared resource. + // We can't inspect the resource directly, but we verify the MeterProvider is non-nil + // and otelResource was used (not rebuilt). + found := false + for _, attr := range otelResource.Attributes() { + if string(attr.Key) == "service.name" && attr.Value.AsString() == "shared-svc" { + found = true + break + } + } + if !found { + t.Error("shared resource should contain service.name=shared-svc") + } +} + +func TestBuildOTELOptions_MethodAttributeFilter(t *testing.T) { + // Save and restore global OTel state. + oldTP := otel.GetTracerProvider() + oldProp := otel.GetTextMapPropagator() + + tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample())) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + defer func() { + tp.Shutdown(context.Background()) + otel.SetTracerProvider(oldTP) + otel.SetTextMapPropagator(oldProp) + }() + + oldMP := otelMeterProvider + otelMeterProvider = nil + defer func() { otelMeterProvider = oldMP }() + + opts := buildOTELOptions() + + // MethodAttributeFilter should filter health/ready/reflection + filter := opts.MetricsOptions.MethodAttributeFilter + if filter == nil { + t.Fatal("MethodAttributeFilter should be set") + } + + // Filtered methods — ColdBrew's default filter matches substrings + // "healthcheck", "readycheck", "serverreflectioninfo" (case-insensitive). + for _, method := range []string{ + "/myservice.v1.MyService/HealthCheck", + "/myservice.v1.MyService/ReadyCheck", + "/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo", + } { + if filter(method) { + t.Errorf("MethodAttributeFilter(%q) = true, want false", method) + } + } + + // Normal methods (should return true — not filtered) + for _, method := range []string{ + "/mypackage.MyService/MyMethod", + "/users.v1.UserService/GetUser", + "/grpc.health.v1.Health/Check", // standard gRPC health check is NOT filtered (no contiguous "healthcheck" substring) + } { + if !filter(method) { + t.Errorf("MethodAttributeFilter(%q) = false, want true", method) + } + } + + // TraceOptions should have provider and propagator + if opts.TraceOptions.TracerProvider == nil { + t.Error("TraceOptions.TracerProvider should be set") + } + if opts.TraceOptions.TextMapPropagator == nil { + t.Error("TraceOptions.TextMapPropagator should be set") + } +} + +func TestBuildOTELOptions_WithMeterProvider(t *testing.T) { + oldTP := otel.GetTracerProvider() + oldProp := otel.GetTextMapPropagator() + + tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample())) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.TraceContext{}) + defer func() { + tp.Shutdown(context.Background()) + otel.SetTracerProvider(oldTP) + otel.SetTextMapPropagator(oldProp) + }() + + // Set a MeterProvider + oldMP := otelMeterProvider + otelMeterProvider = sdkmetric.NewMeterProvider() + defer func() { + otelMeterProvider.Shutdown(context.Background()) + otelMeterProvider = oldMP + }() + + opts := buildOTELOptions() + if opts.MetricsOptions.MeterProvider == nil { + t.Error("MetricsOptions.MeterProvider should be set when otelMeterProvider is non-nil") + } +} + +func TestProcessConfig_NativeOTEL(t *testing.T) { + // Save and restore all globals mutated by processConfig. + oldSet := otelGRPCOptionsSet + oldOpts := otelGRPCOptions + oldTP := otelTracerProvider + oldRes := otelResource + oldUseLegacy := otelUseLegacy + oldMP := otelMeterProvider + oldGlobalTP := otel.GetTracerProvider() + oldGlobalProp := otel.GetTextMapPropagator() + defer func() { + otelGRPCOptionsSet = oldSet + otelGRPCOptions = oldOpts + otelTracerProvider = oldTP + otelResource = oldRes + otelUseLegacy = oldUseLegacy + otelMeterProvider = oldMP + otel.SetTracerProvider(oldGlobalTP) + otel.SetTextMapPropagator(oldGlobalProp) + }() + + otelGRPCOptionsSet = false + + c := &cb{ + config: config.Config{ + DisableSignalHandler: true, + DisableAutoMaxProcs: true, + OTLPEndpoint: "localhost:4317", + OTLPInsecure: true, + AppName: "test-native", + }, + } + c.processConfig() + + if !otelGRPCOptionsSet { + t.Error("otelGRPCOptionsSet should be true after processConfig with default config") + } + if otelMeterProvider != nil { + t.Error("otelMeterProvider should be nil when EnableOTELMetrics is false") + } + + // Clean up TracerProvider + if otelTracerProvider != nil { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + otelTracerProvider.Shutdown(ctx) + } +} + +func TestProcessConfig_LegacyFallback(t *testing.T) { + oldSet := otelGRPCOptionsSet + oldUseLegacy := otelUseLegacy + defer func() { + otelGRPCOptionsSet = oldSet + otelUseLegacy = oldUseLegacy + }() + + otelGRPCOptionsSet = false + + c := &cb{ + config: config.Config{ + DisableSignalHandler: true, + DisableAutoMaxProcs: true, + OTELUseLegacyInstrumentation: true, + }, + } + c.processConfig() + + if otelGRPCOptionsSet { + t.Error("otelGRPCOptionsSet should be false when OTELUseLegacyInstrumentation is true") + } +} + +func TestProcessConfig_UserSetOTELOptions(t *testing.T) { + oldSet := otelGRPCOptionsSet + oldOpts := otelGRPCOptions + oldUseLegacy := otelUseLegacy + defer func() { + otelGRPCOptionsSet = oldSet + otelGRPCOptions = oldOpts + otelUseLegacy = oldUseLegacy + }() + + // Simulate user calling SetOTELOptions during init + tp := sdktrace.NewTracerProvider() + defer tp.Shutdown(context.Background()) + + customOpts := grpcotel.Options{ + TraceOptions: experimental.TraceOptions{ + TracerProvider: tp, + }, + } + SetOTELOptions(customOpts) + + c := &cb{ + config: config.Config{ + DisableSignalHandler: true, + DisableAutoMaxProcs: true, + }, + } + c.processConfig() + + // Verify user options were not overwritten + if otelGRPCOptions.TraceOptions.TracerProvider != tp { + t.Error("processConfig should not overwrite user-provided OTELOptions") + } +}